From 5de8a8484d99b92b5aeec46ccd8a7f14ea646292 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 14:03:23 +0300 Subject: [PATCH 01/18] [feat] 30b P0: game sounds and game music (C5) - procedural sfx set, seven music presets, two local volumes - gameSfx.js (leaf): twenty procedural WebAudio effects with no asset files - click, pop, whoosh, success, fail, hit, kick, shoot, laser, explosion, coin, levelup, goal, whistle, cheer, step, ring, sparkle, hurt, portal - on the app's one AudioContext, through ONE "Game sounds" gain into the engine's sfx bus; a position spatialises it; a 24-voice cap so a sweep or a barrage cannot stall the audio thread. renderGameSound measures a sound offline (the suite's proof that every name makes one). - gameMusicPresets.js (pure, vitest) + gameMusic.js: arcade / ambient / dungeon / stadium / space / puzzle / studio as four-bar loops of data, scheduled with a 25 ms lookahead timer (not rAF - an immersive session stops window rAF) and numbered off the SESSION clock, so two peers on one preset hear the same bar. Quiet under the effects (MIX 0.55). Refused in Edit (a module starting music from register() must not score the editor) and stopped on the falling edge of Interact/Play. - gameFeel.js (leaf): the ONE "is the player playing" predicate (isLocked === true or editorMode === 'interact') every piece of game feel reads. - moduleSDK: api.playSound routes a game name to the new set, keeps the ping chimes (ding/chime/pluck/bell), and an UNKNOWN name is now a quiet no-op returning false (it used to fall through to the ding) - the C5 contract; api.music.play/stop/current/presets. - Settings > Interface > Sound: "Game sounds" and "Music" sliders, LOCAL, persisted via safeStorage (game:soundVolume / game:musicVolume). - gameKit.js: the new leaves under ONE debug-hook name (App.svelte tails 215/215/215). Suite game-audio (new): 25/25. vitest gameMusicPresets 6/6 (all vitest 233 green). Counterfactuals (all four broken in one run, each red on its own checks): - the stop-on-leave subscription removed -> 7.1, 7.2, 8.2 red (music kept playing, peak 0.0597) - the Edit refusal removed -> 5.1, 5.2 red (music in the editor, peak 0.0751) - the music volume no longer reaching the live gain -> 9.2 red - the coin built as silence -> 1.2 red (quiet: ["coin"]) Held: hud-actions 65/0, game-editor-flow 41/0, sdk-game-seams 78/0 (module-toolbox and editor-modes waited on the machine lock; run at P1). svelte-check 335/47, identical list to base. Build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 8 +- src/components/menu/Settings.svelte | 14 ++ src/lib/gameFeel.js | 38 +++ src/lib/gameKit.js | 7 + src/lib/gameMusic.js | 236 +++++++++++++++++ src/lib/gameMusicPresets.js | 202 +++++++++++++++ src/lib/gameSfx.js | 378 ++++++++++++++++++++++++++++ src/lib/moduleSDK.js | 39 ++- tests/e2e/game-audio.test.cjs | 190 ++++++++++++++ tests/unit/gameMusicPresets.test.js | 62 +++++ 10 files changed, 1168 insertions(+), 6 deletions(-) create mode 100644 src/lib/gameFeel.js create mode 100644 src/lib/gameKit.js create mode 100644 src/lib/gameMusic.js create mode 100644 src/lib/gameMusicPresets.js create mode 100644 src/lib/gameSfx.js create mode 100644 tests/e2e/game-audio.test.cjs create mode 100644 tests/unit/gameMusicPresets.test.js diff --git a/src/App.svelte b/src/App.svelte index 40cf9592..01ac8446 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -478,9 +478,11 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/importGate'), import('./lib/decimate'), import('./lib/playCursor'), - import('./lib/gameStorage') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib } + import('./lib/gameStorage'), + // 30b (vr-play): the game-feel leaves under ONE name + import('./lib/gameKit') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib } }) } }) diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 496ad0be..be5b89b2 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -3,6 +3,9 @@ import { HardDrive, Lock, RotateCcw, X } from '@lucide/svelte'; import ThemedSelect from '../ui/ThemedSelect.svelte'; import SettingRow from './SettingRow.svelte'; + // 30b (vr-play) C5: the two LOCAL game-audio volumes + import { gameSoundVolume } from '$lib/gameSfx'; + import { gameMusicVolume } from '$lib/gameMusic'; import { showGrid, vrOverride, vrMenuHand, vrSnapAngle, vrMirrorSnapTurn, vrTeleportEnabled, vrSleeveEnabled, vrVertexHold, vrFlying, vrPassthrough, vrMenuHold, vrTargetHz, peerHandStyle } from '../../stores/sceneStore.js'; import { applyVRFrameRate } from '$lib/vrControls'; import { settingsOpen, settingsSection, hidePanels, restorePanels, advancedMode, showEnvInList, objectSearchEnabled, showSimControls, showToast, showRoomsButton, toastsInDrawerOnly, mobileUndockAllowed, enableShiftAdd, noteDoubleClickToOpen, duplicateCarriesAnimation, duplicateCarriesFlow, duplicateCarriesShader, touchTools, floatingToolbar, toolbarAlwaysOnTop } from '../../stores/appStore.js'; @@ -767,6 +770,17 @@ {/if} + + + + How loud a game's effects are on this device — coins, goals, hits, the clicks a + game makes. Local to you; {Math.round($gameSoundVolume * 100)}% + + + + The background music a game plays while you are in Interact or Play (it stops when + you go back to editing). Local to you; {Math.round($gameMusicVolume * 100)}% + diff --git a/src/lib/gameFeel.js b/src/lib/gameFeel.js new file mode 100644 index 00000000..400353ae --- /dev/null +++ b/src/lib/gameFeel.js @@ -0,0 +1,38 @@ +// 30b (vr-play): "IS THE PLAYER PLAYING?" — the ONE predicate behind every piece of game +// feel: controller haptics, game music, the VR game panel and the hold-and-sweep. +// +// The user's rule, verbatim from the Quest report: "The vibration should be only +// interactive mode, not in edit mode." A game feels like a game in INTERACT and in PLAY, +// and the editor stays quiet. Both halves are LOCAL state on this device (`editorMode` is +// never sent, `isLocked` is this peer's play press), so nothing here replicates. +// +// `isLocked` is THREE-state (null editor / true playing / false the exit transient), so +// playing is `=== true` — the transient must read "not playing" (the 21-E3 rule). +// VR has no pointer lock: a headset session enters INTERACT (30b-vr-modes' C1), which is +// why `editorMode === 'interact'` alone is also enough. +// +// A LEAF: sceneStore + svelte/store only, so vrControls, moduleSDK, the audio leaves and +// the HUD layer can all read it without reaching the history cycle family. +import { derived, get } from 'svelte/store'; +import { isLocked, editorMode } from '../stores/sceneStore'; + +/** true while this device is in Interact or Play @returns {boolean} */ +export function gameFeelActive() { + return get(isLocked) === true || get(editorMode) === 'interact'; +} + +/** the same answer as a store — a falling edge is "the player left the game" */ +export const gameFeelOn = derived( + [isLocked, editorMode], + ([$locked, $mode]) => $locked === true || $mode === 'interact' +); + +/** + * The click MODE a game-side press dispatches with (moduleSDK's CLICK_MODES): 'play' + * under desktop Play, 'interact' in Interact (VR Play enters Interact), 'edit' otherwise. + * @returns {'edit' | 'interact' | 'play'} + */ +export function gameClickMode() { + if (get(isLocked) === true) return 'play'; + return get(editorMode) === 'interact' ? 'interact' : 'edit'; +} diff --git a/src/lib/gameKit.js b/src/lib/gameKit.js new file mode 100644 index 00000000..a3e6c5ce --- /dev/null +++ b/src/lib/gameKit.js @@ -0,0 +1,7 @@ +// 30b (vr-play): the game-feel leaves under ONE debug-hook name (`__stores.gameKit`), so +// this lane adds one entry to App.svelte's three tails instead of one per leaf. Nothing in +// the app imports this file; it exists for the suites. +export * as gameFeel from './gameFeel'; +export * as gameSfx from './gameSfx'; +export * as gameMusic from './gameMusic'; +export * as gameMusicPresets from './gameMusicPresets'; diff --git a/src/lib/gameMusic.js b/src/lib/gameMusic.js new file mode 100644 index 00000000..40fdbde4 --- /dev/null +++ b/src/lib/gameMusic.js @@ -0,0 +1,236 @@ +// 30b (vr-play) C5 — GAME MUSIC: `api.music.play(preset, {volume})` / `api.music.stop()`. +// Seven procedural looping presets (gameMusicPresets.js is the tune as data), scheduled on +// the app's one AudioContext and LOCAL to this device — a peer's music is theirs. +// +// THREE RULES, each one the user's: +// · it plays only while the player is PLAYING (Interact or Play — gameFeel.js). A call +// from the editor is refused, and leaving the game stops it: "music stops on leaving +// Play/Interact". Refusing in Edit is stricter than "stop on leave" and is on purpose — +// a module that started its music from register() would otherwise score the editor. +// · it sits UNDER the effects: one "Music" volume in Settings (persisted via safeStorage) +// times a quiet MIX constant, into the engine's `music` bus. +// · it is TEMPO-SYNCED to the SESSION clock: the step that plays is floor(sessionNow / +// step length), never "steps since the button" — two peers who start one preset hear +// the same bar at the same moment, and a restart lands back on the beat. +// +// The scheduler is the classic lookahead: a 25 ms timer schedules every step whose time +// falls inside the next LOOKAHEAD seconds on the audio clock (audioTimeFor maps a session +// stamp through the engine's filtered clock). A TIMER, not a rAF: an immersive session +// stops window rAF on the Quest, and music must not. +import { writable, get } from 'svelte/store'; +import { ensureAudioContext, bus, audioTimeFor } from './audioEngine'; +import { sessionNow } from './sessionClock'; +import { safeStorage } from './safeStorage'; +import { gameFeelOn, gameFeelActive } from './gameFeel'; +import { sfxTone, sfxNoise } from './gameSfx'; +import { musicPreset, stepSeconds, stepEvents, MUSIC_PRESET_IDS } from './gameMusicPresets'; + +export { MUSIC_PRESET_IDS }; + +const VOLUME_KEY = 'game:musicVolume'; +/** music under effects: the slider's full scale is this loud */ +const MIX = 0.55; +const LOOKAHEAD = 0.18; +const TICK_MS = 25; + +/** @param {string | null} raw @param {number} fallback */ +function readVolume(raw, fallback) { + const n = raw === null ? NaN : Number(raw); + return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback; +} + +/** "Music" volume, 0..1, LOCAL per device (Settings ▸ Interface ▸ Sound). */ +export const gameMusicVolume = writable(readVolume(safeStorage.getItem(VOLUME_KEY), 0.6)); + +/** what is playing now: `{preset, volume}` or null @type {import('svelte/store').Writable<{preset: string, volume: number} | null>} */ +export const gameMusicState = writable(null); + +/** + * The running session. `gain` is PER PLAY: stopping fades and disconnects it, so any note + * already scheduled into it goes silent with it — no voice bookkeeping needed. + * @type {{preset: any, volume: number, gain: GainNode, timer: any, lastStep: number, scheduled: number} | null} + */ +let current = null; +const debug = { steps: 0, notes: 0, refused: 0, stops: 0 }; + +/** @param {number} volume the per-call volume */ +function levelFor(volume) { + return MIX * get(gameMusicVolume) * volume; +} + +// Declared ABOVE the subscribes (they run synchronously at module evaluation). +gameMusicVolume.subscribe((v) => { + safeStorage.setItem(VOLUME_KEY, String(v)); + if (current) current.gain.gain.value = levelFor(current.volume); +}); +// "music stops on leaving Play/Interact" — the falling edge of the one predicate +gameFeelOn.subscribe((on) => { + if (!on && current) stopGameMusic(); +}); + +/** @param {number} midi */ +const hz = (midi) => 440 * Math.pow(2, (midi - 69) / 12); + +/** + * Build one step's notes into `dest` at audio time `t`. Shared by the live scheduler and + * the offline render, so what the suite measures is what a player hears. + * @param {BaseAudioContext} ctx @param {AudioNode} dest @param {any} preset + * @param {number} step @param {number} t @returns {number} notes built + */ +export function buildMusicStep(ctx, dest, preset, step, t) { + const beat = 60 / preset.bpm; + let n = 0; + for (const event of stepEvents(preset, step)) { + const g = preset.drumGain; + switch (event.kind) { + case 'kick': + sfxTone(ctx, dest, { freq: 140, to: 44, t0: t, dur: 0.24, peak: 0.55 * g, attack: 0.002 }); + break; + case 'snare': + sfxNoise(ctx, dest, { t0: t, dur: 0.14, peak: 0.22 * g, filter: 'bandpass', freq: 1800, q: 0.7, attack: 0.002 }); + sfxTone(ctx, dest, { freq: 190, to: 140, t0: t, dur: 0.08, peak: 0.12 * g, attack: 0.002 }); + break; + case 'hat': + sfxNoise(ctx, dest, { t0: t, dur: 0.04, peak: 0.08 * g, filter: 'highpass', freq: 7000, attack: 0.001 }); + break; + case 'bass': { + const dur = Math.max(0.12, (event.beats ?? 0.5) * beat * 0.95); + const v = preset.bassVoice; + const lp = ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = v.cutoff; + lp.connect(dest); + sfxTone(ctx, lp, { freq: hz(/** @type {number} */ (event.midi)), type: v.type, t0: t, dur, peak: v.gain, attack: 0.01 }); + break; + } + case 'pad': { + const dur = (event.beats ?? 4) * beat; + for (const m of /** @type {number[]} */ (event.midi)) + sfxTone(ctx, dest, { freq: hz(m), type: preset.pad.type, t0: t, dur, peak: preset.pad.gain, attack: Math.min(0.6, dur * 0.3) }); + break; + } + case 'arp': + sfxTone(ctx, dest, { freq: hz(/** @type {number} */ (event.midi)), type: preset.arp.type, t0: t, dur: Math.max(0.1, beat * 0.45), peak: preset.arp.gain, attack: 0.004 }); + break; + } + n++; + } + return n; +} + +function tick() { + if (!current) return; + const ctx = ensureAudioContext(); + const stepMs = stepSeconds(current.preset) * 1000; + const now = sessionNow(); + const horizon = now + LOOKAHEAD * 1000; + // the first tick starts at the NEXT step boundary — a step already begun would land + // part-way through its own notes + let step = current.lastStep < 0 ? Math.floor(now / stepMs) + 1 : current.lastStep + 1; + // a stalled timer (a background tab) never replays the backlog: skip to now + if (step * stepMs < now - 200) step = Math.floor(now / stepMs) + 1; + for (; step * stepMs <= horizon; step++) { + const at = audioTimeFor(step * stepMs); + if (at < ctx.currentTime) continue; + debug.notes += buildMusicStep(ctx, current.gain, current.preset, step, at); + debug.steps++; + current.lastStep = step; + } +} + +/** + * Start (or switch to) a preset. Refused — false — outside Interact/Play, and for an + * unknown preset name. The same preset already playing only takes the new volume. + * @param {string} presetId @param {{volume?: number}} [options] + * @returns {boolean} + */ +export function playGameMusic(presetId, options = {}) { + const preset = musicPreset(presetId); + if (!preset || !gameFeelActive()) { + debug.refused++; + return false; + } + const raw = Number(options?.volume); + const volume = Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1; + if (current && current.preset.id === preset.id) { + current.volume = volume; + current.gain.gain.value = levelFor(volume); + gameMusicState.set({ preset: preset.id, volume }); + return true; + } + if (current) stopGameMusic(); + /** @type {AudioContext} */ + let ctx; + try { + ctx = ensureAudioContext(); + } catch { + return false; + } + if (ctx.state === 'suspended') ctx.resume().catch(() => {}); + const gain = ctx.createGain(); + gain.gain.value = levelFor(volume); + gain.connect(bus('music')); + current = { preset, volume, gain, timer: setInterval(tick, TICK_MS), lastStep: -1, scheduled: 0 }; + tick(); + gameMusicState.set({ preset: preset.id, volume }); + return true; +} + +/** Stop whatever is playing (a short fade — a hard cut clicks). Safe to call anytime. */ +export function stopGameMusic() { + if (!current) return; + const { gain, timer } = current; + current = null; + clearInterval(timer); + debug.stops++; + try { + const ctx = ensureAudioContext(); + gain.gain.cancelScheduledValues(ctx.currentTime); + gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime); + gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.3); + } catch { + /* a closed context has nothing left to fade */ + } + setTimeout(() => { + try { + gain.disconnect(); + } catch {} + }, 450); + gameMusicState.set(null); +} + +/** + * Render `bars` bars of a preset OFFLINE and measure them (the suite's proof that every + * preset plays a real loop). + * @param {string} presetId @param {number} [bars] @param {number} [sampleRate] + * @returns {Promise<{preset: string, seconds: number, rms: number, peak: number, notes: number} | null>} + */ +export async function renderGameMusic(presetId, bars = 1, sampleRate = 22050) { + const preset = musicPreset(presetId); + const Offline = typeof window !== 'undefined' ? /** @type {any} */ (window).OfflineAudioContext : null; + if (!preset || !Offline) return null; + const step = stepSeconds(preset); + const seconds = bars * 16 * step + 1; + const ctx = new Offline(1, Math.ceil(seconds * sampleRate), sampleRate); + let notes = 0; + for (let i = 0; i < bars * 16; i++) notes += buildMusicStep(ctx, ctx.destination, preset, i, i * step); + const buffer = await ctx.startRendering(); + const data = buffer.getChannelData(0); + let sum = 0; + let peak = 0; + for (let i = 0; i < data.length; i++) { + sum += data[i] * data[i]; + peak = Math.max(peak, Math.abs(data[i])); + } + return { preset: preset.id, seconds, rms: Math.sqrt(sum / data.length), peak, notes }; +} + +/** the live gain's value, or null when nothing plays */ +export function gameMusicGainValue() { + return current ? current.gain.gain.value : null; +} + +/** counts for the suites */ +export function gameMusicDebug() { + return { ...debug, playing: current?.preset.id ?? null, lastStep: current?.lastStep ?? -1 }; +} diff --git a/src/lib/gameMusicPresets.js b/src/lib/gameMusicPresets.js new file mode 100644 index 00000000..35082b13 --- /dev/null +++ b/src/lib/gameMusicPresets.js @@ -0,0 +1,202 @@ +// 30b (vr-play) C5 — the game MUSIC presets as DATA, plus the pure arithmetic that turns +// a preset and a step number into the notes that step plays. Imports NOTHING: the +// scheduler (gameMusic.js) is the only thing that touches WebAudio, so everything a +// listener would call "the tune" is testable with no browser. +// +// A preset is a four-bar loop in sixteenth notes: a chord PROGRESSION (scale degrees, one +// per bar), drum lanes as 16-char strings ('x' hit, '.' rest), a bass lane ('r' root, +// 'o' octave, 'f' fifth, 't' third), an optional pad (the bar's triad, held) and an +// optional ARP lane of chord-tone indices ('0'-'5' = triad tones up two octaves). +// +// Everything is keyed to a GLOBAL step count (the session clock divided by the step +// length), never to "when play was pressed" — so two peers who start the same preset hear +// the same bar at the same moment, and "tempo-synced" means synced to the session. + +/** @typedef {{kind: 'kick'|'snare'|'hat'|'bass'|'pad'|'arp', midi?: number[] | number, beats?: number}} MusicEvent */ + +/** + * @typedef {object} MusicPreset + * @property {string} id @property {string} name + * @property {number} bpm + * @property {number} root midi note of the key's tonic (octave 4-ish) + * @property {number[]} scale semitone offsets of the mode + * @property {number[]} progression scale degrees (0-based), one chord per bar + * @property {{kick?: string, snare?: string, hat?: string}} drums 16 chars each + * @property {string} bass 16 chars: r o f t . + * @property {{type: OscillatorType, gain: number} | null} pad + * @property {{lane: string, type: OscillatorType, gain: number, octave: number} | null} arp + * @property {{type: OscillatorType, gain: number, cutoff: number}} bassVoice + * @property {number} drumGain + */ + +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +const MINOR = [0, 2, 3, 5, 7, 8, 10]; +const DORIAN = [0, 2, 3, 5, 7, 9, 10]; +const PHRYGIAN = [0, 1, 3, 5, 7, 8, 10]; +const LYDIAN = [0, 2, 4, 6, 7, 9, 11]; + +/** @type {MusicPreset[]} */ +export const MUSIC_PRESETS = [ + { + id: 'arcade', + name: 'Arcade', + bpm: 138, + root: 60, + scale: MAJOR, + progression: [0, 5, 3, 4], + drums: { kick: 'x...x...x...x...', snare: '....x.......x...', hat: '..x...x...x...x.' }, + bass: 'r.r.o.r.r.r.o.f.', + pad: null, + arp: { lane: '0.1.2.3.2.1.0.1.', type: 'square', gain: 0.045, octave: 1 }, + bassVoice: { type: 'square', gain: 0.07, cutoff: 900 }, + drumGain: 0.8 + }, + { + id: 'ambient', + name: 'Ambient', + bpm: 72, + root: 62, + scale: LYDIAN, + progression: [0, 3, 5, 4], + drums: {}, + bass: 'r...............', + pad: { type: 'triangle', gain: 0.05 }, + arp: { lane: '0.......2.......', type: 'sine', gain: 0.04, octave: 2 }, + bassVoice: { type: 'sine', gain: 0.09, cutoff: 500 }, + drumGain: 0 + }, + { + id: 'dungeon', + name: 'Dungeon', + bpm: 84, + root: 57, + scale: PHRYGIAN, + progression: [0, 0, 1, 6], + drums: { kick: 'x.......x.x.....', snare: '............x...', hat: '' }, + bass: 'r.......r...f...', + pad: { type: 'sawtooth', gain: 0.022 }, + arp: { lane: '....2.......1...', type: 'triangle', gain: 0.035, octave: 1 }, + bassVoice: { type: 'sawtooth', gain: 0.07, cutoff: 350 }, + drumGain: 0.7 + }, + { + id: 'stadium', + name: 'Stadium', + bpm: 120, + root: 55, + scale: MAJOR, + progression: [0, 4, 5, 3], + drums: { kick: 'x...x...x...x...', snare: '....x.......x.x.', hat: 'x.x.x.x.x.x.x.x.' }, + bass: 'r.r.r.r.f.f.o.o.', + pad: { type: 'sawtooth', gain: 0.025 }, + arp: null, + bassVoice: { type: 'sawtooth', gain: 0.07, cutoff: 700 }, + drumGain: 0.9 + }, + { + id: 'space', + name: 'Space', + bpm: 100, + root: 64, + scale: DORIAN, + progression: [0, 6, 5, 4], + drums: { kick: 'x.........x.....', snare: '....x.......x...', hat: '..x...x...x...xx' }, + bass: 'r.....r.....f...', + pad: { type: 'triangle', gain: 0.035 }, + arp: { lane: '0.2.4.2.1.3.5.3.', type: 'triangle', gain: 0.04, octave: 1 }, + bassVoice: { type: 'triangle', gain: 0.09, cutoff: 600 }, + drumGain: 0.6 + }, + { + id: 'puzzle', + name: 'Puzzle', + bpm: 96, + root: 65, + scale: MAJOR, + progression: [0, 3, 1, 4], + drums: { kick: 'x.......x.......', snare: '', hat: '....x.......x...' }, + bass: 'r...f...r...t...', + pad: null, + arp: { lane: '0..2..1..3..2...', type: 'triangle', gain: 0.05, octave: 1 }, + bassVoice: { type: 'triangle', gain: 0.08, cutoff: 800 }, + drumGain: 0.5 + }, + { + id: 'studio', + name: 'Studio', + bpm: 88, + root: 55, + scale: MINOR, + progression: [0, 3, 6, 4], + drums: { kick: 'x......x..x.....', snare: '....x.......x...', hat: 'x.xxx.xxx.xxx.xx' }, + bass: 'r.....r.f.....o.', + pad: { type: 'sine', gain: 0.045 }, + arp: null, + bassVoice: { type: 'sine', gain: 0.1, cutoff: 500 }, + drumGain: 0.6 + } +]; + +/** @param {string} id @returns {MusicPreset | null} */ +export function musicPreset(id) { + return MUSIC_PRESETS.find((p) => p.id === String(id)) ?? null; +} + +/** the names a module can ask for */ +export const MUSIC_PRESET_IDS = MUSIC_PRESETS.map((p) => p.id); + +/** seconds per sixteenth note @param {MusicPreset} preset */ +export function stepSeconds(preset) { + return 60 / preset.bpm / 4; +} + +/** + * The triad (three midi notes) on scale degree `degree`, stacked in thirds inside the + * mode — so a minor key's iv is minor and a major key's V is major, with no chord table. + * @param {MusicPreset} preset @param {number} degree @returns {number[]} + */ +export function triadOn(preset, degree) { + const s = preset.scale; + /** @param {number} i */ + const note = (i) => preset.root + s[((i % 7) + 7) % 7] + 12 * Math.floor(i / 7); + return [note(degree), note(degree + 2), note(degree + 4)]; +} + +/** @param {string | undefined} lane @param {number} i */ +const hitAt = (lane, i) => !!lane && lane[i] !== undefined && lane[i] !== '.'; + +/** + * What global step `step` plays. Pure: same preset + same step = same events, on every + * device. The chord changes per BAR (16 steps), cycling the progression. + * @param {MusicPreset} preset @param {number} step @returns {MusicEvent[]} + */ +export function stepEvents(preset, step) { + const i = ((step % 16) + 16) % 16; + const bar = Math.floor(step / 16); + const degree = preset.progression[((bar % preset.progression.length) + preset.progression.length) % preset.progression.length]; + const triad = triadOn(preset, degree); + /** @type {MusicEvent[]} */ + const out = []; + if (hitAt(preset.drums.kick, i)) out.push({ kind: 'kick' }); + if (hitAt(preset.drums.snare, i)) out.push({ kind: 'snare' }); + if (hitAt(preset.drums.hat, i)) out.push({ kind: 'hat' }); + const b = preset.bass[i]; + if (b && b !== '.') { + const base = triad[0] - 24; + const midi = b === 'o' ? base + 12 : b === 'f' ? base + 7 : b === 't' ? triad[1] - 24 : base; + // a bass note lasts until the next one in its lane + let len = 1; + while (len < 16 && (preset.bass[(i + len) % 16] ?? '.') === '.' && i + len < 16) len++; + out.push({ kind: 'bass', midi, beats: len / 4 }); + } + if (preset.pad && i === 0) out.push({ kind: 'pad', midi: triad, beats: 4 }); + if (preset.arp) { + const c = preset.arp.lane[i]; + if (c && c !== '.') { + const k = Number(c); + const tone = triad[k % 3] + 12 * (Math.floor(k / 3) + preset.arp.octave); + out.push({ kind: 'arp', midi: tone, beats: 0.25 }); + } + } + return out; +} diff --git a/src/lib/gameSfx.js b/src/lib/gameSfx.js new file mode 100644 index 00000000..702caca5 --- /dev/null +++ b/src/lib/gameSfx.js @@ -0,0 +1,378 @@ +// 30b (vr-play) C5 — THE GAME SOUND SET. Twenty procedural WebAudio effects a game can +// name (`api.playSound('coin')`), with NO asset files: every sound is oscillators, a +// shared noise buffer, filters and envelopes, built fresh per play on the app's one +// AudioContext (audioEngine — a second context would have its own listener and clock). +// +// Why procedural and not samples: a module is a self-contained zip, the sound set must be +// the same on every peer with nothing to fetch, and "the user asked for sounds in every +// game" is not worth a megabyte of wav per build. The ping chimes (pingAudio.js) set the +// precedent; this is the same idea grown to a game's vocabulary. +// +// ROUTING: every game sound goes through ONE gain (the "Game sounds" volume in Settings, +// persisted LOCALLY through safeStorage) into the engine's `sfx` bus, so the mixer and the +// limiter see it like any other effect. A `position` spatialises it through a PannerNode +// (the engine keeps the listener on the camera/headset whatever spatialVoice says). +// +// CAPPED: a sweep across a keyboard or a round of explosions must not stack hundreds of +// live voices — past MAX_LIVE the oldest-ending ones are simply not started (a dropped +// sound is inaudible under that many; a stalled audio thread is not). +// +// A LEAF: svelte/store + audioEngine + safeStorage. `renderGameSound` renders one into an +// OfflineAudioContext and MEASURES it — the suite's proof that every name makes a sound. +import { writable, get } from 'svelte/store'; +import { ensureAudioContext, bus } from './audioEngine'; +import { safeStorage } from './safeStorage'; + +/** The names a game can play. Anything else is a quiet no-op (never an error). */ +export const GAME_SOUNDS = [ + 'click', + 'pop', + 'whoosh', + 'success', + 'fail', + 'hit', + 'kick', + 'shoot', + 'laser', + 'explosion', + 'coin', + 'levelup', + 'goal', + 'whistle', + 'cheer', + 'step', + 'ring', + 'sparkle', + 'hurt', + 'portal' +]; +const SOUND_SET = new Set(GAME_SOUNDS); + +/** @param {string} name */ +export function isGameSound(name) { + return SOUND_SET.has(String(name)); +} + +const VOLUME_KEY = 'game:soundVolume'; +const MAX_LIVE = 24; + +/** @param {string | null} raw @param {number} fallback */ +function readVolume(raw, fallback) { + const n = raw === null ? NaN : Number(raw); + // Number.isFinite, never `|| fallback`: a slider dragged to ZERO is a real volume + return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback; +} + +/** "Game sounds" volume, 0..1, LOCAL per device (Settings ▸ Interface ▸ Sound). */ +export const gameSoundVolume = writable(readVolume(safeStorage.getItem(VOLUME_KEY), 0.8)); + +/** @type {GainNode | null} */ +let gameGain = null; + +// Declared ABOVE the subscribe (the module-level subscribe runs synchronously — TDZ rule). +gameSoundVolume.subscribe((v) => { + safeStorage.setItem(VOLUME_KEY, String(v)); + if (gameGain) gameGain.gain.value = v; +}); + +/** the game-sound gain on the sfx bus, built once @returns {GainNode} */ +function gameOut() { + const ctx = ensureAudioContext(); + if (!gameGain) { + gameGain = ctx.createGain(); + gameGain.gain.value = get(gameSoundVolume); + gameGain.connect(bus('sfx')); + } + return gameGain; +} + +/* ------------------------------------------------------------------ the synth ---- */ + +/** @type {WeakMap} one second of white noise per context */ +const noiseBuffers = new WeakMap(); + +/** @param {BaseAudioContext} ctx @returns {AudioBuffer} */ +function noiseBuffer(ctx) { + let buffer = noiseBuffers.get(ctx); + if (buffer) return buffer; + buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); + const data = buffer.getChannelData(0); + // a fixed LCG, so the same sound renders the same bytes on every device + let seed = 0x2f6b1d; + for (let i = 0; i < data.length; i++) { + seed = (seed * 1664525 + 1013904223) >>> 0; + data[i] = (seed / 0xffffffff) * 2 - 1; + } + noiseBuffers.set(ctx, buffer); + return buffer; +} + +/** exponential ramps cannot reach zero */ +const FLOOR = 0.0001; + +/** + * One enveloped oscillator, optionally gliding. + * @param {BaseAudioContext} ctx @param {AudioNode} dest + * @param {{freq: number, to?: number, type?: OscillatorType, t0: number, dur: number, + * peak?: number, attack?: number, vibrato?: {rate: number, depth: number}}} o + */ +function tone(ctx, dest, o) { + const { freq, to, type = 'sine', t0, dur, peak = 0.25, attack = 0.008, vibrato } = o; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, t0); + if (to) osc.frequency.exponentialRampToValueAtTime(Math.max(20, to), t0 + dur); + if (vibrato) { + const lfo = ctx.createOscillator(); + const depth = ctx.createGain(); + lfo.frequency.value = vibrato.rate; + depth.gain.value = vibrato.depth; + lfo.connect(depth).connect(osc.frequency); + lfo.start(t0); + lfo.stop(t0 + dur + 0.05); + } + gain.gain.setValueAtTime(FLOOR, t0); + gain.gain.exponentialRampToValueAtTime(peak, t0 + attack); + gain.gain.exponentialRampToValueAtTime(FLOOR, t0 + dur); + osc.connect(gain).connect(dest); + osc.start(t0); + osc.stop(t0 + dur + 0.05); +} + +/** + * A filtered noise burst, optionally sweeping its filter. + * @param {BaseAudioContext} ctx @param {AudioNode} dest + * @param {{t0: number, dur: number, peak?: number, attack?: number, + * filter?: BiquadFilterType, freq?: number, to?: number, q?: number, flutter?: number}} o + */ +function noise(ctx, dest, o) { + const { t0, dur, peak = 0.3, attack = 0.005, filter = 'lowpass', freq = 2000, to, q = 0.8, flutter } = o; + const src = ctx.createBufferSource(); + src.buffer = noiseBuffer(ctx); + src.loop = true; + const biquad = ctx.createBiquadFilter(); + biquad.type = filter; + biquad.Q.value = q; + biquad.frequency.setValueAtTime(freq, t0); + if (to) biquad.frequency.exponentialRampToValueAtTime(Math.max(20, to), t0 + dur); + const gain = ctx.createGain(); + gain.gain.setValueAtTime(FLOOR, t0); + gain.gain.exponentialRampToValueAtTime(peak, t0 + attack); + gain.gain.exponentialRampToValueAtTime(FLOOR, t0 + dur); + src.connect(biquad).connect(gain); + if (flutter) { + // a crowd is not one steady hiss: an amplitude wobble makes it read as voices + const wobble = ctx.createGain(); + wobble.gain.value = 0.6; + const lfo = ctx.createOscillator(); + const depth = ctx.createGain(); + lfo.frequency.value = flutter; + depth.gain.value = 0.4; + lfo.connect(depth).connect(wobble.gain); + lfo.start(t0); + lfo.stop(t0 + dur + 0.05); + gain.connect(wobble).connect(dest); + } else gain.connect(dest); + src.start(t0, (t0 * 7.13) % 0.5); + src.stop(t0 + dur + 0.05); +} + +/** equal-tempered frequency of a midi note @param {number} midi */ +const hz = (midi) => 440 * Math.pow(2, (midi - 69) / 12); + +/** + * Build one sound into `dest` starting at `t0`. Returns its length in seconds, or -1 for + * a name that is not in the set. Pure WebAudio graph building — the live player and the + * offline measurement share it, so what the suite measures is what a player hears. + * @param {BaseAudioContext} ctx @param {AudioNode} dest @param {string} name @param {number} t0 + * @returns {number} + */ +export function buildGameSound(ctx, dest, name, t0) { + switch (name) { + case 'click': + tone(ctx, dest, { freq: 1900, type: 'square', t0, dur: 0.035, peak: 0.12, attack: 0.002 }); + noise(ctx, dest, { t0, dur: 0.03, peak: 0.12, filter: 'highpass', freq: 3000 }); + return 0.05; + case 'pop': + tone(ctx, dest, { freq: 380, to: 1100, t0, dur: 0.09, peak: 0.3, attack: 0.004 }); + return 0.1; + case 'whoosh': + noise(ctx, dest, { t0, dur: 0.45, peak: 0.35, attack: 0.12, filter: 'bandpass', freq: 300, to: 2600, q: 1.4 }); + return 0.5; + case 'success': + [72, 76, 79, 84].forEach((m, i) => + tone(ctx, dest, { freq: hz(m), type: 'triangle', t0: t0 + i * 0.08, dur: i === 3 ? 0.5 : 0.18, peak: 0.28 }) + ); + return 0.8; + case 'fail': + tone(ctx, dest, { freq: 330, type: 'square', t0, dur: 0.22, peak: 0.12 }); + tone(ctx, dest, { freq: 247, type: 'square', t0: t0 + 0.2, dur: 0.22, peak: 0.12 }); + tone(ctx, dest, { freq: 196, to: 150, type: 'sawtooth', t0: t0 + 0.4, dur: 0.45, peak: 0.12 }); + return 0.9; + case 'hit': + tone(ctx, dest, { freq: 160, to: 55, t0, dur: 0.18, peak: 0.5, attack: 0.002 }); + noise(ctx, dest, { t0, dur: 0.12, peak: 0.4, freq: 1400, to: 300, attack: 0.002 }); + return 0.22; + case 'kick': + tone(ctx, dest, { freq: 150, to: 42, t0, dur: 0.3, peak: 0.7, attack: 0.002 }); + noise(ctx, dest, { t0, dur: 0.02, peak: 0.15, filter: 'highpass', freq: 2000, attack: 0.001 }); + return 0.32; + case 'shoot': + tone(ctx, dest, { freq: 900, to: 140, type: 'square', t0, dur: 0.16, peak: 0.16, attack: 0.002 }); + noise(ctx, dest, { t0, dur: 0.1, peak: 0.3, freq: 3000, to: 500, attack: 0.001 }); + return 0.2; + case 'laser': + tone(ctx, dest, { freq: 1500, to: 280, type: 'sawtooth', t0, dur: 0.26, peak: 0.14, attack: 0.003 }); + tone(ctx, dest, { freq: 3000, to: 560, type: 'sine', t0, dur: 0.2, peak: 0.08, attack: 0.003 }); + return 0.3; + case 'explosion': + noise(ctx, dest, { t0, dur: 1.3, peak: 0.7, freq: 2200, to: 120, attack: 0.004, q: 0.5 }); + tone(ctx, dest, { freq: 90, to: 28, t0, dur: 0.9, peak: 0.6, attack: 0.004 }); + return 1.35; + case 'coin': + tone(ctx, dest, { freq: hz(83), type: 'square', t0, dur: 0.08, peak: 0.13, attack: 0.002 }); + tone(ctx, dest, { freq: hz(88), type: 'square', t0: t0 + 0.07, dur: 0.35, peak: 0.13, attack: 0.002 }); + return 0.45; + case 'levelup': + [60, 64, 67, 72, 76, 79, 84, 88].forEach((m, i) => + tone(ctx, dest, { freq: hz(m), type: 'triangle', t0: t0 + i * 0.055, dur: i === 7 ? 0.6 : 0.12, peak: 0.24 }) + ); + return 1.05; + case 'goal': + // a bright major stab that swells, with a rising run on top — a stadium moment + [60, 64, 67, 72].forEach((m) => + tone(ctx, dest, { freq: hz(m), type: 'sawtooth', t0, dur: 1.1, peak: 0.07, attack: 0.05 }) + ); + [79, 84, 88, 91].forEach((m, i) => + tone(ctx, dest, { freq: hz(m), type: 'triangle', t0: t0 + 0.05 + i * 0.07, dur: 0.4, peak: 0.16 }) + ); + noise(ctx, dest, { t0: t0 + 0.1, dur: 1.4, peak: 0.12, attack: 0.3, filter: 'bandpass', freq: 1600, q: 0.7, flutter: 9 }); + return 1.5; + case 'whistle': + // a referee's pea whistle: a high tone with a fast trill + tone(ctx, dest, { freq: 2900, type: 'sine', t0, dur: 0.55, peak: 0.2, attack: 0.02, vibrato: { rate: 32, depth: 160 } }); + return 0.6; + case 'cheer': + noise(ctx, dest, { t0, dur: 1.8, peak: 0.3, attack: 0.35, filter: 'bandpass', freq: 1200, to: 1700, q: 0.6, flutter: 7 }); + noise(ctx, dest, { t0: t0 + 0.1, dur: 1.6, peak: 0.14, attack: 0.4, filter: 'bandpass', freq: 2600, q: 1.2, flutter: 11 }); + return 1.9; + case 'step': + noise(ctx, dest, { t0, dur: 0.08, peak: 0.3, freq: 500, to: 180, attack: 0.002 }); + return 0.1; + case 'ring': + tone(ctx, dest, { freq: 1320, t0, dur: 1.0, peak: 0.2, attack: 0.003 }); + tone(ctx, dest, { freq: 1320 * 2.76, t0, dur: 0.5, peak: 0.07, attack: 0.003 }); + tone(ctx, dest, { freq: 1320 * 5.4, t0, dur: 0.25, peak: 0.04, attack: 0.003 }); + return 1.05; + case 'sparkle': { + // seven high pings on a fixed pattern (deterministic — no Math.random) + const notes = [96, 100, 103, 98, 105, 101, 108]; + notes.forEach((m, i) => tone(ctx, dest, { freq: hz(m), t0: t0 + i * 0.045, dur: 0.22, peak: 0.1, attack: 0.002 })); + return 0.55; + } + case 'hurt': + tone(ctx, dest, { freq: 320, to: 110, type: 'sawtooth', t0, dur: 0.32, peak: 0.2, attack: 0.004 }); + noise(ctx, dest, { t0, dur: 0.15, peak: 0.2, freq: 900, attack: 0.002 }); + return 0.36; + case 'portal': + tone(ctx, dest, { freq: 180, to: 1300, t0, dur: 0.9, peak: 0.18, attack: 0.3, vibrato: { rate: 9, depth: 40 } }); + tone(ctx, dest, { freq: 270, to: 1950, type: 'triangle', t0: t0 + 0.05, dur: 0.85, peak: 0.08, attack: 0.3 }); + noise(ctx, dest, { t0, dur: 0.9, peak: 0.08, attack: 0.4, filter: 'bandpass', freq: 800, to: 4000, q: 2 }); + return 0.95; + default: + return -1; + } +} + +/* ------------------------------------------------------------------ the player --- */ + +/** end times (context seconds) of the sounds still ringing @type {number[]} */ +const live = []; +const debug = { played: 0, dropped: 0, unknown: 0, last: '' }; + +/** + * Play a game sound, LOCAL to this device. Returns true when a sound started; false for + * an unknown name, a missing audio context, or the voice cap. + * @param {string} name @param {number[] | null} [position] world-space, spatialised + * @returns {boolean} + */ +export function playGameSound(name, position = null) { + if (!isGameSound(name)) { + debug.unknown++; + return false; + } + /** @type {AudioContext} */ + let ctx; + try { + ctx = ensureAudioContext(); + } catch { + return false; + } + if (ctx.state === 'suspended') ctx.resume().catch(() => {}); + const now = ctx.currentTime; + while (live.length && live[0] <= now) live.shift(); + if (live.length >= MAX_LIVE) { + debug.dropped++; + return false; + } + /** @type {AudioNode} */ + let dest = gameOut(); + if (Array.isArray(position) && position.length >= 3 && position.every((n) => Number.isFinite(n))) { + const panner = ctx.createPanner(); + panner.panningModel = 'HRTF'; + panner.distanceModel = 'inverse'; + panner.refDistance = 2; + if (panner.positionX) { + panner.positionX.value = position[0]; + panner.positionY.value = position[1]; + panner.positionZ.value = position[2]; + } else panner.setPosition(position[0], position[1], position[2]); + panner.connect(dest); + dest = panner; + } + const length = buildGameSound(ctx, dest, name, now + 0.005); + live.push(now + length); + live.sort((a, b) => a - b); + debug.played++; + debug.last = name; + return true; +} + +/** + * Render one sound OFFLINE and measure it (the suite's "does every name make a sound"). + * @param {string} name @param {number} [sampleRate] + * @returns {Promise<{name: string, seconds: number, rms: number, peak: number} | null>} + */ +export async function renderGameSound(name, sampleRate = 22050) { + const Offline = typeof window !== 'undefined' ? /** @type {any} */ (window).OfflineAudioContext : null; + if (!Offline) return null; + const probe = new Offline(1, 64, sampleRate); + const seconds = buildGameSound(probe, probe.destination, name, 0); + if (seconds < 0) return { name, seconds: -1, rms: 0, peak: 0 }; + const ctx = new Offline(1, Math.ceil((seconds + 0.1) * sampleRate), sampleRate); + buildGameSound(ctx, ctx.destination, name, 0); + const buffer = await ctx.startRendering(); + const data = buffer.getChannelData(0); + let sum = 0; + let peak = 0; + for (let i = 0; i < data.length; i++) { + sum += data[i] * data[i]; + peak = Math.max(peak, Math.abs(data[i])); + } + return { name, seconds, rms: Math.sqrt(sum / data.length), peak }; +} + +/** the live gain's value (null before the first sound) — the volume's measurable end */ +export function gameSoundGainValue() { + return gameGain ? gameGain.gain.value : null; +} + +/** counts for the suites @returns {{played: number, dropped: number, unknown: number, last: string, live: number}} */ +export function gameSfxDebug() { + return { ...debug, live: live.length }; +} + +// the two building blocks, shared with the music scheduler (gameMusic.js) so a game's +// music and its effects are made of the same parts +export { tone as sfxTone, noise as sfxNoise }; diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index dff5a4d6..b2c81d6d 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -51,6 +51,12 @@ export { moduleContentDebug } from './moduleContent'; import { safeStorage } from './safeStorage'; // 30 P4: api.storage — a LEAF (safeStorage only), shared with the Store Value flow node import { makeModuleStorage } from './gameStorage'; +// 30b (vr-play) C5: the game sound set and game music — LEAVES (svelte/store, audioEngine, +// safeStorage, sessionClock, sceneStore), so static edges close no cycle +import { isGameSound, playGameSound } from './gameSfx'; +import { playGameMusic, stopGameMusic, gameMusicState, MUSIC_PRESET_IDS } from './gameMusic'; +/** the ping chimes `api.playSound` still reaches (pingAudio's PING_SOUNDS ids) */ +const PING_NAMES = new Set(['ding', 'chime', 'pluck', 'bell']); /** modules already told they hit the storage cap this session (ONE toast each, never * one per write — a game saving every frame would otherwise bury the screen) */ @@ -1020,10 +1026,37 @@ function makeApi(moduleId, moduleName = moduleId) { flyTo(position, lookAt) { objectActionsRef?.flyTo(position, lookAt ?? position); }, - /** A spatial UI chime (the ping sounds). LOCAL — broadcast your own op if - * peers should hear it too. @param {string=} sound @param {number[]=} position */ + /** + * A sound, LOCAL to this device — broadcast your own op if peers should hear it. + * 30b: the GAME set (procedural, no assets, the "Game sounds" volume): 'click', + * 'pop', 'whoosh', 'success', 'fail', 'hit', 'kick', 'shoot', 'laser', + * 'explosion', 'coin', 'levelup', 'goal', 'whistle', 'cheer', 'step', 'ring', + * 'sparkle', 'hurt', 'portal'; plus the ping chimes 'ding' (the default), + * 'chime', 'pluck', 'bell'. An unknown name is a quiet no-op. `position` + * spatialises it. Returns whether a sound started. + * @param {string=} sound @param {number[]=} position @returns {boolean} + */ playSound(sound = 'ding', position = undefined) { - pingAudioRef?.playPing(sound, position ?? null); + const name = String(sound ?? 'ding'); + if (isGameSound(name)) return playGameSound(name, position ?? null); + if (!PING_NAMES.has(name)) return false; + pingAudioRef?.playPing(name, position ?? null); + return !!pingAudioRef; + }, + /** + * 30b: game MUSIC — procedural loops, LOCAL to this device, tempo-synced to the + * session clock (two peers on one preset hear the same bar), under the effects + * and on the "Music" volume. Plays only in Interact/Play (a call from Edit returns + * false) and stops by itself when the player leaves the game. + * Presets: 'arcade', 'ambient', 'dungeon', 'stadium', 'space', 'puzzle', 'studio'. + */ + music: { + /** @param {string} preset @param {{volume?: number}=} options 0..1 @returns {boolean} */ + play: (preset, options = {}) => playGameMusic(preset, options ?? {}), + stop: () => stopGameMusic(), + /** the preset playing now, or null @returns {string | null} */ + current: () => get(gameMusicState)?.preset ?? null, + presets: () => [...MUSIC_PRESET_IDS] }, /** Park the editor camera behind an object and follow it (the car's chase * cam) — LOCAL, no selection, no undo. @param {string} uuid */ diff --git a/tests/e2e/game-audio.test.cjs b/tests/e2e/game-audio.test.cjs new file mode 100644 index 00000000..67d29852 --- /dev/null +++ b/tests/e2e/game-audio.test.cjs @@ -0,0 +1,190 @@ +// 30b (vr-play) P0 — C5 SOUND AND MUSIC. The user, from a Quest: "There is also no score, +// no sounds, fix this" / "Make for each game improvements, add sounds, music". +// +// 1 every name in the game sound set RENDERS a real sound (offline, measured) · 2 the SDK +// routes playSound: a game name plays, a ping name still plays, an unknown name is a quiet +// no-op · 3 a game sound reaches the speakers (the destination tap, measured live) · 4 every +// music preset renders a real bar · 5 music is REFUSED in Edit · 6 music plays in Interact, +// on the SESSION step grid, audibly · 7 leaving Interact stops it and the output goes +// silent · 8 the same in desktop Play · 9 the two volumes are LOCAL, persisted, and reach +// the gains · 10 the Settings rows drive the stores. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.AUDIO_ARGS }); + const A = await h.setupPage(browser, 'A', { audio: true }); + const page = A.page; + + await page.evaluate(async () => { + const s = window.__stores; + await s.moduleSDK.initModules([ + { id: 'audio30b', name: 'Audio test', version: '1.0.0', description: 'the 30b sound seams', register(api) { window.__api = api; } } + ]); + }); + + console.log('\n=== 1. every game sound renders a real sound ==='); + const renders = await page.evaluate(async () => { + const k = window.__stores.gameKit.gameSfx; + const out = []; + for (const name of k.GAME_SOUNDS) out.push(await k.renderGameSound(name)); + return out; + }); + h.check(renders.length === 20, '1.1 the set holds the 20 contract names (' + renders.length + ')'); + const quiet = renders.filter((r) => !r || r.rms < 0.004 || r.peak < 0.02); + h.check(quiet.length === 0, '1.2 every name renders energy (quiet: ' + JSON.stringify(quiet.map((r) => r && r.name)) + ')'); + const distinct = new Set(renders.map((r) => r && Math.round(r.rms * 1e5))); + h.check(distinct.size >= 18, '1.3 the sounds are distinct from each other (' + distinct.size + ' distinct levels)'); + const unknownRender = await page.evaluate(() => window.__stores.gameKit.gameSfx.renderGameSound('nope')); + h.check(unknownRender && unknownRender.seconds === -1 && unknownRender.rms === 0, '1.4 an unknown name builds nothing'); + + console.log('\n=== 2. the SDK routes playSound ==='); + const routed = await page.evaluate(async () => { + const k = window.__stores.gameKit.gameSfx; + const before = k.gameSfxDebug(); + const coin = window.__api.playSound('coin', [0, 1, -2]); + const mid = k.gameSfxDebug(); + const unknown = window.__api.playSound('definitely-not-a-sound'); + const after = k.gameSfxDebug(); + // the ping chimes still play through their own module (primed at boot) + await new Promise((r) => setTimeout(r, 300)); + const ding = window.__api.playSound('ding'); + return { coin, unknown, ding, played: mid.played - before.played, playedAfter: after.played - mid.played, last: mid.last }; + }); + h.check(routed.coin === true && routed.played === 1 && routed.last === 'coin', '2.1 playSound("coin") plays the game coin (' + JSON.stringify(routed) + ')'); + h.check(routed.unknown === false && routed.playedAfter === 0, '2.2 an unknown name is a quiet no-op (it used to fall through to the ding)'); + h.check(routed.ding === true, '2.3 a ping name still plays'); + + console.log('\n=== 3. a game sound reaches the speakers ==='); + await page.waitForTimeout(1200); // let 2's sounds ring out + await page.evaluate(() => window.__api.playSound('explosion')); + const live = await h.audioMetrics(A, 500); + h.check(!live.silent && live.peak > 0.01, '3.1 the explosion is audible at the destination (peak ' + live.peak.toFixed(4) + ')'); + + console.log('\n=== 4. every music preset renders a real bar ==='); + const bars = await page.evaluate(async () => { + const m = window.__stores.gameKit.gameMusic; + const out = []; + for (const id of m.MUSIC_PRESET_IDS) out.push(await m.renderGameMusic(id, 1)); + return out; + }); + h.check(bars.length === 7, '4.1 seven presets (' + bars.map((b) => b && b.preset).join(',') + ')'); + const flat = bars.filter((b) => !b || b.rms < 0.003 || b.notes < 4); + h.check(flat.length === 0, '4.2 every preset plays notes with energy (' + JSON.stringify(bars.map((b) => b && [b.preset, +b.rms.toFixed(4), b.notes])) + ')'); + + await page.waitForTimeout(1500); // the explosion's tail + console.log('\n=== 5. music is refused in Edit ==='); + const refused = await page.evaluate(() => { + const s = window.__stores; + s.objectActions.setEditorMode('edit'); + return { ok: window.__api.music.play('arcade'), current: window.__api.music.current() }; + }); + h.check(refused.ok === false && refused.current === null, '5.1 music.play from the editor returns false and plays nothing'); + const editQuiet = await h.audioMetrics(A, 400); + h.check(editQuiet.silent, '5.2 the output is silent (peak ' + editQuiet.peak.toFixed(4) + ')'); + + console.log('\n=== 6. music plays in Interact, on the session step grid ==='); + const started = await page.evaluate(() => { + const s = window.__stores; + s.objectActions.setEditorMode('interact'); + return { ok: window.__api.music.play('arcade', { volume: 0.8 }), current: window.__api.music.current(), unknown: window.__api.music.play('polka') }; + }); + h.check(started.ok === true && started.current === 'arcade', '6.1 music.play("arcade") starts in Interact'); + h.check(started.unknown === false && (await page.evaluate(() => window.__api.music.current())) === 'arcade', '6.2 an unknown preset is refused and the current one keeps playing'); + await page.waitForTimeout(900); + const grid = await page.evaluate(() => { + const k = window.__stores.gameKit; + const d = k.gameMusic.gameMusicDebug(); + const stepMs = k.gameMusicPresets.stepSeconds(k.gameMusicPresets.musicPreset('arcade')) * 1000; + const now = window.__stores.connectionState?.sessionNow?.() ?? null; + return { d, stepMs, now }; + }); + h.check(grid.d.steps >= 5 && grid.d.notes > 0, '6.3 the scheduler scheduled steps and notes (' + JSON.stringify(grid.d) + ')'); + const audible = await h.audioMetrics(A, 700); + h.check(!audible.silent && audible.peak > 0.005, '6.4 the music is audible (peak ' + audible.peak.toFixed(4) + ')'); + const synced = await page.evaluate(async () => { + const k = window.__stores.gameKit; + const { sessionNow } = await import('/src/lib/sessionClock.js'); + const d = k.gameMusic.gameMusicDebug(); + const stepMs = k.gameMusicPresets.stepSeconds(k.gameMusicPresets.musicPreset('arcade')) * 1000; + // the last scheduled step sits inside the lookahead window of the SESSION clock + const lead = d.lastStep * stepMs - sessionNow(); + return { lead, stepMs }; + }); + h.check(synced.lead > -synced.stepMs && synced.lead < 250, '6.5 steps are numbered off the session clock (last step leads now by ' + synced.lead.toFixed(1) + ' ms)'); + + console.log('\n=== 7. leaving Interact stops the music ==='); + const stopped = await page.evaluate(() => { + window.__stores.objectActions.setEditorMode('edit'); + return { current: window.__api.music.current(), gain: window.__stores.gameKit.gameMusic.gameMusicGainValue() }; + }); + h.check(stopped.current === null && stopped.gain === null, '7.1 back to Edit: nothing is playing'); + await page.waitForTimeout(700); + const afterStop = await h.audioMetrics(A, 500); + h.check(afterStop.silent, '7.2 the output went silent (peak ' + afterStop.peak.toFixed(4) + ')'); + + console.log('\n=== 8. the same in desktop Play ==='); + const playPath = await page.evaluate(async () => { + const s = window.__stores; + s.isLocked.set(true); + const ok = window.__api.music.play('dungeon'); + const during = window.__api.music.current(); + s.isLocked.set(false); + const after = window.__api.music.current(); + await new Promise((r) => setTimeout(r, 50)); + return { ok, during, after }; + }); + h.check(playPath.ok && playPath.during === 'dungeon', '8.1 music plays in Play'); + h.check(playPath.after === null, '8.2 leaving Play stops it'); + + console.log('\n=== 9. the volumes are local, persisted, and reach the gains ==='); + const vols = await page.evaluate(async () => { + const s = window.__stores; + const k = s.gameKit; + k.gameSfx.gameSoundVolume.set(0.25); + window.__api.playSound('click'); + const sfxGain = k.gameSfx.gameSoundGainValue(); + s.objectActions.setEditorMode('interact'); + window.__api.music.play('puzzle', { volume: 0.5 }); + k.gameMusic.gameMusicVolume.set(0.4); + const musicGain = k.gameMusic.gameMusicGainValue(); + window.__api.music.stop(); + s.objectActions.setEditorMode('edit'); + return { + sfxGain, + musicGain, + sfxKey: localStorage.getItem('game:soundVolume'), + musicKey: localStorage.getItem('game:musicVolume') + }; + }); + h.check(Math.abs(vols.sfxGain - 0.25) < 1e-6 && vols.sfxKey === '0.25', '9.1 the Game sounds volume sets the gain and persists (' + JSON.stringify(vols) + ')'); + h.check(Math.abs(vols.musicGain - 0.55 * 0.4 * 0.5) < 1e-6 && vols.musicKey === '0.4', '9.2 the Music volume (x the call volume, under the effects) sets the gain and persists'); + + console.log('\n=== 10. the Settings rows drive the stores ==='); + await page.evaluate(() => window.__stores.settingsOpen.set(true)); + await page.waitForTimeout(600); + // the Interface section holds the rows; open it if it is collapsed + const hasRow = await page.evaluate(() => !!document.querySelector('#setting-game-sound-volume')); + if (!hasRow) await page.getByText('Interface', { exact: true }).first().click().catch(() => {}); + await page.waitForSelector('#setting-game-sound-volume', { timeout: 5000 }).catch(() => {}); + const rows = await page.evaluate(() => { + const s = window.__stores.gameKit; + const set = (id, v) => { + const el = document.querySelector(id); + if (!el) return false; + el.value = String(v); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return true; + }; + const a = set('#setting-game-sound-volume', 0.6); + const b = set('#setting-game-music-volume', 0.35); + let sv, mv; + s.gameSfx.gameSoundVolume.subscribe((v) => (sv = v))(); + s.gameMusic.gameMusicVolume.subscribe((v) => (mv = v))(); + return { a, b, sv, mv }; + }); + h.check(rows.a && rows.b, '10.1 both rows render in Settings'); + h.check(Math.abs(rows.sv - 0.6) < 1e-6 && Math.abs(rows.mv - 0.35) < 1e-6, '10.2 dragging them sets the stores (' + JSON.stringify(rows) + ')'); + + await h.finish(browser); +}); diff --git a/tests/unit/gameMusicPresets.test.js b/tests/unit/gameMusicPresets.test.js new file mode 100644 index 00000000..4ba951bd --- /dev/null +++ b/tests/unit/gameMusicPresets.test.js @@ -0,0 +1,62 @@ +// 30b (vr-play) C5: the game-music presets as data, with no browser — every preset the +// contract names exists, every lane is a real 16-step bar, the triads are built inside the +// mode, and a step is a pure function of (preset, step), which is what makes two peers on +// one preset hear the same bar. +import { describe, it, expect } from 'vitest'; +import { MUSIC_PRESETS, MUSIC_PRESET_IDS, musicPreset, stepEvents, triadOn, stepSeconds } from '../../src/lib/gameMusicPresets.js'; + +describe('game music presets', () => { + it('ships exactly the contract presets', () => { + expect([...MUSIC_PRESET_IDS].sort()).toEqual(['ambient', 'arcade', 'dungeon', 'puzzle', 'space', 'stadium', 'studio']); + expect(musicPreset('nope')).toBeNull(); + }); + + it('every lane is a 16-step bar and the tempo is sane', () => { + for (const p of MUSIC_PRESETS) { + expect(p.bass.length).toBe(16); + for (const lane of Object.values(p.drums)) if (lane) expect(lane.length).toBe(16); + if (p.arp) expect(p.arp.lane.length).toBe(16); + expect(p.bpm).toBeGreaterThanOrEqual(60); + expect(p.bpm).toBeLessThanOrEqual(160); + expect(stepSeconds(p)).toBeCloseTo(60 / p.bpm / 4, 9); + } + }); + + it('triads stack thirds inside the mode (minor i is minor, major I is major)', () => { + const arcade = /** @type {any} */ (musicPreset('arcade')); // C major + expect(triadOn(arcade, 0)).toEqual([60, 64, 67]); + expect(triadOn(arcade, 4)).toEqual([67, 71, 74]); // G major + const studio = /** @type {any} */ (musicPreset('studio')); // G minor + const [r, t, f] = triadOn(studio, 0); + expect(t - r).toBe(3); + expect(f - r).toBe(7); + }); + + it('every preset plays notes in every bar of its loop', () => { + for (const p of MUSIC_PRESETS) { + for (let bar = 0; bar < p.progression.length; bar++) { + let notes = 0; + for (let i = 0; i < 16; i++) notes += stepEvents(p, bar * 16 + i).length; + expect(notes, p.id + ' bar ' + bar).toBeGreaterThan(0); + } + } + }); + + it('a step is a pure function of (preset, step) and loops with the progression', () => { + const space = /** @type {any} */ (musicPreset('space')); + const loop = space.progression.length * 16; + for (const step of [0, 5, 17, 40, 63]) { + expect(stepEvents(space, step)).toEqual(stepEvents(space, step)); + expect(stepEvents(space, step)).toEqual(stepEvents(space, step + loop)); + } + // the chord changes with the bar: the bass root of bar 0 differs from bar 1 + const root = (/** @type {number} */ step) => stepEvents(space, step).find((e) => e.kind === 'bass')?.midi; + expect(root(0)).not.toEqual(root(16)); + }); + + it('the pad holds the bar triad on its downbeat only', () => { + const ambient = /** @type {any} */ (musicPreset('ambient')); + expect(stepEvents(ambient, 0).some((e) => e.kind === 'pad')).toBe(true); + expect(stepEvents(ambient, 1).some((e) => e.kind === 'pad')).toBe(false); + }); +}); From 552d09020ec88830c89dcf3282635cd3b8f622d3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 14:39:58 +0300 Subject: [PATCH 02/18] [feat] 30b P1: haptics (C4) - silent in Edit, api.hapticPattern, tap/bump/hit/knock defaults in Interact/Play - vrControls.hapticPulse is now a NO-OP in Edit (gameFeelActive), the one funnel every core pulse and api.haptic go through - "the vibration should be only interactive mode, not in edit mode" (the user, Quest 3). A 64-pulse debug ring + a suppressed count (hapticDebug) is the suites' view, since no headset is attached headless. - hapticPatterns.js (pure, vitest): tap, bump, hit, success, fail, rumble, heartbeat as timed, never-overlapping pulses (a new actuator pulse REPLACES the running one), a scale, and knockHapticScale. vrControls.hapticPattern plays one through setTimeout, each pulse re-gated, so leaving the game mid-rumble stops it. - api.hapticPattern(name, hand?) -> boolean (false in Edit / unknown name). - defaults: a VR grip GRAB lands with 'hit'; a KNOCK plays 'hit' sized by its closing speed (hapticKnock, the knock seam Scene hands knock.js); vrGameInput.js (new, plugs in through vrControls' frame + trigger hook registries, started from Scene) taps a hand when its laser ENTERS something clickable and bumps it when the trigger lands there. "Clickable" is one resolver for tap, bump and the P4 sweep: module interactive-group content, a part of a module device (userData.device - the piano key, not the piano), an On Click target, or userData.clickable === true. - flowRuntime: objectHasOnClick(uuid) (fireObjectClick's own two rules), and fireObjectClick returns how many nodes it reached (additive). Suite game-haptics (new, a stubbed XR session with recording actuators): 18/18. vitest hapticPatterns 5/5 (all vitest 238 green). Counterfactuals (four guards broken in one run): - the Edit gate removed -> 1.1, 1.2, 3.4 red (3 pulses in Edit; rumble played 4 of 4 after leaving) - the hover edge removed (tap every frame) -> 4.2 red (2 extra taps on one target) - every object treated as clickable -> 4.3, 5.2 red - the knock scale ignored -> 6.2 red (0.850 -> 0.850) Held: hud-actions 65/0, game-editor-flow 41/0, sdk-game-seams 78/0, module-toolbox 46/0, editor-modes 45/0. svelte-check 334/47 (base 335: the JSDoc on hapticPulse's forEach removed a pre-existing implicit-any; no new message). Build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 9 +- src/lib/flowRuntime.js | 15 ++- src/lib/gameKit.js | 2 + src/lib/hapticPatterns.js | 78 ++++++++++++ src/lib/moduleSDK.js | 12 ++ src/lib/vrControls.js | 55 ++++++++- src/lib/vrGameInput.js | 195 ++++++++++++++++++++++++++++++ tests/e2e/game-haptics.test.cjs | 173 ++++++++++++++++++++++++++ tests/unit/hapticPatterns.test.js | 45 +++++++ 9 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 src/lib/hapticPatterns.js create mode 100644 src/lib/vrGameInput.js create mode 100644 tests/e2e/game-haptics.test.cjs create mode 100644 tests/unit/hapticPatterns.test.js diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index dde54080..10a1cfa4 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -54,7 +54,9 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticPulse } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock } from '$lib/vrControls'; + // 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4) + import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput'; import { vrKeyboardTarget } from '$lib/vrKeyboard'; import { measureMode, measureClick } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; @@ -1419,7 +1421,9 @@ // this seam rather than an import (knock.js stays off vrControls' 3500 lines), // and the two "what am I holding" reads keep a probe off its own carried object. // A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses - startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticPulse }); + startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticKnock }); + // 30b: game feel in VR (a frame hook + a trigger hook through vrControls' registries) + startVrGameInput(); xrControllers.forEach((controller) => { controller.addEventListener('select', onXRSelect); @@ -1434,6 +1438,7 @@ offKeyProbe(); // 30 P1 stopPlayInteract(); // 21-B B3 (releases any carried body with zero velocity) stopKnock(); // 24-A A1 + stopVrGameInput(); // 30b element.removeEventListener('pointerdown', onPointerDown); element.removeEventListener('contextmenu', onContextMenu); element.removeEventListener('webglcontextlost', onContextLost); diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 8f5db677..b2719782 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -2752,14 +2752,27 @@ export function fireModuleTrigger(type, match, opts) { } export function fireObjectClick(uuid) { + let fired = 0; // 30b: how many On Click nodes this reached (additive return) nodes.forEach((node) => { if (node.type !== 'onclick') return; // H1: an unwired OnClick inside the clicked object's own graph also fires - if (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid) + if (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid) { // 21-G4: a perPlayer On Click keeps its pulse LOCAL — that one bit is the // whole per-player collectible (see replicatesPulse) applyNodeTrigger(node.id, syncedNow(), replicatesPulse(node)); + fired++; + } }); + return fired; +} + +/** 30b (vr-play): would a click on `uuid` reach an On Click node? The same two rules + * fireObjectClick fires by — the VR hover tap and the sweep ask it before touching + * anything. @param {string} uuid @returns {boolean} */ +export function objectHasOnClick(uuid) { + return nodes.some( + (node) => node.type === 'onclick' && (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid) + ); } /** diff --git a/src/lib/gameKit.js b/src/lib/gameKit.js index a3e6c5ce..b2524374 100644 --- a/src/lib/gameKit.js +++ b/src/lib/gameKit.js @@ -5,3 +5,5 @@ export * as gameFeel from './gameFeel'; export * as gameSfx from './gameSfx'; export * as gameMusic from './gameMusic'; export * as gameMusicPresets from './gameMusicPresets'; +export * as hapticPatterns from './hapticPatterns'; +export * as vrGameInput from './vrGameInput'; diff --git a/src/lib/hapticPatterns.js b/src/lib/hapticPatterns.js new file mode 100644 index 00000000..f39b99e8 --- /dev/null +++ b/src/lib/hapticPatterns.js @@ -0,0 +1,78 @@ +// 30b (vr-play) C4 — HAPTIC PATTERNS as data. `api.hapticPattern(name, hand)` plays one +// of these on the Quest's controller actuators; core fires the defaults in Interact/Play +// (hover-enter `tap`, a press `bump`, a grab `hit`, a knock scaled by its impulse). +// +// A pattern is a list of PULSES, each `{at, intensity, ms}` with `at` in milliseconds +// from the start. A GamepadHapticActuator plays one pulse at a time and a new pulse +// REPLACES the one running, so a pattern is timed pulses, never overlapping ones — the +// schedule below is what vrControls plays through setTimeout. +// +// Imports NOTHING: the shapes are tested with no browser and no headset. How they FEEL is +// the on-device check (owed) — the numbers are a first pass at "make it cool". + +/** @typedef {{at: number, intensity: number, ms: number}} HapticPulse */ + +/** @type {Record} */ +export const HAPTIC_PATTERNS = { + // a fingertip on a button: the lightest thing that is still felt + tap: [{ at: 0, intensity: 0.18, ms: 12 }], + // a press landing: short and firm + bump: [{ at: 0, intensity: 0.45, ms: 28 }], + // a grab or a solid contact: a hard edge, then a short settle + hit: [ + { at: 0, intensity: 0.85, ms: 35 }, + { at: 55, intensity: 0.3, ms: 25 } + ], + // a win: three rising pulses + success: [ + { at: 0, intensity: 0.3, ms: 40 }, + { at: 90, intensity: 0.5, ms: 40 }, + { at: 180, intensity: 0.8, ms: 70 } + ], + // a miss: two heavy, flat pulses + fail: [ + { at: 0, intensity: 0.7, ms: 90 }, + { at: 160, intensity: 0.7, ms: 140 } + ], + // an engine / an explosion: a long grinding buzz, fading + rumble: [ + { at: 0, intensity: 1, ms: 120 }, + { at: 120, intensity: 0.75, ms: 120 }, + { at: 240, intensity: 0.5, ms: 120 }, + { at: 360, intensity: 0.28, ms: 140 } + ], + // lub-dub + heartbeat: [ + { at: 0, intensity: 0.65, ms: 45 }, + { at: 150, intensity: 0.4, ms: 55 } + ] +}; + +/** the names `api.hapticPattern` accepts */ +export const HAPTIC_PATTERN_NAMES = Object.keys(HAPTIC_PATTERNS); + +/** + * A pattern's pulses, clamped to what an actuator takes (0..1, 1..1000 ms). Unknown -> []. + * `scale` multiplies every intensity — how a knock's impulse sizes a `hit`. + * @param {string} name @param {number} [scale] @returns {HapticPulse[]} + */ +export function hapticSchedule(name, scale = 1) { + const pattern = HAPTIC_PATTERNS[String(name)]; + if (!pattern) return []; + const k = Number.isFinite(scale) ? Math.max(0, scale) : 1; + return pattern.map((p) => ({ + at: Math.max(0, p.at), + intensity: Math.min(1, Math.max(0, p.intensity * k)), + ms: Math.min(1000, Math.max(1, p.ms)) + })); +} + +/** + * The strength a KNOCK buzzes with: a light brush is a tap, a real swing a full hit. + * `speed` is the closing speed in m/s (knockMath's `approach`). + * @param {number} speed @returns {number} 0.2..1 + */ +export function knockHapticScale(speed) { + const s = Number.isFinite(speed) ? Math.max(0, speed) : 0; + return Math.min(1, 0.2 + s / 6); +} diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index b2c81d6d..b6c120b3 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -902,8 +902,20 @@ function makeApi(moduleId, moduleName = moduleId) { * @param {'left'|'right'=} hand */ haptic(intensity = 0.5, durationMs = 50, hand = undefined) { + // 30b: silent in EDIT mode (core's own gate) — vibration is for playing vrControlsRef?.hapticPulse?.(intensity, durationMs, hand); }, + /** + * 30b: a named haptic PATTERN — 'tap' (hover), 'bump' (a press), 'hit' (a grab, + * a contact), 'success', 'fail', 'rumble' (an engine, an explosion), 'heartbeat'. + * On one hand ('left'|'right') or both. LOCAL, Interact/Play only (false in Edit, + * on desktop nothing buzzes). Core already plays tap / bump / hit / knocks for + * you; use this for the game's own moments. + * @param {string} name @param {'left'|'right'=} hand @returns {boolean} + */ + hapticPattern(name, hand = undefined) { + return vrControlsRef?.hapticPattern?.(String(name), hand) ?? false; + }, /** * 24-A A2: every KNOCK this peer sees — its own hand's, and every peer's as the * `hit` message is applied — as `{uuid, by, at, speed, point, linvel, angvel, diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index eef68025..2e0a095e 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -42,6 +42,9 @@ import { peerHandStyle, pokeScene } from '../stores/sceneStore'; import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu'; import { paletteColorAt, barValueAt } from './vrPalette'; +// 30b (vr-play) C4: the ONE game-feel predicate (a leaf) + the pattern shapes (pure) +import { gameFeelActive } from './gameFeel'; +import { hapticSchedule, knockHapticScale } from './hapticPatterns'; import { recordMaterialChange, setMaterialParam } from './materialsHandler'; import { prefabs, instantiatePrefab } from './prefabs'; import { @@ -926,24 +929,71 @@ export function initVRControls(r) { renderer = r; } +/** 30b: what the actuators were asked for (a ring, newest last) and how many pulses the + * Edit-mode gate swallowed — the suites' view, since no headset is attached headless */ +const hapticRing = /** @type {{intensity: number, ms: number, hand: string | null, at: number}[]} */ ([]); +let hapticSuppressed = 0; + /** * Buzz the VR controllers if the session's gamepads support it (no-op on * desktop). Used by modules for press feedback. Optional `hand` targets one * controller — matched by each inputSource's OWN handedness (never a raw slot * index, which diverges from the controller order after a hands<->controllers * swap — 194/210; axesForSlot resolves the same way). + * + * 30b (C4): a NO-OP IN EDIT MODE — "The vibration should be only interactive mode, not + * in edit mode" (the user, from a Quest). Every core pulse and a module's api.haptic + * funnel through here, so this one gate covers all of them. * @param {number} intensity 0..1 @param {number} durationMs * @param {'left'|'right'=} hand omit to pulse both */ export function hapticPulse(intensity = 0.5, durationMs = 50, hand = undefined) { + if (!gameFeelActive()) { + hapticSuppressed++; + return; + } const session = renderer?.xr?.getSession?.(); - session?.inputSources?.forEach((source) => { + if (!session) return; + hapticRing.push({ intensity, ms: durationMs, hand: hand ?? null, at: performance.now() }); + if (hapticRing.length > 64) hapticRing.shift(); + session.inputSources?.forEach((/** @type {any} */ source) => { if (hand && source.handedness !== hand) return; const actuator = source.gamepad?.hapticActuators?.[0]; actuator?.pulse?.(intensity, durationMs); }); } +/** + * 30b (C4): play a named PATTERN ('tap' 'bump' 'hit' 'success' 'fail' 'rumble' + * 'heartbeat' — hapticPatterns.js) as timed pulses. `scale` sizes every pulse (a knock's + * impulse). Returns false for an unknown name or outside Interact/Play; each pulse is + * gated again as it fires, so leaving the game mid-pattern stops it. + * @param {string} name @param {'left'|'right'=} hand @param {number=} scale + * @returns {boolean} + */ +export function hapticPattern(name, hand = undefined, scale = 1) { + const pulses = hapticSchedule(name, scale); + if (!pulses.length || !gameFeelActive()) return false; + for (const pulse of pulses) { + if (pulse.at <= 0) hapticPulse(pulse.intensity, pulse.ms, hand); + else setTimeout(() => hapticPulse(pulse.intensity, pulse.ms, hand), pulse.at); + } + return true; +} + +/** 30b: the knock's haptic seam (Scene hands this to knock.js) — a `hit` sized by the + * knock's own strength, on the hand that hit. `intensity` arrives as knock.js computes + * it (0.2 + speed/10); read back as the speed it encodes. + * @param {number} intensity @param {number} _ms @param {'left'|'right'} hand */ +export function hapticKnock(intensity, _ms, hand) { + hapticPattern('hit', hand, knockHapticScale(Math.max(0, (Number(intensity) - 0.2) * 10))); +} + +/** the suites' view of the actuators @returns {{pulses: any[], suppressed: number}} */ +export function hapticDebug() { + return { pulses: hapticRing.map((p) => ({ ...p })), suppressed: hapticSuppressed }; +} + /** 194: resolve a controller slot by HANDEDNESS. three's getController(i) is a * persistent object; Scene stamps controller.userData.handedness from each * 'connected' event, so this survives a hands<->controllers reorder (the raw @@ -2291,7 +2341,8 @@ function onSqueezeStart(index) { before: transformStateOf(object) }; vrGrabbedHand.set(renderer.xr.getController(index)?.userData?.handedness ?? null); - hapticPulse(0.25, 30); + // 30b (C4): a grab lands with a `hit` (a gated no-op in Edit, like every pulse) + hapticPattern('hit', renderer.xr.getController(index)?.userData?.handedness ?? undefined); selectObject(object.uuid); // locks it for peers, updates selection state } diff --git a/src/lib/vrGameInput.js b/src/lib/vrGameInput.js new file mode 100644 index 00000000..0c858e3e --- /dev/null +++ b/src/lib/vrGameInput.js @@ -0,0 +1,195 @@ +// 30b (vr-play) — THE GAME IN YOUR HANDS, the VR input half: what a controller is pointing +// at or touching in a GAME, and what that feels like. P1 (C4) is the haptic defaults — +// a `tap` when the laser enters something clickable, a `bump` when the trigger lands on +// it; P4 (C3) grows the hold-and-sweep on the same resolver. +// +// "Clickable" is decided HERE, once, for the tap, the bump and the sweep alike: +// · anything under a module's scene-root INTERACTIVE group (a board, a dungeon door); +// · a part of a module DEVICE (`userData.device` on an ancestor — the music lab's piano +// keys, drum pads, transport buttons: every one is its own control); +// · an object an On Click node targets (flowRuntime's own rule), or one a module marked +// `userData.clickable = true`. +// The ENTRY KEY is what "once per entry" counts: the exact mesh for module content and +// device parts (a key is not its piano), the top-level object otherwise (an object with an +// On Click is one thing to press however many meshes it is built from). +// +// Everything is LOCAL and gated on gameFeelActive(): the editor never buzzes (the user's +// rule), and nothing here sends anything — a click that lands replicates through the paths +// it always did (module handlers, the On Click trigger). +// +// Plugs into vrControls through its generic registries (registerVRFrameHook, +// registerVRTriggerHooks) — the vrSleeve precedent — so vrControls never imports it. +// Started from Scene.svelte (`startVrGameInput`), which already imports everything here. +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { objectsGroup, globalScene, globalRenderer } from '../stores/sceneStore'; +import { gameFeelActive } from './gameFeel'; +import { sceneHits } from './scenePick'; +import { pickStack, primaryIndex } from './selectThrough'; +import { topLevelObjectOf } from './objectActions'; +import { objectHasOnClick } from './flowRuntime'; +import { moduleInteractiveGroups } from './moduleSDK'; +import { registerVRFrameHook, registerVRTriggerHooks, hapticPattern } from './vrControls'; + +const _mat = new THREE.Matrix4(); + +/** + * @typedef {{kind: 'object', key: string, mesh: any, top: any | null, point: any, distance: number, group: string | null}} ClickTarget + */ + +/** the scene-root interactive group a mesh sits under, or null @param {any} mesh */ +function interactiveGroupOf(mesh) { + const scene = get(globalScene); + for (let node = mesh; node && node !== scene; node = node.parent) { + if (node.parent === scene && moduleInteractiveGroups.includes(node.name)) return node.name; + } + return null; +} + +/** is there a module device on the path from the mesh up to `top`? @param {any} mesh @param {any} top */ +function deviceBetween(mesh, top) { + for (let node = mesh; node; node = node.parent) { + if (node.userData?.device) return true; + if (node === top) break; + } + return false; +} + +/** + * Resolve a raycast/overlap HIT to the thing a press would reach — or null when nothing + * clickable is there. Pure over the scene; exported for the suites. + * @param {any} hit a THREE intersection ({object, point, distance}) + * @returns {ClickTarget | null} + */ +export function clickTargetOf(hit) { + const mesh = hit?.object; + if (!mesh) return null; + const group = interactiveGroupOf(mesh); + if (group) return { kind: 'object', key: mesh.uuid, mesh, top: null, point: hit.point, distance: hit.distance ?? 0, group }; + const top = topLevelObjectOf(mesh); + if (!top) return null; + if (deviceBetween(mesh, top)) return { kind: 'object', key: mesh.uuid, mesh, top, point: hit.point, distance: hit.distance ?? 0, group: null }; + if (top.userData?.clickable === true || objectHasOnClick(top.uuid)) + return { kind: 'object', key: top.uuid, mesh, top, point: hit.point, distance: hit.distance ?? 0, group: null }; + return null; +} + +/** + * The nearest clickable along a ray: module interactive groups first (the viewport click's + * order), then the scene with the see-through rule (a faint wall does not take the press). + * @param {any} ray a THREE.Raycaster @returns {ClickTarget | null} + */ +export function clickTargetAlong(ray) { + /** @type {ClickTarget | null} */ + let best = null; + const scene = get(globalScene); + for (const name of moduleInteractiveGroups) { + const root = scene?.getObjectByName(name); + if (!root) continue; + const hits = ray.intersectObject(root, true).filter((/** @type {any} */ h) => h.object.visible !== false); + const target = hits.length ? clickTargetOf(hits[0]) : null; + if (target && (!best || target.distance < best.distance)) best = target; + } + if (get(objectsGroup)) { + const stack = pickStack(sceneHits(ray), topLevelObjectOf); + const entry = stack.length ? stack[primaryIndex(stack)] : null; + const target = entry?.hit ? clickTargetOf(entry.hit) : null; + if (target && (!best || target.distance < best.distance)) best = target; + } + return best; +} + +/* -------------------------------------------------------------- controller rays ---- */ + +/** the world ray of three.js controller SLOT `index` @param {number} index */ +export function controllerRayOf(index) { + const renderer = /** @type {any} */ (get(globalRenderer)); + const controller = renderer?.xr?.getController?.(index); + if (!controller) return null; + const ray = new THREE.Raycaster(); + _mat.identity().extractRotation(controller.matrixWorld); + ray.ray.origin.setFromMatrixPosition(controller.matrixWorld); + ray.ray.direction.set(0, 0, -1).applyMatrix4(_mat); + return ray; +} + +/** 'left' | 'right' | undefined for a controller slot @param {number} index */ +export function handOf(index) { + const renderer = /** @type {any} */ (get(globalRenderer)); + const h = renderer?.xr?.getController?.(index)?.userData?.handedness; + return h === 'left' || h === 'right' ? h : undefined; +} + +/* ----------------------------------------------------------------- the defaults ---- */ + +/** what each slot's laser pointed at last frame (entry key) — the hover-enter edge */ +const hoverKey = /** @type {(string | null)[]} */ ([null, null]); +const stats = { taps: 0, bumps: 0 }; + +/** + * One frame of the hover edge for slot `index`: a NEW clickable under the laser taps that + * hand. Exported so the suites drive it with a posed controller (no XR session headless). + * @param {number} index @param {any} [ray] defaults to the controller's own ray + * @returns {ClickTarget | null} + */ +export function hoverFrame(index, ray = controllerRayOf(index)) { + if (!ray || !gameFeelActive()) { + hoverKey[index] = null; + return null; + } + const target = clickTargetAlong(ray); + const key = target?.key ?? null; + if (key && key !== hoverKey[index]) { + hapticPattern('tap', handOf(index)); + stats.taps++; + } + hoverKey[index] = key; + return target; +} + +/** + * A trigger PRESS on slot `index`: a bump when it lands on something clickable. Never + * consumes the press — the click itself still travels the normal select path. + * @param {number} index @param {any} [ray] @returns {boolean} whether it bumped + */ +export function pressFeedback(index, ray = controllerRayOf(index)) { + if (!ray || !gameFeelActive()) return false; + if (!clickTargetAlong(ray)) return false; + hapticPattern('bump', handOf(index)); + stats.bumps++; + return true; +} + +/** @returns {{taps: number, bumps: number, hover: (string | null)[]}} */ +export function vrGameInputDebug() { + return { ...stats, hover: [...hoverKey] }; +} + +/** @type {(() => void)[]} */ +let offs = []; + +/** Wire the defaults into the VR loop. Returns the stop. Idempotent. */ +export function startVrGameInput() { + stopVrGameInput(); + offs = [ + registerVRFrameHook(() => { + const renderer = /** @type {any} */ (get(globalRenderer)); + if (!renderer?.xr?.isPresenting) return; + hoverFrame(0); + hoverFrame(1); + }), + registerVRTriggerHooks({ + start: (/** @type {number} */ index) => { + pressFeedback(index); + return false; // feedback only — the press is not ours to take + } + }) + ]; + return stopVrGameInput; +} + +export function stopVrGameInput() { + for (const off of offs) off(); + offs = []; + hoverKey[0] = hoverKey[1] = null; +} diff --git a/tests/e2e/game-haptics.test.cjs b/tests/e2e/game-haptics.test.cjs new file mode 100644 index 00000000..28286ab1 --- /dev/null +++ b/tests/e2e/game-haptics.test.cjs @@ -0,0 +1,173 @@ +// 30b (vr-play) P1 — C4 HAPTICS. The user, from a Quest: "For games, also enable controller +// vibration, make it cool like haptic, so it would be good for users to interact with the +// world. The vibration should be only interactive mode, not in edit mode." +// +// No headset runs headless, so the XR session is a STUB whose gamepads carry recording +// actuators — every assertion reads what the actuator was actually asked to do. +// 1 Edit is silent (api.haptic, a core pulse, a pattern) · 2 Interact buzzes, on the named +// hand · 3 patterns play as timed pulses and stop when the player leaves mid-pattern · +// 4 the hover-enter TAP: once per entry, only on clickable things (On Click / device part / +// marked), never in Edit · 5 the press BUMP · 6 the knock is scaled by its strength. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + await page.evaluate(async () => { + const s = window.__stores; + await s.moduleSDK.initModules([ + { id: 'haptic30b', name: 'Haptic test', version: '1.0.0', description: 'the 30b haptic seams', register(api) { window.__api = api; } } + ]); + let renderer; + s.globalRenderer.subscribe((r) => (renderer = r))(); + window.__buzz = []; + const source = (hand) => ({ + handedness: hand, + gamepad: { buttons: [], axes: [], hapticActuators: [{ pulse: (i, ms) => window.__buzz.push({ hand, i, ms, t: performance.now() }) }] } + }); + const session = { inputSources: [source('left'), source('right')] }; + renderer.xr.getSession = () => session; + renderer.xr.getController(0).userData.handedness = 'left'; + renderer.xr.getController(1).userData.handedness = 'right'; + }); + const buzz = () => page.evaluate(() => window.__buzz.splice(0)); + const mode = (m) => page.evaluate((m) => window.__stores.objectActions.setEditorMode(m), m); + + console.log('\n=== 1. Edit mode is silent ==='); + await mode('edit'); + const edit = await page.evaluate(() => { + const s = window.__stores; + const before = s.vrControls.hapticDebug().suppressed; + window.__api.haptic(0.6, 50, 'left'); + s.vrControls.hapticPulse(0.4, 40); // a core pulse (menus, teleport, the sleeve) + const pattern = window.__api.hapticPattern('success', 'right'); + return { pattern, suppressed: s.vrControls.hapticDebug().suppressed - before }; + }); + await page.waitForTimeout(300); + const editBuzz = await buzz(); + h.check(editBuzz.length === 0, '1.1 no actuator pulsed in Edit (' + editBuzz.length + ')'); + h.check(edit.suppressed === 2 && edit.pattern === false, '1.2 the pulses were swallowed by the gate and the pattern refused (' + JSON.stringify(edit) + ')'); + + console.log('\n=== 2. Interact buzzes, on the named hand ==='); + await mode('interact'); + await page.evaluate(() => window.__api.haptic(0.6, 50, 'left')); + const one = await buzz(); + h.check(one.length === 1 && one[0].hand === 'left' && one[0].i === 0.6 && one[0].ms === 50, '2.1 api.haptic pulses the LEFT actuator in Interact (' + JSON.stringify(one) + ')'); + await page.evaluate(() => window.__stores.vrControls.hapticPulse(0.3, 20)); + const both = await buzz(); + h.check(both.length === 2 && both.some((b) => b.hand === 'left') && both.some((b) => b.hand === 'right'), '2.2 a pulse with no hand reaches both'); + + console.log('\n=== 3. patterns are timed pulses and stop when the player leaves ==='); + const ok = await page.evaluate(() => ({ s: window.__api.hapticPattern('success', 'right'), u: window.__api.hapticPattern('zzz') })); + await page.waitForTimeout(400); + const success = await buzz(); + h.check(ok.s === true && ok.u === false, '3.1 a known pattern plays, an unknown one is refused'); + h.check( + success.length === 3 && success.every((b) => b.hand === 'right') && success[0].i < success[1].i && success[1].i < success[2].i, + '3.2 success = three rising pulses on the right (' + JSON.stringify(success.map((b) => b.i)) + ')' + ); + h.check(success[2].t - success[0].t > 120, '3.3 they are spaced in time, not fired at once (' + (success[2].t - success[0].t).toFixed(0) + ' ms)'); + await page.evaluate(() => { + window.__api.hapticPattern('rumble', 'left'); + window.__stores.objectActions.setEditorMode('edit'); + }); + await page.waitForTimeout(600); + const cut = await buzz(); + h.check(cut.length === 1, '3.4 leaving Interact mid-rumble stops it after the first pulse (' + cut.length + ' of 4)'); + await mode('interact'); + + console.log('\n=== 4. the hover-enter tap ==='); + const ids = await page.evaluate(async () => { + const s = window.__stores; + const make = async (at) => { + const [uuid] = await window.__api.create('/create box 1 1 1', { at }); + return uuid; + }; + const clickable = await make([0, 1, -4]); + const plain = await make([3, 1, -4]); + const marked = await make([-3, 1, -4]); + await new Promise((r) => setTimeout(r, 400)); + window.__api.flow.addNodes({ graphId: clickable, nodes: [{ type: 'onclick', x: 40, y: 40, data: {} }] }); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + g.getObjectByProperty('uuid', marked).userData.clickable = true; + return { clickable, plain, marked }; + }); + await page.waitForTimeout(500); + const hover = await page.evaluate((ids) => { + const s = window.__stores; + const THREE = s.THREE; + const k = s.gameKit.vrGameInput; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const rayTo = (uuid) => { + const target = g.getObjectByProperty('uuid', uuid).getWorldPosition(new THREE.Vector3()); + const origin = new THREE.Vector3(target.x, 1, 2); + return new THREE.Raycaster(origin, target.clone().sub(origin).normalize()); + }; + const miss = new THREE.Raycaster(new THREE.Vector3(0, 30, 0), new THREE.Vector3(0, 1, 0)); + const out = {}; + out.first = k.hoverFrame(0, rayTo(ids.clickable))?.key ?? null; + out.a = window.__buzz.splice(0); + k.hoverFrame(0, rayTo(ids.clickable)); + k.hoverFrame(0, rayTo(ids.clickable)); + out.b = window.__buzz.splice(0); + out.plain = k.hoverFrame(0, rayTo(ids.plain))?.key ?? null; + out.c = window.__buzz.splice(0); + out.marked = k.hoverFrame(0, rayTo(ids.marked))?.key ?? null; + out.d = window.__buzz.splice(0); + k.hoverFrame(0, miss); + k.hoverFrame(0, rayTo(ids.clickable)); + out.e = window.__buzz.splice(0); + s.objectActions.setEditorMode('edit'); + k.hoverFrame(0, miss); + out.editKey = k.hoverFrame(0, rayTo(ids.clickable))?.key ?? null; + out.f = window.__buzz.splice(0); + s.objectActions.setEditorMode('interact'); + return out; + }, ids); + h.check(hover.first === ids.clickable && hover.a.length === 1 && hover.a[0].hand === 'left' && hover.a[0].i < 0.25, '4.1 the laser entering an On Click object taps that hand lightly (' + JSON.stringify(hover.a) + ')'); + h.check(hover.b.length === 0, '4.2 staying on it taps nothing more (' + hover.b.length + ')'); + h.check(hover.plain === null && hover.c.length === 0, '4.3 a plain object is not clickable: no tap'); + h.check(hover.marked === ids.marked && hover.d.length === 1, '4.4 an object marked userData.clickable taps'); + h.check(hover.e.length === 1, '4.5 leaving and coming back taps again (re-armed)'); + h.check(hover.editKey === null && hover.f.length === 0, '4.6 in Edit the hover resolves nothing and never taps'); + + console.log('\n=== 5. the press bump ==='); + const press = await page.evaluate((ids) => { + const s = window.__stores; + const THREE = s.THREE; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const rayTo = (uuid) => { + const target = g.getObjectByProperty('uuid', uuid).getWorldPosition(new THREE.Vector3()); + const origin = new THREE.Vector3(target.x, 1, 2); + return new THREE.Raycaster(origin, target.clone().sub(origin).normalize()); + }; + const k = s.gameKit.vrGameInput; + const hit = k.pressFeedback(1, rayTo(ids.clickable)); + const a = window.__buzz.splice(0); + const none = k.pressFeedback(1, rayTo(ids.plain)); + const b = window.__buzz.splice(0); + return { hit, a, none, b }; + }, ids); + h.check(press.hit && press.a.length === 1 && press.a[0].hand === 'right' && press.a[0].i >= 0.4, '5.1 a trigger landing on a clickable bumps the pressing hand (' + JSON.stringify(press.a) + ')'); + h.check(press.none === false && press.b.length === 0, '5.2 a press on nothing clickable does not'); + + console.log('\n=== 6. the knock is scaled by its strength ==='); + await page.evaluate(() => { + const v = window.__stores.vrControls; + v.hapticKnock(Math.min(1, 0.2 + 0.4 / 10), 30, 'right'); // a brush at 0.4 m/s + }); + await page.waitForTimeout(800); // headless timers run late — let the second pulse land + const soft = await buzz(); + await page.evaluate(() => window.__stores.vrControls.hapticKnock(Math.min(1, 0.2 + 6 / 10), 30, 'right')); // a swing at 6 m/s + await page.waitForTimeout(800); + const hard = await buzz(); + h.check(soft.length === 2 && hard.length === 2, '6.1 a knock plays the two-pulse hit (' + soft.length + ', ' + hard.length + ')'); + h.check(hard[0].i > soft[0].i * 2 && hard[0].i <= 1, '6.2 a harder knock buzzes harder (' + soft[0].i.toFixed(3) + ' -> ' + hard[0].i.toFixed(3) + ')'); + + await h.finish(browser); +}); diff --git a/tests/unit/hapticPatterns.test.js b/tests/unit/hapticPatterns.test.js new file mode 100644 index 00000000..28f4d547 --- /dev/null +++ b/tests/unit/hapticPatterns.test.js @@ -0,0 +1,45 @@ +// 30b (vr-play) C4: the haptic patterns as data — every contract name, pulses an actuator +// can take (never overlapping: a new pulse REPLACES the running one on a real actuator), +// the scale, and the knock strength curve. +import { describe, it, expect } from 'vitest'; +import { HAPTIC_PATTERN_NAMES, hapticSchedule, knockHapticScale } from '../../src/lib/hapticPatterns.js'; + +describe('haptic patterns', () => { + it('ships the contract presets', () => { + expect([...HAPTIC_PATTERN_NAMES].sort()).toEqual(['bump', 'fail', 'heartbeat', 'hit', 'rumble', 'success', 'tap']); + }); + + it('every pulse fits an actuator and pulses never overlap', () => { + for (const name of HAPTIC_PATTERN_NAMES) { + const pulses = hapticSchedule(name); + expect(pulses.length, name).toBeGreaterThan(0); + pulses.forEach((p, i) => { + expect(p.intensity).toBeGreaterThan(0); + expect(p.intensity).toBeLessThanOrEqual(1); + expect(p.ms).toBeGreaterThanOrEqual(1); + if (i > 0) expect(p.at, name + ' pulse ' + i).toBeGreaterThanOrEqual(pulses[i - 1].at + pulses[i - 1].ms); + }); + } + }); + + it('tap is the lightest and rumble the longest', () => { + const total = (/** @type {string} */ n) => hapticSchedule(n).reduce((s, p) => s + p.ms, 0); + const peak = (/** @type {string} */ n) => Math.max(...hapticSchedule(n).map((p) => p.intensity)); + for (const n of HAPTIC_PATTERN_NAMES) if (n !== 'tap') expect(peak('tap')).toBeLessThan(peak(n)); + for (const n of HAPTIC_PATTERN_NAMES) if (n !== 'rumble') expect(total('rumble')).toBeGreaterThan(total(n)); + }); + + it('unknown names are empty, scale clamps', () => { + expect(hapticSchedule('buzzz')).toEqual([]); + expect(hapticSchedule('hit', 10).every((p) => p.intensity <= 1)).toBe(true); + expect(hapticSchedule('hit', 0.5)[0].intensity).toBeCloseTo(0.425, 6); + expect(hapticSchedule('hit', -3).every((p) => p.intensity === 0)).toBe(true); + }); + + it('a knock scales from a brush to a full hit', () => { + expect(knockHapticScale(0)).toBeCloseTo(0.2, 6); + expect(knockHapticScale(2)).toBeGreaterThan(knockHapticScale(0.5)); + expect(knockHapticScale(50)).toBe(1); + expect(knockHapticScale(NaN)).toBeCloseTo(0.2, 6); + }); +}); From f47d9f246c73ea9852d9dee428307cddcf2f94a5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:16:18 +0300 Subject: [PATCH 03/18] [feat] 30b P2: effects and announce (C6) - pooled particle bursts, api.announce banner - effectsBurst.js: api.effects.burst([x,y,z], {kind, color, count}) - sparkle / confetti / smoke / sparks as a POOL of 12 THREE.Points systems (96 particles each) at the scene root (never objectsGroup - golden rule 5), recycled oldest-first, LOCAL (nothing on the wire). Deterministic spread (a golden-angle spiral), per-kind motion (gravity, drag, rise, swell), additive kinds fade by darkening. Ticked from the flow runtime's frame loop (it runs in a headset, where window rAF stops) and leaving it when idle; the AGE is real time, so a burst lives its life whatever the frame rate (a throttled tab ticks ~5/s). - gameAnnounce.js (leaf) + api.announce(text, {sub, ms, color}): a big centred banner, ONE at a time (a new one replaces), gone by itself; HudLayer draws it on the desktop (#game-announce, pointer-events none, stands down in VR - P3 draws it head-locked). - a scene clear (runSceneClearHandlers) takes the live bursts and the banner with it. - vrControls.hapticPulse gains an additive `force` (4th arg) for 30b-vr-modes' Edit/Interact switch tick, the one pulse that must be felt IN Edit (asked for by that lane). Suite game-effects (new): 22/22. Counterfactuals (four broken in one run): - the burst never joins the frame loop -> 3.1, 3.2 red, and 6.1 (the frame changed 60 px, not 3709) - the pool grows without bound -> 4.1, 4.2 red (21 systems for 20 bursts) - the banner timer never clears -> 7.4 red - the scene-clear hooks removed -> 8.2 red Held: hud-actions 65/0, game-editor-flow 41/0, sdk-game-seams 78/0, module-toolbox 46/0, editor-modes 45/0. svelte-check 334/47, no new message. Build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/hud/HudLayer.svelte | 56 ++++++ src/lib/effectsBurst.js | 291 +++++++++++++++++++++++++++++ src/lib/gameAnnounce.js | 49 +++++ src/lib/gameKit.js | 2 + src/lib/moduleSDK.js | 33 ++++ src/lib/vrControls.js | 7 +- tests/e2e/game-effects.test.cjs | 220 ++++++++++++++++++++++ 7 files changed, 656 insertions(+), 2 deletions(-) create mode 100644 src/lib/effectsBurst.js create mode 100644 src/lib/gameAnnounce.js create mode 100644 tests/e2e/game-effects.test.cjs diff --git a/src/components/hud/HudLayer.svelte b/src/components/hud/HudLayer.svelte index c1659958..f875bee4 100644 --- a/src/components/hud/HudLayer.svelte +++ b/src/components/hud/HudLayer.svelte @@ -26,6 +26,9 @@ import { claimInput, releaseInput, onInput } from '$lib/inputRuntime'; import { gamepadPrefs } from '$lib/gamepadPrefs'; import { fireHudButton } from '$lib/flowRuntime'; + // 30b (vr-play): api.announce's banner — drawn here on the desktop, and by the VR game + // panel's head-locked banner in a headset (DOM is invisible there) + import { gameAnnouncement } from '$lib/gameAnnounce'; // 21-D5: WHICH documents are on screen — the scene HUD, plus the one keyed by the // camera being looked through (attaching a HUD to a camera IS keying it by that @@ -343,6 +346,15 @@ } +{#if $gameAnnouncement && !$isVRMode && layerAllowed} + {#key $gameAnnouncement.id} +
+
{$gameAnnouncement.text}
+ {#if $gameAnnouncement.sub}
{$gameAnnouncement.sub}
{/if} +
+ {/key} +{/if} + {#if anyVisible}
{#each elements as el (el.__key + ':' + el.id)} @@ -380,6 +392,50 @@ .hud-slot { position: absolute; } + /* 30b: api.announce — a big centred banner over the game, never clickable, gone by + itself (the store clears it; the fade only dresses the exit) */ + .game-announce { + position: fixed; + left: 50%; + top: 32%; + transform: translate(-50%, -50%); + z-index: var(--z-hud, 45); + pointer-events: none; + text-align: center; + padding: 18px 40px; + border-radius: 18px; + background: rgb(10 14 22 / 0.72); + box-shadow: 0 10px 40px rgb(0 0 0 / 0.45); + animation: game-announce-in 220ms ease-out, game-announce-out 300ms ease-in calc(var(--announce-ms, 1800ms) - 300ms) forwards; + } + .game-announce-title { + font-size: clamp(32px, 6vw, 72px); + font-weight: 800; + letter-spacing: 0.02em; + line-height: 1.05; + text-shadow: 0 2px 12px rgb(0 0 0 / 0.6); + } + .game-announce-sub { + margin-top: 6px; + font-size: clamp(14px, 2vw, 22px); + color: #e5e7eb; + } + @keyframes game-announce-in { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.85); + } + } + @keyframes game-announce-out { + to { + opacity: 0; + } + } + @media (prefers-reduced-motion: reduce) { + .game-announce { + animation: none; + } + } /* 30 P1: a previewed GAME screen is a picture of the menu, not the menu — every click goes through it to the viewport (the elements opt back INTO pointer events one by one, so the override has to reach them) */ diff --git a/src/lib/effectsBurst.js b/src/lib/effectsBurst.js new file mode 100644 index 00000000..4032f4c4 --- /dev/null +++ b/src/lib/effectsBurst.js @@ -0,0 +1,291 @@ +// 30b (vr-play) C6 — `api.effects.burst([x,y,z], {kind, color, count})`: a short-lived +// particle burst for a game's moments — the sparkle when a Towers ring is reached, the +// confetti on a goal, smoke off an explosion, sparks off a hit. +// +// POOLED: a fixed set of THREE.Points systems (POOL_SIZE x MAX_COUNT particles) built on +// first use and recycled oldest-first, so a burst per frame costs no allocation and no GPU +// upload of a new geometry — only the attribute ranges it rewrites. LOCAL by contract: +// nothing is sent (a module that wants peers to see it broadcasts its own op), and the +// systems live at the SCENE ROOT under fixed names (golden rule 5: objectsGroup is +// replicated, the scene root is local), so no burst can leak into a save or a GLTF sync. +// +// Ticked from the flow runtime's frame loop (the module frame-task list — it runs on the +// desktop AND in a headset, where window rAF stops), and parked invisible between bursts. +// `depthWrite: false` is right for soft sprites; a burst is short enough that the AO/outline +// trade-off (the documented depthWrite trap) does not matter. +// +// Kinds (the motion model per kind is the only thing that differs): +// sparkle gold twinkling stars that drift up, additive +// confetti many-coloured flakes thrown up, falling under gravity, normal blending +// smoke grey puffs that rise, swell and thin +// sparks fast orange streaks, gravity, very short +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { globalScene } from '../stores/sceneStore'; +import { moduleFrameTasks } from './moduleSDK'; + +export const BURST_KINDS = ['sparkle', 'confetti', 'smoke', 'sparks']; +export const POOL_SIZE = 12; +export const MAX_COUNT = 96; + +/** + * The per-kind recipe. Pure data; `life` in seconds, speeds in m/s, `size` in metres. + * @type {Record} + */ +export const BURST_RECIPES = { + sparkle: { count: 40, life: 1.1, speed: 1.2, up: 0.8, gravity: -0.4, drag: 1.6, size: 0.09, grow: -0.5, additive: true, color: '#ffd76a' }, + confetti: { count: 80, life: 2.2, speed: 3.2, up: 3.2, gravity: -5.5, drag: 1.2, size: 0.07, grow: 0, additive: false, color: '#ffffff', palette: ['#ef4444', '#f59e0b', '#22c55e', '#3b82f6', '#a855f7', '#ec4899'] }, + smoke: { count: 28, life: 2.4, speed: 0.5, up: 0.9, gravity: 0.3, drag: 0.8, size: 0.35, grow: 1.4, additive: false, color: '#9ca3af' }, + sparks: { count: 60, life: 0.55, speed: 5.5, up: 1.5, gravity: -9.8, drag: 0.6, size: 0.045, grow: -0.6, additive: true, color: '#ffa53a' } +}; + +/** + * Where each particle starts heading: a deterministic spread over the sphere (a golden- + * angle spiral), so a burst looks the same every time and the maths is testable. Returns + * unit directions with the upward kick of the recipe folded into y later. + * @param {number} n @returns {number[][]} + */ +export function burstDirections(n) { + /** @type {number[][]} */ + const out = []; + const golden = Math.PI * (3 - Math.sqrt(5)); + for (let i = 0; i < n; i++) { + const y = 1 - (2 * (i + 0.5)) / n; + const r = Math.sqrt(Math.max(0, 1 - y * y)); + const a = golden * i; + out.push([Math.cos(a) * r, y, Math.sin(a) * r]); + } + return out; +} + +/** @type {THREE.Texture | null} */ +let sprite = null; +/** a soft round dot, drawn once (a square point reads as a pixel, not a particle) */ +function spriteTexture() { + if (sprite) return sprite; + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 64; + const g = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')); + const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, 'rgba(255,255,255,1)'); + grad.addColorStop(0.35, 'rgba(255,255,255,0.85)'); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + g.fillStyle = grad; + g.fillRect(0, 0, 64, 64); + sprite = new THREE.CanvasTexture(canvas); + sprite.colorSpace = THREE.SRGBColorSpace; + return sprite; +} + +/** + * @typedef {object} BurstSystem + * @property {THREE.Points} points + * @property {Float32Array} pos @property {Float32Array} vel @property {Float32Array} col + * @property {Float32Array} base the per-particle start colour (col fades from it) + * @property {number} count @property {number} age @property {number} life + * @property {any} recipe @property {number} startedAt + */ + +/** @type {BurstSystem[]} */ +const pool = []; +let lastTick = 0; +let ticking = false; +const stats = { fired: 0, recycled: 0 }; + +/** @param {number} index @returns {BurstSystem} */ +function makeSystem(index) { + const geometry = new THREE.BufferGeometry(); + const pos = new Float32Array(MAX_COUNT * 3); + const col = new Float32Array(MAX_COUNT * 3); + geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage)); + geometry.setAttribute('color', new THREE.BufferAttribute(col, 3).setUsage(THREE.DynamicDrawUsage)); + geometry.setDrawRange(0, 0); + const material = new THREE.PointsMaterial({ + size: 0.1, + map: spriteTexture(), + vertexColors: true, + transparent: true, + depthWrite: false, + sizeAttenuation: true + }); + const points = new THREE.Points(geometry, material); + points.name = 'effects-burst-' + index; + points.frustumCulled = false; // the particles fly out of any bounds computed at t=0 + points.visible = false; + points.renderOrder = 996; + points.userData.localOnly = true; + return { + points, + pos, + vel: new Float32Array(MAX_COUNT * 3), + col, + base: new Float32Array(MAX_COUNT * 3), + count: 0, + age: 0, + life: 0, + recipe: null, + startedAt: 0 + }; +} + +/** the free system, or the oldest running one @returns {BurstSystem | null} */ +function takeSystem() { + const scene = /** @type {any} */ (get(globalScene)); + if (!scene) return null; + while (pool.length < POOL_SIZE) pool.push(makeSystem(pool.length)); + for (const sys of pool) if (sys.points.parent !== scene) scene.add(sys.points); + const idle = pool.find((sys) => !sys.points.visible); + if (idle) return idle; + stats.recycled++; + return pool.reduce((a, b) => (a.startedAt <= b.startedAt ? a : b)); +} + +/** @param {string | undefined} kind */ +function recipeOf(kind) { + return BURST_RECIPES[kind && BURST_RECIPES[kind] ? kind : 'sparkle']; +} + +/** + * Fire a burst at a world position. LOCAL. Returns the system's name, or null when there + * is no scene or the position is not three finite numbers. + * @param {number[]} position @param {{kind?: string, color?: string, count?: number}} [options] + * @returns {string | null} + */ +export function burst(position, options = {}) { + if (!Array.isArray(position) || position.length < 3 || !position.slice(0, 3).every((n) => Number.isFinite(Number(n)))) return null; + const sys = takeSystem(); + if (!sys) return null; + const recipe = recipeOf(options?.kind); + const wanted = Number(options?.count); + const count = Math.max(1, Math.min(MAX_COUNT, Math.round(Number.isFinite(wanted) && wanted > 0 ? wanted : recipe.count))); + const dirs = burstDirections(count); + const tint = new THREE.Color(); + const custom = typeof options?.color === 'string' && options.color ? options.color : null; + for (let i = 0; i < count; i++) { + const d = dirs[i]; + // a small per-particle speed variation from the index (deterministic) + const speed = recipe.speed * (0.55 + 0.45 * (((i * 7919) % 97) / 97)); + sys.pos[i * 3] = Number(position[0]); + sys.pos[i * 3 + 1] = Number(position[1]); + sys.pos[i * 3 + 2] = Number(position[2]); + sys.vel[i * 3] = d[0] * speed; + sys.vel[i * 3 + 1] = d[1] * speed + recipe.up; + sys.vel[i * 3 + 2] = d[2] * speed; + const colour = custom ?? (recipe.palette ? recipe.palette[i % recipe.palette.length] : recipe.color); + tint.set(colour); + sys.base[i * 3] = sys.col[i * 3] = tint.r; + sys.base[i * 3 + 1] = sys.col[i * 3 + 1] = tint.g; + sys.base[i * 3 + 2] = sys.col[i * 3 + 2] = tint.b; + } + sys.count = count; + sys.age = 0; + sys.life = recipe.life; + sys.recipe = recipe; + sys.startedAt = performance.now(); + const material = /** @type {THREE.PointsMaterial} */ (sys.points.material); + material.blending = recipe.additive ? THREE.AdditiveBlending : THREE.NormalBlending; + material.size = recipe.size; + material.opacity = 1; + material.needsUpdate = true; + const geometry = sys.points.geometry; + geometry.setDrawRange(0, count); + geometry.attributes.position.needsUpdate = true; + geometry.attributes.color.needsUpdate = true; + sys.points.visible = true; + stats.fired++; + ensureTicking(); + return sys.points.name; +} + +/** + * Advance every live burst by `dt` seconds. Exported so the suites step it + * deterministically; the frame task calls it with the real frame time. + * @param {number} dt + */ +export function stepBursts(dt) { + // the AGE runs on the real clock (a burst lives its life whatever the frame rate — a + // throttled tab ticks five times a second); only the INTEGRATION step is capped, so a + // long frame cannot fling a particle across the room + const elapsed = Math.min(1, Math.max(0, dt)); + const step = Math.min(0.1, elapsed); + for (const sys of pool) { + if (!sys.points.visible) continue; + sys.age += elapsed; + const r = sys.recipe; + if (sys.age >= sys.life) { + sys.points.visible = false; + sys.points.geometry.setDrawRange(0, 0); + continue; + } + const damp = Math.exp(-r.drag * step); + const fade = 1 - sys.age / sys.life; + for (let i = 0; i < sys.count; i++) { + const k = i * 3; + sys.vel[k] *= damp; + sys.vel[k + 1] = sys.vel[k + 1] * damp + r.gravity * step; + sys.vel[k + 2] *= damp; + sys.pos[k] += sys.vel[k] * step; + sys.pos[k + 1] += sys.vel[k + 1] * step; + sys.pos[k + 2] += sys.vel[k + 2] * step; + // additive particles fade by DARKENING (black adds nothing); a sparkle twinkles + const twinkle = r === BURST_RECIPES.sparkle ? 0.6 + 0.4 * Math.sin(sys.age * 24 + i * 1.7) : 1; + const k2 = r.additive ? fade * twinkle : 1; + sys.col[k] = sys.base[k] * k2; + sys.col[k + 1] = sys.base[k + 1] * k2; + sys.col[k + 2] = sys.base[k + 2] * k2; + } + const material = /** @type {THREE.PointsMaterial} */ (sys.points.material); + if (!r.additive) material.opacity = Math.min(1, fade * 1.6); + material.size = Math.max(0.005, r.size * (1 + r.grow * (sys.age / sys.life))); + sys.points.geometry.attributes.position.needsUpdate = true; + sys.points.geometry.attributes.color.needsUpdate = true; + } +} + +function frameTask() { + const now = performance.now(); + const dt = lastTick ? (now - lastTick) / 1000 : 0; + lastTick = now; + stepBursts(dt); + if (!pool.some((sys) => sys.points.visible)) { + // nothing alive: leave the frame loop until the next burst. AFTER the loop that is + // calling us — a splice inside its forEach would skip the next module's task + ticking = false; + lastTick = 0; + queueMicrotask(() => { + if (ticking) return; // a burst fired in the meantime + const i = moduleFrameTasks.indexOf(frameTask); + if (i >= 0) moduleFrameTasks.splice(i, 1); + }); + } +} + +function ensureTicking() { + if (ticking) return; + ticking = true; + lastTick = 0; + if (!moduleFrameTasks.includes(frameTask)) moduleFrameTasks.push(frameTask); +} + +/** live systems for the suites @returns {{fired: number, recycled: number, live: {name: string, count: number, age: number, kind: string}[]}} */ +export function burstDebug() { + return { + ...stats, + live: pool + .filter((sys) => sys.points.visible) + .map((sys) => ({ + name: sys.points.name, + count: sys.count, + age: sys.age, + kind: Object.keys(BURST_RECIPES).find((k) => BURST_RECIPES[k] === sys.recipe) ?? '' + })) + }; +} + +/** Drop every live burst (a scene clear) */ +export function clearBursts() { + for (const sys of pool) { + sys.points.visible = false; + sys.points.geometry.setDrawRange(0, 0); + } +} diff --git a/src/lib/gameAnnounce.js b/src/lib/gameAnnounce.js new file mode 100644 index 00000000..2aea5559 --- /dev/null +++ b/src/lib/gameAnnounce.js @@ -0,0 +1,49 @@ +// 30b (vr-play) — `api.announce(text, {sub, ms, color})`: a BIG centred banner for a +// game's moments — "GOAL!", "Level 3", "Ring 2 reached". The user, on Towers: "When I +// reach some of the rings and go to the top, it should dynamically tell me". +// +// A LEAF holding one store; two renderers read it — HudLayer on the desktop (DOM) and the +// VR game panel's head-locked banner in a headset (DOM is invisible there). LOCAL: nothing +// is sent (a module announces on the peer that saw the moment, or broadcasts its own op). +// A new announcement REPLACES the one showing — a banner queue would still be showing +// "Ring 1" when the player reached ring 3. +import { writable, get } from 'svelte/store'; + +/** @typedef {{id: number, text: string, sub: string, color: string, ms: number, at: number}} Announcement */ + +/** the banner on screen, or null @type {import('svelte/store').Writable} */ +export const gameAnnouncement = writable(null); + +const DEFAULT_MS = 1800; +const MAX_MS = 15000; +let nextId = 1; +/** @type {any} */ +let timer = null; + +/** + * Show a banner. Returns its id (0 for empty text). `ms` is clamped to 300..15000. + * @param {string} text @param {{sub?: string, ms?: number, color?: string}} [options] + * @returns {number} + */ +export function announce(text, options = {}) { + const line = String(text ?? '').trim().slice(0, 80); + if (!line) return 0; + const raw = Number(options?.ms); + const ms = Number.isFinite(raw) ? Math.min(MAX_MS, Math.max(300, raw)) : DEFAULT_MS; + const color = typeof options?.color === 'string' && /^#?[0-9a-z(),.% -]{3,40}$/i.test(options.color) ? options.color : '#ffd76a'; + const id = nextId++; + gameAnnouncement.set({ id, text: line, sub: String(options?.sub ?? '').slice(0, 120), color, ms, at: Date.now() }); + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + if (get(gameAnnouncement)?.id === id) gameAnnouncement.set(null); + }, ms); + return id; +} + +/** Take the banner down now (a scene clear, leaving the game). */ +export function clearAnnouncement() { + if (timer) clearTimeout(timer); + timer = null; + gameAnnouncement.set(null); +} diff --git a/src/lib/gameKit.js b/src/lib/gameKit.js index b2524374..ecc87b37 100644 --- a/src/lib/gameKit.js +++ b/src/lib/gameKit.js @@ -7,3 +7,5 @@ export * as gameMusic from './gameMusic'; export * as gameMusicPresets from './gameMusicPresets'; export * as hapticPatterns from './hapticPatterns'; export * as vrGameInput from './vrGameInput'; +export * as effectsBurst from './effectsBurst'; +export * as gameAnnounce from './gameAnnounce'; diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index b6c120b3..9e0d7a12 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -55,6 +55,8 @@ import { makeModuleStorage } from './gameStorage'; // safeStorage, sessionClock, sceneStore), so static edges close no cycle import { isGameSound, playGameSound } from './gameSfx'; import { playGameMusic, stopGameMusic, gameMusicState, MUSIC_PRESET_IDS } from './gameMusic'; +// 30b (vr-play): api.announce's banner store — a LEAF (svelte/store only) +import { announce as announceBanner, clearAnnouncement } from './gameAnnounce'; /** the ping chimes `api.playSound` still reaches (pingAudio's PING_SOUNDS ids) */ const PING_NAMES = new Set(['ding', 'chime', 'pluck', 'bell']); @@ -182,6 +184,9 @@ const sceneClearHandlers = []; /** Called by the clear-scene path (local and remote) */ export function runSceneClearHandlers() { + // 30b: a cleared scene takes its game's bursts and banner with it + effectsRef?.clearBursts?.(); + clearAnnouncement(); sceneClearHandlers.forEach((fn) => { try { fn(); @@ -259,6 +264,8 @@ let nodeCatalogRef = null; let knockRef = null; /** @type {Promise} */ let knockReady = Promise.resolve(null); +/** 30b: primed for api.effects (effectsBurst imports moduleFrameTasks from here) @type {any} */ +let effectsRef = null; if (typeof window !== 'undefined') { knockReady = import('./knock').then((m) => (knockRef = m)); import('./inputRuntime').then((m) => (inputRuntimeRef = m)); @@ -279,6 +286,8 @@ if (typeof window !== 'undefined') { import('./flowRuntime').then((m) => (flowRuntimeRef = m)); import('./flowGraphs').then((m) => (flowGraphsRef = m)); import('./nodeCatalog').then((m) => (nodeCatalogRef = m)); + // 30b (C6): the burst pool reads moduleFrameTasks from here, so the edge back is dynamic + import('./effectsBurst').then((m) => (effectsRef = m)); } // --- api.pointerRay (190): where the user is POINTING, as a world ray -------- @@ -1062,6 +1071,30 @@ function makeApi(moduleId, moduleName = moduleId) { * false) and stops by itself when the player leaves the game. * Presets: 'arcade', 'ambient', 'dungeon', 'stadium', 'space', 'puzzle', 'studio'. */ + /** + * 30b (C6): a short, pooled particle BURST at a world position — 'sparkle' (the + * default), 'confetti', 'smoke' or 'sparks', an optional CSS colour and a count + * (1..96). LOCAL: broadcast your own op if peers should see it too. Returns + * whether a burst started. + * @param {number[]} position @param {{kind?: string, color?: string, count?: number}=} options + * @returns {boolean} + */ + effects: { + burst: (/** @type {number[]} */ position, /** @type {{kind?: string, color?: string, count?: number}} */ options = {}) => + !!effectsRef?.burst?.(position, options ?? {}), + kinds: () => ['sparkle', 'confetti', 'smoke', 'sparks'] + }, + /** + * 30b: a BIG centred banner — "GOAL!", "Level 3", "Ring 2 reached" — on the desktop + * HUD and, in a headset, head-locked in front of the player. `sub` is a second, + * smaller line; `ms` how long it stays (300..15000, default 1800); `color` the + * title's colour. A new banner replaces the one showing. LOCAL. Returns its id. + * @param {string} text @param {{sub?: string, ms?: number, color?: string}=} options + * @returns {number} + */ + announce(text, options = {}) { + return announceBanner(text, options ?? {}); + }, music: { /** @param {string} preset @param {{volume?: number}=} options 0..1 @returns {boolean} */ play: (preset, options = {}) => playGameMusic(preset, options ?? {}), diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 2e0a095e..4fa27da7 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -946,9 +946,12 @@ let hapticSuppressed = 0; * funnel through here, so this one gate covers all of them. * @param {number} intensity 0..1 @param {number} durationMs * @param {'left'|'right'=} hand omit to pulse both + * @param {boolean=} force pulse even in Edit (the mode-switch tick only) */ -export function hapticPulse(intensity = 0.5, durationMs = 50, hand = undefined) { - if (!gameFeelActive()) { +export function hapticPulse(intensity = 0.5, durationMs = 50, hand = undefined, force = false) { + // `force`: the one pulse that must be felt IN Edit — 30b-vr-modes' Edit/Interact switch + // tick, which confirms the switch INTO Edit + if (!force && !gameFeelActive()) { hapticSuppressed++; return; } diff --git a/tests/e2e/game-effects.test.cjs b/tests/e2e/game-effects.test.cjs new file mode 100644 index 00000000..4f21db1b --- /dev/null +++ b/tests/e2e/game-effects.test.cjs @@ -0,0 +1,220 @@ +// 30b (vr-play) P2 — C6 EFFECTS and api.announce. The user, on Towers: "When I reach some of +// the rings and go to the top, it should dynamically tell me and give effects such as +// sparkles or anything similar." +// +// 1 a burst is a scene-root, pooled, LOCAL particle system (nothing in objectsGroup, +// nothing on the wire) · 2 it moves the way its kind says (confetti falls, smoke rises) · +// 3 it dies after its life and leaves the frame loop · 4 the pool is bounded and recycles · +// 5 inputs are clamped and bad ones refused · 6 it reaches the PIXELS · 7 announce shows a +// big banner, replaces the one showing, and goes by itself · 8 a scene clear takes both. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + + await page.evaluate(async () => { + const s = window.__stores; + await s.moduleSDK.initModules([ + { id: 'fx30b', name: 'Effects test', version: '1.0.0', description: 'the 30b effect seams', register(api) { window.__api = api; } } + ]); + window.__sent = []; + let peer; + s.peers.subscribe((p) => (peer = p))(); + const orig = peer.send.bind(peer); + peer.send = (data) => { + window.__sent.push(data?.type); + return orig(data); + }; + }); + await page.waitForTimeout(600); // the primed effects import + + console.log('\n=== 1. a burst is local, scene-root, pooled ==='); + const fired = await page.evaluate(() => { + const s = window.__stores; + let scene, group; + s.globalScene.subscribe((v) => (scene = v))(); + s.objectsGroup.subscribe((v) => (group = v))(); + const before = group.children.length; + const ok = window.__api.effects.burst([0, 1.5, -3], { kind: 'confetti' }); + const fx = s.gameKit.effectsBurst.burstDebug(); + const live = fx.live[0]; + const points = live ? scene.getObjectByName(live.name) : null; + return { + ok, + live, + parentIsScene: points?.parent === scene, + inGroup: !!group.getObjectByName(live?.name ?? '-'), + groupDelta: group.children.length - before, + drawCount: points?.geometry?.drawRange?.count ?? -1, + sent: window.__sent.slice() + }; + }); + h.check(fired.ok === true && fired.live?.kind === 'confetti', '1.1 api.effects.burst starts a confetti burst (' + JSON.stringify(fired.live) + ')'); + h.check(fired.parentIsScene && !fired.inGroup && fired.groupDelta === 0, '1.2 it lives at the scene root, never in objectsGroup'); + h.check(fired.drawCount === 80, '1.3 confetti draws its recipe count (80, got ' + fired.drawCount + ')'); + h.check(!fired.sent.length, '1.4 nothing was sent (' + JSON.stringify(fired.sent) + ')'); + + console.log('\n=== 2. each kind moves the way it says ==='); + const motion = await page.evaluate(() => { + const s = window.__stores; + const fx = s.gameKit.effectsBurst; + fx.clearBursts(); + let scene; + s.globalScene.subscribe((v) => (scene = v))(); + const meanY = (name) => { + const p = scene.getObjectByName(name); + const a = p.geometry.attributes.position.array; + const n = p.geometry.drawRange.count; + let y = 0; + for (let i = 0; i < n; i++) y += a[i * 3 + 1]; + return y / n; + }; + const spread = (name) => { + const p = scene.getObjectByName(name); + const a = p.geometry.attributes.position.array; + let m = 0; + for (let i = 0; i < p.geometry.drawRange.count; i++) m = Math.max(m, Math.hypot(a[i * 3], a[i * 3 + 1] - 1, a[i * 3 + 2])); + return m; + }; + const c = fx.burst([0, 1, 0], { kind: 'confetti' }); + const m = fx.burst([0, 1, 0], { kind: 'smoke' }); + for (let i = 0; i < 12; i++) fx.stepBursts(1 / 60); + const early = { confetti: meanY(c), smoke: meanY(m), spread: spread(c) }; + for (let i = 0; i < 60; i++) fx.stepBursts(1 / 60); + const late = { confetti: meanY(c), smoke: meanY(m) }; + return { early, late }; + }); + h.check(motion.early.spread > 0.3, '2.1 the particles fly apart (' + motion.early.spread.toFixed(2) + ' m after 0.2 s)'); + h.check(motion.late.confetti < motion.early.confetti, '2.2 confetti falls under gravity (' + motion.early.confetti.toFixed(2) + ' -> ' + motion.late.confetti.toFixed(2) + ')'); + h.check(motion.late.smoke > motion.early.smoke && motion.late.smoke > 1, '2.3 smoke rises (' + motion.early.smoke.toFixed(2) + ' -> ' + motion.late.smoke.toFixed(2) + ')'); + + console.log('\n=== 3. a burst dies and leaves the frame loop ==='); + const lifetime = await page.evaluate(async () => { + const s = window.__stores; + const fx = s.gameKit.effectsBurst; + fx.clearBursts(); + await new Promise((r) => setTimeout(r, 200)); // let the idle task leave + const tasksBefore = s.moduleSDK.moduleFrameTasks.length; + fx.burst([0, 1, -2], { kind: 'sparks' }); + const during = s.moduleSDK.moduleFrameTasks.length; + await new Promise((r) => setTimeout(r, 1400)); // sparks live 0.55 s, on the REAL frame loop + return { tasksBefore, during, after: s.moduleSDK.moduleFrameTasks.length, live: fx.burstDebug().live.length }; + }); + h.check(lifetime.during === lifetime.tasksBefore + 1, '3.1 a live burst ticks from the frame loop (' + JSON.stringify(lifetime) + ')'); + h.check(lifetime.live === 0, '3.2 the real frame loop aged it out (no live bursts after its life)'); + h.check(lifetime.after === lifetime.tasksBefore, '3.3 and its frame task left the loop with it'); + + console.log('\n=== 4. the pool is bounded and recycles ==='); + const pool = await page.evaluate(() => { + const s = window.__stores; + const fx = s.gameKit.effectsBurst; + let scene; + s.globalScene.subscribe((v) => (scene = v))(); + const before = fx.burstDebug().recycled; + for (let i = 0; i < 20; i++) fx.burst([i * 0.1, 1, -2], { kind: 'sparkle' }); + const systems = scene.children.filter((c) => /^effects-burst-/.test(c.name)).length; + return { systems, live: fx.burstDebug().live.length, recycled: fx.burstDebug().recycled - before, cap: fx.POOL_SIZE }; + }); + h.check(pool.systems === pool.cap && pool.live === pool.cap, '4.1 twenty bursts use at most the pool (' + JSON.stringify(pool) + ')'); + h.check(pool.recycled === 20 - pool.cap, '4.2 the oldest ones were recycled'); + + console.log('\n=== 5. inputs are clamped and bad ones refused ==='); + const inputs = await page.evaluate(() => { + const s = window.__stores; + const fx = s.gameKit.effectsBurst; + fx.clearBursts(); + let scene; + s.globalScene.subscribe((v) => (scene = v))(); + const big = fx.burst([0, 1, 0], { count: 5000, kind: 'nope', color: '#00ff00' }); + const p = scene.getObjectByName(big); + const col = p.geometry.attributes.color.array; + return { + count: p.geometry.drawRange.count, + kind: fx.burstDebug().live[0].kind, + green: col[1] > 0.9 && col[0] < 0.1, + bad: window.__api.effects.burst([0, NaN, 0]), + short: window.__api.effects.burst([1, 2]) + }; + }); + h.check(inputs.count === 96, '5.1 count clamps to 96 (' + inputs.count + ')'); + h.check(inputs.kind === 'sparkle' && inputs.green, '5.2 an unknown kind falls back to sparkle, the colour is honoured'); + h.check(inputs.bad === false && inputs.short === false, '5.3 a bad position is refused'); + + console.log('\n=== 6. it reaches the pixels ==='); + await page.evaluate(() => { + window.__stores.gameKit.effectsBurst.clearBursts(); + window.__stores.objectActions.deselectObject?.(); + }); + await page.waitForTimeout(400); + const quiet1 = await h.grabFrame(A); + await page.waitForTimeout(150); + const quiet2 = await h.grabFrame(A); + await page.evaluate(() => { + const s = window.__stores; + let cam; + s.globalCamera.subscribe((c) => (cam = c))(); + const THREE = s.THREE; + const at = cam.getWorldPosition(new THREE.Vector3()).add(cam.getWorldDirection(new THREE.Vector3()).multiplyScalar(4)); + window.__api.effects.burst(at.toArray(), { kind: 'sparkle', count: 96, color: '#ffffff' }); + }); + await page.waitForTimeout(120); + const lit = await h.grabFrame(A); + const idle = await h.frameDelta(page, quiet1, quiet2); + const burstDelta = await h.frameDelta(page, quiet2, lit); + h.check(burstDelta.changed > idle.changed + 400, '6.1 a sparkle in view changes the frame (' + burstDelta.changed + ' px vs idle ' + idle.changed + ')'); + + console.log('\n=== 7. announce ==='); + const ann = await page.evaluate(async () => { + const api = window.__api; + const empty = api.announce(' '); + const id1 = api.announce('Ring 1 reached', { ms: 3000 }); + const id2 = api.announce('GOAL!', { sub: 'Blue 1 - 0', ms: 900, color: '#22c55e' }); + await new Promise((r) => setTimeout(r, 100)); + const el = document.querySelector('#game-announce'); + const title = el?.querySelector('.game-announce-title'); + const out = { + empty, + id1, + id2, + count: document.querySelectorAll('#game-announce').length, + text: el?.textContent?.replace(/\s+/g, ' ').trim(), + color: title ? getComputedStyle(title).color : null, + pe: el ? getComputedStyle(el).pointerEvents : null + }; + await new Promise((r) => setTimeout(r, 1200)); + out.gone = !document.querySelector('#game-announce'); + return out; + }); + h.check(ann.empty === 0 && ann.id1 > 0 && ann.id2 > ann.id1, '7.1 an empty line is refused, each banner gets an id'); + h.check(ann.count === 1 && ann.text === 'GOAL! Blue 1 - 0', '7.2 ONE banner, the newest replacing the first (' + ann.text + ')'); + h.check(ann.color === 'rgb(34, 197, 94)' && ann.pe === 'none', '7.3 the title takes its colour and the banner never takes a click'); + h.check(ann.gone, '7.4 it goes by itself after its ms'); + const vr = await page.evaluate(async () => { + const s = window.__stores; + s.isVRMode.set(true); + window.__api.announce('Level 3'); + await new Promise((r) => setTimeout(r, 100)); + const shown = !!document.querySelector('#game-announce'); + s.isVRMode.set(false); + return shown; + }); + h.check(vr === false, '7.5 in VR the DOM banner stands down (the head-locked one takes over, P3)'); + + console.log('\n=== 8. a scene clear takes both ==='); + const cleared = await page.evaluate(async () => { + const s = window.__stores; + window.__api.effects.burst([0, 1, -2], { kind: 'smoke' }); + window.__api.announce('Wave 2', { ms: 5000 }); + await new Promise((r) => setTimeout(r, 50)); + const before = { live: s.gameKit.effectsBurst.burstDebug().live.length, banner: !!document.querySelector('#game-announce') }; + s.moduleSDK.runSceneClearHandlers(); + await new Promise((r) => setTimeout(r, 100)); + return { before, live: s.gameKit.effectsBurst.burstDebug().live.length, banner: !!document.querySelector('#game-announce') }; + }); + h.check(cleared.before.live > 0 && cleared.before.banner, '8.1 premise: a burst and a banner are up'); + h.check(cleared.live === 0 && !cleared.banner, '8.2 a scene clear removes both (' + JSON.stringify(cleared) + ')'); + + await h.finish(browser); +}); From 616d6d2e7fa7edf72d2ea772d277747c18bc66a1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:32:25 +0300 Subject: [PATCH 04/18] =?UTF-8?q?[fix]=2030b=20P0:=20the=20one-eye=20light?= =?UTF-8?q?=20helper=20=E2=80=94=20helpers=20leave=20three's=20XR=20eye=20?= =?UTF-8?q?layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cause: helpers lived on render layer 1, and three's WebXRManager renders each eye through a sub-camera whose mask it rewrites every frame as (camera.mask | 0b110) & ~0b100 (left) and & ~0b010 (right) — layer 1 IS the left eye. So a light helper was drawn by the left eye whatever the editor camera enabled, and never by the right: the "light source helper with one eye" in Towers, Stars Room and Football on the Quest. - helperLayer: HELPER_LAYER 1 -> 30 (3..30 are only ever inherited by the eye cameras; 31 is overloadGuard's REDUCED_LAYER, postprocessing Selection ids count up from 2). helpersHiddenFor(locked, mode, debug) is the one predicate: Play OR Interact hide helpers (unless the debug toggle); editorHelpersShown is the same answer as a store. The editor camera and the camera-marker hop now follow editorMode as well as isLocked. - helpers-in-play reads the layer from the app instead of assuming 1. - New suite vr-helper-eyes (13): asserts three's installed WebXRManager still reserves 1/2 for the eyes, then computes both eye masks with that formula — Edit: both eyes draw the helper and its proxy; VR Interact: neither; desktop Interact and Play: none. - Counterfactual: HELPER_LAYER back to 1 -> vr-helper-eyes red ("the helper layer is not an XR eye layer", "Edit: BOTH eyes draw the light helper" L true R false). - svelte-check 335/47 (unchanged list); vitest 222 green; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/cameraHelpers.js | 3 +- src/lib/helperLayer.js | 41 +++++++++-- tests/e2e/helpers-in-play.test.cjs | 33 +++++---- tests/e2e/vr-helper-eyes.test.cjs | 112 +++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/vr-helper-eyes.test.cjs diff --git a/src/lib/cameraHelpers.js b/src/lib/cameraHelpers.js index ec90b20a..fe2a2d0e 100644 --- a/src/lib/cameraHelpers.js +++ b/src/lib/cameraHelpers.js @@ -7,7 +7,7 @@ import { wireframeActive } from './viewMode'; // 24-E2: frustums live on the helper layer; MARKERS hop onto it while hidden (Play // without the debug toggle, or a camera preview) — see helperLayer.js for the rule import { markHelper, setMarkersHidden, helpersHidden, helpersInPlay } from './helperLayer'; -import { isLocked } from '../stores/sceneStore'; +import { isLocked, editorMode } from '../stores/sceneStore'; import { safeStorage } from './safeStorage'; // 16-P5: frustum visualization for camera OBJECTS — the colliderHelpers pattern. @@ -175,6 +175,7 @@ export function startCameraHelpers() { // 24-E2: the marker hop follows Play, the debug toggle and the preview if (!started && typeof window !== 'undefined') { isLocked.subscribe(applyMarkerLayers); + editorMode.subscribe(applyMarkerLayers); // 30b: Interact hides markers like Play helpersInPlay.subscribe(applyMarkerLayers); frustumSuppressed.subscribe(applyMarkerLayers); } diff --git a/src/lib/helperLayer.js b/src/lib/helperLayer.js index 1a5c55d2..3123ab54 100644 --- a/src/lib/helperLayer.js +++ b/src/lib/helperLayer.js @@ -1,6 +1,6 @@ // 24-E2: THE HELPER LAYER — "the editor sees it, the game does not". Light helpers and // their pick proxies, camera frustums and (while hidden) camera MARKERS live on render -// layer 1; the editor camera enables it outside Play (and in Play with the debug +// layer HELPER_LAYER (30, see below); the editor camera enables it outside Play (and in Play with the debug // toggle), and every camera that renders FOR someone — the play camera, a camera-object // preview, PiP, thumbnails, captureThroughCamera — is a fresh THREE camera on layer 0 // only, so it never sees them. @@ -20,12 +20,26 @@ // Bits are flipped with enable/disable, never `set`: the outline passes mark a // selected object with a layer bit of their own, and `set` would wipe it. // +// +// 30b P0 — THE LAYER NUMBER IS NOT FREE. three's WebXRManager renders each eye through +// its own sub-camera and RESERVES layers 1 and 2 for them: every frame it writes +// `cameraL.mask = (camera.mask | 0b110) & ~0b100` and `cameraR.mask = (camera.mask | +// 0b110) & ~0b010` (WebXRManager.updateCamera, "1 = left, 2 = right"). So on layer 1 a +// helper was ALWAYS drawn by the left eye — whatever the editor camera enabled — and never +// by the right one: the light-source helper a Quest user saw "with one eye" in Towers, +// Stars Room and Football. Any number the XR eye cameras only ever INHERIT (3..30; 31 is +// overloadGuard's REDUCED_LAYER, and postprocessing's Selection ids count up from 2) is +// correct; 30 keeps well clear of both. +// +// 30b P0/P1 — Interact hides helpers too: Edit is the only mode that draws the editor's +// scaffolding, in both eyes; Interact (desktop or VR) and Play draw none of it. +// // Imports sceneStore only (the lightHelpers/cameraHelpers family), no THREE. -import { get, writable } from 'svelte/store'; -import { isLocked, editorCam, globalCamera, objectsGroup } from '../stores/sceneStore'; +import { derived, get, writable } from 'svelte/store'; +import { isLocked, editorCam, globalCamera, objectsGroup, editorMode } from '../stores/sceneStore'; import { safeStorage } from './safeStorage'; -export const HELPER_LAYER = 1; +export const HELPER_LAYER = 30; /** "Show helpers in Play (debug)" — LOCAL pref, default off. While on, helpers render * in Play and a DEBUG chip sits in the play HUD so a screenshot cannot be mistaken for @@ -54,11 +68,25 @@ export function applyHelperLayer(camera, on) { else camera.layers.disable(HELPER_LAYER); } -/** Should helpers be hidden right now (Play without the debug toggle)? */ +/** 30b P1: the ONE predicate every editor helper answers to — Play (`isLocked === true`) + * or Interact hides them, unless the "Show helpers in Play (debug)" toggle keeps them. + * Pure so a suite can pin the table. @param {any} locked @param {any} mode @param {boolean} debug */ +export function helpersHiddenFor(locked, mode, debug) { + return (locked === true || mode === 'interact') && !debug; +} + +/** Should helpers be hidden right now (Play or Interact, without the debug toggle)? */ export function helpersHidden() { - return !!get(isLocked) && !get(helpersInPlay); + return helpersHiddenFor(get(isLocked), get(editorMode), get(helpersInPlay)); } +/** 30b P1: the same answer as a store, for components (the grid, the VR selection shell) + * and for anything that toggles `visible` per frame. */ +export const editorHelpersShown = derived( + [isLocked, editorMode, helpersInPlay], + ([$locked, $mode, $debug]) => !helpersHiddenFor($locked, $mode, $debug) +); + /** the marker-hop state the last `setMarkersHidden` applied (reads for the test seam) */ let markersHidden = false; export function markersAreHidden() { @@ -119,5 +147,6 @@ export function startHelperLayer() { started = true; editorCam.subscribe(applyToEditorCamera); isLocked.subscribe(applyToEditorCamera); + editorMode.subscribe(applyToEditorCamera); helpersInPlay.subscribe(applyToEditorCamera); } diff --git a/tests/e2e/helpers-in-play.test.cjs b/tests/e2e/helpers-in-play.test.cjs index 18e48769..1bc5e736 100644 --- a/tests/e2e/helpers-in-play.test.cjs +++ b/tests/e2e/helpers-in-play.test.cjs @@ -1,7 +1,7 @@ // 24-E2: helpers on their own render layer, hidden in Play, a debug toggle. Light // helpers + pick proxies and camera frustums (scene root) were gated by editor prefs // only; camera MARKERS are replicated meshes that rendered inside the game, in camera -// previews and in captures (reported). Now: scene-root helpers sit on HELPER_LAYER (1) +// previews and in captures (reported). Now: scene-root helpers sit on HELPER_LAYER (30 since 30b P0 — 1 is the XR LEFT-EYE layer) // for good and only the editor camera enables it (off in Play unless the debug toggle); // markers HOP onto the layer while hidden (Play / a preview / a capture's render) and // come back to layer 0 after, so every raycaster in the app keeps picking them. @@ -10,6 +10,7 @@ const h = require('./helpers.cjs'); const state = (page) => page.evaluate(() => { const s = window.__stores; + const HL = s.helperLayer.HELPER_LAYER; const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; const cam = get(s.editorCam) || get(s.globalCamera); const scene = get(s.globalScene); @@ -19,7 +20,7 @@ const state = (page) => const helperMasks = scene.children.filter((c) => /LightHelper$/.test(c.type || '') || c.name === 'light-proxies' || c.name === 'camera-frustums').map((c) => ({ name: c.name || c.type, mask: c.layers.mask, kids: c.children.map((k) => k.layers.mask) })); return { locked: get(s.isLocked), - camHelper: !!cam?.layers?.isEnabled?.(1), + camHelper: !!cam?.layers?.isEnabled?.(HL), camMask: cam?.layers?.mask ?? null, markers, helperMasks, @@ -29,6 +30,8 @@ const state = (page) => }; }); const bit = (mask, n) => (mask & (1 << n)) !== 0; +// 30b P0: the helper layer is read from the app, never assumed (it was 1 — the XR left eye) +let HL = 30; h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); @@ -36,6 +39,8 @@ h.run(async () => { const B = await h.setupPage(browser, 'B'); await h.connect(B, A); + HL = await A.page.evaluate(() => window.__stores.helperLayer.HELPER_LAYER); + h.check(HL !== 1 && HL !== 2, `the helper layer is not one of three's XR EYE layers 1/2 (${HL})`); const ids = await A.page.evaluate(() => { const s = window.__stores; s.commandsHandler.sceneCommand('/light point'); @@ -49,10 +54,10 @@ h.run(async () => { // ---- 1. outside Play: helpers on layer 1 only, the editor camera renders it ------- let s = await state(A.page); - h.check(!s.outlineLayers.includes(1), `premise: the outline passes do not use layer 1 (${JSON.stringify(s.outlineLayers)})`); - h.check(s.helperMasks.length >= 2 && s.helperMasks.every((e) => e.kids.length === 0 || e.kids.every((m) => m === 2)) && s.helperMasks.filter((e) => /LightHelper$/.test(e.name)).every((e) => e.mask === 2), `light helper + proxies + frustum root children sit on layer 1 only (${JSON.stringify(s.helperMasks)})`); + h.check(!s.outlineLayers.includes(HL), `premise: the outline passes do not use the helper layer (${JSON.stringify(s.outlineLayers)})`); + h.check(s.helperMasks.length >= 2 && s.helperMasks.every((e) => e.kids.length === 0 || e.kids.every((m) => m === 1 << HL)) && s.helperMasks.filter((e) => /LightHelper$/.test(e.name)).every((e) => e.mask === 1 << HL), `light helper + proxies + frustum root children sit on the helper layer only (${JSON.stringify(s.helperMasks)})`); h.check(s.camHelper === true, `the editor camera renders the helper layer outside Play (mask ${s.camMask})`); - h.check(s.markers.length === 1 && bit(s.markers[0].mask, 0) && !bit(s.markers[0].mask, 1), `the marker is ordinary scenery outside Play (mask ${s.markers[0]?.mask})`); + h.check(s.markers.length === 1 && bit(s.markers[0].mask, 0) && !bit(s.markers[0].mask, HL), `the marker is ordinary scenery outside Play (mask ${s.markers[0]?.mask})`); // ---- 2. a real click still picks the marker (it never left layer 0) --------------- const at = await h.projectPoint(A.page, [0, 1, 0]); @@ -67,7 +72,7 @@ h.run(async () => { await A.page.waitForTimeout(400); s = await state(A.page); h.check(s.locked === true && s.camHelper === false, `in Play the camera no longer renders the helper layer (mask ${s.camMask})`); - h.check(!bit(s.markers[0].mask, 0) && bit(s.markers[0].mask, 1), `the marker hopped onto the helper layer (mask ${s.markers[0].mask})`); + h.check(!bit(s.markers[0].mask, 0) && bit(s.markers[0].mask, HL), `the marker hopped onto the helper layer (mask ${s.markers[0].mask})`); h.check(!s.chip, 'no DEBUG chip while helpers are hidden'); // ---- 4. the debug toggle brings them back, with the chip ----------------------------- @@ -85,14 +90,14 @@ h.run(async () => { await A.page.evaluate(() => window.__stores.isLocked.set(false)); await A.page.waitForTimeout(400); s = await state(A.page); - h.check(s.camHelper === true && bit(s.markers[0].mask, 0) && !bit(s.markers[0].mask, 1), `leaving Play: layer back on, marker back on layer 0 (mask ${s.markers[0].mask})`); + h.check(s.camHelper === true && bit(s.markers[0].mask, 0) && !bit(s.markers[0].mask, HL), `leaving Play: layer back on, marker back on layer 0 (mask ${s.markers[0].mask})`); // ---- 6. a camera preview hides markers; stopping it restores them ------------------ await A.page.evaluate((u) => window.__stores.cameraPreview.startCameraPreview(u), ids.marker); await A.page.waitForTimeout(500); s = await state(A.page); h.check(!bit(s.markers[0].mask, 0), `a camera preview hides the markers (mask ${s.markers[0].mask})`); - const previewCam = await A.page.evaluate(() => { let c; window.__stores.globalCamera.subscribe((x) => (c = x))(); return c?.layers?.isEnabled?.(1) ?? null; }); + const previewCam = await A.page.evaluate((HL) => { let c; window.__stores.globalCamera.subscribe((x) => (c = x))(); return c?.layers?.isEnabled?.(HL) ?? null; }, HL); h.check(previewCam === false, `the preview camera never renders the helper layer (${previewCam})`); await A.page.evaluate(() => window.__stores.cameraPreview.stopCameraPreview()); await A.page.waitForTimeout(500); @@ -100,7 +105,7 @@ h.run(async () => { h.check(bit(s.markers[0].mask, 0), `stopping the preview restores the marker (mask ${s.markers[0].mask})`); // ---- 7. a capture hides markers for its one render, then puts them back -------------- - const capture = await A.page.evaluate((u) => { + const capture = await A.page.evaluate(({ u, HL }) => { let r; window.__stores.globalRenderer.subscribe((x) => (r = x))(); let g; @@ -108,22 +113,22 @@ h.run(async () => { const marker = g.getObjectByProperty('uuid', u); const seen = []; const original = r.render.bind(r); - r.render = (scene, camera) => { seen.push({ mask: marker.layers.mask, camHelper: camera.layers.isEnabled(1) }); return original(scene, camera); }; + r.render = (scene, camera) => { seen.push({ mask: marker.layers.mask, camHelper: camera.layers.isEnabled(HL) }); return original(scene, camera); }; try { window.__stores.cameraObjects.captureThroughCamera(u, 120); } catch (e) { seen.push({ error: e.message }); } r.render = original; return { seen, after: marker.layers.mask }; - }, ids.marker); + }, { u: ids.marker, HL }); const during = capture.seen.find((x) => x.mask !== undefined); h.check(!!during && !bit(during.mask, 0) && during.camHelper === false, `captureThroughCamera renders with the marker hidden and no helper layer (${JSON.stringify(capture.seen)})`); - h.check(bit(capture.after, 0) && !bit(capture.after, 1), `...and restores the marker afterwards (mask ${capture.after})`); + h.check(bit(capture.after, 0) && !bit(capture.after, HL), `...and restores the marker afterwards (mask ${capture.after})`); // ---- 8. peer B: ordinary mask outside Play; its own Play hides its own copy ------- let b = await state(B.page); - h.check(b.markers.length === 1 && bit(b.markers[0].mask, 0) && !bit(b.markers[0].mask, 1), `peer B's copy of the marker is on layer 0 (mask ${b.markers[0]?.mask})`); + h.check(b.markers.length === 1 && bit(b.markers[0].mask, 0) && !bit(b.markers[0].mask, HL), `peer B's copy of the marker is on layer 0 (mask ${b.markers[0]?.mask})`); await B.page.evaluate(() => window.__stores.isLocked.set(true)); await B.page.waitForTimeout(400); b = await state(B.page); - h.check(b.camHelper === false && bit(b.markers[0].mask, 1) && !bit(b.markers[0].mask, 0), `B entering Play hides its own marker copy (mask ${b.markers[0].mask})`); + h.check(b.camHelper === false && bit(b.markers[0].mask, HL) && !bit(b.markers[0].mask, 0), `B entering Play hides its own marker copy (mask ${b.markers[0].mask})`); // a snapshot taken by A mid-Play on B carries no hop for B's editor: A's copy is normal s = await state(A.page); h.check(bit(s.markers[0].mask, 0), "A's marker is unaffected by B's Play"); diff --git a/tests/e2e/vr-helper-eyes.test.cjs b/tests/e2e/vr-helper-eyes.test.cjs new file mode 100644 index 00000000..1ed7b38c --- /dev/null +++ b/tests/e2e/vr-helper-eyes.test.cjs @@ -0,0 +1,112 @@ +// 30b P0: THE ONE-EYE LIGHT HELPER. A Quest user saw the light-source helper in the +// middle of Towers / Stars Room / Football "with one eye" while playing. The cause is a +// layer number: helpers lived on render layer 1, and three's WebXRManager renders the two +// eyes through sub-cameras whose masks it rewrites EVERY FRAME from the user camera's: +// cameraL.mask = (camera.mask | 0b110) & ~0b100 -> layer 1 ALWAYS on for the left eye +// cameraR.mask = (camera.mask | 0b110) & ~0b010 -> layer 1 always OFF for the right eye +// so a layer-1 helper was drawn by the left eye whatever the editor camera enabled, and +// never by the right one. Headless Chromium cannot present WebXR, so this suite computes +// the two eye masks with three's own formula (and first asserts the installed three still +// SAYS that formula, so a three upgrade that changes it turns this red instead of lying). +// +// The contract (C1): helpers draw in BOTH eyes in Edit and in NEITHER in Interact/Play. +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const XR_SOURCE = path.join(__dirname, '..', '..', 'node_modules', 'three', 'src', 'renderers', 'webxr', 'WebXRManager.js'); + +/** what each eye camera would see of the scene-root light helper and its pick proxy */ +const eyes = (page) => + page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const THREE = s.THREE; + const cam = get(s.editorCam) || get(s.globalCamera); + const scene = get(s.globalScene); + const helper = scene.children.find((c) => /LightHelper$/.test(c.type || '')); + const proxy = scene.getObjectByName('light-proxy'); + // WebXRManager.updateCamera, verbatim in bit form + const xrMask = cam.layers.mask | 0b110; + const left = new THREE.Layers(); + left.mask = xrMask & ~0b100; + const right = new THREE.Layers(); + right.mask = xrMask & ~0b010; + const sees = (eye, node) => !!node && eye.test(node.layers); + return { + layer: s.helperLayer.HELPER_LAYER, + helper: !!helper, + proxy: !!proxy, + leftHelper: sees(left, helper), + rightHelper: sees(right, helper), + leftProxy: sees(left, proxy), + rightProxy: sees(right, proxy), + desktop: !!helper && cam.layers.test(helper.layers), + mode: get(s.editorMode), + vr: get(s.isVRMode), + locked: get(s.isLocked) + }; + }); + +h.run(async () => { + // ---- 0. the premise: three still reserves layers 1/2 for the eyes ----------------- + const src = fs.readFileSync(XR_SOURCE, 'utf8'); + h.check( + /cameraL\.layers\.mask\s*=\s*cameraXR\.layers\.mask\s*&\s*~\s*0b100/.test(src) && + /cameraR\.layers\.mask\s*=\s*cameraXR\.layers\.mask\s*&\s*~\s*0b010/.test(src) && + /cameraXR\.layers\.mask\s*=\s*camera\.layers\.mask\s*\|\s*0b110/.test(src), + 'premise: three WebXRManager still gives the left eye layer 1 and the right eye layer 2' + ); + + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + await A.page.evaluate(() => { + window.__stores.commandsHandler.sceneCommand('/light point'); + window.__stores.objectActions.deselectObject(); + }); + await h.eventually(() => eyes(A.page), (e) => e.helper && e.proxy, 'a point light has its helper + pick proxy', 10000); + + // ---- 1. Edit on the desktop -------------------------------------------------------- + let e = await eyes(A.page); + h.check(e.layer !== 1 && e.layer !== 2, `the helper layer is not an XR eye layer (${e.layer})`); + h.check(e.mode === 'edit' && e.desktop, `Edit: the desktop editor camera draws the helper (${JSON.stringify(e)})`); + h.check(e.leftHelper && e.rightHelper, `Edit: BOTH eyes draw the light helper (L ${e.leftHelper} R ${e.rightHelper})`); + h.check(e.leftProxy && e.rightProxy, `Edit: BOTH eyes draw its pick proxy (L ${e.leftProxy} R ${e.rightProxy})`); + + // ---- 2. Edit in VR (the headset keeps the editor camera, so the same answer) ------- + await A.page.evaluate(() => window.__stores.isVRMode.set(true)); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(e.vr && e.leftHelper && e.rightHelper, `VR Edit: both eyes draw the helper (L ${e.leftHelper} R ${e.rightHelper})`); + + // ---- 3. Interact in VR: neither eye --------------------------------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(e.mode === 'interact' && !e.leftHelper && !e.rightHelper, `VR Interact: NEITHER eye draws the helper (L ${e.leftHelper} R ${e.rightHelper})`); + h.check(!e.leftProxy && !e.rightProxy, `VR Interact: neither eye draws the pick proxy (L ${e.leftProxy} R ${e.rightProxy})`); + + // ---- 4. back to Edit in VR ------------------------------------------------------------ + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(e.leftHelper && e.rightHelper, `VR back to Edit: both eyes again (L ${e.leftHelper} R ${e.rightHelper})`); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + + // ---- 5. Interact on the desktop and desktop Play hide it too --------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(!e.desktop, 'desktop Interact: the editor camera no longer draws the helper'); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.waitForTimeout(200); + e = await eyes(A.page); + h.check(!e.leftHelper && !e.rightHelper, `Play: neither eye (L ${e.leftHelper} R ${e.rightHelper})`); + await A.page.evaluate(() => window.__stores.isLocked.set(false)); + await A.page.waitForTimeout(400); + e = await eyes(A.page); + h.check(e.desktop && e.leftHelper && e.rightHelper, 'leaving Play: the helper is back for both eyes'); + + await h.finish(browser); +}); From 4b46f71b63e7174f3c966516126f854b0749bcbf Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:32:50 +0300 Subject: [PATCH 05/18] [feat] 30b P1: Interact and Play draw no editor scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Quest report: "In interact mode I do not need helper boxes or shapes, outlines around objects." Play already hid the helper layer and the grid; Interact hid nothing. - Every editor helper now answers the ONE predicate from P0 (helperLayer.helpersHidden / editorHelpersShown — Play or Interact, unless "Show helpers in Play (debug)"): the grid (Grid.svelte), collider/trigger wireframes (colliderHelpers), the tiny-object dots (tinyMarkers), a peer's lock box (LockHighlights), the selection + lock outlines (Outline: Interact joins Play), the VR selection shell (VRSelectionShell) and the VR hover box + emissive hover tint (vrControls updateHoverBox / setHovered). Light helpers, frustums and the module-content proxy ride the helper layer (P0). The selection itself survives Interact — only its glare goes. - New suite interact-clean-view (16): a scene holding one of each helper, read in Edit, Interact, back in Edit, VR Interact/Edit, Play, and Play with the debug toggle. - Counterfactuals: colliderHelpers without the helpersHidden gate -> "Interact: no collider wireframes" red (and Play); Outline without the Interact clause -> "Interact: no selection or lock outline (1/1)" red. - Held: editor-modes, play-interact, pointer-capture-play green (= base). - svelte-check 335/47 unchanged; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/LockHighlights.svelte | 3 + src/components/Outline.svelte | 6 +- src/components/play/VRSelectionShell.svelte | 4 +- src/extensions/Grid.svelte | 7 +- src/lib/colliderHelpers.js | 4 +- src/lib/tinyMarkers.js | 6 + src/lib/vrControls.js | 5 + tests/e2e/interact-clean-view.test.cjs | 141 ++++++++++++++++++++ 8 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/interact-clean-view.test.cjs diff --git a/src/components/LockHighlights.svelte b/src/components/LockHighlights.svelte index c61921b1..50e2811a 100644 --- a/src/components/LockHighlights.svelte +++ b/src/components/LockHighlights.svelte @@ -4,6 +4,8 @@ import { lockedObjects, objectsGroup } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; import { peerColor } from '$lib/lockControl'; + // 30b P1: who-holds-what boxes are editor information - none in Interact/Play + import { editorHelpersShown } from '$lib/helperLayer'; // Wireframe box around every object locked by ANOTHER peer, tinted with // that peer's color (same hash as pings/cursors). Boxes track the object @@ -16,6 +18,7 @@ const helpers = new Map(); useTask(() => { + group.visible = $editorHelpersShown; /** @type {any} */ const peer = $peers; const scene = $objectsGroup; diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 760800b0..ccbf5950 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -1,5 +1,5 @@ - {#if showGrid && ($isLocked !== true || $helpersInPlay)} + {#if showGrid && $editorHelpersShown} 0 || !!groundProxy) && !wireframeActive(); + // 30b P1: collider/trigger wireframes are editor scaffolding — gone in Interact and Play + proxyRoot.visible = (entries.size > 0 || !!groundProxy) && !wireframeActive() && !helpersHidden(); if (!proxyRoot.visible) return; entries.forEach((entry) => { entry.object.updateMatrixWorld(); diff --git a/src/lib/tinyMarkers.js b/src/lib/tinyMarkers.js index fae5b1ea..8eff110f 100644 --- a/src/lib/tinyMarkers.js +++ b/src/lib/tinyMarkers.js @@ -2,6 +2,7 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { objectsGroup, globalScene, globalCamera, globalRenderer, selectedObjects } from '../stores/sceneStore'; +import { helpersHidden } from './helperLayer'; // A dot for an object you can no longer see. // @@ -74,6 +75,11 @@ export function updateTinyMarkers() { const renderer = get(globalRenderer); const height = renderer?.domElement?.clientHeight ?? 0; if (!scene || !group || !camera?.isPerspectiveCamera || !height) return; + // 30b P1: an aiming aid for the EDITOR — Interact and Play draw no scaffolding + if (helpersHidden()) { + if (points) points.visible = false; + return; + } /** @type {number[]} */ const spots = []; diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index eef68025..341d0fa9 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -128,6 +128,7 @@ import { suspendAnimation, resumeAnimation } from './flowRuntime'; import { drawMode, toggleDrawMode, addStrokePoint, endStroke } from './drawMode'; import { setPttHeld, cycleMicMode, vrMicMode, micActive, pttActive } from './voiceChat'; import { safeStorage } from './safeStorage'; +import { helpersHidden } from './helperLayer'; import { HOLD_MS, vrWindowAdjust, @@ -310,6 +311,8 @@ function ensureRayLines() { export function updateHoverBox(object) { const scene = get(globalScene); if (!scene) return; + // 30b P1: the hover shell is editor scaffolding — none in Interact/Play + if (helpersHidden()) object = null; if (!hoverBox) { hoverBox = new THREE.Box3Helper(new THREE.Box3(), new THREE.Color(RAY_HOVER)); hoverBox.name = 'vr-hover-box'; @@ -329,6 +332,8 @@ function setHovered(object) { // the shell is the primary, emissive-independent cue; the emissive tint is a // secondary touch for materials that support it updateHoverBox(object); + // 30b P1: ...and neither is the emissive hover tint (it paints a replicated material) + if (helpersHidden()) object = null; if (hoveredObject === object) return; if (hoveredObject?.material?.emissive) hoveredObject.material.emissive.setHex(hoveredEmissive); hoveredObject = null; diff --git a/tests/e2e/interact-clean-view.test.cjs b/tests/e2e/interact-clean-view.test.cjs new file mode 100644 index 00000000..b7d4a797 --- /dev/null +++ b/tests/e2e/interact-clean-view.test.cjs @@ -0,0 +1,141 @@ +// 30b P1: INTERACT AND PLAY DRAW NO EDITOR SCAFFOLDING. The Quest report: "In interact +// mode I do not need helper boxes or shapes, outlines around objects." Before this, Play +// hid the helper LAYER and the grid, and Interact hid nothing at all — the grid, the +// collider/trigger wireframes, the selection outline, a peer's lock box, the tiny-object +// dots, the VR selection shell and the VR hover box all stayed. Every one of them now +// answers ONE predicate (helperLayer.helpersHidden / editorHelpersShown), so this suite +// builds a scene holding one of each and reads each one in Edit, Interact, back in Edit, +// and in Play. +const h = require('./helpers.cjs'); + +const read = (page) => + page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const scene = get(s.globalScene); + const cam = get(s.editorCam) || get(s.globalCamera); + const HL = s.helperLayer.HELPER_LAYER; + const colliders = scene.getObjectByName('collider-proxies'); + const locks = scene.getObjectByName('lock-highlights'); + const tiny = scene.getObjectByName('tiny-object-markers'); + const shell = scene.getObjectByName('vr-selection-shell'); + const hover = scene.getObjectByName('vr-hover-box'); + const outline = window.__outlineDebug?.() ?? null; + return { + mode: get(s.editorMode), + locked: get(s.isLocked), + grid: !!scene.getObjectByName('editor-grid'), + helperLayer: !!cam?.layers?.isEnabled?.(HL), + colliders: !!colliders?.visible && colliders.children.length > 0, + locks: !!locks?.visible && locks.children.length > 0, + tiny: !!tiny?.visible, + shell: !!shell?.visible, + hover: !!hover?.visible, + outlineSelected: outline ? outline.selected : -1, + outlineLocked: outline ? outline.locked : -1 + }; + }); + +const everything = (r) => r.grid && r.helperLayer && r.colliders && r.locks && r.tiny && r.outlineSelected > 0 && r.outlineLocked > 0; +const nothing = (r) => !r.grid && !r.helperLayer && !r.colliders && !r.locks && !r.tiny && r.outlineSelected === 0 && r.outlineLocked === 0; + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- the fixture: one of each helper -------------------------------------------------- + const ids = await A.page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + s.commandsHandler.sceneCommand('/light point'); + s.commandsHandler.sceneCommand('/create box'); + s.commandsHandler.sceneCommand('/create box'); + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 400)); + const boxes = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + const [selected, lockedByPeer, tiny] = boxes; + selected.position.set(-1.5, 0.5, 0); + lockedByPeer.position.set(1.5, 0.5, 0); + tiny.position.set(0, 0.5, -1); + tiny.scale.setScalar(0.0005); + s.colliderHelpers.showColliders.set(true); + // a peer holding the second box (LockHighlights + the locked outline read this) + s.lockedObjects.set([['peer-elsewhere', lockedByPeer.uuid]]); + s.objectActions.selectObject(selected.uuid); + s.objectsGroup.update((g) => g); + return { selected: selected.uuid, locked: lockedByPeer.uuid }; + }); + await A.page.waitForTimeout(1200); + + // ---- 1. Edit shows all of it ------------------------------------------------------------- + let r = await read(A.page); + h.check(r.mode === 'edit', 'premise: the editor starts in Edit'); + h.check(everything(r), `Edit: grid, helper layer, colliders, lock box, tiny dot and both outlines are drawn (${JSON.stringify(r)})`); + + // ---- 2. Interact hides every one ------------------------------------------------------------ + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(600); + r = await read(A.page); + h.check(r.mode === 'interact', 'Interact is on'); + h.check(!r.grid, `Interact: no editor grid (${r.grid})`); + h.check(!r.helperLayer, 'Interact: the camera stops drawing the helper layer (light helpers, proxies, frustums)'); + h.check(!r.colliders, 'Interact: no collider wireframes'); + h.check(!r.locks, "Interact: no peer lock box"); + h.check(!r.tiny, 'Interact: no tiny-object dot'); + h.check(r.outlineSelected === 0 && r.outlineLocked === 0, `Interact: no selection or lock outline (${r.outlineSelected}/${r.outlineLocked})`); + const kept = await A.page.evaluate(() => { let v; window.__stores.selectedObjects.subscribe((x) => (v = x))(); return v.length; }); + h.check(kept === 1, `Interact keeps the selection itself — only its glare goes (${kept})`); + + // ---- 3. VR: the selection shell and the hover box stand down in Interact --------------------- + await A.page.evaluate(() => window.__stores.isVRMode.set(true)); + const vr = await A.page.evaluate((uuid) => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const box = get(s.objectsGroup).getObjectByProperty('uuid', uuid); + s.vrControls.updateHoverBox(box); + const hover = get(s.globalScene).getObjectByName('vr-hover-box'); + return { hover: !!hover?.visible }; + }, ids.selected); + await A.page.waitForTimeout(400); + r = await read(A.page); + h.check(!r.shell, 'VR Interact: no selection shell'); + h.check(!vr.hover, 'VR Interact: the hover box refuses to show'); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + const vrEdit = await A.page.evaluate((uuid) => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const box = get(s.objectsGroup).getObjectByProperty('uuid', uuid); + s.vrControls.updateHoverBox(box); + return !!get(s.globalScene).getObjectByName('vr-hover-box')?.visible; + }, ids.selected); + await A.page.waitForTimeout(400); + r = await read(A.page); + h.check(r.shell && vrEdit, `VR Edit: the selection shell and hover box are back (shell ${r.shell}, hover ${vrEdit})`); + await A.page.evaluate(() => { + window.__stores.vrControls.updateHoverBox(null); + window.__stores.isVRMode.set(false); + }); + await A.page.waitForTimeout(600); + + // ---- 4. back in Edit on the desktop, everything returns --------------------------------------- + r = await read(A.page); + h.check(everything(r), `back to Edit: all of it returns (${JSON.stringify(r)})`); + + // ---- 5. Play hides them all too (the grid and helper layer already did; now the rest) --------- + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.waitForTimeout(600); + r = await read(A.page); + h.check(nothing(r), `Play: nothing of the editor is drawn (${JSON.stringify(r)})`); + // ...unless the debug toggle asks for them + await A.page.evaluate(() => window.__stores.helperLayer.helpersInPlay.set(true)); + await A.page.waitForTimeout(500); + r = await read(A.page); + h.check(r.grid && r.colliders && r.helperLayer, `"Show helpers in Play (debug)" still brings the scaffolding back (${JSON.stringify(r)})`); + await A.page.evaluate(() => { + window.__stores.helperLayer.helpersInPlay.set(false); + window.__stores.isLocked.set(false); + window.__stores.lockedObjects.set([]); + }); + + await h.finish(browser); +}); From 8677f38b335474a29763743042c859196257d0e8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:33:07 +0300 Subject: [PATCH 06/18] =?UTF-8?q?[fix]=2030b=20P2:=20grips=20by=20mode=20?= =?UTF-8?q?=E2=80=94=20Edit=20moves=20the=20world=20in=20game=20scenes,=20?= =?UTF-8?q?Interact=20never=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Quest report: "When in game and in edit mode I should be able to move around the world and scale it with grips, now it disables this for some reason". Nothing disabled it: the world gestures fire only on a grip that closes on EMPTY AIR, and a game scene has none — Stars Room is a closed room, Football a glass box, Towers a 26 m floor ringed by walls — so every grip grabbed the room. - vrGrip.js (pure, imports nothing): SCENERY (world bounds >= 4 m on any axis, or bounds that contain the head) is never held by a grip; pickGripTarget walks the ray's top-level hits: Edit takes the first non-scenery object, Interact only a grabbable one (a dynamic body under a play block whose interaction is 'grab'; a static object in front blocks); gripMovesWorld: only Edit's empty-air grip moves the world. - vrControls: gripTargetOf (ray hits + the hand-inside test through that rule) feeds onSqueezeStart. Interact: no world grab/pan, no two-hand object scale, a RIGID grab with no snapping and no stick reel/scale, no selection (no editor lock), and no undo entry on release (a player's throw is not an edit). vrGripDebug for suites. - knock.js: in VR only Interact's hands knock (an Edit hand is placing things). - tests/e2e/fakeXR.cjs: a fake XR session whose gamepads a suite presses, so the REAL per-frame path (Scene's useTask -> updateVRControls) runs headless; controller poses are written into the controller matrices. - New: vitest vrGrip (10), e2e vr-grips-by-mode (18): Edit two grips on a wall = world grab that scales x2 and leaves the wall; right grip on the floor = pan; a cube is still held, selected and one undo step. Interact: walls/air hold nothing and never move the world; a static podium is not holdable; the dynamic cube is, unselected, with no undo. - Counterfactuals: scenery not skipped in pickGripTarget -> 8 e2e reds (Edit grabs the wall/floor instead of the world) + 3 vitest reds; gripMovesWorld always true -> "Interact: ... start no world gesture" and "Interact: the world stays put (x2.67)" red. - Held: vr-world-grab 9/2 = base (the 2 are its pre-existing two-peer sync checks, red on pristine base with PEER_CONFIG too); vr-locomotion green. - svelte-check 335/47 unchanged; vitest green; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/knock.js | 7 +- src/lib/vrControls.js | 91 ++++++++++++-- src/lib/vrGrip.js | 74 ++++++++++++ tests/e2e/fakeXR.cjs | 175 +++++++++++++++++++++++++++ tests/e2e/vr-grips-by-mode.test.cjs | 179 ++++++++++++++++++++++++++++ tests/unit/vrGrip.test.js | 65 ++++++++++ 6 files changed, 576 insertions(+), 15 deletions(-) create mode 100644 src/lib/vrGrip.js create mode 100644 tests/e2e/fakeXR.cjs create mode 100644 tests/e2e/vr-grips-by-mode.test.cjs create mode 100644 tests/unit/vrGrip.test.js diff --git a/src/lib/knock.js b/src/lib/knock.js index 0775625a..9544f88b 100644 --- a/src/lib/knock.js +++ b/src/lib/knock.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { isLocked, isVRMode, objectsGroup } from '../stores/sceneStore'; +import { isLocked, isVRMode, objectsGroup, editorMode } from '../stores/sceneStore'; import { peers } from '../stores/appStore'; import { sceneKnock } from './scenePhysics'; import { sessionNow } from './sessionClock'; // 25-E: `at` crosses the wire, so it is SESSION time @@ -141,7 +141,10 @@ function armed() { const cfg = get(sceneKnock); if (!cfg?.enabled) return false; if (!get(simulating) && !get(remoteSimulating)) return false; - return get(isLocked) === true || get(isVRMode) === true; + // 30b P2: in VR only a PLAYER's hands knock — an Edit hand is placing things, and a + // knock would fling the object it is reaching for (contract C1: grips grab/knock in + // Interact/Play; Edit moves things as it always has) + return get(isLocked) === true || (get(isVRMode) === true && get(editorMode) === 'interact'); } /** @param {number} now @param {any} group */ diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 341d0fa9..1c82a890 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -39,7 +39,10 @@ import { vrToolMode, vrTargetHz, vrSleeveEnabled, + editorMode, peerHandStyle, pokeScene } from '../stores/sceneStore'; +import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; +import { resolvePlaySettings } from './playSettings'; import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu'; import { paletteColorAt, barValueAt } from './vrPalette'; import { recordMaterialChange, setMaterialParam } from './materialsHandler'; @@ -1577,11 +1580,12 @@ function broadcastMove(object, force = false) { }); } -/** @param {any} object @param {any} before */ +/** @param {any} object @param {any} before null = a PLAYER's grab (Interact): moved and + * thrown like any other, but not an edit, so no undo entry */ function endGrab(object, before) { broadcastMove(object, true); const after = transformStateOf(object); - if (JSON.stringify(before) !== JSON.stringify(after)) + if (before && JSON.stringify(before) !== JSON.stringify(after)) recordTransform({ uuid: object.uuid, before: before, after: after }); // PFX-C: mid-sim release = throw (velocity estimate from the hold samples) import('./physics').then((m) => m.releaseBody(object.uuid)); @@ -1591,6 +1595,17 @@ function endGrab(object, before) { /** @type {{index: number, prev: any} | null} right-grip drag-the-world pan */ let worldPan = null; +/** 30b P2: test/debug view of what the grips are doing right now */ +export function vrGripDebug() { + return { + grab: grab?.object?.uuid ?? null, + grabInteract: !!grab?.interact, + worldGrab: !!worldGrab, + worldPan: !!worldPan, + emptyAir: [...emptyAirSqueeze] + }; +} + // ---- 214: Box Select — a 3D drag-box marquee. Trigger-press anchors a corner, // the controller drags the opposite corner, release selects every top-level // object whose world origin falls inside. The visual is a scene-root mesh @@ -2229,13 +2244,13 @@ function onSqueezeStart(index) { } if (!get(objectsGroup)) return; const controller = renderer.xr.getController(index); - const hits = controllerRay(index).intersectObjects(get(objectsGroup).children, true); - let object = hits.length ? topLevelObjectOf(hits[0].object) : null; - if (!object) { - // hand inside an object grabs it without a pointer (100.3) - object = containedTopLevel(controller.getWorldPosition(new THREE.Vector3()), get(objectsGroup)); - } + // 30b P2: the grip takes what vrGrip.pickGripTarget says — scenery (floors, walls, the + // room you stand in) passes through, and in INTERACT only a player-holdable body counts + const mode = get(editorMode) === 'interact' ? 'interact' : 'edit'; + let object = gripTargetOf(controllerRay(index), controller.getWorldPosition(new THREE.Vector3()), mode); if (!object) { + // 30b P2: Interact's grips never move the world (contract C1) + if (!gripMovesWorld(mode)) return; emptyAirSqueeze[index] = true; // 186: in stretch mode both grips drive the stretch, not a world grab if (get(vrStretchObject)) return; @@ -2255,6 +2270,8 @@ function onSqueezeStart(index) { if (get(lockedObjects).find((lock) => lock[1] === object.uuid)) return; if (grab && grab.object === object && grab.index !== index) { + // 30b P2: a player's second hand does not resize the thing it is holding + if (mode === 'interact') return; // second hand on the same object -> two-hand scale const distance = controllerDistance(); scaleGrab = { @@ -2268,6 +2285,7 @@ function onSqueezeStart(index) { return; } + const interact = mode === 'interact'; suspendAnimation(object.uuid); // animated objects park at their base while held // PFX-C: mid-sim, a VR-grabbed dynamic body follows the hand kinematically // and RELEASE throws it with the estimated hand velocity — the exact desktop @@ -2286,7 +2304,10 @@ function onSqueezeStart(index) { grab = { object, index, - style: get(vrGrabStyle), + // 30b P2: a player's hand is RIGID (no gizmo-style move/rotate), and `interact` + // switches off the editor's extras in updateGrab/endGrab (snap, stick scale, undo) + interact, + style: interact ? 'rigid' : get(vrGrabStyle), relPos: object.position.clone().sub(pPos).applyQuaternion(pQuat.clone().invert()), relQuat: pQuat.clone().invert().multiply(object.quaternion), startScale: object.scale.clone(), @@ -2297,7 +2318,49 @@ function onSqueezeStart(index) { }; vrGrabbedHand.set(renderer.xr.getController(index)?.userData?.handedness ?? null); hapticPulse(0.25, 30); - selectObject(object.uuid); // locks it for peers, updates selection state + // 30b P2: a player picking something up is not SELECTING it — no lock broadcast, no + // selection shell, no inspector (Edit keeps all three) + if (!interact) selectObject(object.uuid); // locks it for peers, updates selection state +} + +/** + * 30b P2: the top-level object a grip closes on, or null for empty air. Ray hits first + * (nearest first, each top-level object once), then the hand-inside test (100.3); both go + * through the vrGrip rule, so a floor, a wall or the room you stand in is never held. + * Exported for the headless suite. @param {any} ray a THREE.Raycaster + * @param {any} handPos the controller's world position @param {'edit'|'interact'} mode + */ +export function gripTargetOf(ray, handPos, mode) { + const group = get(objectsGroup); + if (!group) return null; + /** @type {any} */ + const camera = get(globalCamera); + const head = camera ? camera.getWorldPosition(new THREE.Vector3()) : null; + const locked = get(lockedObjects); + const interaction = mode === 'interact' ? resolvePlaySettings(get(globalScene)).interaction : 'grab'; + /** @param {any} object */ + const describe = (object) => { + const box = new THREE.Box3().setFromObject(object); + return { + scenery: isScenery(box.isEmpty() ? null : box, head), + grabbable: + interaction === 'grab' && + object.userData?.physics?.mode === 'dynamic' && + !locked.find((/** @type {any} */ lock) => lock[1] === object.uuid) + }; + }; + /** @type {any[]} */ + const order = []; + for (const hit of ray.intersectObjects(group.children, true)) { + const top = topLevelObjectOf(hit.object); + if (top && !order.includes(top)) order.push(top); + } + const picked = pickGripTarget(order.map(describe), mode); + if (picked >= 0) return order[picked]; + // a hand INSIDE an object needs no pointer (100.3) — the same rule decides + const inside = containedTopLevel(handPos, group); + if (inside && pickGripTarget([describe(inside)], mode) === 0) return inside; + return null; } /** @param {number} index */ @@ -2364,7 +2427,7 @@ function onSqueezeEnd(index) { hapticPulse(0.4, 60); return; } - endGrab(object, grab.before); + endGrab(object, grab.interact ? null : grab.before); grab = null; vrGrabbedHand.set(null); hapticPulse(0.18, 24); @@ -2424,7 +2487,7 @@ function updateGrab() { const pPos = position.clone().applyMatrix4(parentInv); const pQuat = parentQuat.clone().invert().multiply(quaternion); - const axes = axesForSlot(grab.index); + const axes = grab.interact ? [] : axesForSlot(grab.index); // 30b P2: no reel/scale in Interact const adjusted = grabStickAdjust({ length: Math.max(grab.relPos.length(), 0.05), scale: grab.scaleFactor, @@ -2440,7 +2503,9 @@ function updateGrab() { const pose = rigidGrabPose(pPos, pQuat, grab.relPos, grab.relQuat); object.position.copy(pose.position); object.quaternion.copy(pose.quaternion); - if (get(vrSnapMode) === 'surface') { + if (grab.interact) { + // 30b P2: a player's hand does not snap + } else if (get(vrSnapMode) === 'surface') { dropToSurface(object, get(objectsGroup)); // 156: rest on the nearest surface under it } else if (get(snapEnabled)) { const step = get(snapSettings).translate; diff --git a/src/lib/vrGrip.js b/src/lib/vrGrip.js new file mode 100644 index 00000000..a4aaba3a --- /dev/null +++ b/src/lib/vrGrip.js @@ -0,0 +1,74 @@ +// 30b P2: WHAT A VR GRIP TAKES HOLD OF — a pure leaf (imports nothing), so the rule is +// unit-tested with no headset and no scene. +// +// THE FINDING (the Quest report: "when in game and in edit mode I should be able to move +// around the world and scale it with grips, now it disables this for some reason"). No +// code disabled the world grab. It fires only when a grip closes on EMPTY AIR, and a game +// scene has none: Stars Room is a closed 12 m room (floor, four walls, a ceiling), the +// football pitch sits in a glass box, Towers stands on a 26 m floor ringed by walls. Every +// controller ray ends on one of them, and the hand-inside test (100.3) is no better in a +// room-sized mesh, so every grip GRABBED THE ROOM — the wall moved with your hand, the world +// never did. A "normal" editor scene has sky behind everything, which is why it only broke +// in games. +// +// THE RULE: SCENERY is not held by a grip. An object is scenery when its world bounds +// reach SCENERY_EXTENT on any axis (a floor, a wall, a ceiling, a pitch) or the viewer's +// HEAD is inside them (you are standing in it — a room mesh, an arena). A grip ray passes +// through scenery to what is behind it, and a grip that finds nothing else is empty air — +// which in EDIT is the world gesture again, everywhere. Scenery still moves in Edit through +// every other path (the trigger selects it; the gizmo, the props panel and the menus act on +// the selection). +// +// And by MODE (contract C1): EDIT holds anything that is not scenery; INTERACT holds only +// what a player may hold — a dynamic physics body, under a play block whose interaction is +// 'grab' — and NEVER moves the world. The first non-scenery hit decides: in Interact a +// static podium in front of a ball blocks the grab, the way a wall blocks your hand. + +/** metres: a world-bounds extent at or past this on any axis makes an object scenery */ +export const SCENERY_EXTENT = 4; + +/** + * @param {{min: {x: number, y: number, z: number}, max: {x: number, y: number, z: number}} | null} box + * the object's WORLD bounds (null / empty = nothing to hold, never scenery) + * @param {{x: number, y: number, z: number} | null} head the viewer's head in world space + * @returns {boolean} + */ +export function isScenery(box, head) { + if (!box || !box.min || !box.max) return false; + const dx = box.max.x - box.min.x; + const dy = box.max.y - box.min.y; + const dz = box.max.z - box.min.z; + if (!(dx >= 0 && dy >= 0 && dz >= 0)) return false; // empty / NaN bounds + if (Math.max(dx, dy, dz) >= SCENERY_EXTENT) return true; + if (!head) return false; + return ( + head.x >= box.min.x && head.x <= box.max.x && + head.y >= box.min.y && head.y <= box.max.y && + head.z >= box.min.z && head.z <= box.max.z + ); +} + +/** + * Which candidate a grip takes, in ray order. + * @param {{scenery: boolean, grabbable: boolean}[]} candidates top-level objects along the + * ray, nearest first (each once) + * @param {'edit' | 'interact'} mode + * @returns {number} the index taken, or -1 for "nothing" (empty air in Edit = the world) + */ +export function pickGripTarget(candidates, mode) { + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]; + if (!c || c.scenery) continue; + if (mode === 'interact') return c.grabbable ? i : -1; + return i; + } + return -1; +} + +/** + * Does an empty-air grip move the world in this mode? Only Edit's does. + * @param {'edit' | 'interact'} mode + */ +export function gripMovesWorld(mode) { + return mode !== 'interact'; +} diff --git a/tests/e2e/fakeXR.cjs b/tests/e2e/fakeXR.cjs new file mode 100644 index 00000000..7cdadfd2 --- /dev/null +++ b/tests/e2e/fakeXR.cjs @@ -0,0 +1,175 @@ +// 30b: a FAKE XR SESSION for headless suites. Headless Chromium cannot present WebXR, so +// the VR suites before this drove the pure exports one by one. This drives the REAL +// per-frame path instead: `renderer.xr.getSession` answers a fake session whose two +// inputSources carry gamepads the suite presses, and Scene's own useTask then runs +// `updateVRControls()` (grips, sticks, buttons) every frame exactly as in a headset. +// Controller poses are written straight into the controller groups' MATRICES +// (WebXRManager owns them, `matrixAutoUpdate` is false — the vr-teleport-handedness trick). +// +// Page-side state lives on `window.__fakeXR`; every helper here is a thin page.evaluate. + +/** Install the session (idempotent). Slot 0 = left, slot 1 = right. @param {any} page */ +async function install(page) { + await page.evaluate(() => { + if (window.__fakeXR) return; + const s = window.__stores; + let r; + s.globalRenderer.subscribe((v) => (r = v))(); + const pad = () => ({ + buttons: Array.from({ length: 6 }, () => ({ pressed: false, touched: false, value: 0 })), + axes: [0, 0, 0, 0], + hapticActuators: [{ pulses: [], pulse(i, ms) { this.pulses.push([i, ms]); return Promise.resolve(true); } }] + }); + const sources = [ + { handedness: 'left', gamepad: pad() }, + { handedness: 'right', gamepad: pad() } + ]; + const session = { inputSources: sources, addEventListener() {}, removeEventListener() {} }; + const original = r.xr.getSession.bind(r.xr); + r.xr.getSession = () => (window.__fakeXR?.on ? session : original()); + r.xr.getController(0).userData.handedness = 'left'; + r.xr.getController(1).userData.handedness = 'right'; + window.__fakeXR = { on: true, session, sources, renderer: r, restore: () => (r.xr.getSession = original) }; + }); +} + +/** @param {any} page @param {boolean} on */ +async function setOn(page, on) { + await page.evaluate((on) => { + window.__fakeXR.on = on; + }, on); +} + +/** + * Pose a controller: world position + a yaw/pitch aim (radians; yaw 0 aims -Z). + * @param {any} page @param {'left'|'right'} hand @param {number[]} pos @param {{yaw?: number, pitch?: number}} [aim] + */ +async function pose(page, hand, pos, aim = {}) { + await page.evaluate( + ({ hand, pos, aim }) => { + const { renderer } = window.__fakeXR; + const THREE = window.__stores.THREE; + const c = renderer.xr.getController(hand === 'left' ? 0 : 1); + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(aim.pitch ?? 0, aim.yaw ?? 0, 0, 'YXZ')); + c.matrix.compose(new THREE.Vector3(...pos), q, new THREE.Vector3(1, 1, 1)); + c.updateMatrixWorld(true); + }, + { hand, pos, aim } + ); +} + +/** Press or release a gamepad button (0 trigger, 1 grip, 3 stick, 4 A/X, 5 B/Y). + * @param {any} page @param {'left'|'right'} hand @param {number} index @param {boolean} down */ +async function button(page, hand, index, down) { + await page.evaluate( + ({ hand, index, down }) => { + const src = window.__fakeXR.sources.find((s) => s.handedness === hand); + src.gamepad.buttons[index].pressed = down; + src.gamepad.buttons[index].value = down ? 1 : 0; + }, + { hand, index, down } + ); +} + +/** Stick axes (xr-standard: axes[2] x, axes[3] y; stick UP is NEGATIVE y). + * @param {any} page @param {'left'|'right'} hand @param {number} x @param {number} y */ +async function stick(page, hand, x, y) { + await page.evaluate( + ({ hand, x, y }) => { + const src = window.__fakeXR.sources.find((s) => s.handedness === hand); + src.gamepad.axes[2] = x; + src.gamepad.axes[3] = y; + }, + { hand, x, y } + ); +} + +/** haptic pulses recorded per hand @param {any} page */ +async function pulses(page) { + return page.evaluate(() => + Object.fromEntries(window.__fakeXR.sources.map((s) => [s.handedness, s.gamepad.hapticActuators[0].pulses.length])) + ); +} + +/** + * A SPEC-ACCURATE reference space + frame, for suites that move the player. WebXR: + * `space.getOffsetReferenceSpace(t)` makes a space whose origin sits at `t` in the old one, + * and a pose in it is `inverse(accumulated origin offset) * the pose in the base space`. + * The head stands at `head` (base-space metres) facing `yaw`; the renderer's reference + * space becomes the base, and `vrControls.noteXRBaseSpace()` remembers it the way Scene's + * onsessionstart does. + * @param {any} page @param {{head?: number[], yaw?: number}} [opts] + */ +async function installSpace(page, opts = {}) { + await page.evaluate(({ head, yaw }) => { + const fx = window.__fakeXR; + const THREE = window.__stores.THREE; + const r = fx.renderer; + if (typeof window.XRRigidTransform === 'undefined') { + window.XRRigidTransform = class { + constructor(p = {}, o = {}) { + this.position = { x: p.x ?? 0, y: p.y ?? 0, z: p.z ?? 0, w: 1 }; + this.orientation = { x: o.x ?? 0, y: o.y ?? 0, z: o.z ?? 0, w: o.w ?? 1 }; + } + }; + } + const basePose = new THREE.Matrix4().compose( + new THREE.Vector3(...head), + new THREE.Quaternion().setFromEuler(new THREE.Euler(0, yaw, 0, 'YXZ')), + new THREE.Vector3(1, 1, 1) + ); + const makeSpace = (m) => ({ + __m: m, + getOffsetReferenceSpace(t) { + const p = t.position; + const o = t.orientation; + const offset = new THREE.Matrix4().compose( + new THREE.Vector3(p.x, p.y, p.z), + new THREE.Quaternion(o.x, o.y, o.z, o.w), + new THREE.Vector3(1, 1, 1) + ); + return makeSpace(m.clone().multiply(offset)); + } + }); + const base = makeSpace(new THREE.Matrix4()); + const poseIn = (space) => { + const m = space.__m.clone().invert().multiply(basePose); + const pos = new THREE.Vector3(); + const q = new THREE.Quaternion(); + m.decompose(pos, q, new THREE.Vector3()); + return { transform: { position: { x: pos.x, y: pos.y, z: pos.z }, orientation: { x: q.x, y: q.y, z: q.z, w: q.w } } }; + }; + const frame = { getViewerPose: (space) => (space?.__m ? poseIn(space) : null) }; + fx.originalGetFrame = fx.originalGetFrame ?? r.xr.getFrame.bind(r.xr); + r.xr.getFrame = () => (window.__fakeXR?.on ? frame : fx.originalGetFrame()); + r.xr.setReferenceSpace(base); + fx.base = base; + fx.poseNow = () => { + const pose = poseIn(r.xr.getReferenceSpace()); + const p = pose.transform.position; + const o = pose.transform.orientation; + const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(new THREE.Quaternion(o.x, o.y, o.z, o.w)); + return { x: p.x, y: p.y, z: p.z, yaw: Math.atan2(-fwd.x, -fwd.z) }; + }; + window.__stores.vrControls.noteXRBaseSpace(); + }, { head: opts.head ?? [0, 1.6, 0], yaw: opts.yaw ?? 0 }); +} + +/** the head in the CURRENT reference space (= world, the dolly never moves) @param {any} page */ +async function head(page) { + return page.evaluate(() => window.__fakeXR.poseNow()); +} + +/** uninstall: the renderer's own getSession comes back @param {any} page */ +async function uninstall(page) { + await page.evaluate(() => { + if (!window.__fakeXR) return; + const fx = window.__fakeXR; + if (fx.originalGetFrame) fx.renderer.xr.getFrame = fx.originalGetFrame; + if (fx.base) fx.renderer.xr.setReferenceSpace(null); + fx.restore(); + window.__fakeXR = null; + }); +} + +module.exports = { install, setOn, pose, button, stick, pulses, installSpace, head, uninstall }; diff --git a/tests/e2e/vr-grips-by-mode.test.cjs b/tests/e2e/vr-grips-by-mode.test.cjs new file mode 100644 index 00000000..108026cb --- /dev/null +++ b/tests/e2e/vr-grips-by-mode.test.cjs @@ -0,0 +1,179 @@ +// 30b P2: GRIPS BY MODE. The Quest report: "when in game and in edit mode I should be able +// to move around the world and scale it with grips, now it disables this for some reason". +// Nothing disabled the world grab — it only ever fired on EMPTY AIR, and a game scene has +// none: every controller ray ends on a floor, a wall or a ceiling, so every grip grabbed +// the ROOM. The grip now passes through scenery (vrGrip.js), and the mode decides the rest: +// Edit holds anything that is not scenery and moves the world on air; Interact holds only +// dynamic bodies and never moves the world. +// +// Driven through the REAL per-frame path (Scene's useTask -> updateVRControls) with a fake +// XR session (fakeXR.cjs): the suite presses grips and moves controllers. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +const state = (page) => + page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const rig = get(s.worldRig); + return { + ...s.vrControls.vrGripDebug(), + rigScale: rig.scale.x, + rigPos: rig.position.toArray(), + selected: [...get(s.selectedObjects)], + undo: s.history.undoStack ? get(s.history.undoStack).length : null, + mode: get(s.editorMode) + }; + }); +const posOf = (page, uuid) => + page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + return g.getObjectByProperty('uuid', uuid).position.toArray(); + }, uuid); +const grip = async (page, hand, down) => { + await xr.button(page, hand, 1, down); + await page.waitForTimeout(250); +}; +const resetRig = (page) => page.evaluate(() => window.__stores.vrControls.resetWorldRig()); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- the fixture: a game-shaped room --------------------------------------------------- + // a 20 m floor, a 10 m wall 3 m ahead, a dynamic cube and a static podium in front of it + const ids = await A.page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 4; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 600)); + const boxes = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + const [floor, wall, cube, podium] = boxes; + floor.scale.set(20, 0.5, 20); + floor.position.set(0, -0.25, 0); + floor.userData.physics = { mode: 'static' }; + wall.scale.set(10, 4, 0.5); + wall.position.set(0, 2, -3); + wall.userData.physics = { mode: 'static' }; + cube.scale.setScalar(0.4); + cube.position.set(0, 1.2, -1.5); + cube.userData.physics = { mode: 'dynamic', mass: 1 }; + podium.scale.setScalar(0.4); + podium.position.set(1.2, 1.2, -1.5); + podium.userData.physics = { mode: 'static' }; + for (const b of boxes) b.updateMatrixWorld(true); + s.objectActions.deselectObject(); + s.isVRMode.set(true); + return { floor: floor.uuid, wall: wall.uuid, cube: cube.uuid, podium: podium.uuid }; + }); + await A.page.waitForTimeout(800); + await xr.install(A.page); + // both hands aim straight ahead at the WALL (the only thing along -Z at y 1.6) + const aimWall = async () => { + await xr.pose(A.page, 'left', [-0.3, 1.6, 0]); + await xr.pose(A.page, 'right', [0.3, 1.6, 0]); + }; + await aimWall(); + await A.page.waitForTimeout(200); + + // ---- 1. EDIT: two grips on the wall = the WORLD grab, not the wall ---------------------- + let s = await state(A.page); + h.check(s.mode === 'edit' && Math.abs(s.rigScale - 1) < 1e-6, `premise: Edit, the world at 1:1 (${s.rigScale})`); + const wallBefore = await posOf(A.page, ids.wall); + await grip(A.page, 'left', true); + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === null, `Edit: a grip on a wall does not hold the wall (grab ${s.grab})`); + h.check(s.worldGrab, 'Edit: two grips on scenery start the two-grip WORLD grab'); + // spread the hands: the world scales up + await xr.pose(A.page, 'left', [-0.6, 1.6, 0]); + await xr.pose(A.page, 'right', [0.6, 1.6, 0]); + await A.page.waitForTimeout(300); + s = await state(A.page); + h.check(s.rigScale > 1.8, `Edit: spreading the hands scales the world (x${s.rigScale.toFixed(2)})`); + const wallAfter = await posOf(A.page, ids.wall); + h.check(JSON.stringify(wallAfter) === JSON.stringify(wallBefore), 'Edit: the wall itself never moved'); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + await resetRig(A.page); + await aimWall(); + + // ---- 2. EDIT: one right grip on the floor = the one-hand world PAN -------------------------- + await xr.pose(A.page, 'right', [0.3, 1.2, 0], { pitch: -Math.PI / 2 }); // straight down at the floor + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === null && s.worldPan, `Edit: a right grip on the floor pans the world (grab ${s.grab}, pan ${s.worldPan})`); + await grip(A.page, 'right', false); + await aimWall(); + + // ---- 3. EDIT: a grip on the cube still holds and moves it, as always ------------------------- + await xr.pose(A.page, 'right', [0, 1.2, 0]); // aims -Z through the cube at (0,1.2,-1.5) + const undoBefore = (await state(A.page)).undo; + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === ids.cube && !s.grabInteract, `Edit: a grip on the cube holds it (${s.grab})`); + h.check(s.selected.includes(ids.cube), 'Edit: holding selects it (the editor lock)'); + await xr.pose(A.page, 'right', [0.5, 1.2, 0]); + await A.page.waitForTimeout(250); + await grip(A.page, 'right', false); + const cubeMoved = await posOf(A.page, ids.cube); + h.check(Math.abs(cubeMoved[0] - 0.5) < 0.05, `Edit: the cube followed the hand (x ${cubeMoved[0].toFixed(2)})`); + s = await state(A.page); + if (undoBefore !== null) h.check(s.undo === undoBefore + 1, `Edit: the move is one undo entry (${undoBefore} -> ${s.undo})`); + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + + // ---- 4. INTERACT: scenery and air do nothing — the world never moves ------------------------ + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await aimWall(); + await A.page.waitForTimeout(200); + await grip(A.page, 'left', true); + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === null && !s.worldGrab && !s.worldPan, `Interact: two grips on the wall hold nothing and start no world gesture (${JSON.stringify(s)})`); + await xr.pose(A.page, 'left', [-0.8, 1.6, 0]); + await xr.pose(A.page, 'right', [0.8, 1.6, 0]); + await A.page.waitForTimeout(300); + s = await state(A.page); + h.check(Math.abs(s.rigScale - 1) < 1e-6 && s.rigPos.every((v) => Math.abs(v) < 1e-6), `Interact: the world stays put (x${s.rigScale})`); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + + // ---- 5. INTERACT: the static podium is not holdable ------------------------------------------ + await xr.pose(A.page, 'right', [1.2, 1.2, 0]); + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === null, `Interact: a static podium is not holdable (${s.grab})`); + await grip(A.page, 'right', false); + + // ---- 6. INTERACT: the dynamic cube IS, as a player's hand ----------------------------------- + const cubeNow = await posOf(A.page, ids.cube); + await xr.pose(A.page, 'right', [cubeNow[0], 1.2, 0]); + const undoInteract = (await state(A.page)).undo; + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.grab === ids.cube && s.grabInteract, `Interact: a grip holds the dynamic cube (${s.grab}, interact ${s.grabInteract})`); + h.check(!s.selected.includes(ids.cube), 'Interact: holding is not selecting (no editor lock)'); + await xr.pose(A.page, 'right', [cubeNow[0] - 0.5, 1.2, 0]); + await A.page.waitForTimeout(250); + await grip(A.page, 'right', false); + const cubeThrown = await posOf(A.page, ids.cube); + h.check(Math.abs(cubeThrown[0] - (cubeNow[0] - 0.5)) < 0.05, `Interact: the cube followed the hand (x ${cubeThrown[0].toFixed(2)})`); + s = await state(A.page); + if (undoInteract !== null) h.check(s.undo === undoInteract, `Interact: a player's grab is not an undo step (${undoInteract} -> ${s.undo})`); + + // ---- 7. back to EDIT: the world grab works again --------------------------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await aimWall(); + await grip(A.page, 'left', true); + await grip(A.page, 'right', true); + s = await state(A.page); + h.check(s.worldGrab, 'back in Edit: two grips on the wall grab the world again'); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + + await resetRig(A.page); + await xr.uninstall(A.page); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + await h.finish(browser); +}); diff --git a/tests/unit/vrGrip.test.js b/tests/unit/vrGrip.test.js new file mode 100644 index 00000000..b09e4a80 --- /dev/null +++ b/tests/unit/vrGrip.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { isScenery, pickGripTarget, gripMovesWorld, SCENERY_EXTENT } from '../../src/lib/vrGrip.js'; + +// 30b P2: the grip rule. The game-scene shapes below are the real ones from the Quest report +// (Towers' 26 m floor, Stars Room's 12.5 m walls, the football pitch's 5.5 m glass). +/** @param {number[]} min @param {number[]} max */ +const box = (min, max) => ({ + min: { x: min[0], y: min[1], z: min[2] }, + max: { x: max[0], y: max[1], z: max[2] } +}); +const head = { x: 0, y: 1.6, z: 0 }; + +describe('isScenery', () => { + it('a floor, a wall and a glass side are scenery by extent', () => { + expect(isScenery(box([-13, -0.45, -13], [13, 0.05, 13]), head)).toBe(true); // Towers floor + expect(isScenery(box([-6.25, 0, -6.25], [6.25, 7, -5.75]), head)).toBe(true); // Stars Room wall + expect(isScenery(box([-1.525, 0, -2.75], [-1.475, 2.4, 2.75]), head)).toBe(true); // football glass + }); + it('a cube, a ball, a podium are not', () => { + expect(isScenery(box([-0.3, 0.5, -0.3], [0.3, 1.1, 0.3]), head)).toBe(false); + expect(isScenery(box([-0.22, 1.08, -0.22], [0.22, 1.52, 0.22]), { x: 3, y: 1.6, z: 3 })).toBe(false); + }); + it('the extent threshold is inclusive at SCENERY_EXTENT', () => { + expect(isScenery(box([0, 0, 0], [SCENERY_EXTENT, 1, 1]), null)).toBe(true); + expect(isScenery(box([0, 0, 0], [SCENERY_EXTENT - 0.01, 1, 1]), null)).toBe(false); + }); + it('a small room you stand INSIDE is scenery (head inside the bounds)', () => { + const booth = box([-1.5, 0, -1.5], [1.5, 2.4, 1.5]); + expect(isScenery(booth, head)).toBe(true); + expect(isScenery(booth, { x: 5, y: 1.6, z: 0 })).toBe(false); + }); + it('empty or missing bounds hold nothing and are never scenery', () => { + expect(isScenery(null, head)).toBe(false); + expect(isScenery(box([1, 1, 1], [-1, -1, -1]), head)).toBe(false); // Box3.makeEmpty() + expect(isScenery(box([NaN, 0, 0], [1, 1, 1]), head)).toBe(false); + }); +}); + +describe('pickGripTarget', () => { + const wall = { scenery: true, grabbable: false }; + const podium = { scenery: false, grabbable: false }; + const ball = { scenery: false, grabbable: true }; + it('Edit: scenery is passed through; the first real object is taken', () => { + expect(pickGripTarget([wall, podium, ball], 'edit')).toBe(1); + expect(pickGripTarget([wall, ball], 'edit')).toBe(1); + }); + it('Edit: only scenery along the ray = empty air (the world gesture)', () => { + expect(pickGripTarget([wall, wall], 'edit')).toBe(-1); + expect(pickGripTarget([], 'edit')).toBe(-1); + }); + it('Interact: only a grabbable body is taken, through scenery', () => { + expect(pickGripTarget([wall, ball], 'interact')).toBe(1); + }); + it('Interact: a non-grabbable object in front blocks the grab', () => { + expect(pickGripTarget([podium, ball], 'interact')).toBe(-1); + expect(pickGripTarget([wall, podium], 'interact')).toBe(-1); + }); +}); + +describe('gripMovesWorld', () => { + it('only Edit moves the world', () => { + expect(gripMovesWorld('edit')).toBe(true); + expect(gripMovesWorld('interact')).toBe(false); + }); +}); From 35079f9d07736ad608b38982a0891408e10ebaed Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:33:24 +0300 Subject: [PATCH 07/18] =?UTF-8?q?[feat]=2030b=20P3:=20walk=20like=20a=20ga?= =?UTF-8?q?me=20=E2=80=94=20Interact=20collides,=20falls=20and=20climbs;?= =?UTF-8?q?=20Edit=20still=20flies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Quest report (the dungeon): "I can go through walls, teleport, I want to be able to do this only in edit mode and fly only in edit mode. In interactive mode I should be able to move like in a game." - locomotionPolicy.js (pure): Edit = fly + teleport + world gestures, no walls (exactly the old movement); Interact = walk, collide, gravity, snap turn, NO fly or teleport unless `play.locomotion: {teleport?, fly?}` allows (absent = false; a flier drops gravity but keeps walls). normalizeLocomotion/normalizeSpawn + yaw helpers + the VR spawn as two WebXR offsets (used from P4). - scenePhysics: play.locomotion and play.spawn normalised at the boundary and OMITTED when absent/empty, so saved scenes stay byte-identical. playSettings.resolvePlaySettings resolves `locomotion` field by field from the scene and publishers (the dungeon Kit publishes {teleport:false, fly:false}) and `spawn`. - charController: tickWalker's body became resolveWalk(feet, height, dt, desired) so the VR walker resolves through the SAME tiers (rapier capsule with a sim / dungeon raster / ground plane). Asked by the dungeon lane: the raster now also clamps the rapier tier (Kit walls are module meshes, never rapier colliders). Desktop parity: tickWalker unchanged in behaviour (char-controller 55/55, twice). - vrControls: vrWalkStep (stick -> head-yaw step at 2.2 m/s -> resolveWalk) and tickVRInteractLocomotion (feet = head world y minus the head's height in the BASE reference space captured at session start; moves by offsetting the XR reference space; falls out of the world -> back to the spawn/origin). updateTeleport honours the policy. VRControls.svelte: Interact runs the walker (a held object no longer stops your feet); Edit keeps its stick and now FLIES THROUGH walls (the raster clamp moved to Interact). - Desktop Play keeps its built-in flight (the 21-E6 parity contract) but a 1 m head capsule now stops it at colliders while a simulation runs (collideRigStep; no world = byte-for-byte the old step). - fakeXR.installSpace: a reference space + frame that compose offsets exactly as WebXR specifies, so the real per-frame walker runs headless. - New: vitest locomotionPolicy (12, incl. the spawn offsets against WebXR's composition rule), e2e vr-walk-interact (20): a real rapier world — wall stops at -2.5, gravity lands the feet, a 0.25 m step is climbed, a 0.6 m block is a wall, a flier climbs; the real per-frame path walks forward and stops at the wall; teleport off in Interact, on with play.locomotion.teleport and in Edit; Edit flies through the wall; desktop Play stops. - Counterfactuals: tickVRInteractLocomotion disabled -> "Interact: the wall stops you (z -7.55)" red; the teleport gate removed -> "Interact: the right stick does NOT arm a teleport" red; collideRigStep disabled -> "desktop Play: the wall stops you (z -15.10)" red. - svelte-check 334/47 (one base error gone with the Edit raster clamp); build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../play/PointerLockControls.svelte | 11 +- src/components/play/VRControls.svelte | 27 ++- src/lib/charController.js | 117 ++++++++++-- src/lib/locomotionPolicy.js | 108 +++++++++++ src/lib/playSettings.js | 17 +- src/lib/scenePhysics.js | 15 +- src/lib/vrControls.js | 159 +++++++++++++++- tests/e2e/vr-walk-interact.test.cjs | 169 ++++++++++++++++++ tests/unit/locomotionPolicy.test.js | 117 ++++++++++++ 9 files changed, 699 insertions(+), 41 deletions(-) create mode 100644 src/lib/locomotionPolicy.js create mode 100644 tests/e2e/vr-walk-interact.test.cjs create mode 100644 tests/unit/locomotionPolicy.test.js diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index faa9a53a..f8ddbce4 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/charController.js b/src/lib/charController.js index 949bb2f0..438297f5 100644 --- a/src/lib/charController.js +++ b/src/lib/charController.js @@ -307,13 +307,48 @@ function floorHeight() { */ export function tickWalker(rig, settings, dt, desired) { if (!rig) return { grounded, vy, source: 'none' }; - const step = Math.max(0, Math.min(dt, 0.1)); // a tab that was backgrounded const eyeHeight = Math.max(0.1, Number(settings?.eyeHeight ?? 1.7) || 1.7); - const useGravity = settings?.gravity !== false; - const g = Math.abs(Number(get(sceneGravity)) || 9.81); - rig.getWorldPosition(_worldPos); - let feet = _worldPos.y - eyeHeight; + const moved = resolveWalk( + { x: _worldPos.x, y: _worldPos.y - eyeHeight, z: _worldPos.z }, + eyeHeight, + dt, + desired, + { gravity: settings?.gravity !== false, jumpHeight: settings?.jumpHeight } + ); + // write back through the rig's PARENT: the camera lives in a group at y = 0.9, so a + // world target has to be converted rather than assigned + _target.set(_worldPos.x + moved.dx, moved.feet + eyeHeight, _worldPos.z + moved.dz); + if (rig.parent) rig.parent.worldToLocal(_target); + rig.position.copy(_target); + return { grounded: moved.grounded, vy: moved.vy, source: moved.source }; +} + +/** + * 30b P3: THE WALKER'S RESOLUTION, without a rig — so the VR walker (which moves the + * XR reference space, not a camera parent) resolves against exactly the same tiers. + * `feetPos` is the feet in WORLD space; the capsule spans feet -> feet + `height`. + * Returns the resolved world step and the new feet height. Owns the module's vertical + * state (vy / grounded / the jump edge): desktop play and a VR session never walk at once. + * + * 30b (asked by the dungeon lane): the dungeon RASTER now also clamps the rapier tier. + * A Kit dungeon's walls are module InstancedMeshes, never rapier colliders, so with a + * simulation running the capsule found nothing to stop it and walked through every wall. + * @param {{x: number, y: number, z: number}} feetPos + * @param {number} height eye/capsule height in metres + * @param {number} dt seconds + * @param {{dx: number, dz: number, dy?: number}} desired the wanted step, world units + * (`dy` only without gravity — a flier's vertical intent) + * @param {{gravity?: boolean, jumpHeight?: number}} [opts] + * @returns {{dx: number, dy: number, dz: number, feet: number, grounded: boolean, vy: number, source: 'rapier'|'dungeon'|'plane'}} + */ +export function resolveWalk(feetPos, height, dt, desired, opts = {}) { + const step = Math.max(0, Math.min(dt, 0.1)); // a tab that was backgrounded + const eyeHeight = Math.max(0.1, Number(height) || 1.7); + const useGravity = opts.gravity !== false; + const g = Math.abs(Number(get(sceneGravity)) || 9.81); + _worldPos.set(feetPos.x, feetPos.y + eyeHeight, feetPos.z); + let feet = feetPos.y; // gravity + the jump edge. The jump is spent only while we are ON something, so a // press in mid-air is DROPPED rather than queued — a queued one fires on landing, @@ -321,7 +356,7 @@ export function tickWalker(rig, settings, dt, desired) { if (useGravity) { if (jumpRequested && grounded) { jumpRequested = false; - vy = Math.sqrt(2 * g * Math.max(0, Number(settings?.jumpHeight ?? 0) || 0)); + vy = Math.sqrt(2 * g * Math.max(0, Number(opts.jumpHeight ?? 0) || 0)); } vy -= g * step; } else { @@ -333,7 +368,7 @@ export function tickWalker(rig, settings, dt, desired) { let source = 'plane'; let dx = desired?.dx ?? 0; let dz = desired?.dz ?? 0; - let dy = useGravity ? vy * step : 0; + let dy = useGravity ? vy * step : Number(desired?.dy ?? 0) || 0; const built = ensureCapsule(physicsRuntime(), eyeHeight); if (built && capsule && controller) { @@ -345,6 +380,13 @@ export function tickWalker(rig, settings, dt, desired) { dx = moved.x; dy = moved.y; dz = moved.z; + // 30b: a dungeon raster clamps the capsule's step too (see resolveWalk) + const raster = dungeonData(get(globalScene)); + if (raster && (dx || dz)) { + const slid = slideMove(raster, _worldPos.x, _worldPos.z, dx, dz, CAPSULE_RADIUS); + dx = slid.x - _worldPos.x; + dz = slid.z - _worldPos.z; + } grounded = !!controller.computedGrounded(); feet += dy; if (grounded && vy < 0) vy = 0; @@ -356,7 +398,7 @@ export function tickWalker(rig, settings, dt, desired) { console.log('character controller step failed', error); dropCapsule(); source = 'plane'; - dy = useGravity ? vy * step : 0; + dy = useGravity ? vy * step : Number(desired?.dy ?? 0) || 0; } } @@ -373,8 +415,15 @@ export function tickWalker(rig, settings, dt, desired) { const floor = raster ? 0 : floorHeight(); feet += dy; if (!useGravity) { - feet = floor; - grounded = true; + // a flier (30b: `desired.dy` given) keeps its height above the floor; the walker + // with gravity off never passes dy, so it still stands ON the floor, unchanged + if (desired?.dy == null) { + feet = floor; + grounded = true; + } else { + feet = Math.max(floor, feet); + grounded = feet <= floor + 1e-4; + } } else if (feet <= floor + 1e-4) { feet = floor; if (vy < 0) vy = 0; @@ -384,12 +433,6 @@ export function tickWalker(rig, settings, dt, desired) { } } - // write back through the rig's PARENT: the camera lives in a group at y = 0.9, so a - // world target has to be converted rather than assigned - _target.set(_worldPos.x + dx, feet + eyeHeight, _worldPos.z + dz); - if (rig.parent) rig.parent.worldToLocal(_target); - rig.position.copy(_target); - const state = { vy, grounded, ground: feet, source }; const previous = get(walkerState); if ( @@ -399,7 +442,47 @@ export function tickWalker(rig, settings, dt, desired) { Math.abs(previous.ground - state.ground) > 1e-4 ) walkerState.set(state); - return { grounded, vy, source }; + return { dx, dy, dz, feet, grounded, vy, source }; +} + +/** the head-sized capsule the built-in flier collides with: eye .. eye - FLY_BODY */ +const FLY_BODY = 1.0; + +/** + * 30b P3: DESKTOP PLAY'S BUILT-IN FLIER COLLIDES. With no Character Controller node, + * PointerLockControls moves the camera rig freely (WASD/QE/the pad) and the only wall it + * ever respected was a dungeon raster, so a player flew through every wall of every game. + * After the built-in step, the rig's world displacement since `before` is resolved through + * the walker's rapier capsule (a 1 m body hanging below the eye — a wall stops you, a floor + * holds you up, a low rim can still be flown over). Only while a simulation RUNS, because + * only then does a world with colliders exist; otherwise nothing happens and the step is + * byte-for-byte the old one (the 21-E6 parity contract: an empty scene flies as before). + * @param {any} rig the camera object PointerLockControls drives + * @param {{x: number, y: number, z: number}} before the rig's WORLD position before the step + * @returns {boolean} whether a collision pass ran + */ +export function collideRigStep(rig, before) { + if (!rig) return false; + const rt = physicsRuntime(); + if (!rt) return false; + if (!ensureCapsule(rt, FLY_BODY) || !capsule || !controller) return false; + rig.getWorldPosition(_worldPos); + const d = { x: _worldPos.x - before.x, y: _worldPos.y - before.y, z: _worldPos.z - before.z }; + if (Math.abs(d.x) + Math.abs(d.y) + Math.abs(d.z) < 1e-7) return false; + try { + capsule.setTranslation({ x: before.x, y: before.y - FLY_BODY / 2, z: before.z }); + controller.computeColliderMovement(capsule, d); + const moved = controller.computedMovement(); + _target.set(before.x + moved.x, before.y + moved.y, before.z + moved.z); + capsule.setTranslation({ x: _target.x, y: _target.y - FLY_BODY / 2, z: _target.z }); + if (rig.parent) rig.parent.worldToLocal(_target); + rig.position.copy(_target); + return true; + } catch (error) { + console.log('fly collision failed', error); + dropCapsule(); + return false; + } } /** Forget the walker's motion (a mode change, the controller removed, a test starting diff --git a/src/lib/locomotionPolicy.js b/src/lib/locomotionPolicy.js new file mode 100644 index 00000000..4643180f --- /dev/null +++ b/src/lib/locomotionPolicy.js @@ -0,0 +1,108 @@ +// 30b P3/P4: HOW A PLAYER MAY MOVE, and WHERE THEY START — a pure leaf (imports nothing), +// so the rule table and the spawn maths are unit-tested with no headset. +// +// Contract C1: EDIT keeps the editor's movement (fly, teleport, the world gestures). +// INTERACT and PLAY walk like a game — the stick walks, a capsule stops at walls, gravity +// pulls you down, a ~0.3 m step is climbed, snap turn stays — and NEITHER fly NOR teleport +// unless the scene's play block allows it (`play.locomotion: {teleport?, fly?}`, absent = +// false). The Quest report this answers: "In [the dungeon] I can go through walls, +// teleport, I want to be able to do this only in edit mode and fly only in edit mode." + +/** + * @param {'edit' | 'interact'} mode + * @param {{teleport?: boolean, fly?: boolean} | null | undefined} locomotion the resolved play block + * @returns {{walk: boolean, fly: boolean, teleport: boolean, collide: boolean, gravity: boolean, worldGestures: boolean}} + */ +export function locomotionPolicy(mode, locomotion) { + if (mode !== 'interact') { + // the editor: movement exactly as it has always been, walls included + return { walk: false, fly: true, teleport: true, collide: false, gravity: false, worldGestures: true }; + } + const fly = locomotion?.fly === true; + return { + walk: true, + fly, + teleport: locomotion?.teleport === true, + collide: true, + gravity: !fly, + worldGestures: false + }; +} + +/** + * `play.locomotion` at a store boundary: only booleans survive, and an empty block is + * ABSENT (so a scene that never used it saves byte-identically). + * @param {any} raw @returns {{teleport?: boolean, fly?: boolean} | null} + */ +export function normalizeLocomotion(raw) { + if (!raw || typeof raw !== 'object') return null; + /** @type {{teleport?: boolean, fly?: boolean}} */ + const out = {}; + if (typeof raw.teleport === 'boolean') out.teleport = raw.teleport; + if (typeof raw.fly === 'boolean') out.fly = raw.fly; + return Object.keys(out).length ? out : null; +} + +const SPAWN_LIMIT = 100000; + +/** + * A spawn point at a store/api boundary: `{position: [x, y, z], yaw}` with finite numbers + * (y is the FEET, yaw in radians, three's rotation.y: 0 faces -Z). Accepts the api's + * `(position, yaw)` pair too. Anything else is null. + * @param {any} raw @param {any} [yawArg] + * @returns {{position: [number, number, number], yaw: number} | null} + */ +export function normalizeSpawn(raw, yawArg) { + const position = Array.isArray(raw) ? raw : raw?.position; + if (!Array.isArray(position) || position.length < 3) return null; + const p = position.slice(0, 3).map(Number); + if (!p.every((v) => Number.isFinite(v) && Math.abs(v) <= SPAWN_LIMIT)) return null; + const yawRaw = Array.isArray(raw) ? yawArg : raw?.yaw; + const yaw = Number(yawRaw ?? 0); + return { + position: /** @type {[number, number, number]} */ (p), + yaw: Number.isFinite(yaw) ? yaw : 0 + }; +} + +/** forward (the direction you face) for a yaw: (-sin yaw, 0, -cos yaw) @param {number} yaw */ +export function yawForward(yaw) { + return { x: -Math.sin(yaw), y: 0, z: -Math.cos(yaw) }; +} + +/** + * The yaw you face from a forward vector (the inverse of yawForward; y is ignored). + * @param {{x: number, z: number}} dir + */ +export function yawOf(dir) { + return Math.atan2(-dir.x, -dir.z); +} + +/** + * THE VR SPAWN as two XR reference-space offsets (WebXR: a pose in the NEW space is + * `inverse(originOffset) * pose in the old one`, so an offset rotating by `a` turns the + * viewer by `-a`, and one translating by `t` moves the viewer by `-t`). + * 1. `turn`: rotate about the viewer's head so they face `yaw` (the snap-turn shape), + * 2. `move`: translate so the FEET land on the spawn point. + * @param {{x: number, y: number, z: number}} head the head in the current space + * @param {number} headYaw the yaw the head currently faces + * @param {number} headHeight the head's height above the physical floor + * @param {{position: number[], yaw: number}} spawn + */ +export function vrSpawnOffsets(head, headYaw, headHeight, spawn) { + const a = headYaw - spawn.yaw; // the viewer turns by -a = spawn.yaw - headYaw + const s = Math.sin(a); + const c = Math.cos(a); + const turn = { + angle: a, + position: { x: head.x - (c * head.x + s * head.z), y: 0, z: head.z - (-s * head.x + c * head.z) }, + orientation: { x: 0, y: Math.sin(a / 2), z: 0, w: Math.cos(a / 2) } + }; + const feet = head.y - headHeight; + const move = { + x: head.x - spawn.position[0], + y: feet - spawn.position[1], + z: head.z - spawn.position[2] + }; + return { turn, move }; +} diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 2f739586..4b2ded41 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -1,6 +1,7 @@ import { get } from 'svelte/store'; import { scenePlay } from './scenePhysics'; import { showToast } from '../stores/appStore'; +import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; // 21-B B3: what play mode BEHAVES like in this scene. // @@ -45,7 +46,11 @@ export function playPublishers(scene) { * crosshair, today). A module publishing `userData.play.cursor` overrides the scene's, the * way `grounded` does, which is how a board-game module asks for it without an authored * scene field. - * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number, cursor: 'free'|'locked'}} + * 30b P3/P4: `locomotion` ({teleport, fly}, both false unless the scene or a publisher + * allows them — field by field, like `grounded`) and `spawn` (the runtime api.setSpawn, + * else a publisher's `userData.play.spawn`, else the scene's `play.spawn`, else null). + * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number, cursor: 'free'|'locked', + * locomotion: {teleport: boolean, fly: boolean}, spawn: {position: [number, number, number], yaw: number} | null}} */ export function resolvePlaySettings(scene) { const base = get(scenePlay); @@ -54,8 +59,12 @@ export function resolvePlaySettings(scene) { interaction: base.interaction, grounded: base.grounded, eyeHeight: DEFAULT_EYE_HEIGHT, - cursor: base.cursor === 'free' ? 'free' : 'locked' + cursor: base.cursor === 'free' ? 'free' : 'locked', + locomotion: { teleport: false, fly: false }, + spawn: normalizeSpawn(base.spawn) }; + const baseLoco = normalizeLocomotion(base.locomotion); + if (baseLoco) Object.assign(out.locomotion, baseLoco); const publishers = playPublishers(scene); if (publishers.length > 1 && !warnedMultiple) { warnedMultiple = true; @@ -73,6 +82,10 @@ export function resolvePlaySettings(scene) { if (typeof play.grounded === 'boolean') out.grounded = play.grounded; if (typeof play.eyeHeight === 'number') out.eyeHeight = play.eyeHeight; if (play.cursor === 'free' || play.cursor === 'locked') out.cursor = play.cursor; + const loco = normalizeLocomotion(play.locomotion); + if (loco) Object.assign(out.locomotion, loco); + const spawn = normalizeSpawn(play.spawn); + if (spawn) out.spawn = spawn; } return out; } diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 0bc8c1d5..29a56f87 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -1,6 +1,7 @@ import { writable, derived, get } from 'svelte/store'; import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; +import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; // 30b P3/P4 // CL-A A6 / 21-B B1: scene-wide physics settings. ONE shared object for the // whole session, replicated as its OWN latest-wins singleton message (the @@ -65,6 +66,12 @@ function nullableNum(v, lo, hi) { return Math.max(lo, Math.min(hi, n)); } +/** `{[key]: value}` when there is a value, `{}` when not — for fields that are ABSENT at + * their default. @param {string} key @param {any} value */ +function optional(key, value) { + return value == null ? {} : { [key]: value }; +} + /** @param {any} v @param {boolean} fallback */ function bool(v, fallback) { return typeof v === 'boolean' ? v : fallback; @@ -148,9 +155,13 @@ export function normalizeScenePhysics(raw) { grounded: bool(playRaw.grounded, d.play.grounded), simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay), // 30 P3: present only when free (see PLAY_CURSORS) - ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}) + ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}), + // 30b P3/P4: present only when authored (locomotionPolicy's normalizers), so a + // scene that never used them stays byte-identical + ...optional('locomotion', normalizeLocomotion(playRaw.locomotion)), + ...optional('spawn', normalizeSpawn(playRaw.spawn)) }, - ['interaction', 'grounded', 'simOnPlay', 'cursor'] + ['interaction', 'grounded', 'simOnPlay', 'cursor', 'locomotion', 'spawn'] ), // A1: the 20 ceiling is throwVelocity's MAX_LINVEL, restated rather than imported — // this module is store-only and the response clamps through clampThrow anyway diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 1c82a890..bad5d938 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -43,6 +43,8 @@ import { peerHandStyle, pokeScene } from '../stores/sceneStore'; import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; import { resolvePlaySettings } from './playSettings'; +import { locomotionPolicy, vrSpawnOffsets, yawForward } from './locomotionPolicy'; +import { resolveWalk } from './charController'; import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu'; import { paletteColorAt, barValueAt } from './vrPalette'; import { recordMaterialChange, setMaterialParam } from './materialsHandler'; @@ -808,7 +810,8 @@ export function updateTeleport(session) { const y = source?.gamepad?.axes?.[3] ?? 0; // 157: teleport can be disabled — reset any arm + hide the arc - if (!get(vrTeleportEnabled)) { + // 30b P3: ...and Interact allows it only when the scene's play block says so + if (!get(vrTeleportEnabled) || !vrLocomotionNow().teleport) { teleportEngaged = false; hideArc(); return; @@ -934,6 +937,160 @@ export function initVRControls(r) { renderer = r; } +// ---- 30b P3: WALK LIKE A GAME (Interact) ------------------------------------------------ +// Edit keeps the editor's stick (VRControls.svelte: fly/strafe, left-grip pan/elevate, +// teleport, the world gestures). INTERACT walks: the left stick moves along the head's +// yaw at a walking pace, the step resolves through charController.resolveWalk — the SAME +// three tiers desktop's walker uses (the rapier capsule when a sim runs, a dungeon raster, +// the ground plane) — gravity pulls the feet down, a ~0.3 m step is climbed (the +// capsule's autostep), and nothing flies or teleports unless the play block allows it. +// The rig moves the WebXR way: by offsetting the reference space (offset = -(the viewer's +// displacement), the convention across this file). +// +// FEET. A headset reports the HEAD. The physical head height comes from the viewer pose in +// the BASE reference space captured at session start (local-floor: y 0 is the real floor), +// and the feet are the head's current world y minus it — robust to every offset any gesture +// applied since. Without a base space (a fake session in a suite), a standing 1.6 m head. + +/** metres per second on a full stick */ +export const VR_WALK_SPEED = 2.2; +/** a standing head when no base space can say better */ +const STANDING_HEAD = 1.6; +/** @type {any} */ let xrBaseSpace = null; + +/** Scene's onsessionstart: remember the untouched reference space. */ +export function noteXRBaseSpace() { + xrBaseSpace = renderer?.xr?.getReferenceSpace?.() ?? null; +} + +/** 30b P3: the locomotion rules in force right now (mode + the resolved play block). */ +export function vrLocomotionNow() { + const mode = get(editorMode) === 'interact' ? 'interact' : 'edit'; + return locomotionPolicy(mode, resolvePlaySettings(get(globalScene)).locomotion); +} + +/** the viewer pose in the current space, and the head's physical height @returns {any} */ +function viewerNow() { + const frame = renderer?.xr?.getFrame?.(); + const space = renderer?.xr?.getReferenceSpace?.(); + const pose = frame && space ? frame.getViewerPose?.(space) : null; + if (!pose) return null; + const base = xrBaseSpace ? frame.getViewerPose?.(xrBaseSpace) : null; + const p = pose.transform.position; + const o = pose.transform.orientation; + const q = new THREE.Quaternion(o.x, o.y, o.z, o.w); + const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(q); + return { + head: { x: p.x, y: p.y, z: p.z }, + yaw: Math.atan2(-fwd.x, -fwd.z), + headHeight: base ? base.transform.position.y : STANDING_HEAD + }; +} + +/** @param {{x: number, y: number, z: number}} offset reference-space offset (-(displacement)) + * @param {{x: number, y: number, z: number, w: number}} [orientation] */ +function offsetSpace(offset, orientation) { + const space = renderer?.xr?.getReferenceSpace?.(); + if (!space) return false; + renderer.xr.setReferenceSpace( + space.getOffsetReferenceSpace( + orientation ? new XRRigidTransform(offset, orientation) : new XRRigidTransform(offset) + ) + ); + return true; +} + +/** + * 30b P3: ONE walker step as data — the wanted displacement from the stick, resolved against + * the world. Exported so a suite drives it with a real simulation and no headset. + * @param {{head: {x: number, y: number, z: number}, headHeight: number, yaw: number, + * stick: {x: number, y: number}, dt: number, fly?: boolean, aim?: {x: number, y: number, z: number}}} input + * @returns {{dx: number, dy: number, dz: number, feet: number, grounded: boolean, source: string}} + */ +export function vrWalkStep(input) { + const dead = (/** @type {number} */ v) => (Math.abs(v) > 0.15 ? v : 0); + const sx = dead(input.stick.x); + const sy = dead(input.stick.y); + const dt = Math.max(0, Math.min(input.dt, 0.1)); + const speed = VR_WALK_SPEED * dt; + const fwd = input.fly && input.aim ? input.aim : yawForward(input.yaw); + const flat = yawForward(input.yaw); + // stick UP is negative y in xr-standard; strafe is always horizontal + const right = { x: -flat.z, z: flat.x }; + const desired = { + dx: speed * (-sy * fwd.x + sx * right.x), + dz: speed * (-sy * fwd.z + sx * right.z), + ...(input.fly ? { dy: speed * -sy * (fwd.y ?? 0) } : {}) + }; + // the capsule is quantised to 10 cm so a nodding head does not rebuild it every frame + const height = Math.min(2.1, Math.max(1, Math.round(input.headHeight * 10) / 10)); + const feet = input.head.y - input.headHeight; + const r = resolveWalk({ x: input.head.x, y: feet, z: input.head.z }, height, dt, desired, { + gravity: !input.fly + }); + return { dx: r.dx, dy: r.feet - feet, dz: r.dz, feet: r.feet, grounded: r.grounded, source: r.source }; +} + +/** + * 30b P3: the Interact half of VRControls.svelte's stick task. Returns false in Edit (the + * caller then runs the editor's own stick code unchanged). + * @param {number} dt seconds @param {any} session + */ +export function tickVRInteractLocomotion(dt, session) { + const policy = vrLocomotionNow(); + if (!policy.walk) return false; + const viewer = viewerNow(); + if (!viewer) return true; + const left = [...(session?.inputSources ?? [])].find((s) => s.handedness === 'left'); + const axes = left?.gamepad?.axes ?? []; + /** @type {any} */ + let aim = null; + if (policy.fly) { + const index = controllerIndexFor('left'); + if (index >= 0) { + const v = new THREE.Vector3(0, 0, -1).applyQuaternion( + renderer.xr.getController(index).getWorldQuaternion(new THREE.Quaternion()) + ); + aim = { x: v.x, y: v.y, z: v.z }; + } + } + const step = vrWalkStep({ + head: viewer.head, + headHeight: viewer.headHeight, + yaw: viewer.yaw, + stick: { x: axes[2] ?? 0, y: axes[3] ?? 0 }, + dt, + fly: policy.fly, + aim + }); + // fell out of the world: back to the spawn (or the origin) + if (step.feet < -50) { + // no spawn: stand back up on the origin, feet at 0 + if (!spawnPlayer()) + offsetSpace({ x: viewer.head.x, y: viewer.head.y - viewer.headHeight, z: viewer.head.z }); + return true; + } + if (step.dx || step.dy || step.dz) offsetSpace({ x: -step.dx, y: -step.dy, z: -step.dz }); + return true; +} + +/** + * 30b P4: put the player on the game's spawn — the runtime api.setSpawn, else the scene's + * `play.spawn` (resolvePlaySettings). In VR the FEET land on it facing its yaw; on the + * desktop the play camera does (PointerLockControls reads the same resolution). No spawn, + * no move. @returns {boolean} whether the player was moved + */ +export function spawnPlayer() { + const spawn = resolvePlaySettings(get(globalScene)).spawn; + if (!spawn || !renderer?.xr?.getSession?.()) return false; + const viewer = viewerNow(); + if (!viewer) return false; + const { turn, move } = vrSpawnOffsets(viewer.head, viewer.yaw, viewer.headHeight, spawn); + offsetSpace(turn.position, turn.orientation); + offsetSpace(move); + return true; +} + /** * Buzz the VR controllers if the session's gamepads support it (no-op on * desktop). Used by modules for press feedback. Optional `hand` targets one diff --git a/tests/e2e/vr-walk-interact.test.cjs b/tests/e2e/vr-walk-interact.test.cjs new file mode 100644 index 00000000..572ce28f --- /dev/null +++ b/tests/e2e/vr-walk-interact.test.cjs @@ -0,0 +1,169 @@ +// 30b P3: WALK LIKE A GAME. The Quest report (the dungeon): "I can go through walls, +// teleport, I want to be able to do this only in edit mode and fly only in edit mode. In +// interactive mode I should be able to move like in a game, without going through walls." +// Interact's stick now walks: the step resolves through charController.resolveWalk (the +// rapier capsule while a sim runs), gravity holds the feet on the floor, a ~0.3 m step is +// climbed, and teleport/fly are off unless the play block allows them. Edit is unchanged +// (it flies, through walls, and teleports). +// +// Section 1 drives the walker step with a REAL rapier world; section 2 drives the REAL +// per-frame path (VRControls' task) through a fake XR session whose reference space +// composes offsets exactly as WebXR specifies (fakeXR.installSpace). +// Desktop Play's built-in flier gets the same walls (section 4). +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // ---- the fixture: floor (top 0), a wall ahead, a 0.25 m step, a 0.6 m block -------------- + await A.page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 5; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 600)); + const [floor, wall, step, block, faller] = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + const place = (o, pos, scale) => { o.position.set(...pos); o.scale.set(...scale); o.updateMatrixWorld(true); }; + place(floor, [0, -0.25, 0], [20, 0.5, 20]); + place(wall, [0, 1.5, -3], [10, 3, 0.4]); // front face at z = -2.8 + place(step, [3, 0.125, 0], [2, 0.25, 2]); // x 2..4, top 0.25 + place(block, [-3, 0.3, 0], [2, 0.6, 2]); // x -4..-2, top 0.6 + place(faller, [8, 3, 8], [0.4, 0.4, 0.4]); + for (const o of [floor, wall, step, block]) s.physics.setPhysicsFor(o.uuid, { mode: 'static' }); + s.physics.setPhysicsFor(faller.uuid, { mode: 'dynamic', mass: 1 }); + s.objectActions.deselectObject(); + }); + await A.page.waitForTimeout(800); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually( + () => A.page.evaluate(() => window.__stores.physics.physicsWorldDebug().running), + (ok) => ok, + 'premise: a simulation is running (the capsule tier needs a world)', + 15000 + ); + + // ---- 1. the walker step against a real world ----------------------------------------------- + // charController primes physics with a LAZY dynamic import (a scene with no walker never + // loads rapier), so the very first steps resolve on the plane tier: wait for the capsule + await h.eventually( + () => A.page.evaluate(() => window.__stores.vrControls.vrWalkStep({ head: { x: 6, y: 1.6, z: 6 }, headHeight: 1.6, yaw: 0, stick: { x: 0, y: 0 }, dt: 0.016 }).source), + (src) => src === 'rapier', + 'premise: the walker has its rapier capsule', + 10000 + ); + const walk = (input, steps) => + A.page.evaluate( + ({ input, steps }) => { + const vr = window.__stores.vrControls; + const head = { ...input.head }; + let last = null; + for (let i = 0; i < steps; i++) { + last = vr.vrWalkStep({ ...input, head: { ...head }, dt: 0.05 }); + head.x += last.dx; + head.y += last.dy; + head.z += last.dz; + } + return { head, feet: head.y - input.headHeight, source: last?.source, grounded: last?.grounded }; + }, + { input, steps } + ); + const fwd = { x: 0, y: -1 }; // stick UP + let r = await walk({ head: { x: 0, y: 1.6, z: 0 }, headHeight: 1.6, yaw: 0, stick: fwd }, 80); + h.check(r.source === 'rapier', `the walker resolved through the rapier capsule (${r.source})`); + h.check(r.head.z < -2.2 && r.head.z > -2.6, `walking at a wall STOPS at it (z ${r.head.z.toFixed(2)}; the face is at -2.8, the capsule radius 0.3)`); + r = await walk({ head: { x: 6, y: 4.6, z: 6 }, headHeight: 1.6, yaw: 0, stick: { x: 0, y: 0 } }, 60); + h.check(Math.abs(r.feet) < 0.08 && r.grounded, `gravity puts the feet on the floor (feet ${r.feet.toFixed(3)}, grounded ${r.grounded})`); + // 14 steps of 0.11 m from x 1.2 end ON the step (x 2..4), not past it + r = await walk({ head: { x: 1.2, y: 1.6, z: 0 }, headHeight: 1.6, yaw: -Math.PI / 2, stick: fwd }, 14); + h.check(r.head.x > 2.4 && r.head.x < 3.8 && Math.abs(r.feet - 0.25) < 0.08, `a 0.25 m step is climbed (x ${r.head.x.toFixed(2)}, feet ${r.feet.toFixed(2)})`); + r = await walk({ head: { x: -1.2, y: 1.6, z: 0 }, headHeight: 1.6, yaw: Math.PI / 2, stick: fwd }, 30); + h.check(r.head.x > -2.05 && r.feet < 0.1, `a 0.6 m block is a wall, not a step (x ${r.head.x.toFixed(2)}, feet ${r.feet.toFixed(2)})`); + r = await walk({ head: { x: 6, y: 1.6, z: 6 }, headHeight: 1.6, yaw: 0, stick: fwd, fly: true, aim: { x: 0, y: 0.8, z: -0.6 } }, 20); + h.check(r.feet > 1, `a flier (only when allowed) climbs along its aim (feet ${r.feet.toFixed(2)})`); + + // ---- 2. the real per-frame path: Interact walks into the wall and stops ---------------------- + await A.page.evaluate(() => window.__stores.isVRMode.set(true)); + await xr.install(A.page); + await xr.installSpace(A.page, { head: [0, 1.6, 0], yaw: 0 }); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(300); + let head = await xr.head(A.page); + h.check(Math.abs(head.z) < 0.05 && Math.abs(head.y - 1.6) < 0.05, `premise: the head starts at the origin, 1.6 m up (${JSON.stringify(head)})`); + await xr.stick(A.page, 'left', 0, -1); + await A.page.waitForTimeout(2500); + await xr.stick(A.page, 'left', 0, 0); + head = await xr.head(A.page); + h.check(head.z < -1.5, `Interact: the left stick walks you forward (z ${head.z.toFixed(2)})`); + h.check(head.z > -2.6, `Interact: ...and the wall stops you (z ${head.z.toFixed(2)})`); + h.check(Math.abs(head.y - 1.6) < 0.1, `Interact: the feet stay on the floor (head y ${head.y.toFixed(2)})`); + + // ---- 3. teleport: off in Interact, on in Edit, on in Interact when the play block allows --- + const teleportArms = async () => { + await xr.stick(A.page, 'right', 0, -1); + await A.page.waitForTimeout(300); + const st = await A.page.evaluate(() => window.__stores.vrControls.teleportState()); + await xr.stick(A.page, 'right', 0, 0); + await A.page.waitForTimeout(300); + return st.engaged; + }; + h.check((await teleportArms()) === false, 'Interact: the right stick does NOT arm a teleport'); + await A.page.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: { teleport: true } } })); + await A.page.waitForTimeout(200); + h.check((await teleportArms()) === true, 'Interact + play.locomotion.teleport: the teleport arc arms'); + const saved = await A.page.evaluate(() => { + let v; + window.__stores.scenePhysics.scenePlay.subscribe((x) => (v = x))(); + return v; + }); + h.check(saved.locomotion?.teleport === true && saved.locomotion.fly === undefined, `the play block stores only what was authored (${JSON.stringify(saved.locomotion)})`); + await A.page.evaluate(() => window.__stores.scenePhysics.setScenePhysics({ play: { locomotion: null } })); + await A.page.waitForTimeout(200); + const cleared = await A.page.evaluate(() => { + let v; + window.__stores.scenePhysics.scenePlay.subscribe((x) => (v = x))(); + return 'locomotion' in v; + }); + h.check(cleared === false, 'clearing the block removes the key (a scene that never used it saves byte-identically)'); + + // ---- 4. Edit flies through the same wall ---------------------------------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + h.check((await teleportArms()) === true, 'Edit: the teleport arc arms as it always did'); + await xr.stick(A.page, 'left', 0, -1); + await A.page.waitForTimeout(2500); + await xr.stick(A.page, 'left', 0, 0); + head = await xr.head(A.page); + h.check(head.z < -3.2, `Edit: the same stick flies THROUGH the wall (z ${head.z.toFixed(2)})`); + await xr.uninstall(A.page); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + await A.page.waitForTimeout(300); + + // ---- 5. desktop Play's built-in flier stops at the wall too ------------------------------------ + const cam = () => + A.page.evaluate(() => { + let c; + window.__stores.playerCam.subscribe((x) => (c = x))(); + return c.getWorldPosition(new window.__stores.THREE.Vector3()).toArray(); + }); + await A.page.evaluate(() => { + let c; + window.__stores.playerCam.subscribe((x) => (c = x))(); + const THREE = window.__stores.THREE; + const target = new THREE.Vector3(0, 1.7, 0); + c.parent.worldToLocal(target); + c.position.copy(target); + c.quaternion.identity(); + window.__stores.isLocked.set(true); + }); + await A.page.waitForTimeout(400); + await A.page.evaluate(() => document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW', bubbles: true }))); + await A.page.waitForTimeout(2500); + await A.page.evaluate(() => document.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyW', bubbles: true }))); + const p = await cam(); + h.check(p[2] < -1, `desktop Play: W flies you forward (z ${p[2].toFixed(2)})`); + h.check(p[2] > -2.6, `desktop Play: ...and the wall stops you while a sim runs (z ${p[2].toFixed(2)})`); + await A.page.evaluate(() => window.__stores.isLocked.set(false)); + + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.finish(browser); +}); diff --git a/tests/unit/locomotionPolicy.test.js b/tests/unit/locomotionPolicy.test.js new file mode 100644 index 00000000..081b7316 --- /dev/null +++ b/tests/unit/locomotionPolicy.test.js @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { + locomotionPolicy, + normalizeLocomotion, + normalizeSpawn, + yawForward, + yawOf, + vrSpawnOffsets +} from '../../src/lib/locomotionPolicy.js'; + +// 30b P3/P4: who may fly or teleport, and the VR spawn as WebXR offsets. + +describe('locomotionPolicy', () => { + it('Edit keeps the editor movement: fly, teleport, the world gestures, no walls', () => { + expect(locomotionPolicy('edit', null)).toEqual({ + walk: false, fly: true, teleport: true, collide: false, gravity: false, worldGestures: true + }); + // a play block never narrows the EDITOR + expect(locomotionPolicy('edit', { teleport: false, fly: false }).teleport).toBe(true); + }); + it('Interact walks with walls and gravity; no fly or teleport by default', () => { + expect(locomotionPolicy('interact', null)).toEqual({ + walk: true, fly: false, teleport: false, collide: true, gravity: true, worldGestures: false + }); + expect(locomotionPolicy('interact', {})).toEqual(locomotionPolicy('interact', null)); + }); + it('a play block can allow teleport and fly in Interact (fly drops gravity, keeps walls)', () => { + const p = locomotionPolicy('interact', { teleport: true, fly: true }); + expect(p.teleport).toBe(true); + expect(p.fly).toBe(true); + expect(p.gravity).toBe(false); + expect(p.collide).toBe(true); + expect(p.worldGestures).toBe(false); + }); + it('only a literal true allows (truthy strings do not)', () => { + expect(locomotionPolicy('interact', /** @type {any} */ ({ teleport: 'yes', fly: 1 })).teleport).toBe(false); + }); +}); + +describe('normalizeLocomotion', () => { + it('keeps booleans only; an empty block is absent', () => { + expect(normalizeLocomotion({ teleport: true, fly: 'x', other: 1 })).toEqual({ teleport: true }); + expect(normalizeLocomotion({ fly: false })).toEqual({ fly: false }); + expect(normalizeLocomotion({})).toBe(null); + expect(normalizeLocomotion(null)).toBe(null); + expect(normalizeLocomotion('fly')).toBe(null); + }); +}); + +describe('normalizeSpawn', () => { + it('takes the stored shape and the api pair', () => { + expect(normalizeSpawn({ position: [1, 0, -2], yaw: 1.5 })).toEqual({ position: [1, 0, -2], yaw: 1.5 }); + expect(normalizeSpawn([1, 0.5, -2], 0.25)).toEqual({ position: [1, 0.5, -2], yaw: 0.25 }); + expect(normalizeSpawn([1, 0.5, -2])).toEqual({ position: [1, 0.5, -2], yaw: 0 }); + }); + it('refuses anything that is not three finite numbers', () => { + expect(normalizeSpawn({ position: [1, 2] })).toBe(null); + expect(normalizeSpawn([1, NaN, 2])).toBe(null); + expect(normalizeSpawn([1e9, 0, 0])).toBe(null); + expect(normalizeSpawn(null)).toBe(null); + expect(normalizeSpawn({ position: [0, 0, 0], yaw: 'x' })).toEqual({ position: [0, 0, 0], yaw: 0 }); + }); +}); + +describe('yaw convention (three rotation.y; 0 faces -Z)', () => { + it('forward and back', () => { + expect(yawForward(0)).toEqual({ x: -0, y: 0, z: -1 }); + const f = yawForward(Math.PI / 2); // turned LEFT: facing -X + expect(f.x).toBeCloseTo(-1); + expect(f.z).toBeCloseTo(0); + for (const yaw of [0, 0.7, -2.1, 3]) expect(yawOf(yawForward(yaw))).toBeCloseTo(yaw); + }); +}); + +// ---- the VR spawn, checked against WebXR's own composition rule ------------------------ +// A pose in the new space = inverse(originOffset) * pose in the old one. Rotations here +// are about +Y only, so a pose is {x, y, z, yaw} and an offset is a translation `t` +// followed by a yaw `a` (XRRigidTransform = T(t) * R(a)). +/** @param {number} a @param {{x: number, z: number}} p */ +const rotY = (a, p) => ({ x: Math.cos(a) * p.x + Math.sin(a) * p.z, z: -Math.sin(a) * p.x + Math.cos(a) * p.z }); +/** apply inverse(T(t) R(a)) to a pose @param {{x: number, y: number, z: number, yaw: number}} pose + * @param {{x: number, y: number, z: number}} t @param {number} a */ +function viaOffset(pose, t, a) { + const d = rotY(-a, { x: pose.x - t.x, z: pose.z - t.z }); + return { x: d.x, y: pose.y - t.y, z: d.z, yaw: pose.yaw - a }; +} +/** @param {number} a */ +const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a)); + +describe('vrSpawnOffsets', () => { + const cases = [ + { head: { x: 0, y: 1.6, z: 0 }, yaw: 0, spawn: { position: [3, 0, -4], yaw: Math.PI / 2 } }, + { head: { x: -2, y: 5.1, z: 7 }, yaw: 2.4, spawn: { position: [0.5, 1.2, 0.5], yaw: -1 } }, + { head: { x: 10, y: 1.7, z: -3 }, yaw: -0.3, spawn: { position: [10, 0, -3], yaw: -0.3 } } + ]; + for (const [i, c] of cases.entries()) { + it(`the feet land on the spawn, facing its yaw (case ${i})`, () => { + const headHeight = 1.6; + const { turn, move } = vrSpawnOffsets(c.head, c.yaw, headHeight, c.spawn); + const afterTurn = viaOffset({ ...c.head, yaw: c.yaw }, turn.position, turn.angle); + // the turn is IN PLACE + expect(afterTurn.x).toBeCloseTo(c.head.x, 6); + expect(afterTurn.z).toBeCloseTo(c.head.z, 6); + const after = viaOffset(afterTurn, move, 0); + expect(after.x).toBeCloseTo(c.spawn.position[0], 6); + expect(after.z).toBeCloseTo(c.spawn.position[2], 6); + expect(after.y - headHeight).toBeCloseTo(c.spawn.position[1], 6); // the FEET + expect(wrap(after.yaw - c.spawn.yaw)).toBeCloseTo(0, 6); + }); + } + it('the turn quaternion is the rotation by `angle` about +Y', () => { + const { turn } = vrSpawnOffsets({ x: 0, y: 1.6, z: 0 }, 0.4, 1.6, { position: [0, 0, 0], yaw: -0.6 }); + expect(turn.angle).toBeCloseTo(1.0); + expect(turn.orientation.y).toBeCloseTo(Math.sin(0.5)); + expect(turn.orientation.w).toBeCloseTo(Math.cos(0.5)); + }); +}); From a09a247773f3b2820c73cc9ab40281292a33cf83 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:33:48 +0300 Subject: [PATCH 08/18] [feat] 30b P4: games enter Interact in VR, left Y switches modes, and players spawn - The Quest report: "By default for games there should be interact mode in VR, but I should be able to switch to edit mode", and "if the game should have a specific place where the character should stand, it should move." - vrControls.onVRSessionStart (Scene's XR onsessionstart): remembers the base reference space, and a GAME (a HUD screen bound to a game state, a spawn, or a userData.play publisher) lands in Interact. Entering Interact in a session (editorMode subscription, the LAST statement of the file for the TDZ rule) drops any editor gesture, resets the world to 1:1 and pays the spawn on the first frame with a viewer pose. - The mode button: LEFT Y (B on the right when the radial menu lives on the left, so they never share a button) toggles Edit <-> Interact with a haptic tick and a wrist label on that controller ('vr-mode-label': INTERACT teal / EDIT amber + the button). - Spawn = `play.spawn: {position:[x,y,z], yaw}` (y = FEET, three rotation.y) or a module's `api.setSpawn(position, yaw, {teleport?})` (LOCAL, overrides the scene's, cleared when the module is disabled; without teleport a new spawn is a checkpoint) + `api.respawnPlayer()`. VR: vrControls.spawnPlayer turns the rig in place then moves it so the feet land on the point (locomotionPolicy.vrSpawnOffsets). Desktop Play: the play camera (playSpawn.spawnDesktopPlayer, ahead of the dungeon's per-peer rooms). Desktop Interact: the editor view flies there (objectActions.setEditorMode). - New: e2e vr-mode-spawn (22): a plain scene stays in Edit, a game lands in Interact ON the spawn facing its yaw with the world back at 1:1; left Y -> Edit with one haptic tick and the label; walking off and pressing Y again re-spawns; with the menu on the left the right B switches; api.setSpawn checkpoint vs {teleport:true}, a malformed spawn refused, the module's spawn dropped on disable; desktop Play and desktop Interact spawn. - Counterfactuals: no spawn on entering Interact -> "the player stands ON the spawn" and "facing the spawn's yaw" red; the mode button unwired -> "left Y: Interact -> Edit", "a haptic tick", "the label follows", "the RIGHT B toggles" red. - svelte-check 334/47; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 7 +- src/components/Scene.svelte | 5 +- .../play/PointerLockControls.svelte | 4 + src/lib/moduleSDK.js | 57 ++++++ src/lib/objectActions.js | 10 ++ src/lib/playSettings.js | 25 ++- src/lib/playSpawn.js | 55 ++++++ src/lib/vrControls.js | 164 +++++++++++++++++- tests/e2e/vr-mode-spawn.test.cjs | 142 +++++++++++++++ 9 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 src/lib/playSpawn.js create mode 100644 tests/e2e/vr-mode-spawn.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 40cf9592..239477a5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -478,9 +478,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/importGate'), import('./lib/decimate'), import('./lib/playCursor'), - import('./lib/gameStorage') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib } + import('./lib/gameStorage'), + import('./lib/playSpawn') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, playSpawnLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, playSpawn: playSpawnLib } }) } }) diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index dde54080..5fab55d8 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -54,7 +54,7 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticPulse } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticPulse, onVRSessionStart } from '$lib/vrControls'; import { vrKeyboardTarget } from '$lib/vrKeyboard'; import { measureMode, measureClick } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; @@ -1642,6 +1642,9 @@ position={[0, 2, 3]} // B2.1: request the preferred refresh rate (auto = highest supported) — // without this the Quest stays at its 90Hz default applyVRFrameRate(); + // 30b P4: remember the untouched reference space (the walker's floor), and a + // GAME lands in Interact on its spawn + onVRSessionStart(); }} onsessionend={() => passthroughActive.set(false)} > diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index f8ddbce4..c8f9b900 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -7,6 +7,7 @@ import { userdata, peers } from '../../stores/appStore' import { dungeonData, slideMove, spawnPointFor } from '$lib/dungeonPlay' import { resolvePlaySettings } from '$lib/playSettings' + import { spawnDesktopPlayer } from '$lib/playSpawn' // 30 P3: free-cursor games never take the pointer — the real cursor aims import { playCursorFree } from '$lib/playCursor' import { inputClaims, getGamepadAxes } from '$lib/inputRuntime' @@ -192,6 +193,9 @@ // in your seed-deterministic room (peers take consecutive rooms). // untracked: the effect must only depend on $isLocked. untrack(() => { + // 30b P4: a game's own spawn (the scene's play.spawn, or a module's api.setSpawn) + // wins; the dungeon's per-peer rooms below are what a scene without one gets + if (spawnDesktopPlayer()) return const data = dungeonData($globalScene) // resolve the rig ONCE and mutate the object: `$cameraParent.position.x = v` // compiles to store_mutate -> cameraParent.set(), and useParent() is a diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index dff5a4d6..ed24c97b 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -51,6 +51,8 @@ export { moduleContentDebug } from './moduleContent'; import { safeStorage } from './safeStorage'; // 30 P4: api.storage — a LEAF (safeStorage only), shared with the Store Value flow node import { makeModuleStorage } from './gameStorage'; +import { runtimeSpawn, setRuntimeSpawn } from './playSettings'; // 30b P4 (a leaf) +import { spawnDesktopPlayer, currentSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (a leaf) /** modules already told they hit the storage cap this session (ONE toast each, never * one per write — a game saving every frame would otherwise bury the screen) */ @@ -343,6 +345,27 @@ function physicsApi() { return physicsRef; } +/** + * 30b P4: move the player to the spawn in force — VR moves the rig (vrControls), desktop + * Play moves the play camera, desktop Interact the editor view. Edit is never moved. + * @returns {boolean} + */ +function respawnPlayerNow() { + const spawn = currentSpawn(); + if (!spawn) return false; + if (get(isVRMode)) { + if (get(editorMode) !== 'interact') return false; + return !!vrControlsRef?.spawnPlayer?.(); + } + if (get(isLocked) === true) return spawnDesktopPlayer(spawn); + if (get(editorMode) === 'interact') { + const { eye, lookAt } = spawnEyePose(spawn); + objectActionsRef?.flyTo?.(eye, lookAt); + return !!objectActionsRef; + } + return false; +} + /** @param {string} moduleId @param {string} [moduleName] the DISPLAY name, needed while * register() runs: loadedModules is not appended until it RETURNS, so anything reading the * name from there during registration gets the raw id (which is how a module HUD kind was @@ -351,6 +374,8 @@ function makeApi(moduleId, moduleName = moduleId) { const disposals = (moduleDisposals[moduleId] ??= []); /** record an undo thunk deactivateModule runs at teardown (A2) @param {() => void} fn */ const onDispose = (fn) => disposals.push(fn); + /** 30b P4: setSpawn journals its clear once per module */ + let spawnDisposeHooked = false; /** A value frozen for the undo stack, so a module mutating its patch object later * cannot rewrite history. @param {any} v */ const frozen = (v) => { @@ -644,6 +669,38 @@ function makeApi(moduleId, moduleName = moduleId) { editorMode() { return get(editorMode) === 'interact' ? 'interact' : 'edit'; }, + /** + * 30b P4: where the player STARTS — entering Interact or Play puts them here (VR: the + * rig so the FEET land on it facing `yaw`; desktop Play: the play camera; desktop + * Interact: the editor view). `position` is [x, y, z] with y the FEET height; `yaw` + * is radians, three's rotation.y (0 faces -Z, forward = (-sin yaw, 0, -cos yaw)). + * Overrides the scene's authored `play.spawn`. LOCAL: every peer's module sets its + * own from the same replicated state; never saved. `{teleport: true}` also moves the + * player there NOW when Interact or Play is on (a new level, a new dungeon floor) — + * without it a changed spawn is a checkpoint, used on the next entry. + * `setSpawn(null)` clears it. Cleared when the module is disabled. + * @param {number[] | null} position @param {number=} yaw + * @param {{teleport?: boolean}=} options @returns {boolean} whether it was accepted + */ + setSpawn(position, yaw = 0, options = {}) { + const ok = setRuntimeSpawn(position, yaw, moduleId); + if (!spawnDisposeHooked) { + spawnDisposeHooked = true; + onDispose(() => { + if (get(runtimeSpawn)?.owner === moduleId) setRuntimeSpawn(null); + }); + } + if (ok && position && options?.teleport) respawnPlayerNow(); + return ok; + }, + /** + * 30b P4: move the player to the spawn in force now (see setSpawn) — only while + * Interact or Play is on; the editor's Edit view is never moved. + * @returns {boolean} whether the player was moved + */ + respawnPlayer() { + return respawnPlayerNow(); + }, /** * Where the user is POINTING, as a THREE.Raycaster in world space — * desktop mouse over the viewport, or the VR pointer hand's ray. A fresh diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 5420ded6..c757e6d9 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -17,6 +17,7 @@ import { isVRMode, gizmoSuppressed, editorMode, + isLocked, cameraClaim, pokeScene } from '../stores/sceneStore'; import { attachMultiPivot, releaseMultiPivot, hasCustomOrigin, pivotPose, setPivotOrigin } from './multiTransform'; import { focusTargetFace, faceEditObject, hideElementSelection, restoreElementSelection } from './faceEdit'; @@ -38,6 +39,7 @@ import { stripEditOverlays, isEditOverlay } from './editOverlays'; import { markTransient } from './transientObjects'; // 30 P3: a LEAF (THREE + stores), so a static import here closes no cycle import { clearModuleSelection } from './moduleContent'; +import { currentSpawn, spawnEyePose } from './playSpawn'; // 30b P4 // D2: a LEAF (svelte stores + THREE), so a static import here closes no cycle import { shareDuplicatedMaterials, linkMaterials } from './materialSharing'; import { @@ -239,6 +241,14 @@ export function setEditorMode(mode) { if (next === 'interact') { releaseMultiPivot(); if (controls && !get(isVRMode)) controls.detach(); + // 30b P4: a game that names a spawn puts the desktop view there on the way in (VR + // does the same for the rig — vrControls follows this store; Play's camera spawns in + // PointerLockControls). No spawn, no move: the view stays where the author left it. + const spawn = currentSpawn(); + if (spawn && !get(isVRMode) && get(isLocked) !== true) { + const { eye, lookAt } = spawnEyePose(spawn); + flyTo(eye, lookAt); + } } else if (get(selectedObjects).length) { applySelectionSet([...get(selectedObjects)]); } diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 4b2ded41..39bfe251 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -1,8 +1,29 @@ -import { get } from 'svelte/store'; +import { get, writable } from 'svelte/store'; import { scenePlay } from './scenePhysics'; import { showToast } from '../stores/appStore'; import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; +/** + * 30b P4: a spawn point set at RUNTIME by a module (`api.setSpawn(position, yaw)`) — a + * dungeon's floor, a level's start. LOCAL (every peer's module runs the same code off the + * same replicated state, so each sets its own), never saved, and it wins over the scene's + * authored `play.spawn` (contract C1: a module overrides the scene's). + * @type {import('svelte/store').Writable<{position: [number, number, number], yaw: number, owner?: string} | null>} + */ +export const runtimeSpawn = writable(null); + +/** @param {any} position @param {any} [yaw] @param {string} [owner] @returns {boolean} */ +export function setRuntimeSpawn(position, yaw, owner) { + if (position == null) { + runtimeSpawn.set(null); + return true; + } + const spawn = normalizeSpawn(position, yaw); + if (!spawn) return false; + runtimeSpawn.set(owner ? { ...spawn, owner } : spawn); + return true; +} + // 21-B B3: what play mode BEHAVES like in this scene. // // The shared defaults live in scenePhysics (`play.*`, so they replicate, save @@ -87,6 +108,8 @@ export function resolvePlaySettings(scene) { const spawn = normalizeSpawn(play.spawn); if (spawn) out.spawn = spawn; } + const runtime = get(runtimeSpawn); + if (runtime) out.spawn = { position: runtime.position, yaw: runtime.yaw }; return out; } diff --git a/src/lib/playSpawn.js b/src/lib/playSpawn.js new file mode 100644 index 00000000..bf1a72e6 --- /dev/null +++ b/src/lib/playSpawn.js @@ -0,0 +1,55 @@ +// 30b P4: THE DESKTOP HALF OF A SPAWN — a LEAF (THREE + svelte/store + sceneStore + +// playSettings), so PointerLockControls, objectActions and moduleSDK can all reach it +// without closing a cycle. The VR half lives in vrControls.spawnPlayer (it moves the XR +// reference space, which only vrControls holds). +// +// A spawn is `{position: [x, y, z], yaw}` — y is the FEET, yaw is three's rotation.y (0 faces +// -Z) — resolved by playSettings.resolvePlaySettings: a module's runtime api.setSpawn, else +// a publisher's userData.play.spawn, else the scene's play.spawn. +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { playerCam } from '../stores/sceneStore'; +import { resolvePlaySettings } from './playSettings'; +import { globalScene } from '../stores/sceneStore'; + +/** the play camera's eye above the feet: the walker's 1.7 m person */ +export const SPAWN_EYE = 1.7; + +/** the spawn in force right now, or null @returns {{position: [number, number, number], yaw: number} | null} */ +export function currentSpawn() { + return resolvePlaySettings(get(globalScene)).spawn; +} + +/** + * Where an EYE stands on a spawn, and a point straight ahead of it at eye height (what + * flyTo and lookAt want). Pure. @param {{position: number[], yaw: number}} spawn + */ +export function spawnEyePose(spawn) { + const [x, y, z] = spawn.position; + const eye = [x, y + SPAWN_EYE, z]; + const lookAt = [x - Math.sin(spawn.yaw) * 2, y + SPAWN_EYE, z - Math.cos(spawn.yaw) * 2]; + return { eye, lookAt }; +} + +/** + * Put desktop Play's camera on the spawn: eye at feet + SPAWN_EYE, facing its yaw, level. + * PointerLockControls derives yaw/pitch from the camera's quaternion every look, so writing + * the quaternion is the whole of "face this way". + * @param {{position: number[], yaw: number} | null} [spawn] defaults to the one in force + * @returns {boolean} whether the camera moved + */ +export function spawnDesktopPlayer(spawn = currentSpawn()) { + /** @type {any} */ + const cam = get(playerCam); + if (!spawn || !cam?.isObject3D) return false; + const { eye } = spawnEyePose(spawn); + const target = new THREE.Vector3(eye[0], eye[1], eye[2]); + if (cam.parent) { + cam.parent.updateWorldMatrix(true, false); + cam.parent.worldToLocal(target); + } + cam.position.copy(target); + cam.quaternion.setFromEuler(new THREE.Euler(0, spawn.yaw, 0, 'YXZ')); + cam.updateMatrixWorld(true); + return true; +} diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index bad5d938..a5d8c354 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -42,7 +42,8 @@ import { editorMode, peerHandStyle, pokeScene } from '../stores/sceneStore'; import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; -import { resolvePlaySettings } from './playSettings'; +import { resolvePlaySettings, playPublishers } from './playSettings'; +import { hudDocs, isGameHud } from './hudDocs'; import { locomotionPolicy, vrSpawnOffsets, yawForward } from './locomotionPolicy'; import { resolveWalk } from './charController'; import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu'; @@ -122,7 +123,9 @@ import { focusObject, ungroupObject, applySelectionSet, - selectionUuids + selectionUuids, + setEditorMode, + toggleEditorMode } from './objectActions'; import { vrKeyboardTarget, openVRKeyboard, pressVRKey, closeVRKeyboard } from './vrKeyboard'; import { sceneCommand } from './commandsHandler.svelte'; @@ -239,7 +242,7 @@ vrChatPanelOpen.subscribe((open) => { }); /** @type {any} */ let renderer = null; -/** @type {{menu?: boolean, squeeze?: boolean, stick?: boolean, trigger?: boolean, a?: boolean}[]} */ +/** @type {{menu?: boolean, squeeze?: boolean, stick?: boolean, trigger?: boolean, a?: boolean, mode?: boolean}[]} */ const previousButtons = [{}, {}]; const raycaster = new THREE.Raycaster(); const tempMatrix = new THREE.Matrix4(); @@ -3345,11 +3348,13 @@ export function updateVRControls() { console.log('VR frame hook failed', error); } } + updateModeLabel(); // 30b P4: the wrist label follows the mode (hidden outside a session) if (!session) { hideArc(); teleportEngaged = false; return; } + payPendingSpawn(); // 30b P4 // open menu/panel are modal for the sticks: sector nav / scrolling own them; // a RIGHT-hand grab owns the right stick too (reel/scale beats teleport, 100); // D9: manipulation gestures + ANY held grip stand navigation down entirely @@ -3412,6 +3417,13 @@ export function updateVRControls() { } prev.menu = menuPressed; + // 30b P4: the MODE button — Y on the LEFT hand (B on the right when the radial menu + // lives on the left, so the two never share a button): Edit <-> Interact, in VR + if (source.handedness === modeHand()) { + if (menuPressed && !prev.mode) toggleVRMode(); + prev.mode = menuPressed; + } + // right A held = push-to-talk const aPressed = !!buttons[4]?.pressed; @@ -3760,3 +3772,149 @@ export function updateVRControls() { vrHovered.set(null); } } + +// ---- 30b P4: ENTER INTERACT, SWITCH IN VR, SPAWN ------------------------------------------ +// Contract C1: pressing Play in VR on a GAME enters Interact; the left Y button toggles +// Edit <-> Interact in the headset (a haptic tick + a label on that wrist says which); and +// entering Interact puts the world back to 1:1 and the player on the game's spawn. + +/** Is this scene a game? A HUD screen bound to a game state (the GameChip rule), a spawn, + * or a module publishing the play contract (a dungeon). */ +export function sceneIsGame() { + const scene = get(globalScene); + if (isGameHud(get(hudDocs))) return true; + if (resolvePlaySettings(scene).spawn) return true; + return playPublishers(scene).length > 0; +} + +/** which hand owns the mode button: the LEFT (Y), unless the radial menu lives there */ +export function modeHand() { + return get(vrMenuHand) === 'left' ? 'right' : 'left'; +} + +/** Scene's onsessionstart (after the base space is live). */ +export function onVRSessionStart() { + noteXRBaseSpace(); + // a game is played, not edited: Play in VR lands in Interact (the stores follow below) + if (sceneIsGame() && get(editorMode) !== 'interact') setEditorMode('interact'); + else if (get(editorMode) === 'interact') enterInteractVR(); + updateModeLabel(); +} + +/** Interact starts at 1:1, on the spawn, with no editor gesture half-done. */ +function enterInteractVR() { + worldGrab = null; + worldPan = null; + emptyAirSqueeze[0] = emptyAirSqueeze[1] = false; + if (grab && !grab.interact) { + endGrab(grab.object, grab.before); + grab = null; + vrGrabbedHand.set(null); + } + resetWorldRig(); + // at sessionstart no XR frame exists yet (so no viewer pose to move FROM): the spawn + // waits for the first frame that has one (updateVRControls) + spawnPending = true; + if (viewerNow()) { + spawnPending = false; + spawnPlayer(); + } +} + +/** The button: flip the mode, tick the hand, flash the label. @returns {'edit'|'interact'} */ +export function toggleVRMode() { + const next = toggleEditorMode(); + hapticPulse(0.35, 40, /** @type {any} */ (modeHand())); + modeLabelFlashUntil = Date.now() + 500; + updateModeLabel(); + return next; +} + +let spawnPending = false; +/** a spawn owed since enterInteractVR, paid on the first frame with a viewer pose */ +function payPendingSpawn() { + if (!spawnPending || !viewerNow()) return; + spawnPending = false; + spawnPlayer(); +} + +/** @type {any} */ let modeLabel = null; +/** @type {any} */ let modeLabelCanvas = null; +let modeLabelText = ''; +let modeLabelFlashUntil = 0; + +function ensureModeLabel() { + if (modeLabel || typeof document === 'undefined') return modeLabel; + modeLabelCanvas = document.createElement('canvas'); + modeLabelCanvas.width = 256; + modeLabelCanvas.height = 80; + const texture = new THREE.CanvasTexture(modeLabelCanvas); + texture.colorSpace = THREE.SRGBColorSpace; + modeLabel = new THREE.Mesh( + new THREE.PlaneGeometry(0.075, 0.0234), + new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: true }) + ); + modeLabel.name = 'vr-mode-label'; + // on the back of the controller, tilted up toward the eyes (the wrist you glance at) + modeLabel.position.set(0, 0.03, 0.07); + modeLabel.rotation.set(-1.0, 0, 0); + modeLabel.renderOrder = 996; + return modeLabel; +} + +/** redraw the label when the mode changes; re-parent it onto the mode hand's controller */ +function updateModeLabel() { + const label = ensureModeLabel(); + if (!label || !renderer) return; + const presenting = !!renderer.xr.getSession?.(); + const index = presenting ? controllerIndexFor(modeHand()) : -1; + label.visible = index >= 0; + if (index < 0) return; + const controller = renderer.xr.getController(index); + if (label.parent !== controller) controller.add(label); + const interact = get(editorMode) === 'interact'; + const text = interact ? 'INTERACT' : 'EDIT'; + label.scale.setScalar(Date.now() < modeLabelFlashUntil ? 1.35 : 1); + if (text === modeLabelText) return; + modeLabelText = text; + const ctx = modeLabelCanvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, 256, 80); + ctx.fillStyle = interact ? 'rgba(20, 110, 90, 0.9)' : 'rgba(150, 95, 10, 0.9)'; + ctx.beginPath(); + ctx.roundRect?.(4, 4, 248, 72, 18); + if (!ctx.roundRect) ctx.rect(4, 4, 248, 72); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 34px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, 112, 42); + ctx.font = 'bold 22px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.fillText(modeHand() === 'left' ? 'Y' : 'B', 228, 42); + label.material.map.needsUpdate = true; +} + +/** test/debug view of the label */ +export function vrModeLabelDebug() { + return { + text: modeLabelText, + visible: !!modeLabel?.visible, + hand: modeLabel?.parent?.userData?.handedness ?? null, + flashing: Date.now() < modeLabelFlashUntil + }; +} + +// THE LAST STATEMENT IN THE FILE on purpose: a module-level subscribe runs its callback +// synchronously at evaluation, so every `let` it reaches must already be declared (the +// TDZ rule that took the whole app down twice). Entering Interact in a live session resets +// the world and spawns; either way the label follows. +let lastEditorMode = get(editorMode); +editorMode.subscribe((mode) => { + if (mode === lastEditorMode) return; + lastEditorMode = mode; + if (!renderer?.xr?.getSession?.()) return; + if (mode === 'interact') enterInteractVR(); + updateModeLabel(); +}); diff --git a/tests/e2e/vr-mode-spawn.test.cjs b/tests/e2e/vr-mode-spawn.test.cjs new file mode 100644 index 00000000..7937ffe4 --- /dev/null +++ b/tests/e2e/vr-mode-spawn.test.cjs @@ -0,0 +1,142 @@ +// 30b P4: ENTER INTERACT, SWITCH IN VR, SPAWN. The Quest report: "By default for games +// there should be interact mode in VR, but I should be able to switch to edit mode" and +// "When getting back to interactive mode, if the game should have a specific place where +// the character should stand, it should move." +// · a VR session on a GAME lands in Interact (vrControls.onVRSessionStart), +// · the LEFT Y button toggles Edit <-> Interact, with a haptic tick and a wrist label, +// · entering Interact resets the world to 1:1 and puts the FEET on the spawn, facing it, +// · the spawn is the scene's `play.spawn`, overridden by a module's api.setSpawn, +// · desktop Play spawns the play camera there; desktop Interact flies the editor view. +// Driven through the real per-frame path with a spec-accurate fake XR space (fakeXR.cjs). +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +const mode = (page) => page.evaluate(() => { let v; window.__stores.editorMode.subscribe((x) => (v = x))(); return v; }); +const near = (a, b, eps = 0.05) => Math.abs(a - b) < eps; +const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a)); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await A.page.waitForTimeout(400); + + // ---- 0. no spawn, not a game: a session start keeps Edit -------------------------------- + await A.page.evaluate(() => window.__stores.isVRMode.set(true)); + await xr.install(A.page); + await xr.installSpace(A.page, { head: [0.4, 1.6, 0.2], yaw: 0.3 }); + await A.page.evaluate(() => window.__stores.vrControls.onVRSessionStart()); + h.check((await mode(A.page)) === 'edit', 'a plain scene: entering VR stays in Edit'); + h.check(!(await A.page.evaluate(() => window.__stores.vrControls.sceneIsGame())), 'premise: the scene is not a game'); + + // ---- 1. a game (it names a spawn): entering VR lands in Interact, ON the spawn ------------- + const SPAWN = { position: [3, 0, -4], yaw: Math.PI / 2 }; + await A.page.evaluate((spawn) => window.__stores.scenePhysics.setScenePhysics({ play: { spawn } }), SPAWN); + // the world was left grabbed in Edit: Interact must put it back to 1:1 + await A.page.evaluate(() => { + let rig; + window.__stores.worldRig.subscribe((x) => (rig = x))(); + rig.scale.setScalar(2.5); + rig.position.set(1, 0, 1); + rig.updateMatrixWorld(true); + }); + await A.page.evaluate(() => window.__stores.vrControls.onVRSessionStart()); + await A.page.waitForTimeout(300); + h.check((await mode(A.page)) === 'interact', 'a game: entering VR lands in INTERACT'); + let head = await xr.head(A.page); + h.check(near(head.x, 3) && near(head.z, -4), `the player stands ON the spawn (head ${head.x.toFixed(2)}, ${head.z.toFixed(2)})`); + h.check(near(head.y - 1.6, 0), `the FEET are at the spawn height (feet ${(head.y - 1.6).toFixed(2)})`); + h.check(near(wrap(head.yaw - SPAWN.yaw), 0, 0.02), `facing the spawn's yaw (${head.yaw.toFixed(3)})`); + const rig = await A.page.evaluate(() => { let r; window.__stores.worldRig.subscribe((x) => (r = x))(); return { s: r.scale.x, p: r.position.toArray() }; }); + h.check(rig.s === 1 && rig.p.every((v) => v === 0), `Interact put the world back to 1:1 (${JSON.stringify(rig)})`); + + // ---- 2. the LEFT Y button: to Edit, with a tick and the label ------------------------------- + let label = await A.page.evaluate(() => window.__stores.vrControls.vrModeLabelDebug()); + h.check(label.visible && label.text === 'INTERACT' && label.hand === 'left', `the wrist label says INTERACT on the left hand (${JSON.stringify(label)})`); + const before = await xr.pulses(A.page); + await xr.button(A.page, 'left', 5, true); + await A.page.waitForTimeout(200); + await xr.button(A.page, 'left', 5, false); + await A.page.waitForTimeout(200); + h.check((await mode(A.page)) === 'edit', 'left Y: Interact -> Edit'); + const after = await xr.pulses(A.page); + h.check(after.left === before.left + 1, `a haptic tick on the left hand (${before.left} -> ${after.left})`); + label = await A.page.evaluate(() => window.__stores.vrControls.vrModeLabelDebug()); + h.check(label.text === 'EDIT', `the label follows: ${label.text}`); + h.check(!(await A.page.evaluate(() => { let v; window.__stores.vrMenuOpen.subscribe((x) => (v = x))(); return v; })), 'the radial menu (right B by default) did not open'); + + // walk away in Edit, then Y again: Interact puts you back on the spawn + await A.page.evaluate(() => { + const r = window.__fakeXR.renderer; + r.xr.setReferenceSpace(r.xr.getReferenceSpace().getOffsetReferenceSpace(new XRRigidTransform({ x: -2, y: 0, z: 3 }))); + }); + head = await xr.head(A.page); + h.check(!near(head.x, 3), `premise: moved off the spawn in Edit (x ${head.x.toFixed(2)})`); + await xr.button(A.page, 'left', 5, true); + await A.page.waitForTimeout(200); + await xr.button(A.page, 'left', 5, false); + await A.page.waitForTimeout(200); + head = await xr.head(A.page); + h.check((await mode(A.page)) === 'interact' && near(head.x, 3) && near(head.z, -4), `left Y back to Interact re-spawns (${head.x.toFixed(2)}, ${head.z.toFixed(2)})`); + + // ---- 3. a menu on the LEFT hand moves the mode button to the right B ---------------------- + await A.page.evaluate(() => window.__stores.vrMenuHand.set('left')); + await xr.button(A.page, 'right', 5, true); + await A.page.waitForTimeout(200); + await xr.button(A.page, 'right', 5, false); + await A.page.waitForTimeout(200); + h.check((await mode(A.page)) === 'edit', 'menu on the left: the RIGHT B toggles the mode'); + await A.page.evaluate(() => window.__stores.vrMenuHand.set('right')); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + + // ---- 4. a module's api.setSpawn overrides the scene's; teleport moves you now -------------- + await A.page.evaluate(() => { + window.__stores.moduleSDK.initModules([{ id: 'spawntest', name: 'Spawn test', version: '1', register(api) { window.__spawnApi = api; } }]); + }); + const ok = await A.page.evaluate(() => window.__spawnApi.setSpawn([-5, 0, 2], Math.PI)); + h.check(ok === true, 'api.setSpawn accepts [x, y, z] + yaw'); + head = await xr.head(A.page); + h.check(near(head.x, 3), 'without {teleport} a new spawn is a checkpoint: nobody moves yet'); + await A.page.evaluate(() => window.__spawnApi.setSpawn([-5, 0, 2], Math.PI, { teleport: true })); + await A.page.waitForTimeout(100); + head = await xr.head(A.page); + h.check(near(head.x, -5) && near(head.z, 2) && near(head.y, 1.6), `{teleport: true} moves the player there now (${head.x.toFixed(2)}, ${head.y.toFixed(2)}, ${head.z.toFixed(2)})`); + h.check(near(Math.abs(wrap(head.yaw)), Math.PI, 0.02), `...facing the module's yaw (${head.yaw.toFixed(3)})`); + h.check(!(await A.page.evaluate(() => window.__spawnApi.setSpawn([1, NaN, 2]))), 'a malformed spawn is refused'); + await A.page.evaluate(() => window.__stores.moduleSDK.deactivateModule('spawntest')); + const back = await A.page.evaluate(() => window.__stores.playSettings.resolvePlaySettings((() => { let v; window.__stores.globalScene.subscribe((x) => (v = x))(); return v; })()).spawn); + h.check(back && back.position[0] === 3, `disabling the module drops its spawn: the scene's is back (${JSON.stringify(back)})`); + + await xr.uninstall(A.page); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await A.page.waitForTimeout(300); + + // ---- 5. desktop Play spawns the play camera on the scene's spawn ------------------------------ + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.waitForTimeout(500); + const cam = await A.page.evaluate(() => { + let c; + window.__stores.playerCam.subscribe((x) => (c = x))(); + const THREE = window.__stores.THREE; + const p = c.getWorldPosition(new THREE.Vector3()); + const f = new THREE.Vector3(0, 0, -1).applyQuaternion(c.getWorldQuaternion(new THREE.Quaternion())); + return { p: p.toArray(), yaw: Math.atan2(-f.x, -f.z) }; + }); + h.check(near(cam.p[0], 3) && near(cam.p[2], -4) && near(cam.p[1], 1.7), `desktop Play: the camera's eye is on the spawn (${cam.p.map((v) => v.toFixed(2))})`); + h.check(near(wrap(cam.yaw - SPAWN.yaw), 0, 0.02), `desktop Play: facing the spawn's yaw (${cam.yaw.toFixed(3)})`); + await A.page.evaluate(() => window.__stores.isLocked.set(false)); + await A.page.waitForTimeout(400); + + // ---- 6. desktop Interact flies the editor view there -------------------------------------------- + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.waitForTimeout(900); + const ed = await A.page.evaluate(() => { let c; window.__stores.globalCamera.subscribe((x) => (c = x))(); return c.position.toArray(); }); + h.check(near(ed[0], 3, 0.2) && near(ed[2], -4, 0.2), `desktop Interact: the editor view moved to the spawn (${ed.map((v) => v.toFixed(2))})`); + await A.page.evaluate(() => { + window.__stores.objectActions.setEditorMode('edit'); + window.__stores.scenePhysics.setScenePhysics({ play: { spawn: null } }); + }); + + await h.finish(browser); +}); From af38e5550690f2514af1ef341eb16a2a6987b46b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 15:33:52 +0300 Subject: [PATCH 09/18] [fix] 30b P5: module content follows the VR world (Untangle's dots spin with it) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Quest report: "When I spin the world around, the untangled dots do not spin around." The world gestures transform `world-grab-rig`; module viewport content lives at the SCENE ROOT (golden rule 5 keeps it out of objectsGroup), outside the rig. - moduleWorld.js (a leaf): a `module-world-root` group INSIDE the rig (Scene.svelte, a sibling of objectsGroup — never serialised or sent), and every REGISTERED module group (registerInteractiveGroup / registerSystemGroup / registerListedGroup) is re-homed under it as it reaches the scene root (childadded + registry revision), local transform kept — desktop (rig = identity) moves nothing, VR carries it. For every module at once, no module change: `getObjectByName` still finds it, the scene instance's remove() also takes a re-homed group out, and core's scene-root scans (playSettings.playPublishers, moduleContent's rows) read the module root too. Unregistered content stays put. - New e2e module-world-root (11): an inline module's groups land under the root (also a group added before its name was registered), an unregistered one does not, nothing enters objectsGroup, the dot follows a 90-degree spin + x2 scale of the rig, the Module content rows and the play publishers still find them, api.scene().remove works, disable removes them. - Counterfactual: adoptModuleGroups a no-op -> "a registered group lands under the world rig's module root", "...also when added BEFORE its name was registered" and "the dot followed (1.000,1.000,0.000)" red. - Held: object-list-modules 25/25 (= base, UNTANGLE_ZIP/TPSCENE from the sibling checkouts); svelte-check 334/47; vitest 244; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 5 +- src/components/Scene.svelte | 6 ++ src/lib/moduleContent.js | 4 +- src/lib/moduleWorld.js | 122 +++++++++++++++++++++++++ src/lib/playSettings.js | 6 +- tests/e2e/module-world-root.test.cjs | 127 +++++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 src/lib/moduleWorld.js create mode 100644 tests/e2e/module-world-root.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 239477a5..afdf4daf 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -479,9 +479,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/decimate'), import('./lib/playCursor'), import('./lib/gameStorage'), + import('./lib/moduleWorld'), import('./lib/playSpawn') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, playSpawnLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, playSpawn: playSpawnLib } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, moduleWorldLib, playSpawnLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib } }) } }) diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 5fab55d8..1ae43666 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -31,6 +31,7 @@ import { sceneHits } from '$lib/scenePick'; import { pickStack, chooseInStack } from '$lib/selectThrough'; import { tickModuleProxy, selectModuleGroup, moduleGroupOf } from '$lib/moduleContent'; + import { setModuleWorldRoot } from '$lib/moduleWorld'; import { startPlayInteract, tickPlayInteract, stopPlayInteract, carriedUuid, editorInteractActive, cursorGrabStart, cursorGrabMove, cursorGrabEnd, interactClick } from '$lib/playInteract'; import { registerKeySessionProbe } from '$lib/shortcuts'; import { startKnock, tickKnock, stopKnock } from '$lib/knock'; @@ -1600,6 +1601,11 @@ sceneObjects — they'd leak into GLTF sync). oncreate passes the ref DIRECTLY (the { ref } destructure trap, N1). --> setParticleRoot(ref)} /> + + + setModuleWorldRoot(ref)} /> {#if !$isLocked && !$isVRMode} diff --git a/src/lib/moduleContent.js b/src/lib/moduleContent.js index 457e8afc..580d76e9 100644 --- a/src/lib/moduleContent.js +++ b/src/lib/moduleContent.js @@ -107,7 +107,9 @@ export function moduleContentRows(scene) { const rows = []; for (const entry of moduleGroupList()) { const root = scene.getObjectByName(entry.name); - if (!root || root.parent !== scene) continue; + // 30b P5: a registered group is re-homed under the world rig's module root + // (moduleWorld.js — named, not imported, because that leaf imports this one) + if (!root || (root.parent !== scene && root.parent?.name !== 'module-world-root')) continue; /** @type {{name: string, depth: number, uuid: string}[]} */ const children = []; let total = 0; diff --git a/src/lib/moduleWorld.js b/src/lib/moduleWorld.js new file mode 100644 index 00000000..00025850 --- /dev/null +++ b/src/lib/moduleWorld.js @@ -0,0 +1,122 @@ +// 30b P5: MODULE CONTENT FOLLOWS THE WORLD — a leaf (svelte/store + sceneStore + +// moduleContent's registry). +// +// THE FINDING (the Quest report: "When I spin the world around, the untangled dots do not +// spin around"). The VR world gestures (71: two-grip scale/rotate/pan) transform the +// `world-grab-rig` group, and only what lives INSIDE it moves: objectsGroup, the grid, the +// notes, the particles. A module's viewport content — the Untangle board, the dungeon, the +// piano — lives at the SCENE ROOT (golden rule 5: never in objectsGroup, or it would enter +// GLTF sync), so it stayed pinned to the room while the rest of the world spun away. +// +// THE FIX, for every module at once: a `module-world-root` group INSIDE the rig (a sibling +// of objectsGroup, so golden rule 5 still holds — nothing here is serialised or sent), and +// every REGISTERED module group (registerInteractiveGroup / registerSystemGroup / +// registerListedGroup — the names the Module content list already knows) is re-homed +// under it the moment it reaches the scene root. Its LOCAL transform is untouched, so on +// the desktop (rig = identity) nothing moves at all, and in VR it rides the rig. A module +// converting world hits with `group.worldToLocal` (Untangle does) keeps working unchanged, +// because every matrix it reads is the true one. +// +// COMPATIBILITY, the two things a module can still do to "its scene-root group": +// · `api.scene().remove(group)` — Object3D.remove only removes DIRECT children, so the +// scene instance gets a remove that also takes a re-homed group back out; +// · `scene.getObjectByName(name)` — a traversal, so it still finds it. +// Core's own scene-root scans (playSettings.playPublishers, moduleContent's rows) read +// `moduleWorldChildren()` as well as `scene.children`. +// Content a module adds WITHOUT registering a name cannot be told apart from the app's own +// scene-root helpers, so it stays where it was put. +import { get } from 'svelte/store'; +import { globalScene } from '../stores/sceneStore'; +import { moduleGroupList, moduleGroupsRevision, moduleContentRows } from './moduleContent'; + +export const MODULE_WORLD_ROOT = 'module-world-root'; + +/** @type {any} */ let root = null; +/** @type {any} */ let patchedScene = null; +let started = false; + +/** the group inside the world rig that module content is re-homed under (null before mount) */ +export function moduleWorldRoot() { + return root; +} + +/** the re-homed module groups (for scene-root scans that used to read scene.children) */ +export function moduleWorldChildren() { + return root ? root.children : []; +} + +/** is `object` a module group at the top of the module world (or the scene root)? @param {any} object @param {any} scene */ +export function isModuleTopLevel(object, scene) { + return !!object && (object.parent === scene || (!!root && object.parent === root)); +} + +/** @param {any} object */ +function registeredName(object) { + if (!object?.name) return false; + return moduleGroupList().some((entry) => entry.name === object.name); +} + +/** move every registered module group sitting at the scene root under the module root */ +export function adoptModuleGroups() { + const scene = /** @type {any} */ (get(globalScene)); + if (!scene || !root) return 0; + let moved = 0; + for (const child of [...scene.children]) { + if (child === root || !registeredName(child)) continue; + // .add re-parents (three removes it from the scene first) and KEEPS the local + // transform — which is the point: the group's numbers become rig-relative + root.add(child); + moved++; + } + return moved; +} + +/** give the scene a remove() that also finds a re-homed module group @param {any} scene */ +function patchRemove(scene) { + if (!scene || patchedScene === scene) return; + patchedScene = scene; + const nativeRemove = scene.remove; + /** @param {...any} objects */ + scene.remove = function (...objects) { + for (const object of objects) { + if (root && object?.parent === root) root.remove(object); + else nativeRemove.call(this, object); + } + return this; + }; + // a registered group arriving later is re-homed as it lands (after the add returns — + // never re-parent inside three's own add loop) + scene.addEventListener?.('childadded', (/** @type {any} */ event) => { + if (!registeredName(event.child)) return; + queueMicrotask(adoptModuleGroups); + }); +} + +/** Scene mounts the root inside the world rig. @param {any} group */ +export function setModuleWorldRoot(group) { + root = group; + if (root) root.name = MODULE_WORLD_ROOT; + start(); + adoptModuleGroups(); +} + +function start() { + if (started || typeof window === 'undefined') return; + started = true; + globalScene.subscribe((scene) => { + patchRemove(scene); + adoptModuleGroups(); + }); + // a name registered AFTER its group was added (register order varies by module) + moduleGroupsRevision.subscribe(() => adoptModuleGroups()); +} + +/** test/debug view: what is re-homed, and what the object list's Module content lists */ +export function moduleWorldDebug() { + const scene = /** @type {any} */ (get(globalScene)); + return { + root: !!root, + rehomed: moduleWorldChildren().map((/** @type {any} */ c) => c.name), + rows: moduleContentRows(scene).map((/** @type {any} */ row) => row.name) + }; +} diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 39bfe251..973aed71 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -2,6 +2,7 @@ import { get, writable } from 'svelte/store'; import { scenePlay } from './scenePhysics'; import { showToast } from '../stores/appStore'; import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; +import { moduleWorldChildren } from './moduleWorld'; /** * 30b P4: a spawn point set at RUNTIME by a module (`api.setSpawn(position, yaw)`) — a @@ -50,7 +51,10 @@ let warnedMultiple = false; */ export function playPublishers(scene) { if (!scene?.children) return []; - const found = scene.children.filter((/** @type {any} */ child) => child?.userData?.play); + // 30b P5: registered module groups live under the world rig's module root now + const found = [...scene.children, ...moduleWorldChildren()].filter( + (/** @type {any} */ child) => child?.userData?.play + ); found.sort((/** @type {any} */ a, /** @type {any} */ b) => { if (a.name === 'dungeon-module') return -1; if (b.name === 'dungeon-module') return 1; diff --git a/tests/e2e/module-world-root.test.cjs b/tests/e2e/module-world-root.test.cjs new file mode 100644 index 00000000..fd5d9bc3 --- /dev/null +++ b/tests/e2e/module-world-root.test.cjs @@ -0,0 +1,127 @@ +// 30b P5: MODULE CONTENT FOLLOWS THE WORLD. The Quest report: "When I spin the world +// around, the untangled dots do not spin around." The VR world gestures transform the +// `world-grab-rig` group; module viewport content lived at the SCENE ROOT (golden rule 5 +// keeps it out of objectsGroup), outside the rig, so it stayed pinned to the room. Every +// REGISTERED module group is now re-homed under `module-world-root` inside the rig — for +// every module at once — while staying out of objectsGroup (nothing replicates or saves), +// and the module's own `scene.remove(group)` / `getObjectByName` keep working. +// Driven with an INLINE module through the real SDK (moduleSDK.initModules). +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + + // a module that builds a board (registered BEFORE it is added) and a late board + // (added first, registered after), plus a stray group it never registers + await A.page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const mod = { + id: 'worldroottest', + name: 'World root test', + version: '1', + register(api) { + window.__wrApi = api; + api.registerInteractiveGroup('wr-board'); + const scene = api.scene(); + const board = new THREE.Group(); + board.name = 'wr-board'; + board.userData.play = { grounded: true }; + const dot = new THREE.Mesh(new THREE.SphereGeometry(0.1), new THREE.MeshBasicMaterial()); + dot.name = 'wr-dot'; + dot.position.set(1, 1, 0); + board.add(dot); + scene.add(board); + const late = new THREE.Group(); + late.name = 'wr-late'; + scene.add(late); + api.registerListedGroup('wr-late', { label: 'Late board' }); + const stray = new THREE.Group(); + stray.name = 'wr-stray'; + scene.add(stray); + } + }; + s.moduleSDK.initModules([mod]); + }); + await A.page.waitForTimeout(300); + + const read = () => + A.page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const scene = get(s.globalScene); + const THREE = s.THREE; + const board = scene.getObjectByName('wr-board'); + const late = scene.getObjectByName('wr-late'); + const stray = scene.getObjectByName('wr-stray'); + const dot = scene.getObjectByName('wr-dot'); + let inObjects = false; + get(s.objectsGroup).traverse((n) => { if (n.name === 'wr-board') inObjects = true; }); + return { + board: board?.parent?.name ?? null, + late: late?.parent?.name ?? null, + stray: stray?.parent === scene ? 'scene' : stray?.parent?.name ?? null, + dotWorld: dot ? dot.getWorldPosition(new THREE.Vector3()).toArray() : null, + inObjects, + publishers: s.playSettings.playPublishers(scene).map((p) => p.name) + }; + }); + + // ---- 1. registered groups are re-homed inside the rig; the stray one is not ------------------- + let r = await read(); + h.check(r.board === 'module-world-root', `a registered group lands under the world rig's module root (${r.board})`); + h.check(r.late === 'module-world-root', `...also when it was added BEFORE its name was registered (${r.late})`); + h.check(r.stray === 'scene', `an UNREGISTERED scene-root group stays where it was put (${r.stray})`); + h.check(!r.inObjects, 'golden rule 5: module content never enters objectsGroup'); + const rigParent = await A.page.evaluate(() => { + let v; + window.__stores.globalScene.subscribe((x) => (v = x))(); + return v.getObjectByName('module-world-root')?.parent?.name ?? null; + }); + h.check(rigParent === 'world-grab-rig', `the module root is a child of the world rig (${rigParent})`); + h.check(r.dotWorld && Math.abs(r.dotWorld[0] - 1) < 1e-6 && Math.abs(r.dotWorld[1] - 1) < 1e-6, `at 1:1 nothing moved (dot at ${r.dotWorld})`); + h.check(r.publishers.includes('wr-board'), `a re-homed group still publishes the play contract (${r.publishers})`); + + // ---- 2. spin + scale the world: the dot goes with it -------------------------------------------- + await A.page.evaluate(() => { + const s = window.__stores; + let rig; + s.worldRig.subscribe((x) => (rig = x))(); + rig.quaternion.setFromAxisAngle(new s.THREE.Vector3(0, 1, 0), Math.PI / 2); + rig.scale.setScalar(2); + rig.updateMatrixWorld(true); + }); + r = await read(); + // R(90deg about +Y) maps (1,1,0) to (0,1,-1); scale 2 -> (0,2,-2) + h.check( + r.dotWorld && Math.abs(r.dotWorld[0]) < 1e-6 && Math.abs(r.dotWorld[1] - 2) < 1e-6 && Math.abs(r.dotWorld[2] + 2) < 1e-6, + `the world spun and scaled: the module's dot followed (${r.dotWorld.map((v) => v.toFixed(3))})` + ); + await A.page.evaluate(() => window.__stores.vrControls.resetWorldRig()); + + // ---- 3. the Module content list still lists it ------------------------------------------------------ + const rows = await A.page.evaluate(() => window.__stores.moduleWorld.moduleWorldDebug().rows); + h.check(rows.includes('wr-board') && rows.includes('wr-late'), `the object list's Module content rows still find both (${rows})`); + + // ---- 4. the module's own scene.remove(group) still takes it out --------------------------------- + const removed = await A.page.evaluate(() => { + const scene = window.__wrApi.scene(); + const late = scene.getObjectByName('wr-late'); + scene.remove(late); + return { gone: !scene.getObjectByName('wr-late'), parent: late.parent }; + }); + h.check(removed.gone && removed.parent === null, 'api.scene().remove(group) removes a re-homed group'); + + // ---- 5. disabling the module takes its content away --------------------------------------------------- + await A.page.evaluate(() => { + window.__stores.moduleSDK.deactivateModule('worldroottest'); + const scene = window.__wrApi.scene(); + scene.remove(scene.getObjectByName('wr-stray')); + }); + await A.page.waitForTimeout(200); + r = await read(); + h.check(r.board === null, `deactivating the module removes its re-homed group (${r.board})`); + + await h.finish(browser); +}); From 57a623fef50f5172f7e47f3e5a82218c1f697bb1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 16:57:29 +0300 Subject: [PATCH 10/18] [feat] 30b P3: the game UI in VR (C2) - the HUD menu as a world board, the wrist card, the top strip, the banner - vrGamePanel.js: DOM is invisible in a headset, so the game's HUD documents are drawn into CANVAS TEXTURES on four scene-root meshes (default layer = both eyes, never the helper layer; LOCAL; only while presenting AND in Interact/Play): - the BOARD: a menu-like screen (input 'menu', a control on it, or bound to menu / paused / over) laid out with the desktop's own 9-grid (rectInFrame over the 1280x720 stage), CROPPED to the part of the stage the screen uses and sized from it (1.6 mm per stage px, 1.15 m for the Towers menu) - drawn whole it was a wall of empty backdrop with the menu small in the middle. 1.2 m ahead at chest height, following the head LAZILY with hysteresis: a glance inside 35 degrees leaves it, a real turn brings it all the way back in front (without the latch it parked 28 degrees off to the side). A footer of ours: "Edit mode" (objectActions.setEditorMode('edit')) and "Top strip". - the WRIST card: the other screens (score, timer, level) as short lines on the LEFT wrist, shown when the wrist turns toward the face, with the same two buttons - a way back to editing mid-round. Placed clear of 30b-vr-modes' mode label. - the TOP STRIP: the glanceable readouts in one head-locked row (a sentence-long hint stays on the wrist), LOCAL on/off (vr:gameStrip), default on. - the ANNOUNCE banner, head-locked (api.announce in a headset). A press is the desktop press verbatim (fireHudButton; a toggle writes its value first; sliders/dropdowns/tabs step), with a 'click' sound and a 'bump'. - vrGameInput wires it: the frame hook draws, hovers (the laser on the board wins over the world behind it) and POKES (the tip through a button presses once, re-arms on retreat); the trigger hook presses a board button and CONSUMES the press, and the trailing 'select' is swallowed once; two beam providers end the laser on the board/wrist. Suite vr-game-panel (new): 36/36, including section 13 on the REAL Towers template: its menu on the board, "Start round" pressed by laser starts the round, its HUD on the wrist/strip. Evidence: /home/deck/.code/lanes-30/after-30b/30b-vr-play/. Counterfactuals (five broken in one run): - the Interact/Play gate removed -> 8.2 red (the wrist and strip stayed up in Edit) - the board press not consuming -> 3.2, 8.1 red - the poke never re-arming -> 6.3 red - the crop removed -> 1.6 red (a 2.05 m board) - the follow latch releasing at the band edge -> 9.3 red (parked at 52.6 of 80 degrees) svelte-check 334/47, no new message. Build green. Held suites run once for P3+P4 (machine-wide e2e queue of 11 lanes); see the P4 commit. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/gameKit.js | 1 + src/lib/vrGameInput.js | 90 +++- src/lib/vrGamePanel.js | 898 +++++++++++++++++++++++++++++++ tests/e2e/vr-game-panel.test.cjs | 337 ++++++++++++ 4 files changed, 1319 insertions(+), 7 deletions(-) create mode 100644 src/lib/vrGamePanel.js create mode 100644 tests/e2e/vr-game-panel.test.cjs diff --git a/src/lib/gameKit.js b/src/lib/gameKit.js index ecc87b37..51b0bcfc 100644 --- a/src/lib/gameKit.js +++ b/src/lib/gameKit.js @@ -9,3 +9,4 @@ export * as hapticPatterns from './hapticPatterns'; export * as vrGameInput from './vrGameInput'; export * as effectsBurst from './effectsBurst'; export * as gameAnnounce from './gameAnnounce'; +export * as vrGamePanel from './vrGamePanel'; diff --git a/src/lib/vrGameInput.js b/src/lib/vrGameInput.js index 0c858e3e..a8e245e4 100644 --- a/src/lib/vrGameInput.js +++ b/src/lib/vrGameInput.js @@ -29,7 +29,9 @@ import { pickStack, primaryIndex } from './selectThrough'; import { topLevelObjectOf } from './objectActions'; import { objectHasOnClick } from './flowRuntime'; import { moduleInteractiveGroups } from './moduleSDK'; -import { registerVRFrameHook, registerVRTriggerHooks, hapticPattern } from './vrControls'; +import { registerVRFrameHook, registerVRTriggerHooks, registerPanelGroupProvider, controllerIndexFor, hapticPattern } from './vrControls'; +// P3 (C2): the game UI in VR — the panel, the wrist card, the strip, the banner +import { vrGamePanelFrame, panelTargetAlong, panelHover, pressPanelTarget, pokeFrame, uAcross, vrGameSurface, hideVrGamePanel } from './vrGamePanel'; const _mat = new THREE.Matrix4(); @@ -165,6 +167,55 @@ export function vrGameInputDebug() { return { ...stats, hover: [...hoverKey] }; } +/* --------------------------------------------------------------- P3 the panel ---- */ + +const _pos = new THREE.Vector3(); +const _quat = new THREE.Quaternion(); + +/** one hand's controller world pose, or null @param {'left'|'right'} hand */ +function handPose(hand) { + const renderer = /** @type {any} */ (get(globalRenderer)); + const index = controllerIndexFor(hand); + const controller = index >= 0 ? renderer?.xr?.getController?.(index) : null; + if (!controller) return null; + controller.getWorldPosition(_pos); + controller.getWorldQuaternion(_quat); + return { position: _pos.clone(), quaternion: _quat.clone() }; +} + +/** the controller's TIP for a poke (the target-ray origin is its front end) @param {number} index */ +function tipOf(index) { + const renderer = /** @type {any} */ (get(globalRenderer)); + const controller = renderer?.xr?.getController?.(index); + return controller ? controller.getWorldPosition(new THREE.Vector3()) : null; +} + +/** true once a press of OURS (a panel button) took this trigger: its trailing 'select' + * must not also reach Scene's pick */ +let swallowNext = false; +/** was a headset presenting on the last frame (the session-end edge) */ +let presenting = false; + +/** + * A trigger PRESS on slot `index`: a game-panel button under the laser is pressed and the + * press consumed; anything else only gets its haptic bump and falls through untouched. + * Exported for the suites (the live hook calls it). + * @param {number} index @returns {boolean} consumed + */ +export function triggerStart(index) { + swallowNext = false; + const ray = controllerRayOf(index); + const target = ray && gameFeelActive() ? panelTargetAlong(ray) : null; + if (target) { + pressPanelTarget(target, { source: 'trigger', u: uAcross(target) }); + hapticPattern('bump', handOf(index)); + swallowNext = true; + return true; + } + pressFeedback(index); + return false; +} + /** @type {(() => void)[]} */ let offs = []; @@ -174,15 +225,39 @@ export function startVrGameInput() { offs = [ registerVRFrameHook(() => { const renderer = /** @type {any} */ (get(globalRenderer)); - if (!renderer?.xr?.isPresenting) return; - hoverFrame(0); - hoverFrame(1); + if (!renderer?.xr?.isPresenting) { + // the session ENDED: take the surfaces down once (this hook runs every desktop + // frame too, where there is nothing to do) + if (presenting) hideVrGamePanel(); + presenting = false; + return; + } + presenting = true; + vrGamePanelFrame({ hands: [handPose('left'), null] }); + for (const index of [0, 1]) { + const ray = controllerRayOf(index); + // the laser on the board wins over the world behind it + if (panelHover(index, gameFeelActive() ? ray : null)) hoverKey[index] = null; + else hoverFrame(index, ray); + pokeFrame(index, gameFeelActive() ? tipOf(index) : null); + } }), registerVRTriggerHooks({ - start: (/** @type {number} */ index) => { - pressFeedback(index); - return false; // feedback only — the press is not ours to take + start: (/** @type {number} */ index) => triggerStart(index), + swallow: () => { + if (!swallowNext) return false; + swallowNext = false; + return true; } + }), + // the beam ends ON the board and the wrist card (the reticle sits on the button) + registerPanelGroupProvider(() => { + const s = vrGameSurface('vr-game-panel'); + return s?.mesh.visible ? s.mesh : null; + }), + registerPanelGroupProvider(() => { + const s = vrGameSurface('vr-game-wrist'); + return s?.mesh.visible ? s.mesh : null; }) ]; return stopVrGameInput; @@ -192,4 +267,5 @@ export function stopVrGameInput() { for (const off of offs) off(); offs = []; hoverKey[0] = hoverKey[1] = null; + hideVrGamePanel(); } diff --git a/src/lib/vrGamePanel.js b/src/lib/vrGamePanel.js new file mode 100644 index 00000000..33e856cc --- /dev/null +++ b/src/lib/vrGamePanel.js @@ -0,0 +1,898 @@ +// 30b (vr-play) C2 — THE GAME UI IN VR. The user, on a Quest: "in VR I should be able to +// see the game menu and interact with it", "I also should see the score in the VR". +// +// DOM is invisible in a headset, so the HUD layer (HudLayer.svelte) draws nothing there. +// This module draws the SAME documents into CANVAS TEXTURES on four scene-root meshes: +// +// · the GAME PANEL — a world-space board ~1.2 m in front at chest height that LAZILY +// follows your head yaw (it re-centres only once you have turned past ~35 degrees, so +// it holds still while you read it). It shows a game's MENU-like screens (menu, pause, +// results: a screen with input 'menu', a control on it, or bound to menu/paused/over) +// laid out exactly as the desktop HUD lays them out (the 9-grid over the 1280x720 +// reference stage), plus a footer with two buttons of ours: "Edit mode" (the Edit ⇄ +// Interact switch 30b-vr-modes adds; a plain `editorMode` write is enough to leave) and +// "Top strip" on/off. Its buttons answer the controller LASER + trigger and a +// fingertip/controller-tip POKE. +// · the WRIST card — the game's small overlays (score, timer, level) as short lines on +// the LEFT wrist, shown when you turn the wrist toward your face, with the same two +// buttons, so there is always a way back to editing even mid-round. +// · the TOP STRIP — the same lines in one head-locked row across the top of the view, +// LOCAL on/off (persisted), default on. +// · the ANNOUNCE banner — api.announce(), head-locked, big and centred. +// +// A button press is the desktop press verbatim: `fireHudButton(id)` pulses the element's +// Button nodes through the replicated trigger log, a toggle writes its value first — so a +// press in the headset and a click on the desktop cannot disagree about what happened. +// +// LOCAL and non-replicated throughout; everything sits at the SCENE ROOT under fixed names +// (golden rule 5) on the default layer (both eyes, never the helper layer), and only while +// a headset session is presenting AND the player is in Interact/Play. The frame work is +// driven by vrGameInput's frame hook; the suites drive `vrGamePanelFrame` with a synthetic +// head pose, since no XR session runs headless. +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { globalScene, globalRenderer } from '../stores/sceneStore'; +import { setEditorMode } from './objectActions'; +import { hudDocs, hudRuntime, hudValueOf, setHudValue, visibleScreen, activeHudKeys, rectInFrame, hudScreenOverride } from './hudDocs'; +import { isInteractiveKind, isRenderableKind } from './hudKinds'; +import { fireHudButton, hudOptionsOf } from './flowRuntime'; +import { cameraPreview } from './cameraPreview'; +import { gameState } from './gameState'; +import { gameFeelActive } from './gameFeel'; +import { gameAnnouncement } from './gameAnnounce'; +import { playGameSound } from './gameSfx'; +import { hudImageFor, resolveHudImage } from './hudImages'; +import { safeStorage } from './safeStorage'; + +/** the HUD's authoring reference — the editor artboard's stage */ +export const STAGE_W = 1280; +export const STAGE_H = 720; +/** the panel's footer (our own buttons), in stage pixels */ +export const FOOTER_H = 96; +const PANEL_DIST = 1.2; +const PANEL_DROP = 0.22; // below the eyes: chest height +const FOLLOW_DEG = 35; +const SETTLE_DEG = 2; +const WRIST_W = 0.15; +const STRIP_KEY = 'vr:gameStrip'; + +/** the board canvas's pixels per stage pixel: ~1000 px across a metre-wide board, what a + * headset's ~20 px per degree needs at 1.2 m */ +const SCALE = 1.5; + +/* ------------------------------------------------------------ the classification --- */ + +/** a screen the PANEL shows (a menu, a pause, a results card) rather than the wrist + * @param {any} screen */ +export function isPanelScreen(screen) { + if (!screen) return false; + if (screen.input === 'menu') return true; + if (['menu', 'paused', 'over'].includes(String(screen.showWhile ?? ''))) return true; + return (screen.elements ?? []).some((/** @type {any} */ el) => isInteractiveKind(el.kind) && el.enabled !== false); +} + +/** kinds with no VR form on the board (a crosshair, a plot, the debug pill, module DOM) */ +const NO_VR_FORM = new Set(['crosshair', 'minimap', 'debug', 'custom', 'damageflash']); +/** the board never gets narrower than its footer needs, in stage pixels */ +const MIN_CROP_W = 720; +const CROP_PAD = 36; + +/** + * The part of the 1280x720 stage a screen actually USES — the union of its elements' + * rects, padded, widened to fit the footer. A desktop menu is usually a card in the middle + * of a transparent screen; drawn whole, the headset saw a wall of empty backdrop with the + * menu small in its middle (measured on the Towers template). Pure; exported. + * @param {any} screen @returns {{x: number, y: number, w: number, h: number}} + */ +export function panelCrop(screen) { + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + for (const el of screen?.elements ?? []) { + if (!isRenderableKind(el.kind) || NO_VR_FORM.has(el.kind)) continue; + const r = rectInFrame(el, STAGE_W, STAGE_H); + x0 = Math.min(x0, r.left); + y0 = Math.min(y0, r.top); + x1 = Math.max(x1, r.left + r.w); + y1 = Math.max(y1, r.top + r.h); + } + if (!Number.isFinite(x0)) return { x: 0, y: 0, w: STAGE_W, h: STAGE_H }; + x0 = Math.max(0, x0 - CROP_PAD); + y0 = Math.max(0, y0 - CROP_PAD); + x1 = Math.min(STAGE_W, x1 + CROP_PAD); + y1 = Math.min(STAGE_H, y1 + CROP_PAD); + let w = x1 - x0; + if (w < MIN_CROP_W) { + const cx = (x0 + x1) / 2; + w = MIN_CROP_W; + x0 = Math.min(Math.max(0, cx - w / 2), STAGE_W - w); + } + return { x: x0, y: y0, w, h: Math.max(120, y1 - y0) }; +} + +/** metres per stage pixel on the board: a 14 px label reads ~1 degree tall at 1.2 m */ +const METRES_PER_PX = 0.0016; + +/** + * The screens on show right now, the HudLayer rule (scene doc + the one keyed by the camera + * looked through), split into what the panel shows and what the wrist/strip show. + * @returns {{panel: {key: string, screen: any}[], overlay: {key: string, screen: any}[]}} + */ +export function vrScreens() { + const through = get(cameraPreview)?.uuid ?? null; + /** @type {{key: string, screen: any}[]} */ + const panel = []; + /** @type {{key: string, screen: any}[]} */ + const overlay = []; + for (const key of activeHudKeys(through)) { + const screen = visibleScreen(key); + if (!screen) continue; + (isPanelScreen(screen) ? panel : overlay).push({ key, screen }); + } + return { panel, overlay }; +} + +/** + * The overlay as SHORT LINES — what a score/timer/level card SAYS, readable on a wrist. + * Pure over (elements, runtime, values); exported for the suites. + * @param {any[]} elements @param {Record} runtime @returns {string[]} + */ +export function overlayLines(elements, runtime) { + /** @type {string[]} */ + const out = []; + const sorted = [...elements].sort((a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0)); + for (const el of sorted) { + if (!isRenderableKind(el.kind)) continue; + const rt = runtime?.[el.id] ?? null; + const text = rt?.text !== undefined && rt?.text !== null ? String(rt.text) : String(el.label ?? ''); + if (el.kind === 'text' || el.kind === 'timer' || el.kind === 'richtext' || el.kind === 'panel') { + const clean = text.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim(); + if (clean) out.push(clean); + } else if (el.kind === 'bar' || el.kind === 'progressradial') { + const value = Number(rt?.value ?? el.value ?? 0); + const min = Number(rt?.min ?? el.min ?? 0); + const max = Number(rt?.max ?? el.max ?? 1); + const pct = max - min > 1e-9 ? Math.round(Math.min(1, Math.max(0, (value - min) / (max - min))) * 100) : 0; + out.push((text ? text + ' ' : '') + pct + '%'); + } else if (el.kind === 'list') { + const rows = Array.isArray(rt?.rows) && rt.rows.length ? rt.rows : String(el.rowsText ?? '').split('\n').filter(Boolean); + if (text) out.push(text); + for (const row of rows.slice(0, 3)) out.push(String(row)); + } + if (out.length >= 6) break; + } + return out.slice(0, 6); +} + +/* --------------------------------------------------------------------- drawing ----- */ + +/** a style value, token names resolved to literals (a canvas cannot take var()) */ +function paint(/** @type {any} */ value, /** @type {string} */ fallback) { + if (value === undefined || value === null || value === '') return fallback; + const text = String(value); + if (/^[a-z][a-z0-9-]*$/i.test(text) && !/^(transparent|white|black|red|green|blue|gray|grey|yellow|orange|none)$/i.test(text)) { + try { + const v = getComputedStyle(document.documentElement).getPropertyValue('--' + text).trim(); + return v || fallback; + } catch { + return fallback; + } + } + return text; +} + +/** @param {CanvasRenderingContext2D} g @param {number} x @param {number} y @param {number} w @param {number} h @param {number} r */ +function roundRect(g, x, y, w, h, r) { + const rr = Math.max(0, Math.min(r, w / 2, h / 2)); + g.beginPath(); + g.moveTo(x + rr, y); + g.arcTo(x + w, y, x + w, y + h, rr); + g.arcTo(x + w, y + h, x, y + h, rr); + g.arcTo(x, y + h, x, y, rr); + g.arcTo(x, y, x + w, y, rr); + g.closePath(); +} + +/** @type {Map} */ +const images = new Map(); +let imageTick = 0; + +/** @typedef {{id: string, key: string, kind: string, x: number, y: number, w: number, h: number, footer?: string}} HitRect */ + +/** + * Draw one menu screen + our footer into a 2D context sized (STAGE_W x (STAGE_H+FOOTER_H)) + * * SCALE, and return the pressable rects in CANVAS pixels. `hover` rings the element the + * laser is on. Pure over its arguments (the DOM is only touched for token colours and + * image decode); exported for the suites. + * @param {CanvasRenderingContext2D} g @param {{key: string, screen: any} | null} entry + * @param {Record} runtime @param {{hover?: string | null, strip?: boolean, mode?: string, crop?: {x: number, y: number, w: number, h: number}}} [opts] + * @returns {HitRect[]} + */ +export function drawPanel(g, entry, runtime, opts = {}) { + const crop = opts.crop ?? { x: 0, y: 0, w: STAGE_W, h: STAGE_H }; + const W = g.canvas.width; + const H = g.canvas.height; + const k = W / crop.w; + g.clearRect(0, 0, W, H); + // the board: a dark rounded plate, so a screen authored over the game view still reads + roundRect(g, 0, 0, W, H, 28 * k); + g.fillStyle = 'rgba(12, 16, 26, 0.9)'; + g.fill(); + g.lineWidth = 3 * k; + g.strokeStyle = 'rgba(148, 163, 184, 0.55)'; + g.stroke(); + /** @type {HitRect[]} */ + const hits = []; + const elements = (entry?.screen?.elements ?? []).filter((/** @type {any} */ el) => isRenderableKind(el.kind)); + const sorted = [...elements].sort((a, b) => (a.z ?? 0) - (b.z ?? 0)); + g.save(); + g.beginPath(); + g.rect(0, 0, W, crop.h * k); + g.clip(); + for (const el of sorted) { + const r = rectInFrame(el, STAGE_W, STAGE_H); + const x = (r.left - crop.x) * k; + const y = (r.top - crop.y) * k; + const w = r.w * k; + const h = r.h * k; + const style = el.style ?? {}; + const rt = runtime?.[el.id] ?? null; + const text = rt?.text !== undefined && rt?.text !== null ? String(rt.text) : String(el.label ?? ''); + const size = Math.max(10, Number(style.size ?? 14)) * k; + const weight = String(style.weight ?? (el.kind === 'button' ? 600 : 400)); + const colour = paint(style.color, '#f3f4f6'); + const disabled = el.enabled === false; + g.globalAlpha = Number(style.opacity ?? 1) * (disabled ? 0.45 : 1); + const bg = paint(style.bg, el.kind === 'button' || el.kind === 'toggle' || el.kind === 'dropdown' || el.kind === 'tabs' ? '#374151' : 'transparent'); + const radius = Number(style.radius ?? (el.kind === 'button' ? 8 : 0)) * k; + if (bg !== 'transparent') { + roundRect(g, x, y, w, h, radius); + g.fillStyle = bg; + g.fill(); + } + if (style.border) { + roundRect(g, x, y, w, h, radius); + g.lineWidth = 2 * k; + g.strokeStyle = paint(style.border, 'rgba(75,85,99,0.7)'); + g.stroke(); + } + g.fillStyle = colour; + g.font = `${weight} ${size}px system-ui, sans-serif`; + g.textBaseline = 'middle'; + const align = el.kind === 'button' ? 'center' : String(style.align ?? 'left'); + g.textAlign = align === 'center' ? 'center' : align === 'right' ? 'right' : 'left'; + const pad = Number(style.pad ?? 0) * k; + const tx = align === 'center' ? x + w / 2 : align === 'right' ? x + w - pad - 4 * k : x + pad + 4 * k; + if (el.kind === 'bar' || el.kind === 'progressradial' || el.kind === 'slider') { + const value = Number(el.kind === 'slider' ? hudValueOf(el.id, el.value ?? el.min ?? 0) : rt?.value ?? el.value ?? 0); + const min = Number(rt?.min ?? el.min ?? 0); + const max = Number(rt?.max ?? el.max ?? (el.kind === 'slider' ? 100 : 1)); + const pct = max - min > 1e-9 ? Math.min(1, Math.max(0, (value - min) / (max - min))) : 0; + roundRect(g, x, y + h * 0.3, w, h * 0.4, h * 0.2); + g.fillStyle = 'rgba(255,255,255,0.15)'; + g.fill(); + roundRect(g, x, y + h * 0.3, w * pct, h * 0.4, h * 0.2); + g.fillStyle = paint(style.color, '#ef562f'); + g.fill(); + if (text && el.kind !== 'slider') { + g.fillStyle = '#f3f4f6'; + g.textAlign = 'center'; + g.fillText(text, x + w / 2, y + h / 2); + } + } else if (el.kind === 'toggle') { + const on = !!hudValueOf(el.id, el.value); + roundRect(g, x + 6 * k, y + h * 0.2, h * 1.1, h * 0.6, h * 0.3); + g.fillStyle = on ? '#22c55e' : 'rgba(255,255,255,0.2)'; + g.fill(); + g.beginPath(); + g.arc(x + 6 * k + (on ? h * 0.8 : h * 0.3), y + h / 2, h * 0.24, 0, Math.PI * 2); + g.fillStyle = '#fff'; + g.fill(); + g.fillStyle = colour; + g.textAlign = 'left'; + g.fillText(text, x + h * 1.3 + 10 * k, y + h / 2); + } else if (el.kind === 'dropdown' || el.kind === 'tabs') { + const options = hudOptionsOf(el.id, el); + const held = hudValueOf(el.id, el.value ?? (el.kind === 'tabs' ? 0 : options[0])); + const shown = el.kind === 'tabs' ? options[Math.round(Number(held)) || 0] ?? '' : String(held ?? ''); + g.textAlign = 'center'; + g.fillText((text ? text + ': ' : '') + '‹ ' + shown + ' ›', x + w / 2, y + h / 2); + } else if (el.kind === 'list') { + const rows = Array.isArray(rt?.rows) && rt.rows.length ? rt.rows : String(el.rowsText ?? '').split('\n').filter(Boolean); + const rowH = Number(el.rowHeight ?? 18) * k; + let yy = y + rowH / 2; + if (text) { + g.fillText(text, tx, yy); + yy += rowH; + } + for (const row of rows) { + if (yy > y + h) break; + g.fillText(String(row), tx, yy); + yy += rowH; + } + } else if (el.kind === 'image') { + const url = hudImageFor(String(el.src ?? '')); + if (!url && el.src) void resolveHudImage(String(el.src)); + if (url) { + let img = images.get(url); + if (!img) { + img = new Image(); + img.onload = () => (imageTick += 1); + img.src = url; + images.set(url, img); + } + if (img.complete && img.naturalWidth) g.drawImage(img, x, y, w, h); + } + } else if (el.kind === 'crosshair' || el.kind === 'minimap' || el.kind === 'debug' || el.kind === 'custom' || el.kind === 'damageflash') { + // a crosshair, a plot, a debug pill and a module's own DOM have no VR form here + } else if (text) { + // text, timer, button, panel, richtext and anything else that says something + const lines = el.wrap || el.kind === 'richtext' ? wrapText(g, text.replace(/\[[^\]]*\]/g, ''), w - 8 * k) : [text]; + const lh = size * 1.25; + let yy = y + h / 2 - ((lines.length - 1) * lh) / 2; + for (const line of lines) { + g.fillText(line, tx, yy); + yy += lh; + } + } + g.globalAlpha = 1; + const pressable = isInteractiveKind(el.kind) && !disabled; + if (pressable) { + hits.push({ id: el.id, key: entry?.key ?? 'scene', kind: el.kind, x, y, w, h }); + if (opts.hover === el.id) { + roundRect(g, x - 4 * k, y - 4 * k, w + 8 * k, h + 8 * k, radius + 4 * k); + g.lineWidth = 4 * k; + g.strokeStyle = '#5fd0ff'; + g.stroke(); + } + } + } + g.restore(); + // the footer: OUR two buttons, always there + const fy = crop.h * k; + g.fillStyle = 'rgba(255,255,255,0.06)'; + g.fillRect(0, fy, W, 2 * k); + const buttons = [ + { id: 'edit', label: 'Edit mode' }, + { id: 'strip', label: opts.strip === false ? 'Top strip: off' : 'Top strip: on' } + ]; + const bw = 300 * k; + const bh = 60 * k; + buttons.forEach((b, i) => { + const bx = W / 2 + (i === 0 ? -bw - 20 * k : 20 * k); + const by = fy + (FOOTER_H * k - bh) / 2; + roundRect(g, bx, by, bw, bh, 12 * k); + g.fillStyle = b.id === 'edit' ? '#1f2937' : '#111827'; + g.fill(); + g.lineWidth = (opts.hover === 'footer:' + b.id ? 5 : 2) * k; + g.strokeStyle = opts.hover === 'footer:' + b.id ? '#5fd0ff' : 'rgba(148,163,184,0.6)'; + g.stroke(); + g.fillStyle = '#f3f4f6'; + g.font = `600 ${26 * k}px system-ui, sans-serif`; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText(b.label, bx + bw / 2, by + bh / 2); + hits.push({ id: 'footer:' + b.id, key: '', kind: 'footer', footer: b.id, x: bx, y: by, w: bw, h: bh }); + }); + return hits; +} + +/** a line cut to fit `max` pixels with an ellipsis @param {CanvasRenderingContext2D} g + * @param {string} text @param {number} max */ +function fitText(g, text, max) { + if (g.measureText(text).width <= max) return text; + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (g.measureText(text.slice(0, mid) + '…').width <= max) lo = mid; + else hi = mid - 1; + } + return text.slice(0, lo).trimEnd() + '…'; +} + +/** the strip is one glanceable row: a sentence (a hint, an instruction) belongs on the + * wrist, not across the top of the view @param {string} line */ +export function stripWorthy(line) { + return line.length <= 28; +} + +/** @param {CanvasRenderingContext2D} g @param {string} text @param {number} max */ +function wrapText(g, text, max) { + /** @type {string[]} */ + const out = []; + for (const para of text.split('\n')) { + let line = ''; + for (const word of para.split(/\s+/)) { + const next = line ? line + ' ' + word : word; + if (line && g.measureText(next).width > max) { + out.push(line); + line = word; + } else line = next; + } + if (line) out.push(line); + } + return out.slice(0, 12); +} + +/** + * A compact card of lines (+ optional footer buttons) — the wrist, the top strip and the + * banner are all this. Returns the pressable rects. + * @param {CanvasRenderingContext2D} g @param {string[]} lines + * @param {{title?: string, row?: boolean, buttons?: boolean, strip?: boolean, hover?: string | null, big?: boolean, color?: string}} [opts] + * @returns {HitRect[]} + */ +export function drawCard(g, lines, opts = {}) { + const W = g.canvas.width; + const H = g.canvas.height; + g.clearRect(0, 0, W, H); + roundRect(g, 0, 0, W, H, Math.min(24, H / 4)); + g.fillStyle = 'rgba(12, 16, 26, 0.86)'; + g.fill(); + g.lineWidth = 2; + g.strokeStyle = 'rgba(148, 163, 184, 0.5)'; + g.stroke(); + g.fillStyle = opts.color ?? '#f3f4f6'; + g.textBaseline = 'middle'; + /** @type {HitRect[]} */ + const hits = []; + if (opts.big) { + g.textAlign = 'center'; + g.font = `800 ${Math.round(H * 0.42)}px system-ui, sans-serif`; + g.fillText(fitText(g, lines[0] ?? '', W - 40), W / 2, lines[1] ? H * 0.4 : H / 2); + if (lines[1]) { + g.font = `500 ${Math.round(H * 0.16)}px system-ui, sans-serif`; + g.fillStyle = '#e5e7eb'; + g.fillText(lines[1], W / 2, H * 0.78); + } + return hits; + } + if (opts.row) { + g.textAlign = 'center'; + g.font = `600 ${Math.round(H * 0.46)}px system-ui, sans-serif`; + g.fillText(fitText(g, lines.join(' · '), W - 24), W / 2, H / 2); + return hits; + } + const pad = 18; + const footer = opts.buttons ? 64 : 0; + const rows = Math.max(1, lines.length + (opts.title ? 1 : 0)); + const rowH = Math.min(52, (H - footer - pad * 2) / rows); + let y = pad + rowH / 2; + g.textAlign = 'left'; + if (opts.title) { + g.font = `700 ${Math.round(rowH * 0.62)}px system-ui, sans-serif`; + g.fillStyle = '#93c5fd'; + g.fillText(opts.title, pad, y); + y += rowH; + } + g.fillStyle = '#f3f4f6'; + g.font = `600 ${Math.round(rowH * 0.62)}px system-ui, sans-serif`; + for (const line of lines) { + g.fillText(fitText(g, line, W - pad * 2), pad, y); + y += rowH; + } + if (opts.buttons) { + const labels = [ + { id: 'edit', label: 'Edit' }, + { id: 'strip', label: opts.strip === false ? 'Strip off' : 'Strip on' } + ]; + const bw = (W - pad * 3) / 2; + labels.forEach((b, i) => { + const bx = pad + i * (bw + pad); + const by = H - footer + 8; + roundRect(g, bx, by, bw, footer - 22, 10); + g.fillStyle = '#1f2937'; + g.fill(); + g.lineWidth = opts.hover === 'footer:' + b.id ? 4 : 2; + g.strokeStyle = opts.hover === 'footer:' + b.id ? '#5fd0ff' : 'rgba(148,163,184,0.6)'; + g.stroke(); + g.fillStyle = '#f3f4f6'; + g.font = `600 22px system-ui, sans-serif`; + g.textAlign = 'center'; + g.fillText(b.label, bx + bw / 2, by + (footer - 22) / 2); + hits.push({ id: 'footer:' + b.id, key: '', kind: 'footer', footer: b.id, x: bx, y: by, w: bw, h: footer - 22 }); + }); + } + return hits; +} + +/* ------------------------------------------------------------------ the meshes ----- */ + +/** + * @typedef {{name: string, mesh: THREE.Mesh, canvas: HTMLCanvasElement, g: CanvasRenderingContext2D, + * texture: THREE.CanvasTexture, w: number, h: number, hits: HitRect[], sig: string}} Surface + */ + +/** @type {Record} */ +const surfaces = {}; + +/** @param {string} name @param {number} pxW @param {number} pxH @param {number} worldW */ +function surface(name, pxW, pxH, worldW) { + let s = surfaces[name]; + const scene = /** @type {any} */ (get(globalScene)); + if (!s) { + const canvas = document.createElement('canvas'); + canvas.width = pxW; + canvas.height = pxH; + const g = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + const worldH = (worldW * pxH) / pxW; + const mesh = new THREE.Mesh( + new THREE.PlaneGeometry(worldW, worldH), + new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, side: THREE.DoubleSide }) + ); + mesh.name = name; + mesh.renderOrder = 1000; + mesh.visible = false; + mesh.frustumCulled = false; + mesh.userData.localOnly = true; + s = { name, mesh, canvas, g, texture, w: worldW, h: worldH, hits: [], sig: '' }; + surfaces[name] = s; + } + if (scene && s.mesh.parent !== scene) scene.add(s.mesh); + return s; +} + +/** give a surface a new canvas size and world size (a new crop) @param {Surface} s + * @param {number} pxW @param {number} pxH @param {number} worldW */ +function resizeSurface(s, pxW, pxH, worldW) { + if (s.canvas.width === pxW && s.canvas.height === pxH && Math.abs(s.w - worldW) < 1e-6) return; + s.canvas.width = pxW; + s.canvas.height = pxH; + // a CanvasTexture keeps its GPU allocation at the OLD size unless it is disposed + s.texture.dispose(); + s.texture.needsUpdate = true; + s.w = worldW; + s.h = (worldW * pxH) / pxW; + s.mesh.geometry.dispose(); + s.mesh.geometry = new THREE.PlaneGeometry(s.w, s.h); +} + +/* -------------------------------------------------------------------- the state ---- */ + +/** LOCAL: the head-locked strip, on by default */ +function stripOn() { + return safeStorage.getItem(STRIP_KEY) !== 'false'; +} +/** @param {boolean} on */ +export function setVrStrip(on) { + safeStorage.setItem(STRIP_KEY, on ? 'true' : 'false'); +} + +const panelYaw = { value: NaN, following: false }; +/** the laser's current panel target per controller slot, for the hover ring */ +const hover = /** @type {(string | null)[]} */ ([null, null]); +const debug = { presses: 0, pokes: 0, lastPress: /** @type {string | null} */ (null), edits: 0 }; + +const _head = new THREE.Vector3(); +const _headQ = new THREE.Quaternion(); +const _fwd = new THREE.Vector3(); +const _up = new THREE.Vector3(); +const _v = new THREE.Vector3(); +const _e = new THREE.Euler(); + +/** the headset's world pose @returns {{position: THREE.Vector3, quaternion: THREE.Quaternion} | null} */ +function headPose() { + const renderer = /** @type {any} */ (get(globalRenderer)); + if (!renderer?.xr?.isPresenting) return null; + const cam = renderer.xr.getCamera(); + cam.updateMatrixWorld?.(true); + cam.getWorldPosition(_head); + cam.getWorldQuaternion(_headQ); + return { position: _head, quaternion: _headQ }; +} + +/** @param {THREE.Quaternion} q yaw of a quaternion (radians) */ +function yawOf(q) { + _e.setFromQuaternion(q, 'YXZ'); + return _e.y; +} + +/** + * Where the panel should yaw: it FOLLOWS lazily, with HYSTERESIS — a glance inside + * FOLLOW_DEG leaves it alone, and once the head has turned past that the panel eases ALL + * the way back in front (stopping at SETTLE_DEG). Without the second half a big turn left + * it parked FOLLOW_DEG off to the side, forever. Pure over (current, head, dt, state); + * `state.following` carries the latch between frames. Exported. + * @param {number} current @param {number} head @param {number} dt seconds + * @param {{following: boolean}} state + * @returns {number} + */ +export function followYaw(current, head, dt, state) { + if (!Number.isFinite(current)) { + state.following = false; + return head; + } + let d = head - current; + d = Math.atan2(Math.sin(d), Math.cos(d)); + const off = Math.abs(d); + if (!state.following && off > (FOLLOW_DEG * Math.PI) / 180) state.following = true; + if (state.following && off < (SETTLE_DEG * Math.PI) / 180) state.following = false; + if (!state.following) return current; + return current + d * Math.min(1, dt * 3); +} + +/** + * One frame of the VR game UI. `opts.head` injects a head pose and `opts.hands` the two + * controller world poses — the suites drive both; the live frame reads the XR camera and + * the controllers. Returns what is on show. + * @param {{head?: {position: any, quaternion: any} | null, hands?: ({position: any, quaternion: any} | null)[], dt?: number, force?: boolean}} [opts] + */ +export function vrGamePanelFrame(opts = {}) { + const head = opts.head === undefined ? headPose() : opts.head; + const live = !!head && (opts.force || gameFeelActive()); + const { panel, overlay } = live ? vrScreens() : { panel: [], overlay: [] }; + const runtime = get(hudRuntime); + const strip = stripOn(); + void get(hudDocs); + void get(hudScreenOverride); + void get(gameState); + + // ---- the panel + const entry = panel[0] ?? null; + const crop = panelCrop(entry?.screen); + const board = surface('vr-game-panel', Math.round(crop.w * SCALE), Math.round((crop.h + FOOTER_H) * SCALE), crop.w * METRES_PER_PX); + board.mesh.visible = !!entry; + if (entry && head) { + const sig = JSON.stringify([entry.key, entry.screen, runtime, hover, strip, imageTick, valuesSig(entry.screen)]); + if (sig !== board.sig) { + board.sig = sig; + resizeSurface(board, Math.round(crop.w * SCALE), Math.round((crop.h + FOOTER_H) * SCALE), crop.w * METRES_PER_PX); + board.hits = drawPanel(board.g, entry, runtime, { hover: hover[0] ?? hover[1], strip, crop }); + board.texture.needsUpdate = true; + } + const headYaw = yawOf(head.quaternion); + panelYaw.value = followYaw(panelYaw.value, headYaw, opts.dt ?? 1 / 72, panelYaw); + _fwd.set(-Math.sin(panelYaw.value), 0, -Math.cos(panelYaw.value)); + board.mesh.position.copy(head.position).addScaledVector(_fwd, PANEL_DIST); + board.mesh.position.y = head.position.y - PANEL_DROP; + board.mesh.rotation.set(-0.12, panelYaw.value, 0, 'YXZ'); + board.mesh.updateMatrixWorld(true); + } else if (!entry) panelYaw.value = NaN; // a new menu appears straight ahead + + // ---- the wrist + the strip (the overlay lines; the wrist is there even with none) + const lines = overlayLines( + overlay.flatMap((o) => o.screen.elements ?? []), + runtime + ); + const wrist = surface('vr-game-wrist', 320, 300, WRIST_W); + const leftHand = opts.hands?.[0] ?? null; + if (live && leftHand) { + const sig = JSON.stringify([lines, strip, hover]); + if (sig !== wrist.sig) { + // a line count change re-sizes the card + wrist.sig = sig; + wrist.hits = drawCard(wrist.g, lines.length ? lines : ['No score yet'], { title: 'Game', buttons: true, strip, hover: hover[0] ?? hover[1] }); + wrist.texture.needsUpdate = true; + } + // up the forearm, facing up off it — clear of 30b-vr-modes' mode label, which sits on + // the same controller at (0, 0.03, 0.07) + _v.set(-0.01, 0.035, 0.21).applyQuaternion(leftHand.quaternion).add(leftHand.position); + wrist.mesh.position.copy(_v); + wrist.mesh.quaternion.copy(leftHand.quaternion).multiply(WRIST_TILT); + wrist.mesh.updateMatrixWorld(true); + // shown when the card's face points at the head ("turn the wrist to read") + _up.set(0, 0, 1).applyQuaternion(wrist.mesh.quaternion); + _v.copy(head ? head.position : _v).sub(wrist.mesh.position).normalize(); + wrist.mesh.visible = _up.dot(_v) > 0.35; + } else wrist.mesh.visible = false; + + const stripLines = lines.filter(stripWorthy); + const bar = surface('vr-game-strip', 1024, 72, 0.9); + bar.mesh.visible = live && strip && stripLines.length > 0; + if (bar.mesh.visible && head) { + const sig = JSON.stringify(stripLines); + if (sig !== bar.sig) { + bar.sig = sig; + drawCard(bar.g, stripLines, { row: true }); + bar.texture.needsUpdate = true; + } + headLocked(bar.mesh, head, 1.3, 0.36); + } + + // ---- the announce banner (head-locked; shown in any mode while presenting) + const note = get(gameAnnouncement); + const banner = surface('vr-game-announce', 1024, 256, 0.95); + banner.mesh.visible = !!head && !!note; + if (banner.mesh.visible && head && note) { + const sig = JSON.stringify([note.id, note.text, note.sub, note.color]); + if (sig !== banner.sig) { + banner.sig = sig; + drawCard(banner.g, [note.text, note.sub].filter(Boolean), { big: true, color: note.color }); + banner.texture.needsUpdate = true; + } + headLocked(banner.mesh, head, 1.5, 0.12); + const left = note.at + note.ms - Date.now(); + /** @type {any} */ (banner.mesh.material).opacity = Math.max(0, Math.min(1, left / 300)); + } + return { + panel: board.mesh.visible ? entry?.screen?.id ?? null : null, + lines, + stripLines, + wrist: wrist.mesh.visible, + strip: bar.mesh.visible, + banner: banner.mesh.visible + }; +} + +const WRIST_TILT = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0)); + +/** @param {any} screen the values a menu's inputs hold (a toggle flip must redraw) */ +function valuesSig(screen) { + return (screen?.elements ?? []).filter((/** @type {any} */ el) => isInteractiveKind(el.kind)).map((/** @type {any} */ el) => hudValueOf(el.id, el.value)); +} + +/** @param {THREE.Object3D} mesh @param {{position: any, quaternion: any}} head @param {number} dist @param {number} up */ +function headLocked(mesh, head, dist, up) { + _fwd.set(0, 0, -1).applyQuaternion(head.quaternion); + _up.set(0, 1, 0).applyQuaternion(head.quaternion); + mesh.position.copy(head.position).addScaledVector(_fwd, dist).addScaledVector(_up, up); + mesh.quaternion.copy(head.quaternion); + mesh.updateMatrixWorld(true); +} + +/* ---------------------------------------------------------------- interaction ----- */ + +/** @typedef {{surface: string, hit: HitRect, point: THREE.Vector3, distance: number}} PanelTarget */ + +/** @param {Surface} s @param {THREE.Vector2} uv @returns {HitRect | null} */ +function hitAtUv(s, uv) { + const px = uv.x * s.canvas.width; + const py = (1 - uv.y) * s.canvas.height; + return s.hits.find((r) => px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) ?? null; +} + +/** + * What a ray points at on the panel or the wrist — a pressable rect or null. Exported for + * the sweep (P4) and the suites. + * @param {THREE.Raycaster} ray @returns {PanelTarget | null} + */ +export function panelTargetAlong(ray) { + /** @type {PanelTarget | null} */ + let best = null; + for (const name of ['vr-game-panel', 'vr-game-wrist']) { + const s = surfaces[name]; + if (!s?.mesh.visible) continue; + const hit = ray.intersectObject(s.mesh, false)[0]; + if (!hit?.uv) continue; + const rect = hitAtUv(s, hit.uv); + if (rect && (!best || hit.distance < best.distance)) best = { surface: name, hit: rect, point: hit.point.clone(), distance: hit.distance }; + } + return best; +} + +/** + * The laser's hover ring for slot `index` (redraws the board when it changes). + * @param {number} index @param {THREE.Raycaster | null} ray @returns {PanelTarget | null} + */ +export function panelHover(index, ray) { + const target = ray ? panelTargetAlong(ray) : null; + hover[index] = target?.hit.id ?? null; + return target; +} + +/** + * PRESS a panel target — the desktop press verbatim. Returns true when something acted. + * @param {PanelTarget} target @param {{source?: string, u?: number}} [opts] + */ +export function pressPanelTarget(target, opts = {}) { + const hit = target?.hit; + if (!hit) return false; + debug.presses++; + debug.lastPress = hit.id; + playGameSound('click', target.point ? target.point.toArray() : null); + if (hit.kind === 'footer') { + if (hit.footer === 'edit') { + debug.edits++; + // the ONE Edit/Interact switch (30b-vr-modes' Y button calls it too); the store + // write is the whole of leaving Interact, the rest re-seats a desktop gizmo + setEditorMode('edit'); + } else if (hit.footer === 'strip') setVrStrip(!stripOn()); + return true; + } + const el = elementOf(hit); + if (!el) return false; + if (el.kind === 'button') fireHudButton(el.id); + else if (el.kind === 'toggle') { + setHudValue(el.id, !hudValueOf(el.id, el.value), { shared: !!el.shared }); + fireHudButton(el.id); + } else if (el.kind === 'slider') { + const min = Number(el.min ?? 0); + const max = Number(el.max ?? 100); + const step = Number(el.step || 1); + const u = Number.isFinite(opts.u) ? Number(opts.u) : 0.5; + const raw = min + (max - min) * Math.min(1, Math.max(0, u)); + setHudValue(el.id, Math.round(raw / step) * step, { shared: !!el.shared }); + } else if (el.kind === 'dropdown' || el.kind === 'tabs') { + const options = hudOptionsOf(el.id, el); + if (!options.length) return false; + if (el.kind === 'tabs') { + const at = Math.max(0, Math.round(Number(hudValueOf(el.id, el.value ?? 0)))); + setHudValue(el.id, (at + 1) % options.length, { shared: !!el.shared }); + } else { + const held = String(hudValueOf(el.id, el.value ?? options[0])); + setHudValue(el.id, options[(Math.max(0, options.indexOf(held)) + 1) % options.length], { shared: !!el.shared }); + } + } else fireHudButton(el.id); // a module kind with sub-presses: its own id pulses + return true; +} + +/** @param {HitRect} hit */ +function elementOf(hit) { + for (const { screen } of vrScreens().panel) { + const el = (screen.elements ?? []).find((/** @type {any} */ e) => e.id === hit.id); + if (el) return el; + } + return null; +} + +/** the u (0..1 across the rect) of a panel target — a slider's value @param {PanelTarget} target */ +export function uAcross(target) { + const s = surfaces[target.surface]; + if (!s) return 0.5; + const local = s.mesh.worldToLocal(target.point.clone()); + const px = (local.x / s.w + 0.5) * s.canvas.width; + return (px - target.hit.x) / Math.max(1, target.hit.w); +} + +/* ------------------------------------------------------------------------ poke ---- */ + +/** a tip is TOUCHING within this of the plane; it must back off past RELEASE to re-arm */ +const POKE_TOUCH = 0.018; +const POKE_RELEASE = 0.04; +const pokeArmed = [true, true]; + +/** + * One frame of the fingertip / controller-tip POKE for slot `index`: pushing the tip + * through a button presses it once; pulling back re-arms. Returns the pressed target. + * @param {number} index @param {THREE.Vector3 | null} tip world position + * @returns {PanelTarget | null} + */ +export function pokeFrame(index, tip) { + if (!tip) return null; + let pressed = null; + let nearest = Infinity; + for (const name of ['vr-game-panel', 'vr-game-wrist']) { + const s = surfaces[name]; + if (!s?.mesh.visible) continue; + const local = s.mesh.worldToLocal(tip.clone()); + if (Math.abs(local.x) > s.w / 2 || Math.abs(local.y) > s.h / 2) continue; + const depth = Math.abs(local.z); + nearest = Math.min(nearest, depth); + if (depth > POKE_TOUCH || !pokeArmed[index]) continue; + const uv = new THREE.Vector2(local.x / s.w + 0.5, local.y / s.h + 0.5); + const rect = hitAtUv(s, uv); + if (!rect) continue; + const target = { surface: name, hit: rect, point: tip.clone(), distance: 0 }; + pokeArmed[index] = false; + debug.pokes++; + pressPanelTarget(target, { source: 'poke', u: uAcross(target) }); + pressed = target; + } + if (nearest > POKE_RELEASE) pokeArmed[index] = true; + return pressed; +} + +/** @returns {{presses: number, pokes: number, lastPress: string | null, edits: number, hover: (string | null)[], strip: boolean, hits: Record}} */ +export function vrGamePanelDebug() { + /** @type {Record} */ + const hits = {}; + for (const [name, s] of Object.entries(surfaces)) hits[name] = s.hits.map((h) => ({ ...h })); + return { ...debug, hover: [...hover], strip: stripOn(), hits }; +} + +/** the surfaces' meshes (suites read visibility, poses and canvases) @param {string} name */ +export function vrGameSurface(name) { + const s = surfaces[name]; + return s ? { mesh: s.mesh, canvas: s.canvas } : null; +} + +/** Hide everything (the session ended, the player left the game). */ +export function hideVrGamePanel() { + for (const s of Object.values(surfaces)) s.mesh.visible = false; + hover[0] = hover[1] = null; + panelYaw.value = NaN; +} diff --git a/tests/e2e/vr-game-panel.test.cjs b/tests/e2e/vr-game-panel.test.cjs new file mode 100644 index 00000000..2607cb74 --- /dev/null +++ b/tests/e2e/vr-game-panel.test.cjs @@ -0,0 +1,337 @@ +// 30b (vr-play) P3 — C2 THE GAME UI IN VR. The user, on a Quest 3: "in VR I should be able to +// see the game menu and interact with it", "I also should see the score in the VR", "there +// should be an option available to go into the edit mode". +// +// No headset runs headless: the suite drives vrGamePanelFrame with a SYNTHETIC head pose and +// posed controllers (matrixAutoUpdate on — the documented XR-controller trap), and reads +// what a player would see: which surfaces are visible, where they sit, and what their +// canvases hold. +// 1 a menu screen becomes the world panel, 1.2 m ahead at chest height, drawn · 2 its +// buttons are hit rects, ours included · 3 the LASER + trigger presses Start through the +// real trigger hook: the game starts, the press is consumed and its trailing select +// swallowed · 4 in play the score becomes the wrist card + the top strip · 5 the wrist +// shows only when turned toward the face · 6 a POKE presses once and re-arms on retreat · +// 7 the strip toggles · 8 Edit mode leaves Interact and every surface goes · 9 the panel +// follows the head LAZILY · 10 the announce banner in VR · 11 not presenting = nothing · +// 12 a toggle flips its value · 13 the REAL Towers template: its menu in VR, Start pressed. +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_REPO = [path.resolve(__dirname, '../../../theprototype.app-scenes'), path.resolve(__dirname, '../../../scenes')].find((p) => + fs.existsSync(p) +); +const TOWERS = process.env.TOWERS_TPSCENE || (SCENES_REPO && path.join(SCENES_REPO, 'games/towers/scene.tpscene')); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + await page.evaluate(async () => { + const s = window.__stores; + await s.moduleSDK.initModules([ + { id: 'panel30b', name: 'Panel test', version: '1.0.0', description: 'the 30b VR panel', register(api) { window.__api = api; } } + ]); + localStorage.removeItem('vr:gameStrip'); + // a game: a menu screen bound to 'menu' with Start + a music toggle, and a play HUD + s.hudDocs.setHudDocFor('scene', { + active: '', + screens: [ + { + id: 'menu', + name: 'Menu', + showWhile: 'menu', + input: 'menu', + elements: [ + { id: 'title', kind: 'text', anchor: 'top-center', x: 0, y: 80, w: 600, h: 90, label: 'TOWERS', style: { size: 64, align: 'center', weight: '800' } }, + { id: 'start', kind: 'button', anchor: 'center', x: 0, y: 0, w: 320, h: 80, label: 'Start', style: { bg: '#2563eb', size: 32 } }, + { id: 'music', kind: 'toggle', anchor: 'center', x: 0, y: 120, w: 320, h: 56, label: 'Music', value: false } + ] + }, + { + id: 'hud', + name: 'HUD', + showWhile: 'playing', + elements: [ + { id: 'score', kind: 'text', anchor: 'top-left', x: 24, y: 24, w: 240, h: 40, label: 'Height: 0 m' }, + { id: 'time', kind: 'timer', anchor: 'top-right', x: 24, y: 24, w: 160, h: 40, label: '1:30' } + ] + } + ] + }); + s.gameState.setGameState('menu'); + s.hudActions.addBinding('start', 'start'); + s.objectActions.setEditorMode('interact'); + // posed controllers: three writes their matrices per XR frame, so a test must switch + // that back on before posing them by hand + let r; + s.globalRenderer.subscribe((v) => (r = v))(); + for (const i of [0, 1]) { + const c = r.xr.getController(i); + c.matrixAutoUpdate = true; + c.userData.handedness = i === 0 ? 'left' : 'right'; + } + window.__T = { + head: (yawDeg = 0) => { + const THREE = s.THREE; + return { + position: new THREE.Vector3(0, 1.6, 0), + quaternion: new THREE.Quaternion().setFromEuler(new THREE.Euler(0, (yawDeg * Math.PI) / 180, 0, 'YXZ')) + }; + }, + // the left hand held up, palm-side card facing the eyes (or turned away) + leftHand: (facing = true) => { + const THREE = s.THREE; + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(facing ? 0.9 : -2.2, 0, 0, 'YXZ')); + return { position: new THREE.Vector3(-0.1, 1.2, -0.35), quaternion: q }; + }, + // the world point of a hit rect's centre on a surface + rectPoint: (surfaceName, id) => { + const k = s.gameKit.vrGamePanel; + const surf = k.vrGameSurface(surfaceName); + const rect = k.vrGamePanelDebug().hits[surfaceName].find((x) => x.id === id); + if (!surf || !rect) return null; + const g = surf.mesh.geometry.parameters; + const cx = (rect.x + rect.w / 2) / surf.canvas.width; + const cy = (rect.y + rect.h / 2) / surf.canvas.height; + return surf.mesh.localToWorld(new s.THREE.Vector3((cx - 0.5) * g.width, (0.5 - cy) * g.height, 0)); + }, + aim: (index, from, to) => { + const c = r.xr.getController(index); + c.position.copy(from); + // Object3D.lookAt turns a plain object's +Z toward the target, and a controller's + // ray is its -Z: look at the point MIRRORED through the controller instead + c.lookAt(from.clone().multiplyScalar(2).sub(to)); + c.updateMatrixWorld(true); + } + }; + }); + const frame = (opts = '{}') => + page.evaluate((o) => { + const T = window.__T; + const k = window.__stores.gameKit.vrGamePanel; + return k.vrGamePanelFrame({ head: T.head(o.yaw ?? 0), hands: [o.hand === false ? null : T.leftHand(o.facing !== false), null], dt: o.dt ?? 1 / 72 }); + }, JSON.parse(opts)); + + console.log('\n=== 1. the menu screen becomes the world panel ==='); + const f1 = await frame(); + const panel = await page.evaluate(() => { + const k = window.__stores.gameKit.vrGamePanel; + const surf = k.vrGameSurface('vr-game-panel'); + const g = surf.canvas.getContext('2d'); + const data = g.getImageData(0, 0, surf.canvas.width, surf.canvas.height).data; + let ink = 0; + for (let i = 0; i < data.length; i += 4) if (data[i + 3] > 200 && data[i] + data[i + 1] + data[i + 2] > 600) ink++; + const start = k.vrGamePanelDebug().hits['vr-game-panel'].find((x) => x.id === 'start'); + const px = g.getImageData(Math.round(start.x + 8), Math.round(start.y + start.h / 2), 1, 1).data; + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + return { + pos: surf.mesh.position.toArray(), + visible: surf.mesh.visible, + parentIsScene: surf.mesh.parent === scene, + layer0: surf.mesh.layers.test(new window.__stores.THREE.Layers()), + ink, + width: surf.mesh.geometry.parameters.width, + startPx: Array.from(px) + }; + }); + h.check(f1.panel === 'menu' && panel.visible, '1.1 the menu screen is on the panel (' + JSON.stringify(f1) + ')'); + h.check(Math.abs(panel.pos[2] + 1.2) < 0.05 && panel.pos[1] < 1.6 && panel.pos[1] > 1.2, '1.2 ~1.2 m ahead, below the eyes (' + panel.pos.map((n) => n.toFixed(2)) + ')'); + h.check(panel.parentIsScene && panel.layer0, '1.3 a scene-root mesh on the default layer (both eyes)'); + h.check(panel.ink > 1500, '1.4 the canvas holds drawn text (' + panel.ink + ' white px)'); + h.check(panel.width > 0.6 && panel.width < 1.3, '1.6 the board is sized to the menu it shows, not the whole stage (' + panel.width.toFixed(2) + ' m wide)'); + h.check(panel.startPx[2] > 200 && panel.startPx[0] < 80, '1.5 the Start button is drawn in its authored blue (' + panel.startPx + ')'); + + console.log('\n=== 2. the buttons are hit rects, ours included ==='); + const ids = await page.evaluate(() => window.__stores.gameKit.vrGamePanel.vrGamePanelDebug().hits['vr-game-panel'].map((x) => x.id)); + h.check(ids.includes('start') && ids.includes('music') && !ids.includes('title'), '2.1 Start and the toggle press, the title does not (' + ids + ')'); + h.check(ids.includes('footer:edit') && ids.includes('footer:strip'), '2.2 our Edit mode and Top strip buttons are on the footer'); + + console.log('\n=== 3. laser + trigger presses Start through the real trigger hook ==='); + await page.waitForTimeout(700); // a fresh binding must be seen by a tick before its first press (actionSeenAt) + const press = await page.evaluate(async () => { + const s = window.__stores; + const T = window.__T; + const target = T.rectPoint('vr-game-panel', 'start'); + T.aim(1, new s.THREE.Vector3(0.25, 1.3, -0.3), target); + const hover = s.gameKit.vrGamePanel.panelHover(1, s.gameKit.vrGameInput.controllerRayOf(1))?.hit.id ?? null; + const consumed = s.vrControls.vrModuleTriggerStart(1); + await new Promise((r) => setTimeout(r, 500)); // the Set Game State node acts on the next tick + let state; + s.gameState.gameState.subscribe((v) => (state = v.state))(); + const swallow1 = s.vrControls.vrModuleSelectSwallowed(); + const swallow2 = s.vrControls.vrModuleSelectSwallowed(); + s.vrControls.vrModuleTriggerEnd(1); + return { hover, consumed, state, swallow1, swallow2 }; + }); + h.check(press.hover === 'start', '3.1 the laser hovers Start (' + press.hover + ')'); + h.check(press.consumed === true && press.state === 'playing', '3.2 the trigger pressed it: the game is playing (' + JSON.stringify(press) + ')'); + h.check(press.swallow1 === true && press.swallow2 === false, '3.3 the trailing select is swallowed once, not forever'); + + console.log('\n=== 4. in play the score is on the wrist and the strip ==='); + const f4 = await frame(); + h.check(f4.panel === null, '4.1 the panel goes when the menu does'); + h.check(JSON.stringify(f4.lines) === JSON.stringify(['Height: 0 m', '1:30']), '4.2 the overlay reads as lines (' + JSON.stringify(f4.lines) + ')'); + h.check(f4.wrist && f4.strip, '4.3 the wrist card and the top strip are up'); + const stripPose = await page.evaluate(() => window.__stores.gameKit.vrGamePanel.vrGameSurface('vr-game-strip').mesh.position.toArray()); + h.check(stripPose[1] > 1.8 && stripPose[2] < -1, '4.4 the strip rides high in front of the eyes (' + stripPose.map((n) => n.toFixed(2)) + ')'); + + console.log('\n=== 5. the wrist shows only when turned to the face ==='); + const away = await frame('{"facing": false}'); + h.check(away.wrist === false && away.strip === true, '5.1 turned away: no wrist card (the strip stays)'); + const noHand = await frame('{"hand": false}'); + h.check(noHand.wrist === false, '5.2 no tracked left hand: no card'); + + console.log('\n=== 6. a POKE presses once and re-arms on retreat ==='); + await frame(); + const poke = await page.evaluate(() => { + const s = window.__stores; + const T = window.__T; + const k = s.gameKit.vrGamePanel; + const at = T.rectPoint('vr-game-wrist', 'footer:strip'); + const surf = k.vrGameSurface('vr-game-wrist'); + const normal = new s.THREE.Vector3(0, 0, 1).applyQuaternion(surf.mesh.quaternion); + const before = k.vrGamePanelDebug().strip; + const first = k.pokeFrame(0, at.clone().addScaledVector(normal, 0.005))?.hit.id ?? null; + const afterFirst = k.vrGamePanelDebug().strip; + const held = k.pokeFrame(0, at.clone().addScaledVector(normal, -0.004))?.hit.id ?? null; + k.pokeFrame(0, at.clone().addScaledVector(normal, 0.1)); // pull back + const again = k.pokeFrame(0, at.clone().addScaledVector(normal, 0.003))?.hit.id ?? null; + return { before, first, afterFirst, held, again, after: k.vrGamePanelDebug().strip }; + }); + h.check(poke.first === 'footer:strip' && poke.before === true && poke.afterFirst === false, '6.1 pushing the tip into "Strip on" presses it (' + JSON.stringify(poke) + ')'); + h.check(poke.held === null, '6.2 holding the tip in the button does not press again'); + h.check(poke.again === 'footer:strip' && poke.after === true, '6.3 pulling back re-arms it: the next push presses'); + + console.log('\n=== 7. the strip toggles ==='); + await page.evaluate(() => window.__stores.gameKit.vrGamePanel.setVrStrip(false)); + const off = await frame(); + h.check(off.strip === false && off.wrist === true, '7.1 strip off: gone, the wrist stays'); + h.check((await page.evaluate(() => localStorage.getItem('vr:gameStrip'))) === 'false', '7.2 persisted locally'); + await page.evaluate(() => window.__stores.gameKit.vrGamePanel.setVrStrip(true)); + + console.log('\n=== 8. Edit mode leaves Interact, and everything goes ==='); + await frame(); + const edit = await page.evaluate(() => { + const s = window.__stores; + const T = window.__T; + const at = T.rectPoint('vr-game-wrist', 'footer:edit'); + T.aim(1, at.clone().add(new s.THREE.Vector3(0.1, 0.25, 0.1)), at); + const consumed = s.vrControls.vrModuleTriggerStart(1); + s.vrControls.vrModuleSelectSwallowed(); + s.vrControls.vrModuleTriggerEnd(1); + let mode; + s.editorMode.subscribe((v) => (mode = v))(); + return { consumed, mode }; + }); + h.check(edit.consumed && edit.mode === 'edit', '8.1 "Edit" on the wrist switched to Edit (' + JSON.stringify(edit) + ')'); + const f8 = await frame(); + h.check(!f8.panel && !f8.wrist && !f8.strip, '8.2 in Edit no game surface shows (' + JSON.stringify(f8) + ')'); + await page.evaluate(() => { + window.__stores.objectActions.setEditorMode('interact'); + window.__stores.gameState.setGameState('menu'); + }); + + console.log('\n=== 9. the panel follows the head LAZILY ==='); + await page.evaluate(() => window.__stores.gameKit.vrGamePanel.hideVrGamePanel()); + await frame('{"yaw": 0}'); + const yaws = await page.evaluate(() => { + const k = window.__stores.gameKit.vrGamePanel; + const T = window.__T; + const pos = () => k.vrGameSurface('vr-game-panel').mesh.position.clone(); + const at0 = pos(); + k.vrGamePanelFrame({ head: T.head(20), hands: [null, null], dt: 0.5 }); + const at20 = pos(); + for (let i = 0; i < 30; i++) k.vrGamePanelFrame({ head: T.head(80), hands: [null, null], dt: 0.1 }); + const at80 = pos(); + const bearing = (p) => (Math.atan2(-p.x, -p.z) * 180) / Math.PI; + return { b0: bearing(at0), b20: bearing(at20), b80: bearing(at80) }; + }); + h.check(Math.abs(yaws.b20 - yaws.b0) < 0.5, '9.1 a 20 degree glance leaves it where it is (' + yaws.b0.toFixed(1) + ' -> ' + yaws.b20.toFixed(1) + ')'); + h.check(yaws.b80 > 40, '9.2 turning 80 degrees brings it round (' + yaws.b80.toFixed(1) + ' degrees)'); + h.check(yaws.b80 > 75, '9.3 ...all the way back in front, not parked at the edge of the band (' + yaws.b80.toFixed(1) + ')'); + + console.log('\n=== 10. the announce banner, head-locked ==='); + const ann = await page.evaluate(async () => { + const k = window.__stores.gameKit.vrGamePanel; + const T = window.__T; + window.__api.announce('Ring 2 reached', { sub: '+50', ms: 500 }); + const on = k.vrGamePanelFrame({ head: T.head(0), hands: [null, null] }).banner; + const pos = k.vrGameSurface('vr-game-announce').mesh.position.toArray(); + await new Promise((r) => setTimeout(r, 700)); + const later = k.vrGamePanelFrame({ head: T.head(0), hands: [null, null] }).banner; + return { on, pos, later }; + }); + h.check(ann.on && Math.abs(ann.pos[0]) < 0.01 && ann.pos[2] < -1.4, '10.1 api.announce shows a banner straight ahead (' + ann.pos.map((n) => n.toFixed(2)) + ')'); + h.check(ann.later === false, '10.2 and it goes after its ms'); + + console.log('\n=== 11. not presenting: nothing ==='); + const flat = await page.evaluate(() => window.__stores.gameKit.vrGamePanel.vrGamePanelFrame({ hands: [null, null] })); + h.check(!flat.panel && !flat.wrist && !flat.strip && !flat.banner, '11.1 with no headset session nothing is drawn (' + JSON.stringify(flat) + ')'); + + console.log('\n=== 12. a toggle flips its value ==='); + await frame(); + const tog = await page.evaluate(() => { + const s = window.__stores; + const T = window.__T; + const at = T.rectPoint('vr-game-panel', 'music'); + T.aim(1, new s.THREE.Vector3(0.2, 1.3, -0.2), at); + const before = s.hudDocs.hudValueOf('music', false); + s.vrControls.vrModuleTriggerStart(1); + s.vrControls.vrModuleSelectSwallowed(); + s.vrControls.vrModuleTriggerEnd(1); + return { before, after: s.hudDocs.hudValueOf('music', false) }; + }); + h.check(tog.before === false && tog.after === true, '12.1 the Music toggle flipped through the laser (' + JSON.stringify(tog) + ')'); + + console.log('\n=== 13. the REAL Towers template ==='); + if (!TOWERS || !fs.existsSync(TOWERS)) console.log('SKIP 13: no games/towers/scene.tpscene'); + else { + const bytes = Array.from(fs.readFileSync(TOWERS)); + await page.evaluate(async (arr) => { + const s = window.__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, bytes); + await page.waitForTimeout(2500); + const towers = await page.evaluate(() => { + const s = window.__stores; + s.gameState.setGameState('menu'); + s.objectActions.setEditorMode('interact'); + const k = s.gameKit.vrGamePanel; + const T = window.__T; + k.hideVrGamePanel(); + const f = k.vrGamePanelFrame({ head: T.head(0), hands: [T.leftHand(true), null] }); + const hits = k.vrGamePanelDebug().hits['vr-game-panel'].filter((x) => x.kind !== 'footer'); + const labels = []; + for (const { screen } of k.vrScreens().panel) for (const el of screen.elements) if (hits.some((x) => x.id === el.id)) labels.push(el.label); + return { panel: f.panel, labels, hits: hits.map((x) => x.id) }; + }); + h.check(!!towers.panel && towers.hits.length > 0, '13.1 the Towers menu is on the VR panel with pressable controls (' + JSON.stringify(towers) + ')'); + const startId = towers.hits.find((id, i) => /start|play/i.test(towers.labels[i] ?? '')) ?? towers.hits[0]; + const started = await page.evaluate(async (id) => { + const s = window.__stores; + const T = window.__T; + const at = T.rectPoint('vr-game-panel', id); + T.aim(1, new s.THREE.Vector3(0.2, 1.3, -0.2), at); + s.vrControls.vrModuleTriggerStart(1); + s.vrControls.vrModuleSelectSwallowed(); + s.vrControls.vrModuleTriggerEnd(1); + await new Promise((r) => setTimeout(r, 400)); + let state; + s.gameState.gameState.subscribe((v) => (state = v.state))(); + const f = s.gameKit.vrGamePanel.vrGamePanelFrame({ head: T.head(0), hands: [T.leftHand(true), null] }); + return { state, f }; + }, startId); + h.check(started.state === 'playing', '13.2 pressing its Start in VR starts the round (' + JSON.stringify(started) + ')'); + h.check(started.f.lines.length > 0, '13.3 the round\'s HUD reads on the wrist/strip (' + JSON.stringify(started.f.lines) + ')'); + h.check( + started.f.stripLines.length > 0 && started.f.stripLines.every((l) => l.length <= 28) && started.f.stripLines.length < started.f.lines.length, + '13.4 the strip keeps the short readouts and leaves the sentence-long hint to the wrist (' + JSON.stringify(started.f.stripLines) + ')' + ); + } + + await h.finish(browser); +}); From fbf517da37a98bb762c7ecddf9de61fda931e452 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 17:51:26 +0300 Subject: [PATCH 11/18] [feat] 30b P4: hold the trigger and sweep (C3) - tip and laser sweep in Interact/Play, ctx.source, {sweep: false} - vrGameInput: while the trigger is held in Interact/Play, the controller TIP (a 6 cm sphere just ahead of the controller) clicks the clickable it becomes NEAREST to, once, spent while still touched and re-armed on leaving; with the tip touching nothing, the LASER does the same for targets out of reach (> 25 cm) and for the game board's buttons. The first entry is the press itself (source 'trigger', fired on the PRESS so an instrument sounds when hit), later entries are 'sweep'. The sweep's hook does not consume; on its first frame it reads vrControls.triggerClaimed(index) and stands down under a gesture another hook claimed (knob drag, cable, sleeve). A press that clicked anything swallows its trailing 'select'. - nearest-only is a deliberate reading of "every clickable the tip ENTERS": a 6 cm sphere is wider than a 2.2 cm key and the literal reading played clusters (QUESTIONS fork 2). - moduleSDK: every click handler gets ctx = {source, mode} (additive); a handler registered with {sweep: false} still hears the press and never a sweep. - vrControls: triggerClaims per slot + triggerClaimed(index) (additive). - Scene: in Interact/Play the VR trigger's final fallthrough is interactClick (handlers, On Click, the miss) instead of raycastSelect - an Interact press selects nothing (30b-vr-modes handed this path to C3). Suite vr-sweep (new): 24/24, including the REAL Jam Room (a held sweep over three piano keys plays key-64, key-65, key-67 once each; over three drum steps flips each). Counterfactuals: - re-arm removed -> 2.1 red; {sweep:false} ignored -> 3.1 red; the swallow never set -> 4.1, 5.2 red; the claim check removed -> 6.1 red; the Interact fallthrough removed -> 9.1 red (the release selected the object) [one run, five broken] - every touched target fired (no nearest rule) -> 11.1 red (["n1","n0","n2","n3","n4","n5","n6"]) svelte-check 334/47, no new message. Build green. Held suites for P3+P4 NOT yet re-run (budget checkpoint; P2's held run was green) - owed before the PR. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 8 + src/lib/moduleSDK.js | 34 ++- src/lib/vrControls.js | 12 +- src/lib/vrGameInput.js | 187 +++++++++++++-- tests/e2e/vr-sweep.test.cjs | 455 ++++++++++++++++++++++++++++++++++++ 5 files changed, 676 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/vr-sweep.test.cjs diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 10a1cfa4..36d17b7d 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -57,6 +57,7 @@ import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock } from '$lib/vrControls'; // 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4) import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput'; + import { gameFeelActive } from '$lib/gameFeel'; import { vrKeyboardTarget } from '$lib/vrKeyboard'; import { measureMode, measureClick } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; @@ -1388,6 +1389,13 @@ tempMatrix.identity().extractRotation(controller.matrixWorld); selectionRaycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld); selectionRaycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix); + // 30b (C3): in Interact/Play a VR press is a CLICK — module handlers, On Click, + // the miss — and selects nothing, ever (the desktop Interact rule); the sweep + // already fired anything it knew was clickable and swallowed this release + if (gameFeelActive()) { + interactClick(selectionRaycaster); + return; + } raycastSelect(); }; // 182: hold-to-move a vertex — grab on trigger press, drop on release diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 9e0d7a12..c6084ed8 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -89,7 +89,8 @@ export const moduleMenuItems = writable([]); export const moduleEffects = {}; /** @type {Record} node type -> Svelte component */ export const moduleNodeComponents = {}; -/** @type {((object: any) => boolean)[]} */ +/** 30b: a handler also gets `ctx = {source, mode}` (see runClickHandlers) + * @type {((object: any, ctx?: {source: string, mode: string}) => boolean)[]} */ export const moduleClickHandlers = []; /** 30 P1: the three places a viewport click can come from. */ @@ -112,6 +113,11 @@ export function normalizeClickModes(modes) { return known.length ? known : [...DEFAULT_CLICK_MODES]; } +/** 30b (C3): handlers that asked NOT to be swept (`{sweep: false}`) — a knob you drag, a + * dot you carry. They still hear the press itself; only the later entries of a held + * trigger skip them. @type {WeakSet} */ +const noSweepHandlers = new WeakSet(); + /** Does this handler run for a click in `mode`? A null mode is VR's trigger, which has * no editor mode of its own yet and keeps offering every handler, as it always has. * @param {Function} fn @param {string | null} mode */ @@ -125,13 +131,22 @@ export function clickHandlerRunsIn(fn, mode) { * Offer a clicked mesh to every handler that runs in `mode`, in registration order; the * first to return true consumes the click. ONE dispatch for the editor's pick, Interact * and Play's tap, so the three can never disagree about who hears what. - * @param {any} object @param {string | null} mode @returns {boolean} + * + * 30b (C3): every handler also gets `ctx = {source, mode}` — `source` is 'click' (the + * desktop / a VR release), 'trigger' (the VR press itself, fired on the press) or 'sweep' + * (a later entry while the trigger is held). A 'sweep' skips handlers registered with + * `{sweep: false}`. Additive: a one-argument handler is byte-unchanged. + * @param {any} object @param {string | null} mode @param {{source?: string}} [ctx] + * @returns {boolean} */ -export function runClickHandlers(object, mode) { +export function runClickHandlers(object, mode, ctx = {}) { + const source = ctx?.source ?? 'click'; + const info = { source, mode: mode ?? 'vr' }; for (const handler of [...moduleClickHandlers]) { if (!clickHandlerRunsIn(handler, mode)) continue; + if (source === 'sweep' && noSweepHandlers.has(handler)) continue; try { - if (handler(object)) return true; + if (handler(object, info)) return true; } catch (error) { log('warn', 'module', 'click handler failed', String(error)); } @@ -576,11 +591,18 @@ function makeApi(moduleId, moduleName = moduleId) { * a pad, a puzzle piece) no longer eats the editor's select click. A handler * that is an editor TOOL (a toolbox pick) passes {modes: ['edit']}, or all three. * In Edit an 'edit' handler still runs BEFORE the selection, so it can consume. - * @param {(object: any) => boolean} fn - * @param {{modes?: string[]}} [options] + * + * 30b: `fn(object, ctx)` — `ctx.source` is 'click', 'trigger' (the VR press) or + * 'sweep' (VR, Interact/Play: the trigger HELD and the controller tip or laser + * passing into this mesh — each entry clicks once, re-armed when it leaves). Pass + * `{sweep: false}` for a control that must not be swept (a knob you drag, a dot you + * carry); it still hears the press. + * @param {(object: any, ctx?: {source: string, mode: string}) => boolean} fn + * @param {{modes?: string[], sweep?: boolean}} [options] */ registerClickHandler(fn, options = {}) { clickHandlerModes.set(fn, normalizeClickModes(options?.modes)); + if (options?.sweep === false) noSweepHandlers.add(fn); moduleClickHandlers.push(fn); onDispose(() => arrayRemove(moduleClickHandlers, fn)); }, diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 4fa27da7..26a76ccc 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -384,9 +384,17 @@ export function registerVRTriggerHooks(hooks) { if (i >= 0) triggerHooks.splice(i, 1); }; } +/** 30b (C3): did SOME hook take the last trigger press on each slot? The sweep starts + * from a non-consuming hook, so it reads this on its next frame to stand down under a + * gesture another feature claimed (a knob drag, a cable, the sleeve). */ +const triggerClaims = [false, false]; +/** @param {number} index */ +export function triggerClaimed(index) { + return !!triggerClaims[index]; +} /** @param {number} index @returns {boolean} */ export function vrModuleTriggerStart(index) { - return triggerHooks.some((h) => { + const claimed = triggerHooks.some((h) => { try { return !!h.start?.(index); } catch (error) { @@ -394,6 +402,8 @@ export function vrModuleTriggerStart(index) { return false; } }); + triggerClaims[index] = claimed; + return claimed; } /** @param {number} index @returns {boolean} */ export function vrModuleTriggerEnd(index) { diff --git a/src/lib/vrGameInput.js b/src/lib/vrGameInput.js index a8e245e4..ec0ff9c8 100644 --- a/src/lib/vrGameInput.js +++ b/src/lib/vrGameInput.js @@ -23,13 +23,13 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { objectsGroup, globalScene, globalRenderer } from '../stores/sceneStore'; -import { gameFeelActive } from './gameFeel'; +import { gameFeelActive, gameClickMode } from './gameFeel'; import { sceneHits } from './scenePick'; import { pickStack, primaryIndex } from './selectThrough'; import { topLevelObjectOf } from './objectActions'; -import { objectHasOnClick } from './flowRuntime'; -import { moduleInteractiveGroups } from './moduleSDK'; -import { registerVRFrameHook, registerVRTriggerHooks, registerPanelGroupProvider, controllerIndexFor, hapticPattern } from './vrControls'; +import { objectHasOnClick, fireObjectClick } from './flowRuntime'; +import { moduleInteractiveGroups, runClickHandlers } from './moduleSDK'; +import { registerVRFrameHook, registerVRTriggerHooks, registerPanelGroupProvider, controllerIndexFor, hapticPattern, triggerClaimed } from './vrControls'; // P3 (C2): the game UI in VR — the panel, the wrist card, the strip, the banner import { vrGamePanelFrame, panelTargetAlong, panelHover, pressPanelTarget, pokeFrame, uAcross, vrGameSurface, hideVrGamePanel } from './vrGamePanel'; @@ -126,7 +126,7 @@ export function handOf(index) { /** what each slot's laser pointed at last frame (entry key) — the hover-enter edge */ const hoverKey = /** @type {(string | null)[]} */ ([null, null]); -const stats = { taps: 0, bumps: 0 }; +const stats = { taps: 0, bumps: 0, sweepClicks: 0 }; /** * One frame of the hover edge for slot `index`: a NEW clickable under the laser taps that @@ -162,9 +162,9 @@ export function pressFeedback(index, ray = controllerRayOf(index)) { return true; } -/** @returns {{taps: number, bumps: number, hover: (string | null)[]}} */ +/** @returns {{taps: number, bumps: number, sweepClicks: number, hover: (string | null)[], sweeps: boolean[]}} */ export function vrGameInputDebug() { - return { ...stats, hover: [...hoverKey] }; + return { ...stats, hover: [...hoverKey], sweeps: sweeps.map(Boolean) }; } /* --------------------------------------------------------------- P3 the panel ---- */ @@ -196,26 +196,184 @@ let swallowNext = false; /** was a headset presenting on the last frame (the session-end edge) */ let presenting = false; +/* ---------------------------------------------------------------- P4 the sweep ---- */ + +// C3 — HOLD THE TRIGGER AND SWEEP. The user: "I want to be able to hold trigger and +// automatically press the buttons where I move the controller around. So, for example, I +// can hit different keys on piano holding trigger and on the mixer enable/disable". +// +// While the trigger is held in Interact/Play, every clickable the controller TIP (a +// ~6 cm sphere just ahead of the controller) ENTERS is clicked once, and re-armed when +// the tip leaves it; with the tip touching nothing, the LASER does the same for things +// out of reach (and for the game board's buttons). The first entry is the press itself +// (`source: 'trigger'`), fired on the PRESS rather than the release, so an instrument +// sounds when you hit it; every later one is `source: 'sweep'`, which a handler can opt +// out of (`registerClickHandler(fn, {sweep: false})`). A press another feature CLAIMED +// (a knob drag, a cable, the sleeve) is left alone — read one frame later, because this +// hook does not consume and cannot see the hooks after it. When the sweep clicked +// anything, the press's trailing 'select' is swallowed: that click already happened. + +/** the tip sphere's radius (~6 cm across) and how far ahead of the controller it sits */ +export const TIP_RADIUS = 0.03; +const TIP_AHEAD = 0.03; +/** the laser sweeps only what the tip cannot reach */ +const LASER_MIN = 0.25; + +/** @typedef {{first: boolean, inside: Set, laserKey: string | null, panelKey: string | null, fired: number}} Sweep */ +/** @type {(Sweep | null)[]} */ +const sweeps = [null, null]; + +const _box = new THREE.Box3(); +const _sphere = new THREE.Sphere(); + /** - * A trigger PRESS on slot `index`: a game-panel button under the laser is pressed and the - * press consumed; anything else only gets its haptic bump and falls through untouched. - * Exported for the suites (the live hook calls it). - * @param {number} index @returns {boolean} consumed + * Every clickable a TIP sphere touches (bounding sphere, then the world box — a key, a + * pad, a step cell is a box). Keyed by entry, so a device part is its own control. + * @param {THREE.Vector3} tip @param {number} [radius] @returns {ClickTarget[]} + */ +export function clickTargetsTouching(tip, radius = TIP_RADIUS) { + /** @type {any[]} */ + const roots = []; + const group = get(objectsGroup); + if (group) roots.push(group); + const scene = /** @type {any} */ (get(globalScene)); + for (const name of moduleInteractiveGroups) { + const root = scene?.getObjectByName(name); + if (root) roots.push(root); + } + /** @type {Map} */ + const found = new Map(); + for (const root of roots) + root.traverseVisible((/** @type {any} */ node) => { + if (!node.isMesh || !node.geometry) return; + const geo = node.geometry; + if (!geo.boundingSphere) geo.computeBoundingSphere(); + _sphere.copy(geo.boundingSphere).applyMatrix4(node.matrixWorld); + if (_sphere.distanceToPoint(tip) > radius) return; + if (!geo.boundingBox) geo.computeBoundingBox(); + _box.copy(geo.boundingBox).applyMatrix4(node.matrixWorld); + if (_box.distanceToPoint(tip) > radius) return; + const distance = _box.distanceToPoint(tip); + const target = clickTargetOf({ object: node, point: tip.clone(), distance }); + const known = target ? found.get(target.key) : null; + if (target && (!known || distance < known.distance)) found.set(target.key, target); + }); + return [...found.values()].sort((a, b) => a.distance - b.distance); +} + +/** + * CLICK a target the way Interact/Play's tap does — the module handlers for this mode + * first, then the object's On Click — and bump the hand when something acted. + * @param {ClickTarget} target @param {string} source @param {number} index @returns {boolean} + */ +export function fireClickTarget(target, source, index) { + let acted = runClickHandlers(target.mesh, gameClickMode(), { source }); + if (!acted && target.top) acted = fireObjectClick(target.top.uuid) > 0; + if (acted) { + hapticPattern('bump', handOf(index)); + stats.sweepClicks++; + } + return acted; +} + +/** the tip sphere's centre for slot `index` @param {number} index */ +function tipAhead(index) { + const ray = controllerRayOf(index); + return ray ? ray.ray.origin.clone().addScaledVector(ray.ray.direction, TIP_AHEAD) : null; +} + +/** + * One frame of a held trigger on slot `index`. `opts.tip`/`opts.ray` inject the pose (the + * suites; no headset headless). Returns the entry keys this frame clicked, or null when + * no sweep is live. + * @param {number} index @param {{tip?: THREE.Vector3 | null, ray?: THREE.Raycaster | null}} [opts] + * @returns {string[] | null} + */ +export function sweepFrame(index, opts = {}) { + const sweep = sweeps[index]; + if (!sweep) return null; + if (!gameFeelActive() || (sweep.first && triggerClaimed(index))) { + sweeps[index] = null; + return null; + } + const source = sweep.first ? 'trigger' : 'sweep'; + sweep.first = false; + const tip = opts.tip === undefined ? tipAhead(index) : opts.tip; + const ray = opts.ray === undefined ? controllerRayOf(index) : opts.ray; + /** @type {string[]} */ + const fired = []; + // the tip plays what it is NEAREST to: a 6 cm sphere is wider than a piano key, and + // "every key it touches" would sound clusters. A target fires when it becomes the + // nearest touching one; it stays spent while the tip still touches it, and LEAVING + // re-arms it. + const touching = tip ? clickTargetsTouching(tip) : []; + const nearest = touching[0] ?? null; + const still = new Set([...sweep.inside].filter((key) => touching.some((t) => t.key === key))); + if (nearest && !still.has(nearest.key)) { + if (fireClickTarget(nearest, source, index)) fired.push(nearest.key); + still.add(nearest.key); + } + sweep.inside = still; + if (!touching.length && ray) { + const board = panelTargetAlong(ray); + const boardKey = board?.hit.id ?? null; + if (board && boardKey !== sweep.panelKey && pressPanelTarget(board, { source, u: uAcross(board) })) { + hapticPattern('bump', handOf(index)); + fired.push(board.hit.id); + } + sweep.panelKey = boardKey; + const far = board ? null : clickTargetAlong(ray); + const farKey = far && far.distance > LASER_MIN ? far.key : null; + if (far && farKey && farKey !== sweep.laserKey && fireClickTarget(far, source, index)) fired.push(farKey); + sweep.laserKey = farKey; + } else { + sweep.laserKey = null; + sweep.panelKey = null; + } + if (fired.length) { + sweep.fired += fired.length; + swallowNext = true; + } + return fired; +} + +/** + * A trigger PRESS on slot `index`. In Interact/Play: a game-board button under the laser + * is pressed at once and the press consumed (the sweep carries on across the board); any + * other press starts a SWEEP, which fires on the next frame once it knows no other + * gesture claimed this press. Returns whether the press was consumed. Exported for the + * suites (the live hook calls it). + * @param {number} index @returns {boolean} */ export function triggerStart(index) { swallowNext = false; + sweeps[index] = null; + if (!gameFeelActive()) return false; const ray = controllerRayOf(index); - const target = ray && gameFeelActive() ? panelTargetAlong(ray) : null; + const target = ray ? panelTargetAlong(ray) : null; if (target) { pressPanelTarget(target, { source: 'trigger', u: uAcross(target) }); hapticPattern('bump', handOf(index)); swallowNext = true; + sweeps[index] = { first: false, inside: new Set(), laserKey: null, panelKey: target.hit.id, fired: 1 }; return true; } - pressFeedback(index); + sweeps[index] = { first: true, inside: new Set(), laserKey: null, panelKey: null, fired: 0 }; return false; } +/** The trigger RELEASED on slot `index`: the sweep ends (the swallow stays armed for the + * trailing 'select', which WebXR fires before 'selectend'). @param {number} index */ +export function triggerEnd(index) { + sweeps[index] = null; + return false; +} + +/** is a sweep live on slot `index`? (suites) @param {number} index */ +export function sweepLive(index) { + return !!sweeps[index]; +} + /** @type {(() => void)[]} */ let offs = []; @@ -240,10 +398,12 @@ export function startVrGameInput() { if (panelHover(index, gameFeelActive() ? ray : null)) hoverKey[index] = null; else hoverFrame(index, ray); pokeFrame(index, gameFeelActive() ? tipOf(index) : null); + sweepFrame(index); } }), registerVRTriggerHooks({ start: (/** @type {number} */ index) => triggerStart(index), + end: (/** @type {number} */ index) => triggerEnd(index), swallow: () => { if (!swallowNext) return false; swallowNext = false; @@ -267,5 +427,6 @@ export function stopVrGameInput() { for (const off of offs) off(); offs = []; hoverKey[0] = hoverKey[1] = null; + sweeps[0] = sweeps[1] = null; hideVrGamePanel(); } diff --git a/tests/e2e/vr-sweep.test.cjs b/tests/e2e/vr-sweep.test.cjs new file mode 100644 index 00000000..057353e6 --- /dev/null +++ b/tests/e2e/vr-sweep.test.cjs @@ -0,0 +1,455 @@ +// 30b (vr-play) P4 — C3 HOLD THE TRIGGER AND SWEEP. The user, on a Quest 3: "I want to be able +// to hold trigger and automatically press the buttons where I move the controller around. So, +// for example, I can hit different keys on piano holding trigger and on the mixer +// enable/disable, toggle multiple knobs." +// +// Headless: controllers are posed by hand (matrixAutoUpdate on), the press goes through the +// REAL trigger hooks (vrModuleTriggerStart / End / SelectSwallowed) and each frame through the +// exported sweepFrame, with the tip / laser injected. +// 1 the tip clicks every key it ENTERS, once, the first as the 'trigger' press · 2 leaving +// re-arms · 3 a {sweep: false} handler hears the press and never a sweep · 4 the release is +// swallowed after a sweep, and only then · 5 the LASER sweeps what the tip cannot reach · +// 6 a press another gesture CLAIMED is left alone · 7 Edit mode: no sweep, the old path · +// 8 the game board's buttons sweep too · 9 an Interact release on a plain object CLICKS and +// never selects · 10 the REAL Jam Room: a sweep across piano keys plays each, across drum +// steps flips each. +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_REPO = [path.resolve(__dirname, '../../../theprototype.app-scenes'), path.resolve(__dirname, '../../../scenes')].find((p) => + fs.existsSync(p) +); +const JAM = process.env.JAM_ROOM_TPSCENE || (SCENES_REPO && path.join(SCENES_REPO, 'games/jam-room/scene.tpscene')); + +h.run(async () => { + const browser = await h.launch({ args: h.AUDIO_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + await page.evaluate(async () => { + const s = window.__stores; + window.__log = []; + await s.moduleSDK.initModules([ + { + id: 'sweep30b', + name: 'Sweep test', + version: '1.0.0', + description: 'the 30b sweep', + register(api) { + window.__api = api; + // a keyboard of five keys: this handler plays them, sweeps included + api.registerClickHandler((object, ctx) => { + if (!object.userData?.sweepKey) return false; + window.__log.push({ key: object.userData.sweepKey, source: ctx?.source, mode: ctx?.mode }); + return true; + }); + // a dot you CARRY: it must hear the press and never a sweep + api.registerClickHandler( + (object, ctx) => { + if (!object.userData?.dot) return false; + window.__log.push({ key: 'dot', source: ctx?.source }); + return true; + }, + { sweep: false } + ); + } + } + ]); + let r; + s.globalRenderer.subscribe((v) => (r = v))(); + for (const i of [0, 1]) { + const c = r.xr.getController(i); + c.matrixAutoUpdate = true; + c.userData.handedness = i === 0 ? 'left' : 'right'; + } + // five keys in a row, 12 cm apart, marked clickable, at hand height + const THREE = s.THREE; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const keys = []; + for (let i = 0; i < 5; i++) { + const [uuid] = await window.__api.create('/create box 0.08 0.04 0.2', { at: [-0.24 + i * 0.12, 1.0, -0.5] }); + keys.push(uuid); + } + const [dot] = await window.__api.create('/create box 0.08 0.08 0.08', { at: [0.5, 1.0, -0.5] }); + const [plain] = await window.__api.create('/create box 0.3 0.3 0.3', { at: [0, 1.2, -3] }); + await new Promise((res) => setTimeout(res, 600)); + keys.forEach((uuid, i) => { + const o = g.getObjectByProperty('uuid', uuid); + o.userData.clickable = true; + o.traverse((n) => n.isMesh && (n.userData.sweepKey = 'k' + i)); + o.userData.physics = { mode: 'static' }; + }); + const d = g.getObjectByProperty('uuid', dot); + d.userData.clickable = true; + d.traverse((n) => n.isMesh && (n.userData.dot = true)); + g.updateMatrixWorld(true); + window.__ids = { keys, dot, plain }; + s.objectActions.deselectObject?.(); + s.objectActions.setEditorMode('interact'); + window.__T = { + keyAt: (i) => g.getObjectByProperty('uuid', keys[i]).getWorldPosition(new THREE.Vector3()), + aim: (index, from, to) => { + const c = r.xr.getController(index); + c.position.copy(from); + c.lookAt(from.clone().multiplyScalar(2).sub(to)); // a Group's +Z faces the target + c.updateMatrixWorld(true); + }, + // a sweep frame with the TIP at a point and no laser + tipFrame: (index, point) => s.gameKit.vrGameInput.sweepFrame(index, { tip: point, ray: null }), + press: (index) => s.vrControls.vrModuleTriggerStart(index), + release: (index) => ({ swallowed: s.vrControls.vrModuleSelectSwallowed(), end: s.vrControls.vrModuleTriggerEnd(index) }) + }; + }); + const log = () => page.evaluate(() => window.__log.splice(0)); + + console.log('\n=== 1. the tip clicks every key it enters, once ==='); + const run1 = await page.evaluate(() => { + const T = window.__T; + const THREE = window.__stores.THREE; + const above = (i) => T.keyAt(i).add(new THREE.Vector3(0, 0.035, 0)); // just touching the top + T.aim(1, above(0).add(new THREE.Vector3(0, 0.05, 0.2)), above(0)); + const consumed = T.press(1); + const frames = []; + frames.push(T.tipFrame(1, above(0))); // the press itself + frames.push(T.tipFrame(1, above(0))); // still on k0 + frames.push(T.tipFrame(1, above(1))); + frames.push(T.tipFrame(1, above(1))); + frames.push(T.tipFrame(1, above(2))); + frames.push(T.tipFrame(1, above(3))); + return { consumed, frames }; + }); + const l1 = await log(); + h.check(run1.consumed === false, '1.1 the press is NOT consumed by the sweep (other gestures may still take it)'); + h.check(JSON.stringify(l1.map((e) => e.key)) === JSON.stringify(['k0', 'k1', 'k2', 'k3']), '1.2 four keys entered, four clicks, each once (' + JSON.stringify(l1.map((e) => e.key)) + ')'); + h.check(l1[0]?.source === 'trigger' && l1.slice(1).every((e) => e.source === 'sweep'), '1.3 the first is the press (trigger), the rest are sweeps (' + l1.map((e) => e.source) + ')'); + h.check(l1.every((e) => e.mode === 'interact'), '1.4 dispatched as Interact clicks'); + + console.log('\n=== 2. leaving re-arms ==='); + const run2 = await page.evaluate(() => { + const T = window.__T; + const THREE = window.__stores.THREE; + const above = (i) => T.keyAt(i).add(new THREE.Vector3(0, 0.035, 0)); + T.tipFrame(1, above(3).add(new THREE.Vector3(0, 0.3, 0))); // lift off + T.tipFrame(1, above(3)); // back down on k3 + T.tipFrame(1, above(2)); // and k2 again + }); + void run2; + const l2 = await log(); + h.check(JSON.stringify(l2.map((e) => e.key)) === JSON.stringify(['k3', 'k2']), '2.1 lifting off and coming back plays the key again (' + JSON.stringify(l2.map((e) => e.key)) + ')'); + + console.log('\n=== 3. {sweep: false} hears the press, never a sweep ==='); + const dotRun = await page.evaluate(() => { + const s = window.__stores; + const T = window.__T; + const THREE = s.THREE; + const dot = s.gameKit.vrGameInput; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const at = g.getObjectByProperty('uuid', window.__ids.dot).getWorldPosition(new THREE.Vector3()); + T.release(1); + window.__log.splice(0); + // a held press that starts on k4 (premise: it plays), then is swept INTO the dot + const k4 = T.keyAt(4).add(new THREE.Vector3(0, 0.035, 0)); + T.aim(1, k4.clone().add(new THREE.Vector3(0, 0.05, 0.2)), k4); + T.press(1); + T.tipFrame(1, k4); + T.tipFrame(1, at.clone()); + const swept = window.__log.splice(0); + T.release(1); + // pressed ON the dot: heard + T.aim(1, at.clone().add(new THREE.Vector3(0, 0.05, 0.2)), at); + T.press(1); + T.tipFrame(1, at.clone()); + const pressed = window.__log.splice(0); + T.release(1); + return { swept, pressed, live: dot.sweepLive(1) }; + }); + h.check( + dotRun.swept.length === 1 && dotRun.swept[0].key === 'k4', + '3.1 a held sweep plays k4, then passes into the {sweep:false} dot without clicking it (' + JSON.stringify(dotRun.swept) + ')' + ); + h.check(dotRun.pressed.length === 1 && dotRun.pressed[0].source === 'trigger', '3.2 a press ON it does (' + JSON.stringify(dotRun.pressed) + ')'); + h.check(dotRun.live === false, '3.3 the release ended the sweep'); + + console.log('\n=== 4. the release is swallowed after a sweep, and only then ==='); + const sw = await page.evaluate(() => { + const T = window.__T; + const THREE = window.__stores.THREE; + const above = (i) => T.keyAt(i).add(new THREE.Vector3(0, 0.035, 0)); + T.press(1); + T.tipFrame(1, above(4)); + const after = T.release(1); + window.__log.splice(0); + T.press(1); + T.tipFrame(1, new THREE.Vector3(3, 3, 3)); // nothing there + const empty = T.release(1); + return { after: after.swallowed, empty: empty.swallowed }; + }); + h.check(sw.after === true, '4.1 a press that swept something swallows its trailing select'); + h.check(sw.empty === false, '4.2 a press that clicked nothing lets its select through'); + + console.log('\n=== 5. the laser sweeps what the tip cannot reach ==='); + const laser = await page.evaluate(() => { + const s = window.__stores; + const T = window.__T; + const THREE = s.THREE; + const k = s.gameKit.vrGameInput; + const from = new THREE.Vector3(0, 1.6, 1.5); + T.aim(1, from, T.keyAt(0)); + T.press(1); + const hits = []; + for (let i = 0; i < 5; i++) { + T.aim(1, from, T.keyAt(i)); + hits.push(k.sweepFrame(1, { tip: from.clone() })); + T.aim(1, from, T.keyAt(i)); // a second frame on the same key + hits.push(k.sweepFrame(1, { tip: from.clone() })); + } + const r = T.release(1); + return { hits, swallowed: r.swallowed, log: window.__log.splice(0) }; + }); + h.check(JSON.stringify(laser.log.map((e) => e.key)) === JSON.stringify(['k0', 'k1', 'k2', 'k3', 'k4']), '5.1 the laser, held and moved along the row from 2 m, clicks each key once (' + JSON.stringify(laser.log.map((e) => e.key)) + ')'); + h.check(laser.swallowed === true, '5.2 and swallows its release'); + + console.log('\n=== 6. a press another gesture claimed is left alone ==='); + const claimed = await page.evaluate(() => { + const s = window.__stores; + const T = window.__T; + const THREE = s.THREE; + const above = (i) => T.keyAt(i).add(new THREE.Vector3(0, 0.035, 0)); + // a knob-drag-like hook registered AFTER ours: it takes this press + const off = s.vrControls.registerVRTriggerHooks({ start: () => true }); + const consumed = T.press(1); + const frame = T.tipFrame(1, above(1)); + const live = s.gameKit.vrGameInput.sweepLive(1); + off(); + T.release(1); + return { consumed, frame, live, log: window.__log.splice(0) }; + }); + h.check(claimed.consumed === true && claimed.log.length === 0 && claimed.live === false, '6.1 the sweep stands down under a claimed gesture (' + JSON.stringify(claimed) + ')'); + + console.log('\n=== 7. Edit mode: no sweep ==='); + const edit = await page.evaluate(() => { + const s = window.__stores; + const T = window.__T; + const THREE = s.THREE; + const above = (i) => T.keyAt(i).add(new THREE.Vector3(0, 0.035, 0)); + s.objectActions.setEditorMode('edit'); + const consumed = T.press(1); + const frame = T.tipFrame(1, above(0)); + const r = T.release(1); + s.objectActions.setEditorMode('interact'); + return { consumed, frame, swallowed: r.swallowed, log: window.__log.splice(0) }; + }); + h.check(edit.consumed === false && edit.frame === null && edit.log.length === 0 && edit.swallowed === false, '7.1 in Edit a held trigger sweeps nothing and the select passes (' + JSON.stringify(edit) + ')'); + + console.log('\n=== 8. the game board\'s buttons sweep too ==='); + const board = await page.evaluate(async () => { + const s = window.__stores; + const T = window.__T; + const THREE = s.THREE; + s.hudDocs.setHudDocFor('scene', { + active: 'pads', + screens: [ + { + id: 'pads', + name: 'Pads', + input: 'menu', + elements: [0, 1, 2].map((i) => ({ id: 'pad' + i, kind: 'toggle', anchor: 'center', x: -220 + i * 220, y: 0, w: 180, h: 80, label: 'Pad ' + (i + 1), value: false })) + } + ] + }); + const k = s.gameKit.vrGamePanel; + const head = { position: new THREE.Vector3(0, 1.6, 0), quaternion: new THREE.Quaternion() }; + k.hideVrGamePanel(); + k.vrGamePanelFrame({ head, hands: [null, null] }); + const surf = k.vrGameSurface('vr-game-panel'); + const pt = (id) => { + const rect = k.vrGamePanelDebug().hits['vr-game-panel'].find((x) => x.id === id); + const g = surf.mesh.geometry.parameters; + return surf.mesh.localToWorld(new 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 THREE.Vector3(0.2, 1.3, -0.3); + T.aim(1, from, pt('pad0')); + const consumed = T.press(1); + for (const id of ['pad0', 'pad1', 'pad1', 'pad2']) { + T.aim(1, from, pt(id)); + s.gameKit.vrGameInput.sweepFrame(1, { tip: from.clone() }); + } + const r = T.release(1); + const values = [0, 1, 2].map((i) => s.hudDocs.hudValueOf('pad' + i, false)); + s.hudDocs.setHudDocFor('scene', null); + k.hideVrGamePanel(); + return { consumed, values, swallowed: r.swallowed }; + }); + h.check(board.consumed === true && JSON.stringify(board.values) === '[true,true,true]', '8.1 one press swept across three board toggles flips each once (' + JSON.stringify(board) + ')'); + + console.log('\n=== 9. an Interact release on a plain object clicks, never selects ==='); + const release = await page.evaluate(async () => { + const s = window.__stores; + const T = window.__T; + const THREE = s.THREE; + let r, g; + s.globalRenderer.subscribe((v) => (r = v))(); + s.objectsGroup.subscribe((v) => (g = v))(); + const target = g.getObjectByProperty('uuid', window.__ids.plain).getWorldPosition(new THREE.Vector3()); + const c = r.xr.getController(1); + T.aim(1, new THREE.Vector3(0, 1.4, 0), target); + s.objectActions.deselectObject?.(); + const sel = () => { + let v; + s.selectedObjects.subscribe((x) => (v = x))(); + return [...v]; + }; + c.dispatchEvent({ type: 'select', target: c }); + const inInteract = sel(); + s.objectActions.setEditorMode('edit'); + c.dispatchEvent({ type: 'select', target: c }); + const inEdit = sel(); + s.objectActions.deselectObject?.(); + s.objectActions.setEditorMode('interact'); + return { inInteract, inEdit, plain: window.__ids.plain }; + }); + h.check(release.inInteract.length === 0, '9.1 in Interact the VR release selected nothing (' + JSON.stringify(release.inInteract) + ')'); + h.check(release.inEdit.includes(release.plain), '9.2 in Edit the same release selects it, as before'); + + console.log('\n=== 11. a real-width keyboard: the tip plays the key it is NEAREST ==='); + const narrow = await page.evaluate(async () => { + const s = window.__stores; + const T = window.__T; + const THREE = s.THREE; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + // seven keys 2.2 cm wide on a 2.4 cm pitch — narrower than the 6 cm tip, like a piano + const keys = []; + for (let i = 0; i < 7; i++) { + const [uuid] = await window.__api.create('/create box 0.022 0.02 0.12', { at: [-0.072 + i * 0.024, 1.3, -0.8] }); + keys.push(uuid); + } + await new Promise((res) => setTimeout(res, 600)); + keys.forEach((uuid, i) => { + const o = g.getObjectByProperty('uuid', uuid); + o.userData.clickable = true; + o.userData.physics = { mode: 'static' }; + o.traverse((n) => n.isMesh && (n.userData.sweepKey = 'n' + i)); + }); + g.updateMatrixWorld(true); + window.__log.splice(0); + const top = (i) => g.getObjectByProperty('uuid', keys[i]).getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0, 0.012, 0)); + T.aim(1, top(1).add(new THREE.Vector3(0, 0.05, 0.2)), top(1)); + T.press(1); + // glide along the keys, several frames per key, centred over keys 1..5 + for (let i = 1; i <= 5; i++) for (let f = 0; f < 3; f++) T.tipFrame(1, top(i)); + T.release(1); + return window.__log.splice(0).map((e) => e.key); + }); + h.check(JSON.stringify(narrow) === JSON.stringify(['n1', 'n2', 'n3', 'n4', 'n5']), '11.1 gliding over five 2.2 cm keys plays those five, one each, no clusters (' + JSON.stringify(narrow) + ')'); + + console.log('\n=== 10. the REAL Jam Room: piano keys and drum steps ==='); + const zip = (id) => h.installModule(A, id); + if (!JAM || !fs.existsSync(JAM)) console.log('SKIP 10: no games/jam-room/scene.tpscene'); + else if (!(await zip('music-lab')) || !(await zip('music-fx'))) console.log('SKIP 10: no music-lab / music-fx zips'); + else { + await page.evaluate(() => window.__stores.modulesOpen.set(false)); + const bytes = Array.from(fs.readFileSync(JAM)); + await page.evaluate(async (arr) => { + const s = window.__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, bytes); + await page.waitForTimeout(3000); + const jam = await page.evaluate(async () => { + const s = window.__stores; + const THREE = s.THREE; + s.objectActions.setEditorMode('interact'); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + g.updateMatrixWorld(true); + // every piano key, in order of x; every drum STEP cell of the first row + const keys = []; + const steps = []; + g.traverse((o) => { + if (!o.isMesh) return; + if (/^key-\d+$/.test(o.name) || typeof o.userData?.midi === 'number') keys.push(o); + if (/step/i.test(o.name) && o.parent) steps.push(o); + }); + keys.sort((a, b) => a.getWorldPosition(new THREE.Vector3()).x - b.getWorldPosition(new THREE.Vector3()).x); + // spy the dispatch: which handler consumed which mesh, with which source + const consumed = []; + const handlers = s.moduleSDK.moduleClickHandlers; + for (let i = 0; i < handlers.length; i++) { + const fn = handlers[i]; + if (fn.__spied) continue; + const wrapped = (o, ctx) => { + const r = fn(o, ctx); + if (r) consumed.push({ name: o.name, source: ctx?.source }); + return r; + }; + wrapped.__spied = true; + handlers[i] = wrapped; + } + const T = window.__T; + const topOf = (o) => { + const b = new THREE.Box3().setFromObject(o); + return new THREE.Vector3((b.min.x + b.max.x) / 2, b.max.y + 0.01, (b.min.z + b.max.z) / 2); + }; + // three neighbouring WHITE keys: the tip plays whichever it is nearest + const white = keys.filter((k) => ![1, 3, 6, 8, 10].includes(((Number(k.userData?.midi ?? k.name.slice(4)) % 12) + 12) % 12)); + const pick = [white[2], white[3], white[4]].filter(Boolean); + T.aim(1, topOf(pick[0]).add(new THREE.Vector3(0, 0.05, 0.2)), topOf(pick[0])); + T.press(1); + for (const key of pick) { + T.tipFrame(1, topOf(key)); + T.tipFrame(1, topOf(key)); + } + T.release(1); + const keyClicks = consumed.splice(0); + // the drum machine: the step cells of one row, swept left to right + const drums = []; + g.traverse((o) => { + if (o.userData?.device?.kind && /drums/.test(o.userData.device.kind)) drums.push(o); + }); + const drum = drums[0]; + const patternOf = () => String(drum?.userData?.device?.params?.pattern ?? ''); + const cells = []; + drum?.traverse((o) => o.isMesh && /^step-\d+-\d+$/.test(o.name) && cells.push(o)); + // one row (pad 0), every other step, so the tip never sits on two cells at once + const cellPick = cells + .filter((c) => c.name.startsWith('step-0-')) + .sort((a, b) => Number(a.name.split('-')[2]) - Number(b.name.split('-')[2])) + .filter((c) => Number(c.name.split('-')[2]) % 2 === 0) + .slice(0, 3); + const before = patternOf(); + if (cellPick[0]) { + T.aim(1, topOf(cellPick[0]).add(new THREE.Vector3(0, 0.05, 0.2)), topOf(cellPick[0])); + T.press(1); + for (const cell of cellPick) T.tipFrame(1, topOf(cell)); + T.release(1); + } + await new Promise((r) => setTimeout(r, 1500)); // the stroke commits after it idles + const after = patternOf(); + let diff = 0; + for (let i = 0; i < Math.max(before.length, after.length); i++) if (before[i] !== after[i]) diff++; + return { + keys: keys.length, + picked: pick.map((k) => k.name), + keyClicks, + cells: cells.length, + cellNames: cellPick.map((c) => c.name), + stepClicks: consumed.splice(0), + diff, + before: before.slice(0, 20), + after: after.slice(0, 20) + }; + }); + h.check(jam.keys > 10, '10.1 premise: the Jam Room piano is there (' + jam.keys + ' keys)'); + h.check( + jam.keyClicks.length === 3 && jam.keyClicks.every((c, i) => c.name === jam.picked[i]) && jam.keyClicks[0].source === 'trigger' && jam.keyClicks[2].source === 'sweep', + '10.2 one held sweep across three piano keys plays each, once (' + JSON.stringify(jam.keyClicks) + ')' + ); + h.check(jam.stepClicks.length === 3 && jam.diff >= 3, '10.3 a sweep across three drum steps flips each (' + JSON.stringify({ cells: jam.cellNames, clicks: jam.stepClicks.length, diff: jam.diff }) + ')'); + } + + await h.finish(browser); +}); From e89aed5d5bc4411308482adff5c91baa77aeb632 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 17:52:22 +0300 Subject: [PATCH 12/18] [docs] 30b P5: MODULES.md - game feel (sound, music, haptics, effects, banners), the VR board, hold-and-sweep - a "Game feel" section with a ten-line example: api.playSound's game set (and the unknown-name no-op), api.music, api.hapticPattern (and Edit silence + the core defaults), api.effects.burst, api.announce; how a game's HUD reaches the VR board, wrist card and strip with nothing to do on the module side; the hold-and-sweep, what counts as clickable, ctx = {source, mode} and {sweep: false}. - the Interaction section: the VR trigger now follows the editor modes. - Misc: api.hapticPattern beside api.haptic. Co-Authored-By: Claude Opus 5.5 (1M context) --- MODULES.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/MODULES.md b/MODULES.md index f72638a3..58c0b736 100644 --- a/MODULES.md +++ b/MODULES.md @@ -243,7 +243,10 @@ api.registerClickHandler(pressKey, { modes: ['interact', 'play'] }); // the defa api.registerClickHandler(pickTool, { modes: ['edit'] }); // an editor TOOL ``` -The VR trigger has no editor mode yet and still offers every handler. +In a headset the same modes apply (1.17, 30b): Play in VR enters Interact, where the +trigger clicks (module handlers, On Click) and selects nothing; in Edit it selects as +before. Handlers also get `ctx = {source, mode}` — see *Game feel* below for the +hold-and-sweep and `{sweep: false}`. `api.editorMode()` reads the mode on this screen (`'edit'` | `'interact'`) — for a module whose own pointer listeners run outside click routing (a drag), so it can stand down in Edit. @@ -478,6 +481,76 @@ if (api.storage) { Feature-detect it; on an older app write the SAME key yourself (`localStorage['tp:mod::'] = JSON.stringify(value)`) so progress carries over. +### Game feel: sound, music, haptics, effects, banners (1.17, roadmap 30b) + +Everything here is LOCAL to the device it runs on — broadcast your own op +(`api.send`) when every peer should hear, feel or see it — and all of it is +feature-detected, so a module written against it still loads on an older app. + +```js +register(api) { + api.registerClickHandler((object, ctx) => { + if (!object.userData.ring) return false; + const at = object.getWorldPosition(new api.THREE.Vector3()).toArray(); + api.playSound?.('success', at); + api.effects?.burst(at, { kind: 'sparkle' }); + api.announce?.('Ring ' + object.userData.ring + ' reached', { sub: '+50' }); + api.hapticPattern?.('success'); // both hands; 'left' | 'right' for one + return true; + }); + api.game.onChange?.(() => (api.game.roundUnderway() ? api.music?.play('arcade') : api.music?.stop())); +} +``` + +- **`api.playSound(name, position?)`** → `true` when a sound started. Twenty + procedural game sounds, no assets: `click` `pop` `whoosh` `success` `fail` `hit` + `kick` `shoot` `laser` `explosion` `coin` `levelup` `goal` `whistle` `cheer` + `step` `ring` `sparkle` `hurt` `portal`, plus the ping chimes `ding` (the default), + `chime`, `pluck`, `bell`. An unknown name is a quiet no-op (it used to play the + ding). `position` spatialises it. Played at the player's "Game sounds" volume. +- **`api.music.play(preset, {volume?})` / `api.music.stop()`** — a looping + procedural track under the effects, at the player's "Music" volume: `arcade` + `ambient` `dungeon` `stadium` `space` `puzzle` `studio`. It is TEMPO-SYNCED to the + session clock (two peers on one preset hear the same bar), plays only in Interact + or Play (`false` from the editor) and stops by itself when the player goes back to + editing. `api.music.current()` names what is playing; `api.music.presets()` lists them. +- **`api.hapticPattern(name, hand?)`** — `tap` `bump` `hit` `success` `fail` + `rumble` `heartbeat` on the VR controllers. `api.haptic(intensity, ms, hand?)` + still exists. BOTH are silent in Edit mode (vibration is for playing), and core + already plays the defaults for you in Interact/Play: a `tap` when the laser enters + something clickable, a `bump` when a press lands, a `hit` on a grab, and a knock + sized by how hard the hand hit. +- **`api.effects.burst([x, y, z], {kind?, color?, count?})`** — a short, pooled + particle burst at the scene root: `sparkle` (default), `confetti`, `smoke`, + `sparks`; `count` 1..96. Nothing is saved or replicated. +- **`api.announce(text, {sub?, ms?, color?})`** — a big centred banner ("GOAL!", + "Level 3"): the desktop HUD, and in a headset a banner fixed in front of the + player. A new one replaces the one showing; `ms` 300..15000 (default 1800). + +**In VR, your game's HUD is in the player's hands.** A screen with `input: 'menu'`, +a control on it, or bound to the `menu` / `paused` / `over` game state is drawn on +a board ~1.2 m in front of the player that follows their head lazily; its buttons +work with the laser and trigger and with a poke. The other screens (score, timer, +level) read as short lines on a card on the left wrist and a strip across the top +of the view. The board and the wrist card always carry an **Edit mode** button. +Nothing to do on your side — author the HUD once and it works on both. + +**Hold the trigger and sweep (VR, Interact/Play).** While the trigger is held, the +controller TIP clicks each clickable it passes over — the one it is NEAREST to, so a +glide along a keyboard plays one key at a time — once, and again after it leaves and +comes back: piano keys, pads, toggles, HUD buttons; the laser does the same for things +out of reach. "Clickable" (also what earns the hover `tap`) means: under a group you +passed to `registerInteractiveGroup`, a part of an audio device (`userData.device` on +an ancestor — each key/pad is its own control), an object an On Click node targets, or +an object you mark `userData.clickable = true`. Your handler gets a second argument +`ctx = {source, mode}`: `source` is `'click'` (desktop), `'trigger'` (the VR press +itself) or `'sweep'` (a later entry while held). A control that must not be swept +(a knob you drag, a dot you carry) opts out: + +```js +api.registerClickHandler(pickDot, { sweep: false }); // presses still reach it; sweeps do not +``` + ### Physics (P-A) All mutations are INITIATOR-ONLY — the peer that started the simulation steps @@ -625,8 +698,9 @@ api.scene(); // THREE.Scene api.objectsGroup(); // the replicated objects root (add scene content here) api.peerId(); // our peer id (undefined before the mesh is up) api.toast('hi'); // toast in the corner -api.haptic(0.6, 60); // buzz the VR controllers (no-op on desktop); +api.haptic(0.6, 60); // buzz the VR controllers (no-op on desktop and in Edit); api.haptic(0.6, 60, 'right'); // optional hand targets one controller (17-A1) +api.hapticPattern('success'); // a named pattern (1.17, see Game feel) api.isVR(); // true inside a VR session api.vrHand('left'); // one hand's WORLD pose + buttons, or null: // {position, quaternion, trigger, gripped, connected} From ee0378a556226f61c61983cdc366efeead71c08f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 17:53:02 +0300 Subject: [PATCH 13/18] [docs] 30b P5: refresh static/llms-full.txt from MODULES.md (npm run sync-llms) Co-Authored-By: Claude Opus 5.5 (1M context) --- static/llms-full.txt | 130 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/static/llms-full.txt b/static/llms-full.txt index cb43b90b..4e569f61 100644 --- a/static/llms-full.txt +++ b/static/llms-full.txt @@ -282,6 +282,31 @@ api.registerClickHandler((object) => { Handlers see desktop clicks and VR trigger presses (the exact mesh hit, not the top-level group). Return `false` to let normal selection continue. +**Modes (1.17).** The editor has two click modes, Edit (every click selects) and +Interact (play-style clicks and drags without starting the game), plus Play. A +handler runs in Interact and Play by default, so an Edit click on your game piece +selects it like any object. Say so when you want something else: + +```js +api.registerClickHandler(pressKey, { modes: ['interact', 'play'] }); // the default, spelled out +api.registerClickHandler(pickTool, { modes: ['edit'] }); // an editor TOOL +``` + +In a headset the same modes apply (1.17, 30b): Play in VR enters Interact, where the +trigger clicks (module handlers, On Click) and selects nothing; in Edit it selects as +before. Handlers also get `ctx = {source, mode}` — see *Game feel* below for the +hold-and-sweep and `{sweep: false}`. +`api.editorMode()` reads the mode on this screen (`'edit'` | `'interact'`) — for a module +whose own pointer listeners run outside click routing (a drag), so it can stand down in Edit. + +**Listed in the object list (1.17).** Content you build at the scene root shows in +the object list's *Module content* section — a read-only row that frames it, hides +it locally, or opens your toolbox. Give it a readable name: + +```js +api.registerListedGroup('piano-module', { label: 'Piano' }); +``` + ```js api.registerFrameTask((time) => { /* runs every frame, synced time */ }); @@ -456,6 +481,14 @@ api.onInput((kind, code) => {}); // 'down'/'up' events; returns unsubscri // 'locomotion' — VR left-stick locomotion api.claimInput('keys'); // ALWAYS release when your mode ends api.releaseInput('keys'); + +// reading a keydown YOURSELF (a toolbox's own handler)? resolve the key the way the +// editor does — `event.key` when it is an ASCII letter/digit, the physical +// `event.code` position otherwise — so it works on a Cyrillic/Greek/Hebrew layout: +window.addEventListener('keydown', (e) => { + if (api.keyOf(e) === 'G') grab(); // 'G' on QWERTY, AZERTY, Dvorak AND ЙЦУКЕН + if (api.letterOf(e) === 'w') drive(); // lowercase form; named keys ('Escape') unchanged +}); ``` ### Pointer ray (190) @@ -473,6 +506,100 @@ api.registerFrameTask(() => { }); ``` +### Free-cursor games (1.17) + +A board, puzzle or instrument game can play with the real mouse cursor and no +pointer lock: publish `userData.play.cursor = 'free'` on your scene-root group +(or the scene's physics block sets `play.cursor: 'free'`). `api.pointerRay()` is +then the cursor's ray in play; in a locked game it is the CROSSHAIR ray (the view's +centre), never the stale mouse position the lock pinned. + +### Storage (1.17) + +```js +// JSON on THIS device, namespaced to your module (tp:mod::), 256 KB per +// module; never replicated, never saved into a scene, survives disable/remove. +if (api.storage) { + const progress = api.storage.get('progress', { unlocked: 1 }); + api.storage.set('progress', progress); // -> true, or false over the quota + api.storage.keys(); api.storage.bytes(); api.storage.remove('progress'); + api.storage.clear(); // your "Reset progress" +} +``` + +Feature-detect it; on an older app write the SAME key yourself +(`localStorage['tp:mod::'] = JSON.stringify(value)`) so progress carries over. + +### Game feel: sound, music, haptics, effects, banners (1.17, roadmap 30b) + +Everything here is LOCAL to the device it runs on — broadcast your own op +(`api.send`) when every peer should hear, feel or see it — and all of it is +feature-detected, so a module written against it still loads on an older app. + +```js +register(api) { + api.registerClickHandler((object, ctx) => { + if (!object.userData.ring) return false; + const at = object.getWorldPosition(new api.THREE.Vector3()).toArray(); + api.playSound?.('success', at); + api.effects?.burst(at, { kind: 'sparkle' }); + api.announce?.('Ring ' + object.userData.ring + ' reached', { sub: '+50' }); + api.hapticPattern?.('success'); // both hands; 'left' | 'right' for one + return true; + }); + api.game.onChange?.(() => (api.game.roundUnderway() ? api.music?.play('arcade') : api.music?.stop())); +} +``` + +- **`api.playSound(name, position?)`** → `true` when a sound started. Twenty + procedural game sounds, no assets: `click` `pop` `whoosh` `success` `fail` `hit` + `kick` `shoot` `laser` `explosion` `coin` `levelup` `goal` `whistle` `cheer` + `step` `ring` `sparkle` `hurt` `portal`, plus the ping chimes `ding` (the default), + `chime`, `pluck`, `bell`. An unknown name is a quiet no-op (it used to play the + ding). `position` spatialises it. Played at the player's "Game sounds" volume. +- **`api.music.play(preset, {volume?})` / `api.music.stop()`** — a looping + procedural track under the effects, at the player's "Music" volume: `arcade` + `ambient` `dungeon` `stadium` `space` `puzzle` `studio`. It is TEMPO-SYNCED to the + session clock (two peers on one preset hear the same bar), plays only in Interact + or Play (`false` from the editor) and stops by itself when the player goes back to + editing. `api.music.current()` names what is playing; `api.music.presets()` lists them. +- **`api.hapticPattern(name, hand?)`** — `tap` `bump` `hit` `success` `fail` + `rumble` `heartbeat` on the VR controllers. `api.haptic(intensity, ms, hand?)` + still exists. BOTH are silent in Edit mode (vibration is for playing), and core + already plays the defaults for you in Interact/Play: a `tap` when the laser enters + something clickable, a `bump` when a press lands, a `hit` on a grab, and a knock + sized by how hard the hand hit. +- **`api.effects.burst([x, y, z], {kind?, color?, count?})`** — a short, pooled + particle burst at the scene root: `sparkle` (default), `confetti`, `smoke`, + `sparks`; `count` 1..96. Nothing is saved or replicated. +- **`api.announce(text, {sub?, ms?, color?})`** — a big centred banner ("GOAL!", + "Level 3"): the desktop HUD, and in a headset a banner fixed in front of the + player. A new one replaces the one showing; `ms` 300..15000 (default 1800). + +**In VR, your game's HUD is in the player's hands.** A screen with `input: 'menu'`, +a control on it, or bound to the `menu` / `paused` / `over` game state is drawn on +a board ~1.2 m in front of the player that follows their head lazily; its buttons +work with the laser and trigger and with a poke. The other screens (score, timer, +level) read as short lines on a card on the left wrist and a strip across the top +of the view. The board and the wrist card always carry an **Edit mode** button. +Nothing to do on your side — author the HUD once and it works on both. + +**Hold the trigger and sweep (VR, Interact/Play).** While the trigger is held, the +controller TIP clicks each clickable it passes over — the one it is NEAREST to, so a +glide along a keyboard plays one key at a time — once, and again after it leaves and +comes back: piano keys, pads, toggles, HUD buttons; the laser does the same for things +out of reach. "Clickable" (also what earns the hover `tap`) means: under a group you +passed to `registerInteractiveGroup`, a part of an audio device (`userData.device` on +an ancestor — each key/pad is its own control), an object an On Click node targets, or +an object you mark `userData.clickable = true`. Your handler gets a second argument +`ctx = {source, mode}`: `source` is `'click'` (desktop), `'trigger'` (the VR press +itself) or `'sweep'` (a later entry while held). A control that must not be swept +(a knob you drag, a dot you carry) opts out: + +```js +api.registerClickHandler(pickDot, { sweep: false }); // presses still reach it; sweeps do not +``` + ### Physics (P-A) All mutations are INITIATOR-ONLY — the peer that started the simulation steps @@ -620,8 +747,9 @@ api.scene(); // THREE.Scene api.objectsGroup(); // the replicated objects root (add scene content here) api.peerId(); // our peer id (undefined before the mesh is up) api.toast('hi'); // toast in the corner -api.haptic(0.6, 60); // buzz the VR controllers (no-op on desktop); +api.haptic(0.6, 60); // buzz the VR controllers (no-op on desktop and in Edit); api.haptic(0.6, 60, 'right'); // optional hand targets one controller (17-A1) +api.hapticPattern('success'); // a named pattern (1.17, see Game feel) api.isVR(); // true inside a VR session api.vrHand('left'); // one hand's WORLD pose + buttons, or null: // {position, quaternion, trigger, gripped, connected} From 770f93e5a93fd687af7c890389c130ded5a06be2 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 18:44:32 +0300 Subject: [PATCH 14/18] [fix] 30b: the first burst of a session was lost; the timing-bound checks wait on the thing - effectsBurst: the Points program was compiled on the FIRST draw that needed it, and on a loaded device that stall ate the first burst of a session whole (measured: the first burst on a fresh page changed 0 pixels, every later one ~1500) - the first "ring reached" sparkle is exactly the one a player would miss. warmUp() compiles the burst material when the pool is built (renderer.compile on one system; compile() walks visible objects only, so it is shown for the call). - game-effects: 1.5 asserts the program exists before any frame is drawn; the pixel check uses a confetti burst (2.2 s of life - a slow frame cannot outlive it) and samples until a frame differs instead of one fixed 120 ms sleep, with a state dump in its message. (While finding this, zero-pixel frames on a long-lived dev server turned out to be the edited-under-a-running-server dual-instance trap; a fresh server reads 3361 px.) - game-haptics: the pattern checks wait for the PULSES (headless timers ran ~2x late on a machine running two e2e slots), not a clock. Counterfactual: warmUp removed -> 1.5 red (16 -> 16 programs). game-effects 24/24, game-haptics 18/18 on a fresh server; svelte-check 334/47; build green. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/effectsBurst.js | 26 ++++++++++++++++- tests/e2e/game-effects.test.cjs | 49 ++++++++++++++++++++++++++++----- tests/e2e/game-haptics.test.cjs | 11 ++++---- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/lib/effectsBurst.js b/src/lib/effectsBurst.js index 4032f4c4..02d98344 100644 --- a/src/lib/effectsBurst.js +++ b/src/lib/effectsBurst.js @@ -21,7 +21,7 @@ // sparks fast orange streaks, gravity, very short import * as THREE from 'three'; import { get } from 'svelte/store'; -import { globalScene } from '../stores/sceneStore'; +import { globalScene, globalRenderer, globalCamera } from '../stores/sceneStore'; import { moduleFrameTasks } from './moduleSDK'; export const BURST_KINDS = ['sparkle', 'confetti', 'smoke', 'sparks']; @@ -128,12 +128,36 @@ function makeSystem(index) { }; } +/** + * Compile the burst material NOW, while nothing is showing. three builds a program on the + * first draw that needs it, and on a loaded device (a headset mid-game) that stall ate the + * first burst of a session whole — measured: the first burst on a page changed 0 pixels, + * every later one ~1500. One material, one program; cheap. + * @param {any} scene + */ +function warmUp(scene) { + const renderer = /** @type {any} */ (get(globalRenderer)); + const camera = /** @type {any} */ (get(globalCamera)); + const probe = pool[0]?.points; + if (!renderer?.compile || !camera || !probe) return; + const was = probe.visible; + probe.visible = true; // compile() walks VISIBLE objects only + try { + renderer.compile(probe, camera, scene); + } catch { + /* a lost context compiles nothing; the first draw will */ + } + probe.visible = was; +} + /** the free system, or the oldest running one @returns {BurstSystem | null} */ function takeSystem() { const scene = /** @type {any} */ (get(globalScene)); if (!scene) return null; + const fresh = pool.length === 0; while (pool.length < POOL_SIZE) pool.push(makeSystem(pool.length)); for (const sys of pool) if (sys.points.parent !== scene) scene.add(sys.points); + if (fresh) warmUp(scene); const idle = pool.find((sys) => !sys.points.visible); if (idle) return idle; stats.recycled++; diff --git a/tests/e2e/game-effects.test.cjs b/tests/e2e/game-effects.test.cjs index 4f21db1b..3103f850 100644 --- a/tests/e2e/game-effects.test.cjs +++ b/tests/e2e/game-effects.test.cjs @@ -37,7 +37,12 @@ h.run(async () => { s.globalScene.subscribe((v) => (scene = v))(); s.objectsGroup.subscribe((v) => (group = v))(); const before = group.children.length; + let rd; + s.globalRenderer.subscribe((v) => (rd = v))(); + const pointsBefore = rd.info.programs.length; const ok = window.__api.effects.burst([0, 1.5, -3], { kind: 'confetti' }); + // synchronously, before ANY frame has drawn the burst + const pointsAfter = rd.info.programs.length; const fx = s.gameKit.effectsBurst.burstDebug(); const live = fx.live[0]; const points = live ? scene.getObjectByName(live.name) : null; @@ -48,13 +53,19 @@ h.run(async () => { inGroup: !!group.getObjectByName(live?.name ?? '-'), groupDelta: group.children.length - before, drawCount: points?.geometry?.drawRange?.count ?? -1, - sent: window.__sent.slice() + sent: window.__sent.slice(), + pointsBefore, + pointsAfter }; }); h.check(fired.ok === true && fired.live?.kind === 'confetti', '1.1 api.effects.burst starts a confetti burst (' + JSON.stringify(fired.live) + ')'); h.check(fired.parentIsScene && !fired.inGroup && fired.groupDelta === 0, '1.2 it lives at the scene root, never in objectsGroup'); h.check(fired.drawCount === 80, '1.3 confetti draws its recipe count (80, got ' + fired.drawCount + ')'); h.check(!fired.sent.length, '1.4 nothing was sent (' + JSON.stringify(fired.sent) + ')'); + h.check( + fired.pointsAfter > fired.pointsBefore, + '1.5 the burst program is compiled when the pool is built, not on the first draw (' + fired.pointsBefore + ' -> ' + fired.pointsAfter + ')' + ); console.log('\n=== 2. each kind moves the way it says ==='); const motion = await page.evaluate(() => { @@ -151,19 +162,43 @@ h.run(async () => { const quiet1 = await h.grabFrame(A); await page.waitForTimeout(150); const quiet2 = await h.grabFrame(A); - await page.evaluate(() => { + const fired6 = await page.evaluate(() => { const s = window.__stores; let cam; s.globalCamera.subscribe((c) => (cam = c))(); const THREE = s.THREE; const at = cam.getWorldPosition(new THREE.Vector3()).add(cam.getWorldDirection(new THREE.Vector3()).multiplyScalar(4)); - window.__api.effects.burst(at.toArray(), { kind: 'sparkle', count: 96, color: '#ffffff' }); + const ok = window.__api.effects.burst(at.toArray(), { kind: 'confetti', count: 96 }); // 2.2 s of life: a slow frame cannot outlive it + return { ok, live: s.gameKit.effectsBurst.burstDebug().live, tasks: s.moduleSDK.moduleFrameTasks.length }; }); - await page.waitForTimeout(120); - const lit = await h.grabFrame(A); + // the burst lives 1.1 s; a loaded machine can take a while to render the next frame, + // so sample until one differs (or the burst is surely gone) const idle = await h.frameDelta(page, quiet1, quiet2); - const burstDelta = await h.frameDelta(page, quiet2, lit); - h.check(burstDelta.changed > idle.changed + 400, '6.1 a sparkle in view changes the frame (' + burstDelta.changed + ' px vs idle ' + idle.changed + ')'); + let burstDelta = { changed: 0 }; + for (let i = 0; i < 6 && burstDelta.changed <= idle.changed + 400; i++) { + await page.waitForTimeout(80); + const frame = await h.grabFrame(A); + burstDelta = await h.frameDelta(page, quiet2, frame); + } + const diag = await page.evaluate(async () => { + const s = window.__stores; + let rd, scene, cam; + s.globalRenderer.subscribe((v) => (rd = v))(); + s.globalScene.subscribe((v) => (scene = v))(); + s.globalCamera.subscribe((v) => (cam = v))(); + const f0 = rd.info.render.frame; + await new Promise((r) => setTimeout(r, 300)); + const live = s.gameKit.effectsBurst.burstDebug().live; + const p = live[0] ? scene.getObjectByName(live[0].name) : null; + return { + frames: rd.info.render.frame - f0, + live, + parent: p?.parent === scene, + cam: cam?.position.toArray().map((v) => +v.toFixed(1)), + pos: p ? Array.from(p.geometry.attributes.position.array.slice(0, 3)).map((v) => +v.toFixed(1)) : null + }; + }); + h.check(burstDelta.changed > idle.changed + 400, '6.1 a burst in view changes the frame (' + burstDelta.changed + ' px vs idle ' + idle.changed + ') ' + JSON.stringify({ fired6, diag })); console.log('\n=== 7. announce ==='); const ann = await page.evaluate(async () => { diff --git a/tests/e2e/game-haptics.test.cjs b/tests/e2e/game-haptics.test.cjs index 28286ab1..18faa841 100644 --- a/tests/e2e/game-haptics.test.cjs +++ b/tests/e2e/game-haptics.test.cjs @@ -61,19 +61,20 @@ h.run(async () => { console.log('\n=== 3. patterns are timed pulses and stop when the player leaves ==='); const ok = await page.evaluate(() => ({ s: window.__api.hapticPattern('success', 'right'), u: window.__api.hapticPattern('zzz') })); - await page.waitForTimeout(400); + // wait on the PULSES, not a clock: headless timers run late under load + await page.waitForFunction(() => window.__buzz.length >= 3, null, { timeout: 5000 }).catch(() => {}); const success = await buzz(); h.check(ok.s === true && ok.u === false, '3.1 a known pattern plays, an unknown one is refused'); h.check( success.length === 3 && success.every((b) => b.hand === 'right') && success[0].i < success[1].i && success[1].i < success[2].i, '3.2 success = three rising pulses on the right (' + JSON.stringify(success.map((b) => b.i)) + ')' ); - h.check(success[2].t - success[0].t > 120, '3.3 they are spaced in time, not fired at once (' + (success[2].t - success[0].t).toFixed(0) + ' ms)'); + h.check(success.length === 3 && success[2].t - success[0].t > 120, '3.3 they are spaced in time, not fired at once (' + (success.length === 3 ? (success[2].t - success[0].t).toFixed(0) : '-') + ' ms)'); await page.evaluate(() => { window.__api.hapticPattern('rumble', 'left'); window.__stores.objectActions.setEditorMode('edit'); }); - await page.waitForTimeout(600); + await page.waitForTimeout(2000); // past the whole rumble even with late timers const cut = await buzz(); h.check(cut.length === 1, '3.4 leaving Interact mid-rumble stops it after the first pulse (' + cut.length + ' of 4)'); await mode('interact'); @@ -161,10 +162,10 @@ h.run(async () => { const v = window.__stores.vrControls; v.hapticKnock(Math.min(1, 0.2 + 0.4 / 10), 30, 'right'); // a brush at 0.4 m/s }); - await page.waitForTimeout(800); // headless timers run late — let the second pulse land + await page.waitForFunction(() => window.__buzz.length >= 2, null, { timeout: 5000 }).catch(() => {}); const soft = await buzz(); await page.evaluate(() => window.__stores.vrControls.hapticKnock(Math.min(1, 0.2 + 6 / 10), 30, 'right')); // a swing at 6 m/s - await page.waitForTimeout(800); + await page.waitForFunction(() => window.__buzz.length >= 2, null, { timeout: 5000 }).catch(() => {}); const hard = await buzz(); h.check(soft.length === 2 && hard.length === 2, '6.1 a knock plays the two-pulse hit (' + soft.length + ', ' + hard.length + ')'); h.check(hard[0].i > soft[0].i * 2 && hard[0].i <= 1, '6.2 a harder knock buzzes harder (' + soft[0].i.toFixed(3) + ' -> ' + hard[0].i.toFixed(3) + ')'); From 8b604de3c26aef3fa54bbd8707954a78ddde0b2f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 19:27:45 +0300 Subject: [PATCH 15/18] [feat] 30b core-games P0: game feel as flow nodes - announce, game sound, effect burst, controller buzz, game music, on grab The three core games are FLOW graphs, and 30b-vr-play's game-feel kit (api.announce, the procedural sound set, api.effects.burst, api.hapticPattern, api.music) was reachable from a MODULE only - so a Towers ring could not say it was reached, sparkle, chime or buzz. - five Game nodes (nodeCatalog 'Game' group, AnimationNode cards, flowSockets, nodeDocs): Announce ({v} filled from a wired value), Game Sound (the 20 procedural sounds, placed at a wired object), Effect Burst (pooled sparkle/confetti/smoke/sparks at a wired object, or in front of the player), Controller Buzz (a haptic pattern), Game Music (a DECLARATION: plays while in Interact/Play, 'always' or only during a 'round', `on` switches it) - the four actions run on the trigger's STAMP EDGE inside flowRuntime's actionSeenAt family (updateGameNodes), LOCAL on every peer from the replicated stamp - no message of their own; haptics and music keep the core Interact/Play gate (silent in Edit) - gameFeelActions.js holds the runtime half (effectsBurst and vrControls PRIMED, the flowRuntime rule); musicWanted + fillValue are pure and vitest-covered - On Grab (Triggers): fireObjectGrab from the desktop play/Interact carry (playInteract beginGrab) and a VR Interact grip (vrControls, never an Edit move) - Towers' crates lift with a sound - option lists come from the leaves that own them (GAME_SOUNDS, HAPTIC_PATTERN_NAMES, MUSIC_PRESET_IDS); gameKit exports gameFeelActions for the suites - outside the lane, one line: NodeWrapper gains the Music group's accent - palette-groups was red on feat/1.17 ("Music missing of 12") Suites: NEW game-feel-nodes 29/29 (two peers); vitest gameFeelActions 7 (262 total); flow-unknown-node 24/0, palette-groups 10/0 (base 9/1), flow-node-docs green. svelte-check 333/47 (unchanged from the merged base). Counterfactuals (each red, then restored): - action dispatch moved ahead of staleTrigger -> 3.1/3.2 red (fresh nodes announce an old stamp) - musicWanted ignoring `while` -> 5.4 red (music in menu and over) - worldPositionOf returning null -> 1.4/1.7 red (sound unplaced, burst in front of the camera) - the family dropped from updateGameNodes' filter -> 14 red (1.x, 3.2, 4.x, 6.x) - beginGrab not calling fireObjectGrab -> 7.2 red Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/Nodes.svelte | 7 + .../editors/nodes/NodeWrapper.svelte | 1 + .../editors/nodes/OnClickNode.svelte | 1 + src/lib/flowRuntime.js | 34 ++- src/lib/flowSockets.js | 9 + src/lib/gameFeelActions.js | 220 ++++++++++++++ src/lib/gameKit.js | 1 + src/lib/nodeCatalog.js | 77 +++++ src/lib/nodeDocs.js | 6 + src/lib/playInteract.js | 4 +- src/lib/vrControls.js | 4 +- tests/e2e/game-feel-nodes.test.cjs | 286 ++++++++++++++++++ tests/unit/gameFeelActions.test.js | 45 +++ 13 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 src/lib/gameFeelActions.js create mode 100644 tests/e2e/game-feel-nodes.test.cjs create mode 100644 tests/unit/gameFeelActions.test.js diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index a7f71a2a..d4d3393c 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -160,6 +160,7 @@ visibility: EffectNode, setuniform: EffectNode, onclick: OnClickNode, + ongrab: OnClickNode, // 30b (core-games): the same pulse card onimpact: AnimationNode, // 24-A A2: its own card — the pulse dot PLUS speed/byMe value rows (the MoveInput // shape: several source handles need labelled rows, not one right-edge dot) @@ -214,6 +215,12 @@ // 30 P4: the device-local pair (the second registry — the catalog is the first) storevalue: AnimationNode, storedvalue: AnimationNode, + // 30b (core-games): Game Feel — spec-driven like the rest of the game shell + announce: AnimationNode, + gamesound: AnimationNode, + effectburst: AnimationNode, + hapticpulse: AnimationNode, + gamemusic: AnimationNode, gametime: AnimationNode, // 21-F3's `collectcount` card MOVED to the collectible module (R3a) — an old // scene's node renders as UnknownNode until the module is installed, honestly diff --git a/src/components/editors/nodes/NodeWrapper.svelte b/src/components/editors/nodes/NodeWrapper.svelte index c0121443..4a3ca613 100644 --- a/src/components/editors/nodes/NodeWrapper.svelte +++ b/src/components/editors/nodes/NodeWrapper.svelte @@ -21,6 +21,7 @@ HUD: '#f0abfc', // A3: pink-violet, distinct from Effects' purple Game: '#34d399', // 21-D6: emerald — the game shell Character: '#fbbf24', // 21-E6: amber — the player's own movement + Music: '#fb7185', // 23-B3's group had no accent (palette-groups red on feat/1.17): rose // 'Object Flow' was MISSING, so flowinput/flowoutput/objectflow fell through to // the module-node gray and read as third-party cards. Fixed while adding HUD. 'Object Flow': '#818cf8' // indigo diff --git a/src/components/editors/nodes/OnClickNode.svelte b/src/components/editors/nodes/OnClickNode.svelte index e24a3ead..4edd854a 100644 --- a/src/components/editors/nodes/OnClickNode.svelte +++ b/src/components/editors/nodes/OnClickNode.svelte @@ -19,6 +19,7 @@ onclick: ['clicked!', 'connect to the object; pulses on click'], onenter: ['entered!', 'pulses when something enters the sensor object'], onexit: ['exited!', 'pulses when a sensor overlap ends'], + ongrab: ['grabbed!', 'pulses when a player picks the object up'], // 30b (core-games) animfinished: ['finished!', 'pulses when the clip on this object reaches its end'], // 17-E F5: the same pulse card, plus a marker NAME to watch for animmarker: ['marker!', 'pulses as the playhead crosses a marker; blank = any'] diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index b2719782..81bdbecf 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -70,6 +70,8 @@ import { log } from './diagnostics'; import { safeStorage } from './safeStorage'; // 30 P4: the device-local store Store Value / Stored Value read and write (a leaf) import { sceneStorageKey, readStored, writeStored } from './gameStorage'; +// 30b (core-games): the game-feel nodes' runtime half (announce/sound/burst/haptic/music) +import { GAME_FEEL_ACTIONS, runGameFeelAction, updateGameMusicNodes, primeGameFeelActions } from './gameFeelActions'; // H3: inputRuntime is reached via a PRIMED dynamic import (the moduleSDK // pattern) — a static edge would close the TDZ cycle history -> flowRuntime -> @@ -1190,7 +1192,7 @@ function updateGameNodes(time, ctx) { // 1. the ACTIONS, on a fresh trigger stamp only for (const node of nodes) { const type = node.type; - if (type !== 'setgamestate' && type !== 'setcamera' && type !== 'setvariable' && type !== 'setlook' && type !== 'travel' && type !== 'storevalue') + if (type !== 'setgamestate' && type !== 'setcamera' && type !== 'setvariable' && type !== 'setlook' && type !== 'travel' && type !== 'storevalue' && !GAME_FEEL_ACTIONS.includes(type)) continue; seeActionNode(node, time); const stamp = triggerStampFor(node.id, ctx); @@ -1233,6 +1235,11 @@ function updateGameNodes(time, ctx) { const hash = typeof data.level === 'string' ? data.level : ''; if (sceneName && levelsRef?.travelToScene) levelsRef.travelToScene(sceneName); else if (hash && levelsRef) levelsRef.travelToLevel(hash, String(data.levelName ?? '')); + } else if (GAME_FEEL_ACTIONS.includes(type)) { + // 30b (core-games): a banner, a sound, a burst or a buzz — LOCAL on every peer from + // the replicated stamp, inside the actionSeenAt family above like storevalue (a + // fresh node adopting an old stamp must not announce on connect) + runGameFeelAction(type, data); } else if (type === 'storevalue') { // 30 P4: a LOCAL write on the stamp edge, inside the actionSeenAt family above (a // fresh node adopting an old stamp must not overwrite a best on connect). It @@ -1296,6 +1303,9 @@ function updateGameNodes(time, ctx) { for (const id of [...gameActed.keys()]) if (!nodes.some((/** @type {any} */ n) => n.id === id)) gameActed.delete(id); + // 1a. 30b (core-games): Game Music is a DECLARATION (present = wanted), read per frame + updateGameMusicNodes(nodes, (/** @type {any} */ node) => resolveInputs(node, nodes, edges, time, ctx), game.state); + // 1b. 21-F4 ALLPLAYERS — the group-travel gate. Each peer evaluates the wired // condition for ITSELF (my answer about my player), publishes the verdict through // the presence channel ON CHANGE, and derives "everyone in play says yes" from the @@ -1859,6 +1869,7 @@ export const valueTypes = [ 'gamepadbutton', // 21-E5: pad trigger — the keypress model verbatim 'gamepadaxis', // 21-E5: a stick, read LOCALLY (never streamed) 'onimpact', // PFX-C: physics impact trigger + 'ongrab', // 30b (core-games): a player picked it up 'onhit', // 24-A A2: the knock's trigger — a handle map: __default pulse + speed/byMe 'onenter', 'onexit', // CL-C: sensor overlap triggers 'velocity', // CL-C: live speed readout (m/s) @@ -2232,6 +2243,7 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) { } case 'animfinished': // 17-E: fired locally when a clip reaches its end case 'animmarker': // 17-E F5: fired locally when the playhead crosses one + case 'ongrab': // 30b (core-games): the On Click window, fired on a player's grab case 'onclick': { const trig = ctx && ctx.triggers ? ctx.triggers[node.id] : null; const dt = trig ? time - trig.lastT : Infinity; @@ -2766,6 +2778,25 @@ export function fireObjectClick(uuid) { return fired; } +/** + * 30b (core-games): a PLAYER picked `uuid` up — the desktop play/interact carry + * (playInteract.beginGrab) or a VR Interact grip. Pulses every On Grab node wired to the + * object (or unwired inside its own graph), exactly as fireObjectClick does for a click; the + * stamp replicates (unless the node is perPlayer), so every peer hears the crate lift. + * Returns how many nodes it reached. @param {string} uuid @returns {number} + */ +export function fireObjectGrab(uuid) { + let fired = 0; + nodes.forEach((node) => { + if (node.type !== 'ongrab') return; + if (reachesObjectSelector(node.id, uuid) || implicitOwnerOf(node) === uuid) { + applyNodeTrigger(node.id, syncedNow(), replicatesPulse(node)); + fired++; + } + }); + return fired; +} + /** 30b (vr-play): would a click on `uuid` reach an On Click node? The same two rules * fireObjectClick fires by — the VR hover tap and the sweep ask it before touching * anything. @param {string} uuid @returns {boolean} */ @@ -3634,6 +3665,7 @@ export function startFlowRuntime() { // knock.js imports physics, which imports this module. import('./knock').then((m) => m.registerHitListener((hit, local) => fireObjectHit(hit, local))); import('./gamePresence').then((m) => (presenceRef = m)); + primeGameFeelActions(); // 30b: effectsBurst + vrControls, primed (see gameFeelActions) flowGraphs.subscribe(() => { nodes = allNodes(); edges = allEdges(); diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js index b2fb8e33..a3b4f2c7 100644 --- a/src/lib/flowSockets.js +++ b/src/lib/flowSockets.js @@ -18,6 +18,7 @@ const OUTPUT = { colorpicker: 'color', objectselector: 'object', onclick: 'event', + ongrab: 'event', // 30b (core-games) keypress: 'event', // H3 // 21-E5: a pad button is an EVENT (the keypress channel), a stick is a NUMBER. Both // therefore reach every existing consumer with no coercion of their own. @@ -172,6 +173,14 @@ const INPUT = { setlook: { trigger: 'event', camera: 'object', on: 'boolean' }, setvariable: { trigger: 'event', value: 'number' }, storevalue: { trigger: 'event', value: 'number' }, // 30 P4 + // 30b (core-games): the Game Feel family — an event, a wired number for {v}, a PLACE + // (an object: an undeclared handle types as 'number' and would refuse an Object + // Selector), and Game Music's on/off + announce: { trigger: 'event', value: 'number' }, + gamesound: { trigger: 'event', at: 'object' }, + effectburst: { trigger: 'event', at: 'object' }, + hapticpulse: { trigger: 'event' }, + gamemusic: { on: 'boolean' }, gamestart: { camera: 'object' }, // 21-F4: travel fires on its trigger edge; allplayers takes each player's own // boolean answer (a Latch, a Gate, a Compare — anything true/false) diff --git a/src/lib/gameFeelActions.js b/src/lib/gameFeelActions.js new file mode 100644 index 00000000..5dbf6f4b --- /dev/null +++ b/src/lib/gameFeelActions.js @@ -0,0 +1,220 @@ +// 30b (core-games): GAME FEEL AS FLOW NODES. 30b-vr-play gave MODULES the game-feel kit +// (api.playSound's procedural set, api.music, api.hapticPattern, api.effects.burst, +// api.announce), but the three core games — Towers, Stars Room, Jam Room — are authored as +// FLOW GRAPHS, and a graph had no way to reach any of it: a ring reached could not say so, +// sparkle, chime or buzz. These are the five nodes that close that gap, and this file is +// their runtime half (flowRuntime owns the stamp edge; this owns what happens on it). +// +// THE SYNC MODEL, one rule for all five: every one is LOCAL, acted on by EVERY peer from +// the REPLICATED trigger stamp — the setcamera / storevalue / hudscreen house rule. The +// trigger already travelled, so a banner, a sound, a burst and a buzz happen on each +// device from the same pulse with NO message of their own. A per-player trigger (a +// perPlayer On Click) keeps its pulse on the machine that pressed it, so only that player +// gets the feedback — which composes rather than special-cases. +// +// Haptics and music keep the core's own gate (gameFeel: Interact or Play, silent in Edit), +// because both funnel through the functions that own it (vrControls.hapticPattern, +// gameMusic.playGameMusic). A sound, a banner and a burst are not gated: a spectator in +// the editor watching a peer's round sees the ring light and hears it, like every other +// consequence of a replicated pulse. +// +// Deliberately NOT a leaf in the strict sense: it reaches effectsBurst (which imports +// moduleSDK) and vrControls through PRIMED dynamic imports, the flowRuntime rule — a static +// edge from a module flowRuntime imports into either closes a cycle into history. +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { globalCamera, objectsGroup } from '../stores/sceneStore'; +import { playGameSound, isGameSound, GAME_SOUNDS } from './gameSfx'; +import { announce } from './gameAnnounce'; +import { playGameMusic, stopGameMusic, gameMusicState, MUSIC_PRESET_IDS } from './gameMusic'; +import { gameFeelActive } from './gameFeel'; +import { HAPTIC_PATTERN_NAMES } from './hapticPatterns'; + +export { GAME_SOUNDS, MUSIC_PRESET_IDS, HAPTIC_PATTERN_NAMES }; +export const BURST_KIND_NAMES = ['sparkle', 'confetti', 'smoke', 'sparks']; + +/** the flow node types this file acts for on a trigger's stamp edge */ +export const GAME_FEEL_ACTIONS = ['announce', 'gamesound', 'effectburst', 'hapticpulse']; + +/** @type {any} */ let burstRef = null; +/** @type {any} */ let vrRef = null; + +/** flowRuntime's init: resolve the two primed modules (see the header). */ +export function primeGameFeelActions() { + import('./effectsBurst').then((m) => (burstRef = m)); + import('./vrControls').then((m) => (vrRef = m)); +} + +/** what each node type did — the suites' view (no headset, no ears) */ +const debug = { + /** @type {Record} */ fired: {}, + /** @type {any[]} */ last: [], + musicOwner: /** @type {string | null} */ (null), + musicStarts: 0, + musicStops: 0 +}; + +/** + * Put a wired number into a text: every `{v}` becomes the value, `decimals` places (a + * non-number passes through as text). PURE — the hudtext format rule, so an author who + * knows one knows the other. + * @param {string} text @param {any} value @param {number} [decimals] + * @returns {string} + */ +export function fillValue(text, value, decimals = 0) { + const s = String(text ?? ''); + if (!s.includes('{v}')) return s; + const n = Number(value); + const d = Math.max(0, Math.min(6, Math.round(Number(decimals) || 0))); + const shown = value === undefined || value === null || value === '' ? '' : Number.isFinite(n) ? n.toFixed(d) : String(value); + return s.split('{v}').join(shown); +} + +/** a named object's world position, or null @param {any} uuid @returns {number[] | null} */ +function worldPositionOf(uuid) { + if (typeof uuid !== 'string' || !uuid) return null; + const object = get(objectsGroup)?.getObjectByProperty?.('uuid', uuid); + if (!object) return null; + object.updateWorldMatrix(true, false); + const p = object.getWorldPosition(new THREE.Vector3()); + return [p.x, p.y, p.z]; +} + +/** a point `metres` in front of the viewer's eyes at eye height — where a banner-sized + * burst belongs when nothing names a place (the confetti at a round's end) */ +function inFrontOfPlayer(metres = 1.6) { + /** @type {any} */ + const camera = get(globalCamera); + if (!camera?.getWorldPosition) return null; + camera.updateMatrixWorld?.(true); + const eye = camera.getWorldPosition(new THREE.Vector3()); + const dir = camera.getWorldDirection(new THREE.Vector3()); + dir.y = Math.max(-0.2, Math.min(0.2, dir.y)); + dir.normalize(); + const p = eye.addScaledVector(dir, metres); + return [p.x, p.y, p.z]; +} + +/** @param {string} type @param {any} what */ +function note(type, what) { + debug.fired[type] = (debug.fired[type] ?? 0) + 1; + debug.last.push({ type, ...what, at: Date.now() }); + if (debug.last.length > 40) debug.last.shift(); +} + +/** + * ONE stamp edge of a game-feel action node, already past flowRuntime's freshness and + * staleness checks. `data` is the node's resolved inputs. + * @param {string} type @param {any} data + * @returns {boolean} whether it did something + */ +export function runGameFeelAction(type, data) { + if (type === 'announce') { + const text = fillValue(data.text ?? '', data.value, data.decimals); + const sub = fillValue(data.sub ?? '', data.value, data.decimals); + const id = announce(text, { sub, ms: Number(data.seconds ?? 1.8) * 1000, color: data.color || undefined }); + if (id) note(type, { text, sub }); + return id > 0; + } + if (type === 'gamesound') { + const name = String(data.sound ?? 'click'); + if (!isGameSound(name)) return false; + const at = worldPositionOf(data.at); + const ok = playGameSound(name, at); + note(type, { sound: name, spatial: !!at, played: ok }); + return ok; + } + if (type === 'effectburst') { + const at = worldPositionOf(data.at); + const lift = Number(data.lift ?? 0) || 0; + const where = at ? [at[0], at[1] + lift, at[2]] : inFrontOfPlayer(); + if (!where || !burstRef) return false; + const kind = BURST_KIND_NAMES.includes(data.kind) ? data.kind : 'sparkle'; + const count = Number(data.count); + const name = burstRef.burst(where, { + kind, + ...(typeof data.color === 'string' && data.color ? { color: data.color } : {}), + ...(Number.isFinite(count) && count > 0 ? { count } : {}) + }); + note(type, { kind, where, fired: !!name }); + return !!name; + } + if (type === 'hapticpulse') { + const pattern = String(data.pattern ?? 'tap'); + const hand = data.hand === 'left' || data.hand === 'right' ? data.hand : undefined; + // gated inside (silent in Edit, the user's rule) — the count still says we asked + const ok = vrRef?.hapticPattern?.(pattern, hand) ?? false; + note(type, { pattern, hand: hand ?? 'both', felt: !!ok }); + return !!ok; + } + return false; +} + +/** + * Which Game Music node wants to play right now — PURE (the stores stay in the caller), so + * the rule is unit-tested: the FIRST node in graph order whose `on` is not false (unwired + * = on) and whose `while` allows the shell's state ('round' = playing or paused), with a + * known preset. @param {any[]} nodes @param {(node: any) => any} resolve + * @param {string} gameStateName @returns {{id: string, preset: string, volume: number} | null} + */ +export function musicWanted(nodes, resolve, gameStateName) { + for (const node of nodes) { + if (node?.type !== 'gamemusic') continue; + const data = resolve(node) ?? {}; + if (data.on === false || data.on === 0) continue; + const during = data.while ?? 'always'; + if (during === 'round' && gameStateName !== 'playing' && gameStateName !== 'paused') continue; + const preset = String(data.preset ?? ''); + if (!MUSIC_PRESET_IDS.includes(preset)) continue; + const v = Number(data.volume); + return { id: node.id, preset, volume: Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.7 }; + } + return null; +} + +/** + * THE MUSIC NODE is a DECLARATION, not an action (the Character Controller's shape): a + * Game Music node that WANTS to play — its `on` input true or unwired, and during a round + * when `while` says so — plays its preset while this device is in Interact or Play, and + * stops when nothing wants it. Level-triggered, so leaving and re-entering Interact brings + * the music back with no pulse, and a late joiner hears it the moment it arrives. + * The FIRST wanting node in graph order wins; music a MODULE started is never stopped + * here (we stop only what we started). + * @param {any[]} nodes every flow node @param {(node: any) => any} resolve resolved inputs + * @param {string} gameStateName the shell's state ('menu' | 'playing' | ...) + */ +export function updateGameMusicNodes(nodes, resolve, gameStateName) { + const want = musicWanted(nodes, resolve, gameStateName); + const playing = get(gameMusicState); + if (want && gameFeelActive()) { + if (!playing || playing.preset !== want.preset || Math.abs((playing.volume ?? 1) - want.volume) > 1e-3) { + // a preset someone else started is left alone unless we are the owner already + if (playing && debug.musicOwner === null) return; + if (playGameMusic(want.preset, { volume: want.volume })) { + if (!playing || playing.preset !== want.preset) debug.musicStarts++; + debug.musicOwner = want.id; + } + } + return; + } + if (debug.musicOwner !== null) { + if (playing) { + stopGameMusic(); + debug.musicStops++; + } + debug.musicOwner = null; + } +} + +/** the suites' view @returns {any} */ +export function gameFeelActionsDebug() { + return { fired: { ...debug.fired }, last: debug.last.map((e) => ({ ...e })), musicOwner: debug.musicOwner, musicStarts: debug.musicStarts, musicStops: debug.musicStops }; +} + +/** forget the counters (a suite section's clean slate) */ +export function resetGameFeelActionsDebug() { + debug.fired = {}; + debug.last = []; + debug.musicStarts = 0; + debug.musicStops = 0; +} diff --git a/src/lib/gameKit.js b/src/lib/gameKit.js index 51b0bcfc..8306fbbb 100644 --- a/src/lib/gameKit.js +++ b/src/lib/gameKit.js @@ -10,3 +10,4 @@ export * as vrGameInput from './vrGameInput'; export * as effectsBurst from './effectsBurst'; export * as gameAnnounce from './gameAnnounce'; export * as vrGamePanel from './vrGamePanel'; +export * as gameFeelActions from './gameFeelActions'; // 30b (core-games): the Game Feel flow nodes diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index ab712820..e03f8b05 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -14,6 +14,14 @@ import { GAME_STATES } from './gameState'; // svelte/store). Reaching them through inputRuntime instead would close a cycle: // inputRuntime imports shortcuts, which reaches history. import { GAMEPAD_BUTTONS, GAMEPAD_AXES } from './gamepadPrefs'; +// 30b (core-games): the Game Feel nodes' option lists come from the leaves that own them, +// so a sound, a pattern or a preset added there reaches the palette with no second edit +import { GAME_SOUNDS as GAME_SOUND_NAMES } from './gameSfx'; +import { HAPTIC_PATTERN_NAMES } from './hapticPatterns'; +import { MUSIC_PRESET_IDS } from './gameMusicPresets'; +/** Announce's value mark, spelled once OUTSIDE the catalog literals: a brace inside a node's + * strings breaks the brace-matching scan flow-node-docs reads this file with */ +const V_MARK = '{v}'; /** * A1: `kind: 'text'` is a free-text param. It writes on COMMIT (change/blur), @@ -241,6 +249,72 @@ export const nodeCatalog = [ ], note: 'Reads what THIS device saved — each player sees their own.' }, + // 30b (core-games): GAME FEEL — the flow half of 30b-vr-play's module kit, so a + // graph-authored game (Towers, Stars Room, Jam Room) can say "Ring 2 reached", + // sparkle, chime, buzz and score itself. All LOCAL on every peer from the + // replicated trigger stamp (the storevalue / setcamera rule): no message of their + // own. Haptics and music keep the core's Interact/Play gate — silent in Edit. + { + type: 'announce', + label: 'Announce', + defaults: { text: 'Level ' + V_MARK, sub: '', seconds: 1.8, color: '#ffd76a', decimals: 0 }, + inputs: ['trigger', 'value'], + inputLabels: { value: 'value - fills ' + V_MARK + ' in the text' }, + params: [ + { key: 'text', kind: 'text', placeholder: 'Ring ' + V_MARK + ' reached', maxLength: 80 }, + { key: 'sub', kind: 'text', placeholder: 'a smaller second line', maxLength: 120 }, + { key: 'seconds', kind: 'range', min: 0.3, max: 15, step: 0.1 }, + { key: 'color', kind: 'text', placeholder: '#ffd76a', maxLength: 20 } + ], + note: 'A big banner on every screen (and in VR) — each player sees it from the same pulse.' + }, + { + type: 'gamesound', + label: 'Game Sound', + defaults: { sound: 'coin' }, + inputs: ['trigger', 'at'], + inputLabels: { at: 'at - an object (unwired = not placed)' }, + params: [{ key: 'sound', kind: 'select', options: [...GAME_SOUND_NAMES] }], + note: 'Built-in sounds, no files — played on each device, at the wired object when there is one.' + }, + { + type: 'effectburst', + label: 'Effect Burst', + defaults: { kind: 'sparkle', color: '', count: 48, lift: 0 }, + inputs: ['trigger', 'at'], + inputLabels: { at: 'at - an object (unwired = in front of the player)' }, + params: [ + { key: 'kind', kind: 'select', options: ['sparkle', 'confetti', 'smoke', 'sparks'] }, + { key: 'count', kind: 'range', min: 4, max: 96, step: 1 }, + { key: 'lift', kind: 'range', min: -2, max: 4, step: 0.1 }, + { key: 'color', kind: 'text', placeholder: 'the kind\'s own colours', maxLength: 20 } + ], + note: 'A short particle burst, pooled — any number of these costs nothing between pulses.' + }, + { + type: 'hapticpulse', + label: 'Controller Buzz', + defaults: { pattern: 'success', hand: 'both' }, + inputs: ['trigger'], + params: [ + { key: 'pattern', kind: 'select', options: [...HAPTIC_PATTERN_NAMES] }, + { key: 'hand', kind: 'select', options: ['both', 'left', 'right'] } + ], + note: 'VR controllers only, and only in Interact or Play — the editor stays still.' + }, + { + type: 'gamemusic', + label: 'Game Music', + defaults: { preset: 'arcade', volume: 0.7, while: 'always' }, + inputs: ['on'], + inputLabels: { on: 'on - unwired = always' }, + params: [ + { key: 'preset', kind: 'select', options: [...MUSIC_PRESET_IDS] }, + { key: 'volume', kind: 'range', min: 0, max: 1, step: 0.05 }, + { key: 'while', kind: 'select', options: ['always', 'round'] } + ], + note: 'Plays while you are in Interact or Play (never in the editor); each device hears its own.' + }, // the round clock, derived from the shared startedAt stamp — no clock of its own { type: 'gametime', @@ -612,6 +686,9 @@ export const nodeCatalog = [ items: [ // 134: EVENT nodes — ride small replicated trigger messages, not state { type: 'onclick', label: 'On Click', defaults: { pulse: 0.3 } }, + // 30b (core-games): a player picked the object up — desktop play/Interact carry or + // a VR Interact grip (never an editor move). Towers' crates sound on the lift. + { type: 'ongrab', label: 'On Grab', defaults: { pulse: 0.3 } }, // H3: keyboard trigger — LOCAL key presses replicate as trigger pulses // (golden rule: never stream local state); held keys re-pulse so the // output stays high while held diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index d93cf64d..a73658e3 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -37,6 +37,12 @@ export const NODE_DOCS = { getvariable: "Reads a named scene variable as a value - the read side of Set Variable, live on every peer.", storevalue: "Saves a value on THIS device when a pulse arrives (set, keep the max or min, or add) - a best score or a level reached that survives a reload; never sent to other players.", storedvalue: "Reads what Store Value saved on this device, as a number or text - the read side of a best score; each player sees their own.", + ongrab: "Fires a pulse when a player picks the object up - a carry in Play or Interact, or a VR grip in Interact; editor moves never fire it.", + announce: "Shows a big banner - 'Ring 2 reached', 'GOAL!' - on every screen and in VR when a pulse arrives; {v} in the text shows a wired number.", + gamesound: "Plays one of the built-in game sounds (coin, ring, levelup, cheer...) when a pulse arrives, at the wired object if one is wired; no sound files needed.", + effectburst: "Fires a short sparkle, confetti, smoke or sparks burst at the wired object (or in front of the player) when a pulse arrives; pooled and local to each device.", + hapticpulse: "Buzzes the VR controllers with a named pattern (tap, bump, success...) when a pulse arrives - only in Interact or Play, never while editing.", + gamemusic: "Plays a looping built-in music preset while you are in Interact or Play - always, or only during a round; wire 'on' to switch it by a condition.", gametime: "Seconds since the game (or the round) started, the same on every peer - a clock that pauses with the game.", peervariable: "A value kept PER PLAYER (score, lives, team) - each peer reads its own row, or a named player's.", charcontroller: "Turns the connected object into a walking, jumping character driven by the player's movement input.", diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js index c853d3b6..baf66577 100644 --- a/src/lib/playInteract.js +++ b/src/lib/playInteract.js @@ -13,7 +13,7 @@ import { remoteSimulating, isInitiator } from './physics'; -import { suspendAnimation, resumeAnimation, fireObjectClick } from './flowRuntime'; +import { suspendAnimation, resumeAnimation, fireObjectClick, fireObjectGrab } from './flowRuntime'; import { velocityFromSamples } from './throwVelocity'; import { resolvePlaySettings } from './playSettings'; import { pickStack, primaryIndex } from './selectThrough'; @@ -256,6 +256,8 @@ function beginGrab(object, camera) { ); suspendAnimation(object.uuid); playInteractState.set({ mode: 'carrying', distance: carryDistance, uuid: object.uuid, blocked: null }); + // 30b (core-games): an On Grab node hears it (a crate's lift sound in Towers) + fireObjectGrab(object.uuid); } /** diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index e9275db1..0c469a3d 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -135,7 +135,7 @@ import { sceneCommand } from './commandsHandler.svelte'; import { sendPing, pingColor } from './ping'; import { peerColor } from './lockControl'; import { setVRAxes, setVRButtons } from './inputRuntime'; -import { suspendAnimation, resumeAnimation } from './flowRuntime'; +import { suspendAnimation, resumeAnimation, fireObjectGrab } from './flowRuntime'; import { drawMode, toggleDrawMode, addStrokePoint, endStroke } from './drawMode'; import { setPttHeld, cycleMicMode, vrMicMode, micActive, pttActive } from './voiceChat'; import { safeStorage } from './safeStorage'; @@ -2545,6 +2545,8 @@ function onSqueezeStart(index) { // 30b P2: a player picking something up is not SELECTING it — no lock broadcast, no // selection shell, no inspector (Edit keeps all three) if (!interact) selectObject(object.uuid); // locks it for peers, updates selection state + // 30b (core-games): a PLAYER's grab reaches On Grab nodes (Edit moves things, it does not play) + else fireObjectGrab(object.uuid); } /** diff --git a/tests/e2e/game-feel-nodes.test.cjs b/tests/e2e/game-feel-nodes.test.cjs new file mode 100644 index 00000000..425df7f7 --- /dev/null +++ b/tests/e2e/game-feel-nodes.test.cjs @@ -0,0 +1,286 @@ +// 30b (core-games) P0 — GAME FEEL AS FLOW NODES. The three core games are FLOW graphs, and +// 30b-vr-play's game-feel kit (banner, sounds, bursts, haptics, music) was reachable from a +// MODULE only. The user on Towers: "When I reach some of the rings and go to the top, it +// should dynamically tell me and give effects such as sparkles ... there should be some +// sounds." These five nodes are how a graph does that. +// +// 1 a pulse through Announce / Game Sound / Effect Burst / Controller Buzz does its thing, +// with {v} filled, the sound and burst AT the wired object · 2 nothing of their own goes on +// the wire · 3 a node adopting an OLD stamp stays quiet (actionSeenAt) · 4 the buzz is +// silent in Edit and asks in Interact · 5 Game Music is a declaration: silent in Edit, +// plays in Interact, 'round' waits for the round, an `on` wired false stops it, leaving +// Interact stops it · 6 a replicated pulse makes the second peer announce too. +const h = require('./helpers.cjs'); + +const NOW = () => (Date.now() % 86400000) / 1000; + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + + // a box to aim the sound and the burst at, then the graph + await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await page.waitForTimeout(600); + const box = await page.evaluate(async () => { + const s = window.__stores; + let group; + s.objectsGroup.subscribe((v) => (group = v))(); + let target = group.children.find((c) => c.isMesh); + if (!target) { + const THREE = s.THREE ?? (await import('/node_modules/.vite/deps/three.js').catch(() => null)); + return { missing: true, have: group.children.length, hasTHREE: !!THREE }; + } + // hold it still and somewhere known (a /create box is dynamic and would fall) + target.userData.physics = { mode: 'static' }; + target.position.set(2, 3, -4); + target.updateMatrixWorld(true); + return { uuid: target.uuid }; + }); + h.check(!!box.uuid, 'a box to aim at (' + JSON.stringify(box) + ')'); + + const build = (extra = {}) => + page.evaluate( + async ({ uuid, extra }) => { + const s = window.__stores; + const x = extra.suffix ?? ''; + const N = (id, type, data) => ({ id, type, position: { x: 0, y: 0 }, data: { label: id, ...data }, class: 'w-[150px]' }); + const nodes = [ + N('fKey', 'keypress', { code: 'KeyJ', edge: 'down', pulse: 0.3 }), + N('fCount', 'counter', { op: 'up', step: 1 }), + N('fSel', 'objectselector', { selected: uuid }), + N('fAnn' + x, 'announce', { text: 'Ring {v} reached', sub: 'keep going', seconds: 2, color: '#7fd4ff' }), + N('fSnd' + x, 'gamesound', { sound: 'ring' }), + N('fFx' + x, 'effectburst', { kind: 'confetti', count: 30, lift: 0.5 }), + N('fBuzz' + x, 'hapticpulse', { pattern: 'success', hand: 'both' }), + ...(extra.music ? [N('fMus', 'gamemusic', { preset: 'space', volume: 0.4, while: extra.music })] : []) + ]; + const E = (s0, t, th) => ({ id: 'e-' + s0 + '-' + t + (th ? '.' + th : ''), source: s0, target: t, ...(th ? { targetHandle: th } : {}) }); + const edges = [ + E('fKey', 'fCount', 'pulse'), + E('fKey', 'fAnn' + x, 'trigger'), + E('fCount', 'fAnn' + x, 'value'), + E('fKey', 'fSnd' + x, 'trigger'), + E('fSel', 'fSnd' + x, 'at'), + E('fKey', 'fFx' + x, 'trigger'), + E('fSel', 'fFx' + x, 'at'), + E('fKey', 'fBuzz' + x, 'trigger') + ]; + s.flowGraphs.update((g) => ({ ...g, scene: { nodes, edges } })); + s.flowNodes.set(nodes); + s.flowEdges.set(edges); + s.gameKit.gameFeelActions.resetGameFeelActionsDebug(); + await new Promise((r) => setTimeout(r, 800)); // past the nodes' first-seen (actionSeenAt) + return true; + }, + { uuid: box.uuid, extra } + ); + await build(); + await page.evaluate(() => { + let peer; + window.__stores.peers.subscribe((v) => (peer = v))(); + window.__sent = []; + const send = peer.send.bind(peer); + peer.send = (msg) => { + window.__sent.push(msg?.type); + return send(msg); + }; + }); + + console.log('\n=== 1. a pulse: banner, sound, burst, buzz ==='); + const pulse = async (replicate = false) => { + await page.evaluate(({ t, replicate }) => window.__stores.flowRuntime.applyNodeTrigger('fKey', t, replicate), { t: NOW(), replicate }); + await page.waitForTimeout(500); + }; + await pulse(); + await pulse(); + const one = await page.evaluate(() => { + const k = window.__stores.gameKit; + let ann; + k.gameAnnounce.gameAnnouncement.subscribe((v) => (ann = v))(); + const dbg = k.gameFeelActions.gameFeelActionsDebug(); + const banner = document.querySelector('#game-announce'); + return { + ann, + fired: dbg.fired, + last: dbg.last, + sfx: k.gameSfx.gameSfxDebug(), + live: k.effectsBurst.burstDebug().live, + bannerText: banner?.textContent ?? null + }; + }); + h.check(one.ann?.text === 'Ring 2 reached' && one.ann?.sub === 'keep going', '1.1 Announce shows the text with {v} = the wired counter (' + JSON.stringify(one.ann) + ')'); + h.check(one.ann?.color === '#7fd4ff' && one.ann?.ms === 2000, '1.2 in its colour, for its seconds'); + h.check(/Ring 2 reached/.test(one.bannerText ?? ''), '1.3 the desktop banner draws it (' + JSON.stringify(one.bannerText) + ')'); + const snd = one.last.filter((e) => e.type === 'gamesound'); + h.check(snd.length === 2 && snd.every((e) => e.sound === 'ring' && e.spatial), '1.4 Game Sound played `ring` twice, placed at the wired box (' + JSON.stringify(snd) + ')'); + h.check(one.sfx.last === 'ring', '1.5 through the procedural set (' + JSON.stringify(one.sfx) + ')'); + const fx = one.last.filter((e) => e.type === 'effectburst'); + h.check(fx.length === 2 && fx.every((e) => e.fired && e.kind === 'confetti'), '1.6 Effect Burst fired a confetti burst per pulse'); + const where = fx[0]?.where ?? []; + h.check(Math.abs(where[0] - 2) < 1e-3 && Math.abs(where[1] - 3.5) < 1e-3 && Math.abs(where[2] + 4) < 1e-3, '1.7 at the wired box, lifted 0.5 m (' + JSON.stringify(where) + ')'); + h.check(one.live.some((b) => b.kind === 'confetti'), '1.8 a live burst is in the pool'); + h.check((one.fired.hapticpulse ?? 0) === 2, '1.9 Controller Buzz asked twice'); + + console.log('\n=== 2. nothing of their own on the wire ==='); + const sent = await page.evaluate(() => window.__sent); + h.check(!sent.some((t) => /announce|sound|burst|haptic|music/i.test(String(t))), '2.1 no game-feel message was sent (' + JSON.stringify([...new Set(sent)]) + ')'); + + console.log('\n=== 3. an OLD stamp does not announce ==='); + // the key's newest stamp is section 1's; NEW action nodes wired to it now would adopt it + await page.waitForTimeout(600); + await build({ suffix: 'New' }); + await page.waitForTimeout(700); + const stale = await page.evaluate(() => window.__stores.gameKit.gameFeelActions.gameFeelActionsDebug().fired); + h.check(!stale.announce && !stale.gamesound && !stale.effectburst && !stale.hapticpulse, '3.1 fresh nodes adopting a stamp older than them stay quiet (' + JSON.stringify(stale) + ')'); + await pulse(); + const fresh = await page.evaluate(() => window.__stores.gameKit.gameFeelActions.gameFeelActionsDebug().fired); + h.check(fresh.announce === 1 && fresh.gamesound === 1, '3.2 and act on the next real pulse (' + JSON.stringify(fresh) + ')'); + + console.log('\n=== 4. the buzz: silent in Edit, asked in Interact ==='); + const buzz = await page.evaluate(async () => { + const s = window.__stores; + const k = s.gameKit; + const t = () => (Date.now() % 86400000) / 1000; + s.objectActions.setEditorMode('edit'); + k.gameFeelActions.resetGameFeelActionsDebug(); + const sup0 = s.vrControls.hapticDebug().suppressed; + s.flowRuntime.applyNodeTrigger('fKey', t(), false); + await new Promise((r) => setTimeout(r, 400)); + const edit = k.gameFeelActions.gameFeelActionsDebug().last.find((e) => e.type === 'hapticpulse'); + const sup1 = s.vrControls.hapticDebug().suppressed; + s.objectActions.setEditorMode('interact'); + s.flowRuntime.applyNodeTrigger('fKey', t(), false); + await new Promise((r) => setTimeout(r, 400)); + const inter = k.gameFeelActions.gameFeelActionsDebug().last.filter((e) => e.type === 'hapticpulse').pop(); + s.objectActions.setEditorMode('edit'); + return { edit, inter, sup0, sup1 }; + }); + h.check(buzz.edit && buzz.edit.felt === false, '4.1 in Edit the buzz is refused by the core gate (' + JSON.stringify(buzz.edit) + ')'); + h.check(buzz.inter && buzz.inter.felt === true && buzz.inter.pattern === 'success', '4.2 in Interact the success pattern is played (' + JSON.stringify(buzz.inter) + ')'); + + console.log('\n=== 5. Game Music is a declaration ==='); + await build({ music: 'always' }); + const music = await page.evaluate(async () => { + const s = window.__stores; + const k = s.gameKit; + const state = () => { + let v; + k.gameMusic.gameMusicState.subscribe((x) => (v = x))(); + return v?.preset ?? null; + }; + const wait = () => new Promise((r) => setTimeout(r, 400)); + s.objectActions.setEditorMode('edit'); + await wait(); + const inEdit = state(); + s.objectActions.setEditorMode('interact'); + await wait(); + const inInteract = state(); + s.objectActions.setEditorMode('edit'); + await wait(); + const backInEdit = state(); + return { inEdit, inInteract, backInEdit, dbg: k.gameFeelActions.gameFeelActionsDebug() }; + }); + h.check(music.inEdit === null, '5.1 the editor stays silent (' + music.inEdit + ')'); + h.check(music.inInteract === 'space', '5.2 Interact plays the preset (' + music.inInteract + ')'); + h.check(music.backInEdit === null, '5.3 back in Edit it stops (' + music.backInEdit + ')'); + await build({ music: 'round' }); + const round = await page.evaluate(async () => { + const s = window.__stores; + const k = s.gameKit; + const state = () => { + let v; + k.gameMusic.gameMusicState.subscribe((x) => (v = x))(); + return v?.preset ?? null; + }; + const wait = () => new Promise((r) => setTimeout(r, 400)); + s.gameState.resetGame?.(); + s.objectActions.setEditorMode('interact'); + await wait(); + const menu = state(); + s.gameState.setGameState('playing'); + await wait(); + const playing = state(); + s.gameState.setGameState('over'); + await wait(); + const over = state(); + // `on` wired to a false boolean: a Compare that can never hold + s.gameState.setGameState('playing'); + const cmp = { id: 'fNo', type: 'compare', position: { x: 0, y: 0 }, data: { label: 'never', op: 'gt', a: 0, b: 1 }, class: 'w-[150px]' }; + const edge = { id: 'e-fNo-fMus.on', source: 'fNo', target: 'fMus', targetHandle: 'on' }; + s.flowGraphs.update((g) => ({ ...g, scene: { nodes: [...g.scene.nodes, cmp], edges: [...g.scene.edges, edge] } })); + await wait(); + const off = state(); + s.objectActions.setEditorMode('edit'); + s.gameState.resetGame?.(); + return { menu, playing, over, off }; + }); + h.check(round.menu === null && round.playing === 'space' && round.over === null, "5.4 while 'round' it waits for the round and stops at its end (" + JSON.stringify(round) + ')'); + h.check(round.off === null, '5.5 an `on` wired false switches it off'); + + console.log('\n=== 7. On Grab: a player lifting a body pulses it ==='); + await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await page.waitForTimeout(800); + const grabbed = await page.evaluate(async () => { + const s = window.__stores; + const THREE = s.THREE; + let group; + s.objectsGroup.subscribe((v) => (group = v))(); + const crate = group.children.filter((c) => c.isMesh).pop(); + crate.name = 'Grab crate'; + crate.position.set(0, 1.2, -3); + crate.userData.physics = { mode: 'dynamic', mass: 1 }; + s.objectsGroup.update((v) => v); + s.scenePhysics.setScenePhysics({ play: { interaction: 'grab' }, ground: { enabled: true, height: 0 } }); + const N = (id, type, data) => ({ id, type, position: { x: 0, y: 0 }, data: { label: id, ...data }, class: 'w-[150px]' }); + const nodes = [N('gGrab', 'ongrab', { pulse: 0.3 }), N('gSel', 'objectselector', { selected: crate.uuid }), N('gPop', 'gamesound', { sound: 'pop' })]; + const edges = [ + { id: 'e-gGrab-gSel', source: 'gGrab', target: 'gSel' }, + { id: 'e-gGrab-gPop.trigger', source: 'gGrab', target: 'gPop', targetHandle: 'trigger' }, + { id: 'e-gSel-gPop.at', source: 'gSel', target: 'gPop', targetHandle: 'at' } + ]; + s.flowGraphs.update((g) => ({ ...g, scene: { nodes, edges } })); + s.flowNodes.set(nodes); + s.flowEdges.set(edges); + await new Promise((r) => setTimeout(r, 800)); + s.gameKit.gameFeelActions.resetGameFeelActionsDebug(); + s.objectActions.setEditorMode('interact'); + await s.physics.toggleSimulation(); + await new Promise((r) => setTimeout(r, 600)); + let camera; + s.globalCamera.subscribe((v) => (camera = v))(); + camera.updateMatrixWorld(true); + const from = camera.getWorldPosition(new THREE.Vector3()); + const to = crate.getWorldPosition(new THREE.Vector3()); + const ray = new THREE.Raycaster(from, to.clone().sub(from).normalize()); + const started = s.playInteract.cursorGrabStart(ray, { x: 0, y: 0 }, camera); + await new Promise((r) => setTimeout(r, 600)); + const dbg = s.gameKit.gameFeelActions.gameFeelActionsDebug(); + s.playInteract.cursorGrabEnd(); + await s.physics.toggleSimulation(); + s.objectActions.setEditorMode('edit'); + return { started, pops: dbg.last.filter((e) => e.type === 'gamesound' && e.sound === 'pop') }; + }); + h.check(grabbed.started === true, '7.1 (premise) the Interact cursor carry picked the crate up'); + h.check(grabbed.pops.length === 1 && grabbed.pops[0].spatial, '7.2 On Grab fired: the pop played once, at the crate (' + JSON.stringify(grabbed.pops) + ')'); + + console.log('\n=== 6. two peers: a replicated pulse announces on both ==='); + // the graph is A's before B dials, so the handshake's full-state nodes reply carries it + await build(); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + await B.page.waitForFunction(() => window.__stores.allNodes().some((n) => n.id === 'fAnn'), null, { timeout: 30000 }); + await B.page.waitForTimeout(900); // B's first tick sees the nodes (actionSeenAt) + await pulse(true); + await B.page.waitForTimeout(800); + const onB = await B.page.evaluate(() => { + let v; + window.__stores.gameKit.gameAnnounce.gameAnnouncement.subscribe((x) => (v = x))(); + return { ann: v, fired: window.__stores.gameKit.gameFeelActions.gameFeelActionsDebug().fired }; + }); + h.check(/^Ring \d+ reached$/.test(onB.ann?.text ?? ''), '6.1 the second peer shows the banner from the same pulse (' + JSON.stringify(onB.ann) + ')'); + h.check((onB.fired.gamesound ?? 0) >= 1, '6.2 and plays the sound itself (' + JSON.stringify(onB.fired) + ')'); + + h.check(h.pageErrors(A).length === 0 && h.pageErrors(B).length === 0, 'no page error'); + await h.finish(browser); +}); diff --git a/tests/unit/gameFeelActions.test.js b/tests/unit/gameFeelActions.test.js new file mode 100644 index 00000000..d122e6e6 --- /dev/null +++ b/tests/unit/gameFeelActions.test.js @@ -0,0 +1,45 @@ +// 30b (core-games): the Game Feel flow nodes' pure halves — the {v} fill an Announce shows, +// and which Game Music node wants to play (a DECLARATION, read every frame). +import { describe, it, expect } from 'vitest'; +import { fillValue, musicWanted, MUSIC_PRESET_IDS } from '../../src/lib/gameFeelActions.js'; + +describe('Announce: {v}', () => { + it('fills every {v} with the wired number at the asked decimals', () => { + expect(fillValue('Ring {v} reached', 2)).toBe('Ring 2 reached'); + expect(fillValue('{v} m of {v}', 3.456, 1)).toBe('3.5 m of 3.5'); + }); + it('passes text through and leaves a plain line alone', () => { + expect(fillValue('Top of the tower!', 9)).toBe('Top of the tower!'); + expect(fillValue('Hi {v}', 'Ada')).toBe('Hi Ada'); + expect(fillValue('Stars: {v}', undefined)).toBe('Stars: '); + }); +}); + +describe('Game Music: who wants to play', () => { + const id = (/** @type {any} */ n) => n.data; + const node = (/** @type {string} */ nid, /** @type {any} */ data) => ({ id: nid, type: 'gamemusic', data }); + it('the first wanting node wins, in graph order', () => { + const nodes = [{ id: 'x', type: 'time', data: {} }, node('a', { preset: 'space', volume: 0.5 }), node('b', { preset: 'arcade' })]; + expect(musicWanted(nodes, id, 'menu')).toEqual({ id: 'a', preset: 'space', volume: 0.5 }); + }); + it('an `on` wired false steps aside for the next one', () => { + const nodes = [node('a', { preset: 'space', on: false }), node('b', { preset: 'arcade', on: true })]; + expect(musicWanted(nodes, id, 'menu')?.id).toBe('b'); + expect(musicWanted([node('a', { preset: 'space', on: 0 })], id, 'menu')).toBeNull(); + }); + it("'round' plays only while the round runs (playing or paused)", () => { + const nodes = [node('a', { preset: 'space', while: 'round' })]; + expect(musicWanted(nodes, id, 'menu')).toBeNull(); + expect(musicWanted(nodes, id, 'over')).toBeNull(); + expect(musicWanted(nodes, id, 'playing')?.id).toBe('a'); + expect(musicWanted(nodes, id, 'paused')?.id).toBe('a'); + }); + it('an unknown preset is nobody, volume is clamped and defaults to 0.7', () => { + expect(musicWanted([node('a', { preset: 'polka' })], id, 'menu')).toBeNull(); + expect(musicWanted([node('a', { preset: 'space', volume: 7 })], id, 'menu')?.volume).toBe(1); + expect(musicWanted([node('a', { preset: 'space' })], id, 'menu')?.volume).toBe(0.7); + }); + it('every preset the palette offers is one the player can play', () => { + expect(MUSIC_PRESET_IDS).toEqual(expect.arrayContaining(['arcade', 'ambient', 'space', 'studio'])); + }); +}); From accd772127e1ea6169a9010a25c05a73d83c8d61 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 19:32:44 +0300 Subject: [PATCH 16/18] [feat] 30b core-games P1: towers rings are milestones, stars room lights with a coin, both sound like games The user on Towers: "When I reach some of the rings and go to the top, it should dynamically tell me and give effects such as sparkles ... there should be some sounds." Built on P0's Game Feel nodes, so the defs stay data. Towers (scripts/author-templates.cjs): - each ring is a MILESTONE: its first crossing in a round (a perRound Once - a crate jittering in the sensor must not re-announce) says "Ring N reached", bursts sparkle AT the ring, chimes `ring` from it, and asks the `success` buzz; the gold ring at 4 m says "Top of the tower!" with confetti + a sparkle at the ring and the `levelup` fanfare (it replaces 30's particle emitter on the pad) - crates: an On Grab `pop` and an On Impact (0.8) `hit` knock, both AT the crate - round start whistle + "Build!", all-stars cheer + confetti + banner, time-up whistle + banner, every menu button clicks, the `arcade` music while in the game - HOW TO PLAY on the menu (the goal, the milestones, the clock) with a desktop and a VR line - play.spawn [0, 0, 4.5] facing the pad Stars Room: - every knock sparkles AT the star (the core's pooled Effect Burst - which retires 30's Burst anchor + Script + hit-index sum, 49 objects); LIGHTING a star (a perRound Once) pays a `coin` at it and a `tap` buzz, once per star per round - round start banner + whistle, free-play banner, win banner + `levelup`, time-up banner + whistle, confetti + a cheer when the round ends either way, button clicks, `space` music - HOW TO PLAY on the start screen; play.spawn [0, 0, 5.2] inside the south glass - gameFeelActions debug counts per sound name (a suite counts coins among the clicks) Suites (staged scenes, cloud-lane-30-staging/30b-core-games): game-towers 52/52 (base 40, +12 30b checks), game-stars-room 55/55 (base 47; the anchor check became the sparkle-at-the- star check, 50 -> 49 objects, +8 30b checks). Counterfactuals (re-authored into a scratch tree, each red, then restored): - towers milestone wired from the ring sensor instead of the Once -> "a second crossing of ring 1 ... says nothing" red - towers spawn removed -> "Play put the eye on the spawn" red (0, 2.9, 3: Scene's fixed Player) - stars coin wired from every hit -> "no second coin" red (2) and "exactly one coin per star" red (26 / 24) Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 194 ++++++++++++++++++++--------- src/lib/gameFeelActions.js | 7 +- tests/e2e/game-stars-room.test.cjs | 54 ++++++-- tests/e2e/game-towers.test.cjs | 53 ++++++++ 4 files changed, 238 insertions(+), 70 deletions(-) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 97bcf029..bfa58815 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -329,14 +329,33 @@ function towersGraph() { N('storedlast', 'storedvalue', 'This round', 1960, 1180, { key: 'towers-last', output: 'number', fallback: 0 }); N('hfinal', 'hudtext', 'HUD final height', 2200, 1220, { element: 'final-height', format: 'This round: {v} m', decimals: 0, value: 0 }); E('storedlast', 'hfinal', 'value'); - // reaching the top rung earns a sparkle burst on the pad - N('pfx', 'particle', '4m sparkle', 1000, 1400, { - mode: 'burst', count: 120, lifetime: 1.4, speed: 2.5, gravity: 0, - turbulence: 0.4, sizeStart: 0.12, opacity: 0.9, sprite: 'star', blending: 'additive', space: 'world' - }); - N('selpad', 'objectselector', 'Build pad', 1240, 1400, { selected: 'Build pad' }); - E('enr4', 'pfx', 'trigger'); - E('pfx', 'selpad'); + // ---- 30b: the rings are MILESTONES. The first crossing of a ring in a round (a perRound + // Once — a crate re-entering the sensor must not re-announce) tells every player: a banner, + // a burst AT the ring, a chime from the ring, and a success buzz in VR. The gold ring at + // the top is the big one: confetti, the level-up fanfare, "Top of the tower!". The Game + // Feel nodes are LOCAL on every peer from the replicated stamp, so no message of their own. + // (It replaces 30's particle emitter on the pad: the burst pool costs no emitter slot.) + N('buzzring', 'hapticpulse', 'Buzz: a ring reached', 1720, 1480, { pattern: 'success', hand: 'both' }); + for (let i = 1; i <= 4; i++) { + const y = 960 + (i - 1) * 150; + const top = i === 4; + N('first' + i, 'once', 'First time at ' + i + 'm', 2440, y, { perRound: true, pulse: 0.3 }); + E('enr' + i, 'first' + i, 'trigger'); + N('say' + i, 'announce', top ? 'Say: top!' : 'Say: ring ' + i, 2680, y, top + ? { text: 'Top of the tower!', sub: '4 m — the gold ring', seconds: 2.6, color: '#ffc640', decimals: 0 } + : { text: 'Ring ' + i + ' reached', sub: i + ' m — keep stacking', seconds: 1.8, color: '#9ee6ff', decimals: 0 }); + E('first' + i, 'say' + i, 'trigger'); + N('fx' + i, 'effectburst', 'Burst at ring ' + i, 2920, y, { kind: top ? 'confetti' : 'sparkle', count: top ? 96 : 56, lift: 0, color: '' }); + E('first' + i, 'fx' + i, 'trigger'); + E('selr' + i, 'fx' + i, 'at'); + N('chime' + i, 'gamesound', top ? 'Fanfare at the top' : 'Chime at ring ' + i, 3160, y, { sound: top ? 'levelup' : 'ring' }); + E('first' + i, 'chime' + i, 'trigger'); + E('selr' + i, 'chime' + i, 'at'); + E('first' + i, 'buzzring', 'trigger'); + } + N('topsparkle', 'effectburst', 'Sparkle at the top', 2920, 1560, { kind: 'sparkle', count: 80, lift: 0.4, color: '#ffe08a' }); + E('first4', 'topsparkle', 'trigger'); + E('selr4', 'topsparkle', 'at'); // ---- the stars — collectible-module touch pickups (shared team score) ------- for (let i = 1; i <= 3; i++) { @@ -384,8 +403,48 @@ function towersGraph() { N('gotime', 'setgamestate', 'Time over', 1000, 2240, { state: 'over', outcome: "Time's up!", reset: false }); E('alltime', 'gotime', 'trigger'); + // ---- 30b: the round's sound, and the crates' ------------------------------------------ + // Music: the arcade loop while you are in the game (Interact or Play; never the editor). + N('music', 'gamemusic', 'Arcade music', 40, 2480, { preset: 'arcade', volume: 0.45, while: 'always' }); + // Every menu button clicks. + N('click', 'gamesound', 'Button click', 280, 2480, { sound: 'click' }); + for (const b of ['bstart', 'bagain', 'bresume', 'brestart', 'bquit', 'breplay']) E(b, 'click', 'trigger'); + // A round starts with a whistle and a banner; it ends with one of two. + N('saygo', 'announce', 'Say: build!', 1720, 1640, { text: 'Build!', sub: 'Stack crates on the glowing pad — reach the gold ring', seconds: 2.2, color: '#ffd45e', decimals: 0 }); + E('onround', 'saygo', 'trigger'); + N('whistle', 'gamesound', 'Round start whistle', 1720, 1720, { sound: 'whistle' }); + E('onround', 'whistle', 'trigger'); + N('saywin', 'announce', 'Say: all stars', 1240, 2000, { text: 'All stars collected!', sub: '', seconds: 2.4, color: '#ffc640', decimals: 0 }); + E('allwin', 'saywin', 'trigger'); + N('cheer', 'gamesound', 'Cheer', 1240, 2080, { sound: 'cheer' }); + E('allwin', 'cheer', 'trigger'); + N('confetti', 'effectburst', 'Confetti for everyone', 1240, 2160, { kind: 'confetti', count: 96, lift: 0, color: '' }); + E('allwin', 'confetti', 'trigger'); + N('sayup', 'announce', "Say: time's up", 1240, 2300, { text: "Time's up!", sub: '', seconds: 2.2, color: '#e5e9f0', decimals: 0 }); + E('alltime', 'sayup', 'trigger'); + N('upwhistle', 'gamesound', 'Final whistle', 1240, 2380, { sound: 'whistle' }); + E('alltime', 'upwhistle', 'trigger'); + // Each crate: a pop when a player lifts it (On Grab) and a knock when it lands on + // something (On Impact past a gentle landing), both placed AT the crate. + TOWERS_CRATES.forEach((name, k) => { + const y = 2560 + k * 110; + N('csel' + k, 'objectselector', name, 280, y, { selected: name }); + N('cgrab' + k, 'ongrab', name + ' grabbed', 40, y, { pulse: 0.3 }); + E('cgrab' + k, 'csel' + k); + N('cpop' + k, 'gamesound', name + ' lift', 520, y, { sound: 'pop' }); + E('cgrab' + k, 'cpop' + k, 'trigger'); + E('csel' + k, 'cpop' + k, 'at'); + N('cland' + k, 'onimpact', name + ' lands', 760, y, { pulse: 0.3, minStrength: 0.8 }); + E('cland' + k, 'csel' + k); + N('cknock' + k, 'gamesound', name + ' knock', 1000, y, { sound: 'hit' }); + E('cland' + k, 'cknock' + k, 'trigger'); + E('csel' + k, 'cknock' + k, 'at'); + }); + return g.done(); } +/** the nine crates the sound chains above name (the objects below) */ +const TOWERS_CRATES = ['Cube 1', 'Cube 2', 'Cube 3', 'Cube 4', 'Cube 5', 'Cube 6', 'Plank 1', 'Plank 2', 'Plank 3']; const TOWERS_HUD_PANEL = { bg: 'rgba(14, 20, 32, 0.9)', @@ -491,7 +550,9 @@ const TOWERS_DEF = { bounds: { limit: -20, action: 'respawn' }, material: { friction: 0.7, restitution: 0.05 }, damping: { linear: 0.05, angular: 0.3 }, - play: { interaction: 'grab', grounded: false, simOnPlay: true } + // 30b: a SPAWN — Play (and Interact in VR) puts you 4.5 m in front of the pad, facing + // it, with a podium on each hand (yaw 0 faces -Z) + play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 4.5], yaw: 0 } } }, // the shell floor (fork 11): ao -> AgX -> bloom -> smaa, bloom a touch higher so the // rings, the trim and the pad read as light @@ -521,12 +582,15 @@ const TOWERS_DEF = { showWhile: 'menu', input: 'menu', elements: [ - { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 480, h: 380, z: 0, label: '', style: TOWERS_HUD_PANEL }, - { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -135, w: 420, h: 54, z: 1, label: 'TOWERS', style: { size: 42, weight: '700', color: '#ffd45e', align: 'center' } }, - { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -84, w: 440, h: 44, z: 1, label: 'Grab the crates and build the tallest tower on the glowing pad. Touch the floating stars.', style: { size: 14, color: '#d8dee9', align: 'center' }, wrap: true }, - { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -34, w: 420, h: 24, z: 1, label: 'Your best tower: 0 m', style: { size: 15, weight: '600', color: '#9ee6ff', align: 'center' } }, - { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: 28, w: 240, h: 50, z: 1, label: 'Start round', enabled: true, style: TOWERS_BTN }, - { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 120, w: 440, h: 40, z: 1, label: 'Grab: hold click · Push/pull: wheel · Fly: Q/E · Pause: P', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } + { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 500, h: 460, z: 0, label: '', style: TOWERS_HUD_PANEL }, + { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -176, w: 420, h: 54, z: 1, label: 'TOWERS', style: { size: 42, weight: '700', color: '#ffd45e', align: 'center' } }, + // 30b: HOW TO PLAY, said once, plainly — the goal, the milestones, the clock + { id: 'howto-title', kind: 'text', anchor: 'center', x: 0, y: -132, w: 440, h: 20, z: 1, label: 'HOW TO PLAY', style: { size: 12, weight: '700', color: '#9ee6ff', align: 'center' } }, + { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -94, w: 450, h: 60, z: 1, label: 'Grab crates from the podiums and stack them on the glowing pad. Each ring your tower reaches is a milestone — the gold ring at 4 m is the top. Touch the three floating stars. Three minutes a round.', style: { size: 14, color: '#d8dee9', align: 'center' }, wrap: true }, + { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -44, w: 420, h: 24, z: 1, label: 'Your best tower: 0 m', style: { size: 15, weight: '600', color: '#9ee6ff', align: 'center' } }, + { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: 14, w: 240, h: 50, z: 1, label: 'Start round', enabled: true, style: TOWERS_BTN }, + { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 88, w: 460, h: 36, z: 1, label: 'Desktop: hold click to grab · wheel pushes/pulls · Q/E fly · P pause', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, + { id: 'menu-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 128, w: 460, h: 36, z: 1, label: 'VR: grip grabs a crate · left stick walks · Y switches to Edit mode', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } ] }, { @@ -641,12 +705,11 @@ const TOWERS_DEF = { // wire resolves to and Select passes a string through raw — recorded as a follow-up // (a Select typed by its wired inputs, or a Select Colour node). The Round-over title // uses the same trick into HUD Text's `format`; -// · THE POOLED BURST (30): the runtime renders at most 8 emitters and a node wired to 24 -// stars is 24 of them — the "Particle emitter cap (8) reached" toast on LOAD. So there -// is ONE burst emitter on a `Burst anchor`, and a Script node moves the anchor onto the -// star that was just hit: the star's number is Σ i·hit_i (each On Hit reads 1 for its -// 0.3 s pulse), and the same sum is the burst's trigger. Two hits inside one pulse sum -// to a wrong index and simply burst nowhere new — a sparkle, not state; +// · THE BURST (30b): 30 pooled ONE particle emitter on a `Burst anchor` a Script moved onto +// the star just hit, because a particle node wired to 24 stars is 24 of the runtime's 8 +// emitters. The Game Feel Effect Burst is pooled by the CORE (twelve short-lived systems at +// the scene root, no emitter slot), so each star has its own burst node now and the anchor, +// the Script and the hit-index sum are gone; // · the spawner RECYCLES oldest-out at maxAlive, it does not refuse — "More stars" // therefore never fails, and the room holds at most 27 + 32 dynamic bodies; // · the chime is a def-level `sounds` entry (A4, additive): fetched like `music`, @@ -797,10 +860,9 @@ function starsGraph() { E('timeandplay', 'alltime', 'condition'); N('gotime', 'setgamestate', 'Time over', 1000, 1760, { state: 'over', outcome: "Time's up!", reset: false }); E('alltime', 'gotime', 'trigger'); - // ---- per star: a chime on any hit, a per-player touch on MY hit, a perRound latch that - // paints the star lit during a round, and its share of the pooled burst's index -------- + // ---- per star: a chime on any hit, a per-player touch on MY hit, and a perRound latch + // that paints the star lit during a round ------------------------------------------------ let prevSum = ''; - let prevIdx = ''; for (let i = 1; i <= 24; i++) { const y = 1900 + (i - 1) * 180; N('sel' + i, 'objectselector', 'Star ' + i, 1000, y, { selected: 'Star ' + i }); @@ -821,46 +883,35 @@ function starsGraph() { N('col' + i, 'setcolor', 'Star ' + i + ' paint', 1720, y, { color: STAR_DIM, whilePlaying: true }); E('lit' + i, 'col' + i, 'color'); E('col' + i, 'sel' + i); - N('idx' + i, 'math', 'Star ' + i + ' index', 280, y, { op: 'mul', a: 0, b: i }); - E('hit' + i, 'idx' + i, 'a'); if (i === 2) { N('sum2', 'math', 'Lit 1-2', 1960, y, { op: 'add', a: 0, b: 0 }); E('lat1', 'sum2', 'a'); E('lat2', 'sum2', 'b'); prevSum = 'sum2'; - N('isum2', 'math', 'Hit index 1-2', 520, y, { op: 'add', a: 0, b: 0 }); - E('idx1', 'isum2', 'a'); - E('idx2', 'isum2', 'b'); - prevIdx = 'isum2'; } else if (i > 2) { N('sum' + i, 'math', 'Lit 1-' + i, 1960, y, { op: 'add', a: 0, b: 0 }); E(prevSum, 'sum' + i, 'a'); E('lat' + i, 'sum' + i, 'b'); prevSum = 'sum' + i; - N('isum' + i, 'math', 'Hit index 1-' + i, 520, y, { op: 'add', a: 0, b: 0 }); - E(prevIdx, 'isum' + i, 'a'); - E('idx' + i, 'isum' + i, 'b'); - prevIdx = 'isum' + i; } } - // ---- the pooled burst: one emitter, moved onto the star just hit ------------------- - N('selanchor', 'objectselector', 'Burst anchor', 1000, 1400, { selected: 'Burst anchor' }); - N('hop', 'script', 'Stand on the hit star', 760, 1400, { - code: - '// the pooled burst: stand on the star that was just hit (a = its number, 0 = none).\n' + - '// Deterministic — every peer derives the same index from the replicated hit stamps.\n' + - "const n = Math.round(Number(data.a) || 0);\n" + - "const star = n >= 1 && n <= 24 && object.parent ? object.parent.getObjectByName('Star ' + n) : null;\n" + - 'if (star) object.position.copy(star.position);\n' - }); - E('isum24', 'hop', 'a'); - E('hop', 'selanchor'); - N('pool', 'particle', 'Pooled star burst', 760, 1300, { - mode: 'burst', count: 60, lifetime: 0.9, speed: 1.8, gravity: 0, - turbulence: 0.3, sizeStart: 0.09, opacity: 0.95, sprite: 'star', blending: 'additive', space: 'world' - }); - E('isum24', 'pool', 'trigger'); - E('pool', 'selanchor'); + // ---- 30b: each star SPARKLES where it is hit, and LIGHTING it (the first hit of the round, + // a perRound Once) pays a coin chime and a haptic tap. The Game Feel burst is POOLED by + // the core (twelve short-lived systems at the scene root, no emitter slot), so 24 of them + // cost nothing between hits — which retires 30's one-emitter-plus-Script anchor trick. + N('buzzlit', 'hapticpulse', 'Buzz: a star lit', 1960, 1400, { pattern: 'tap', hand: 'both' }); + for (let i = 1; i <= 24; i++) { + const y = 1900 + (i - 1) * 180; + N('fx' + i, 'effectburst', 'Star ' + i + ' sparkle', 2200, y, { kind: 'sparkle', count: 28, lift: 0, color: '' }); + E('hit' + i, 'fx' + i, 'trigger'); + E('sel' + i, 'fx' + i, 'at'); + N('first' + i, 'once', 'Star ' + i + ' first lit', 2440, y, { perRound: true, pulse: 0.3 }); + E('hit' + i, 'first' + i, 'trigger'); + N('coin' + i, 'gamesound', 'Star ' + i + ' coin', 2680, y, { sound: 'coin' }); + E('first' + i, 'coin' + i, 'trigger'); + E('sel' + i, 'coin' + i, 'at'); + E('first' + i, 'buzzlit', 'trigger'); + } // ---- the round's result, kept where the round's end cannot erase it ------------------ N('hlit', 'hudtext', 'HUD lit', 2200, 2000, { element: 'lit-read', format: 'Lit: {v} / 24', decimals: 0, value: 0 }); E('sum24', 'hlit', 'value'); @@ -915,6 +966,32 @@ function starsGraph() { E('storedbest', 'hbest', 'value'); N('hbest0', 'hudtext', 'HUD best (start)', 3400, 2700, { element: 'best-read', format: 'Your best round: {v} / 24 stars', decimals: 0, value: 0 }); E('storedbest', 'hbest0', 'value'); + + // ---- 30b: the round says what is happening, and sounds like it ----------------------- + N('music', 'gamemusic', 'Space music', 40, 1240, { preset: 'space', volume: 0.45, while: 'always' }); + N('click', 'gamesound', 'Button click', 280, 1100, { sound: 'click' }); + for (const b of ['bstart0', 'bfree', 'bstart', 'bagain', 'breplay', 'bresume', 'brestart', 'bquit', 'bmore']) E(b, 'click', 'trigger'); + N('saygo', 'announce', 'Say: light them', 2680, 2540, { text: 'Light every star!', sub: 'Two minutes — knock each one', seconds: 2.2, color: '#ffe08a', decimals: 0 }); + E('onround', 'saygo', 'trigger'); + N('whistle', 'gamesound', 'Round start whistle', 2680, 2620, { sound: 'whistle' }); + E('onround', 'whistle', 'trigger'); + N('sayfree', 'announce', 'Say: free play', 520, 110, { text: 'Free play', sub: 'Knock the stars around — P or the menu to start a round', seconds: 2, color: '#9ee6ff', decimals: 0 }); + E('bfree', 'sayfree', 'trigger'); + N('saywin', 'announce', 'Say: all lit', 2920, 2150, { text: 'Every star lit!', sub: '', seconds: 2.4, color: '#ffe08a', decimals: 0 }); + E('allwin', 'saywin', 'trigger'); + N('fanfare', 'gamesound', 'Win fanfare', 2920, 2230, { sound: 'levelup' }); + E('allwin', 'fanfare', 'trigger'); + N('sayup', 'announce', "Say: time's up", 1240, 1840, { text: "Time's up!", sub: '', seconds: 2.2, color: '#e5e9f0', decimals: 0 }); + E('alltime', 'sayup', 'trigger'); + N('upwhistle', 'gamesound', 'Final whistle', 1240, 1920, { sound: 'whistle' }); + E('alltime', 'upwhistle', 'trigger'); + // the round's end, whichever way: confetti in front of every player and a cheer, with the + // results on the Round over screen (the VR board in a headset) + N('onover', 'ongamestate', 'When the round ends', 3160, 2700, { state: 'over', edge: 'enter', pulse: 0.3 }); + N('confetti', 'effectburst', 'Confetti', 3400, 2700, { kind: 'confetti', count: 96, lift: 0, color: '' }); + E('onover', 'confetti', 'trigger'); + N('cheer', 'gamesound', 'Cheer', 3400, 2780, { sound: 'cheer' }); + E('onover', 'cheer', 'trigger'); return g.done(); } @@ -958,7 +1035,8 @@ const STARS_DEF = { material: { friction: 0.1, restitution: 0.85 }, damping: { linear: 0.35, angular: 0.2 }, ccd: false, - play: { interaction: 'grab', grounded: false, simOnPlay: true }, + // 30b: a SPAWN just inside the south glass, facing the stars (yaw 0 faces -Z) + play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 5.2], yaw: 0 } }, knock: { enabled: true, gain: 1, maxSpeed: 10, radius: 0.12, spin: 0.6 } }, post: { @@ -989,11 +1067,14 @@ const STARS_DEF = { elements: [ { id: 'start-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 460, h: 400, z: 0, label: '', style: STARS_HUD_PANEL }, { id: 'start-title', kind: 'text', anchor: 'center', x: 0, y: -145, w: 420, h: 48, z: 1, label: 'STARS ROOM', style: { size: 36, weight: '700', color: '#ffd45e', align: 'center' } }, - { id: 'start-sub', kind: 'text', anchor: 'center', x: 0, y: -92, w: 400, h: 44, z: 1, label: 'Zero gravity. Knock the crystal stars with your hands in VR, or walk into them.', style: STARS_TEXT, wrap: true }, + // 30b: HOW TO PLAY + { id: 'howto-title', kind: 'text', anchor: 'center', x: 0, y: -112, w: 400, h: 18, z: 1, label: 'HOW TO PLAY', style: { size: 12, weight: '700', color: '#9ee6ff', align: 'center' } }, + { id: 'start-sub', kind: 'text', anchor: 'center', x: 0, y: -80, w: 420, h: 44, z: 1, label: 'Zero gravity. Knock every crystal star once to light it — in VR swing your hands through them, on a desktop walk into them.', style: STARS_TEXT, wrap: true }, { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -44, w: 400, h: 24, z: 1, label: 'Your best round: 0 / 24 stars', style: { size: 14, weight: '600', color: '#9ee6ff', align: 'center' } }, { id: 'go-btn', kind: 'button', anchor: 'center', x: 0, y: 12, w: 260, h: 48, z: 1, label: 'Start round', enabled: true, style: { ...STARS_BTN, size: 17 } }, { id: 'free-btn', kind: 'button', anchor: 'center', x: 0, y: 70, w: 260, h: 42, z: 1, label: 'Free play', enabled: true, style: STARS_QUIET }, - { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 150, w: 420, h: 40, z: 1, label: 'Light every star in two minutes · P: menu', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } + { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 128, w: 420, h: 22, z: 1, label: 'Light all 24 in two minutes · P: menu', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, + { id: 'start-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 156, w: 420, h: 22, z: 1, label: 'VR: left stick walks · grip grabs · Y switches to Edit mode', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } ] }, { @@ -1085,9 +1166,6 @@ const STARS_DEF = { opacity: 1, fadeIn: 0.3, fadeOut: 0.25, sprite: 'star', blending: 'additive', spin: 0, space: 'local' } }, - // the pooled burst's anchor — parked under the deck until a hit moves it (the emitter - // auto-fires once when it is built, and that must happen out of sight) - { type: 'empty', name: 'Burst anchor', pos: [0, -3, 0] }, { type: 'light', name: 'Room light', kind: 'point', color: 0xa8bcff, intensity: 20, distance: 18, pos: [0, 5.5, 0] }, { type: 'light', name: 'Deck glow', kind: 'point', color: 0x7fd4ff, intensity: 4, distance: 9, pos: [0, 0.6, 0] }, // the two physical buttons (an onclick fires from a VR ray — the HUD is not in a headset) diff --git a/src/lib/gameFeelActions.js b/src/lib/gameFeelActions.js index 5dbf6f4b..fe3dcbc3 100644 --- a/src/lib/gameFeelActions.js +++ b/src/lib/gameFeelActions.js @@ -48,6 +48,7 @@ export function primeGameFeelActions() { /** what each node type did — the suites' view (no headset, no ears) */ const debug = { /** @type {Record} */ fired: {}, + /** per sound name, so a suite can count coins among the clicks @type {Record} */ sounds: {}, /** @type {any[]} */ last: [], musicOwner: /** @type {string | null} */ (null), musicStarts: 0, @@ -99,7 +100,7 @@ function inFrontOfPlayer(metres = 1.6) { function note(type, what) { debug.fired[type] = (debug.fired[type] ?? 0) + 1; debug.last.push({ type, ...what, at: Date.now() }); - if (debug.last.length > 40) debug.last.shift(); + if (debug.last.length > 120) debug.last.shift(); } /** @@ -121,6 +122,7 @@ export function runGameFeelAction(type, data) { if (!isGameSound(name)) return false; const at = worldPositionOf(data.at); const ok = playGameSound(name, at); + debug.sounds[name] = (debug.sounds[name] ?? 0) + 1; note(type, { sound: name, spatial: !!at, played: ok }); return ok; } @@ -208,12 +210,13 @@ export function updateGameMusicNodes(nodes, resolve, gameStateName) { /** the suites' view @returns {any} */ export function gameFeelActionsDebug() { - return { fired: { ...debug.fired }, last: debug.last.map((e) => ({ ...e })), musicOwner: debug.musicOwner, musicStarts: debug.musicStarts, musicStops: debug.musicStops }; + return { fired: { ...debug.fired }, sounds: { ...debug.sounds }, last: debug.last.map((e) => ({ ...e })), musicOwner: debug.musicOwner, musicStarts: debug.musicStarts, musicStops: debug.musicStops }; } /** forget the counters (a suite section's clean slate) */ export function resetGameFeelActionsDebug() { debug.fired = {}; + debug.sounds = {}; debug.last = []; debug.musicStarts = 0; debug.musicStops = 0; diff --git a/tests/e2e/game-stars-room.test.cjs b/tests/e2e/game-stars-room.test.cjs index 6dbeb03a..dd82a81a 100644 --- a/tests/e2e/game-stars-room.test.cjs +++ b/tests/e2e/game-stars-room.test.cjs @@ -124,7 +124,8 @@ h.run(async () => { let st = await snap(); const dyn = st.kids.filter((k) => k.dynamic); // 30: 36 -> 50 (floor/ceiling rings, 8 frame pieces, starfield, burst anchor, deck light, card camera) - h.check(st.kids.length === 50, `50 objects arrived (${st.kids.length})`); + // 30b: -> 49, the burst anchor retired (the core's pooled Effect Burst replaced the trick) + h.check(st.kids.length === 49, `49 objects arrived (${st.kids.length})`); h.check(dyn.length === 27, `27 dynamic bodies: 24 stars + 2 planets + the template (${dyn.length})`); h.check(st.kids.filter((k) => /^Star \d+$/.test(k.name)).length === 24, 'the 24 stars are named Star 1..24'); h.check(st.gravity === 0 && st.ground?.enabled === false, `zero-g with the ground off (${st.gravity}, ground ${st.ground?.enabled})`); @@ -167,12 +168,34 @@ h.run(async () => { const loadToasts = await page.locator('.tp-toast').allTextContents().catch(() => []); h.check(!loadToasts.some((t) => /emitter cap|cap \(8\)/i.test(t)), `no emitter-cap toast on load (${JSON.stringify(loadToasts).slice(0, 120)})`); + // 30b: the game-feel stores the suite reads (sound / burst / banner / music) + const feel = () => + page.evaluate(() => { + const k = window.__stores.gameKit; + let music, ann; + k.gameMusic.gameMusicState.subscribe((v) => (music = v))(); + k.gameAnnounce.gameAnnouncement.subscribe((v) => (ann = v))(); + const d = k.gameFeelActions.gameFeelActionsDebug(); + return { music: music?.preset ?? null, ann: ann?.text ?? null, last: d.last, fired: d.fired, sounds: d.sounds }; + }); + const resetFeel = () => page.evaluate(() => window.__stores.gameKit.gameFeelActions.resetGameFeelActionsDebug()); + // 2 — entering play starts the sim (free play, no round) await page.evaluate(() => window.__stores.isLocked.set(true)); await h.eventually(() => snap().then((v) => v.sim), (v) => v === true, 'entering play starts the sim', 10000); await page.waitForTimeout(600); st = await snap(); h.check(st.state === 'menu' && st.screen === 'start', 'the start screen shows in play, the sim running behind it'); + // 30b: the menu says how to play, the room has its music, and Play put you on the spawn + h.check(/HOW TO PLAY/.test(await hud()) && /Knock every crystal star once to light it/.test(await hud()), '30b: the start screen says HOW TO PLAY'); + await h.eventually(() => feel().then((f) => f.music), (m) => m === 'space', '30b: the space music plays in Play', 6000); + const eye = await page.evaluate(() => { + const s = window.__stores; + let cam; s.playerCam.subscribe((v) => (cam = v))(); + const p = cam.getWorldPosition(new s.THREE.Vector3()); + return [p.x, p.y, p.z].map((n) => +n.toFixed(2)); + }); + h.check(Math.abs(eye[0]) < 0.05 && Math.abs(eye[1] - 1.7) < 0.05 && Math.abs(eye[2] - 5.2) < 0.05, `30b: Play put the eye on the spawn, inside the south glass (${eye})`); h.check(/Start round/.test(await hud()) && /Free play/.test(await hud()) && /Your best round: 0 \/ 24/.test(await hud()), 'it offers Start round and Free play, and this device\'s best (none yet)'); await clickBtn('Free play'); await h.eventually(() => snap().then((v) => `${v.state}/${v.screen}`), (v) => v === 'free/free', 'Free play: the `free` state and its screen', 6000); @@ -194,16 +217,13 @@ h.run(async () => { const k1 = await knockStar('Star 1', 4); h.check(k1.hits === 1, `a 4 m/s pass knocks Star 1 once (${k1.hits})`); h.check(!!k1.atHit && Math.abs(mag(k1.atHit) - 4) < 0.4, `...and it leaves at ~4 m/s (${mag(k1.atHit).toFixed(2)})`); - // 30: the POOLED burst stood on the star that was hit (its anchor left its parking spot - // under the deck; one emitter serves all 24) - await page.waitForTimeout(150); - const anchor = await page.evaluate(() => { - let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); - const a = group.getObjectByName('Burst anchor'); - return a ? a.position.toArray().map((n) => +n.toFixed(2)) : null; - }); + // 30b: the knocked star SPARKLES where it was hit — the core's pooled Effect Burst, at the + // star (the 30 anchor-and-Script trick is retired) + await page.waitForTimeout(250); const star1At = (await snap()).kids.find((k) => k.name === 'Star 1')?.pos; - h.check(!!anchor && anchor[1] > 0 && !!star1At && Math.hypot(anchor[0] - star1At[0], anchor[1] - star1At[1], anchor[2] - star1At[2]) < 1.2, `the pooled burst moved onto the knocked star (anchor ${anchor}, star ${star1At})`); + const sparkle = (await feel()).last.filter((e) => e.type === 'effectburst' && e.kind === 'sparkle' && e.fired); + const near = sparkle.find((e) => star1At && Math.hypot(e.where[0] - star1At[0], e.where[1] - star1At[1], e.where[2] - star1At[2]) < 1.2); + h.check(!!near, `the knocked star sparkled where it was hit (${JSON.stringify(sparkle.map((e) => e.where))}, star ${star1At})`); await page.waitForTimeout(350); const v05 = speedOf(await bodyOf(k1.uuid)); await page.waitForTimeout(2500); @@ -235,12 +255,22 @@ h.run(async () => { h.check(alive <= 27 + 32 && alive >= before + 3, `twelve more presses never exceed 27 + maxAlive 32 (${alive}) — the spawner recycles, it does not pile up`); // 6 — the round: Start -> playing; sweep every star -> Lit 24/24 -> over + await resetFeel(); await clickBtn('Start round: light every star'); await h.eventually(() => snap().then((v) => v.state), (v) => v === 'playing', 'Start flips to playing', 8000); await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'hud', 'the round HUD shows', 6000); + await h.eventually(() => feel(), (f) => f.last.some((e) => e.type === 'announce' && e.text === 'Light every star!') && f.last.some((e) => e.sound === 'whistle'), '30b: the round opens with a banner and a whistle', 4000); await page.waitForTimeout(800); + await resetFeel(); await knockStar('Star 3', 3, 'q'); await h.eventually(async () => await hud(), (t) => /Lit: 1 \/ 24/.test(t), 'one hit lights one star (Lit: 1 / 24)', 8000); + // 30b: LIGHTING a star pays a coin chime at it and a tap in VR — once per star per round + await h.eventually(() => feel(), (f) => f.last.filter((e) => e.sound === 'coin' && e.spatial).length === 1 && (f.fired.hapticpulse ?? 0) === 1, '30b: lighting Star 3 plays one coin at it and asks one tap', 4000); + await page.waitForTimeout(1500); // let it drift, then knock it again inside the same round + await knockStar('Star 3', 3, 'q2'); + await page.waitForTimeout(700); + const again = await feel(); + h.check(again.last.filter((e) => e.sound === 'coin').length === 1 && again.last.filter((e) => e.type === 'effectburst').length >= 2, `30b: a second knock sparkles again but pays no second coin (coins ${again.last.filter((e) => e.sound === 'coin').length}, sparkles ${again.last.filter((e) => e.type === 'effectburst').length})`); const lit3 = await page.evaluate(() => { let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); return '#' + group.getObjectByName('Star 3').material.color.getHexString(); @@ -292,6 +322,10 @@ h.run(async () => { }); await h.eventually(litThisRound, (n) => n >= 24, 'all 24 latches were set this round (hit stamps at or after startedAt)', 10000); await h.eventually(() => snap().then((v) => v.state), (v) => v === 'over', 'every star lit ends the round (over)', 10000); + // 30b: the round ends in confetti and a cheer, and the win is announced + await h.eventually(() => feel(), (f) => f.fired.effectburst > 0 && f.last.some((e) => e.type === 'effectburst' && e.kind === 'confetti') && f.last.some((e) => e.sound === 'cheer') && f.last.some((e) => e.type === 'announce' && e.text === 'Every star lit!'), '30b: over = confetti, a cheer and "Every star lit!"', 4000); + const coins = (await feel()).sounds.coin ?? 0; + h.check(coins === 24, `30b: the sweep paid exactly one coin per star lit (${coins} / 24)`); await h.eventually(async () => await hud(), (t) => /EVERY STAR LIT/.test(t) && /Every star lit in \d+s/.test(t) && /Your best: 24 \/ 24/.test(t), 'the over screen names the time and the best, from storage'); const storedStars = await page.evaluate(() => { const out = {}; diff --git a/tests/e2e/game-towers.test.cjs b/tests/e2e/game-towers.test.cjs index 79a35bb2..27ad9279 100644 --- a/tests/e2e/game-towers.test.cjs +++ b/tests/e2e/game-towers.test.cjs @@ -63,6 +63,16 @@ h.run(async () => { }; }); const hud = async () => (await page.locator('#hud-layer').textContent()) ?? ''; + // 30b: the game-feel stores the suite reads (sound / burst / banner / music) + const feel = () => + page.evaluate(() => { + const k = window.__stores.gameKit; + let music; + k.gameMusic.gameMusicState.subscribe((v) => (music = v))(); + const d = k.gameFeelActions.gameFeelActionsDebug(); + return { music: music?.preset ?? null, last: d.last, fired: d.fired, sounds: d.sounds }; + }); + const resetFeel = () => page.evaluate(() => window.__stores.gameKit.gameFeelActions.resetGameFeelActionsDebug()); const clickBtn = (text) => page.locator('#hud-layer button', { hasText: text }).click(); // 1 — the world arrived whole and lit @@ -113,6 +123,16 @@ h.run(async () => { // the editor half), so the menu check lives on this side of the play press now h.check(/TOWERS/.test(await hud()), 'the menu renders in play (TOWERS)'); h.check(/Your best tower: 0 m/.test(await hud()), 'the menu shows this device\'s best height (none yet: 0 m)'); + // 30b: HOW TO PLAY on the menu, the arcade music in Play, and Play put the eye on the spawn + h.check(/HOW TO PLAY/.test(await hud()) && /gold ring at 4 m is the top/.test(await hud()) && /VR: grip grabs a crate/.test(await hud()), '30b: the menu says how to play, on a desktop and in VR'); + await h.eventually(() => feel().then((f) => f.music), (m) => m === 'arcade', '30b: the arcade music plays in Play', 6000); + const eye = await page.evaluate(() => { + const s = window.__stores; + let cam; s.playerCam.subscribe((v) => (cam = v))(); + const p = cam.getWorldPosition(new s.THREE.Vector3()); + return [p.x, p.y, p.z].map((n) => +n.toFixed(2)); + }); + h.check(Math.abs(eye[0]) < 0.05 && Math.abs(eye[1] - 1.7) < 0.05 && Math.abs(eye[2] - 4.5) < 0.05, `30b: Play put the eye on the spawn in front of the pad (${eye})`); // 30: a centred HUD text is CENTRED — the words, not the box (a flex row shrank the line // to its text, so text-align did nothing and every centred title sat flush left) const centring = await page.evaluate(() => { @@ -131,8 +151,10 @@ h.run(async () => { h.check(Object.keys(centring).length === 2 && Object.values(centring).every((d) => Math.abs(d) < 3), `centred HUD texts are centred on their box (offsets ${JSON.stringify(centring)})`); // 3 — the Start button flips to playing and swaps the menu for the HUD + await resetFeel(); await clickBtn('Start round'); await h.eventually(() => snap().then((v) => v.state), (v) => v === 'playing', 'Start flips to playing', 8000); + await h.eventually(() => feel(), (f) => (f.sounds.click ?? 0) >= 1 && (f.sounds.whistle ?? 0) >= 1 && f.last.some((e) => e.type === 'announce' && e.text === 'Build!'), '30b: Start clicks, whistles and says "Build!"', 4000); await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'hud', 'the in-game HUD screen shows', 6000); // 30: the stars turn and breathe once the round starts (a Play Animation per star) await h.eventually( @@ -173,6 +195,35 @@ h.run(async () => { o.position.set(0, 3.5, 0); o.updateMatrix(); }, crate.uuid); await h.eventually(async () => await hud(), (t) => /Best height: [1-3] m/.test(t), 'lifting a crate latches the height HUD', 12000); + // 30b: every ring the crate crossed is a MILESTONE — a banner, a burst at the ring, its + // chime, and a buzz for a player in VR + await h.eventually( + () => feel(), + (f) => [1, 2, 3].every((i) => f.last.some((e) => e.type === 'announce' && e.text === 'Ring ' + i + ' reached')), + '30b: rings 1, 2 and 3 are announced as they are reached', 6000 + ); + const rings = await feel(); + const bursts = rings.last.filter((e) => e.type === 'effectburst' && e.kind === 'sparkle' && e.fired); + h.check([1, 2, 3].every((y) => bursts.some((b) => Math.abs(b.where[1] - y) < 0.05 && Math.hypot(b.where[0], b.where[2]) < 0.05)), `30b: a sparkle burst AT each ring (${JSON.stringify(bursts.map((b) => b.where.map((n) => +n.toFixed(2))))})`); + h.check((rings.sounds.ring ?? 0) === 3 && rings.last.filter((e) => e.sound === 'ring').every((e) => e.spatial), `30b: each ring chimes once, from the ring (${rings.sounds.ring})`); + h.check((rings.fired.hapticpulse ?? 0) >= 1 && rings.last.some((e) => e.type === 'hapticpulse' && e.pattern === 'success' && e.felt), `30b: and asks the success buzz, felt in Play (${rings.fired.hapticpulse})`); + // a crate re-entering ring 1 in the same round is not a new milestone + await page.evaluate(() => { + let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); + window.__stores.flowRuntime.fireObjectEnter(group.getObjectByName('Height ring 1m').uuid, group.getObjectByName('Cube 2').uuid); + }); + await page.waitForTimeout(700); + const re = await feel(); + h.check(re.last.filter((e) => e.type === 'announce' && e.text === 'Ring 1 reached').length === 1 && (re.sounds.ring ?? 0) === 3, '30b: a second crossing of ring 1 in the same round says nothing (a perRound Once)'); + // THE TOP: the gold ring is the big one + await page.evaluate((uuid) => { + let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const o = group.children.find((c) => c.uuid === uuid); + o.position.set(0, 4.4, 0); o.updateMatrix(); + }, crate.uuid); + await h.eventually(() => feel(), (f) => f.last.some((e) => e.type === 'announce' && e.text === 'Top of the tower!') && (f.sounds.levelup ?? 0) === 1 && f.last.some((e) => e.type === 'effectburst' && e.kind === 'confetti' && Math.abs(e.where[1] - 4) < 0.05), '30b: the gold ring: "Top of the tower!", confetti at the ring and the level-up fanfare', 8000); + // ...and when that crate comes down on the pad it lands with a knock (On Impact -> Game Sound) + await h.eventually(() => feel(), (f) => f.last.some((e) => e.sound === 'hit' && e.spatial), '30b: a falling crate lands with a knock, at the crate', 8000); h.check(/Stars left: 3/.test(await hud()), 'the collectible module reports 3 stars left'); // 30: the crossing SAVED the height on this device (Store Value max + the round's `set`) const stored = () => page.evaluate(() => { @@ -211,6 +262,8 @@ h.run(async () => { return window.__stores.playInteract.playInteractDebug().carrying; }); h.check(carrying === grabCrate, `pointerdown grabs the crate (${carrying === grabCrate})`); + // 30b: the lift makes a sound, placed at the crate (On Grab -> Game Sound) + await h.eventually(() => feel(), (f) => f.last.some((e) => e.sound === 'pop' && e.spatial), '30b: grabbing the crate pops, at the crate', 3000); await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerup', { button: 0, bubbles: true }))); await page.waitForTimeout(300); From 4602922d6241fd816cfae2f8aebea92ada06cf84 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 20:22:44 +0300 Subject: [PATCH 17/18] [feat] 30b core-games P2: the jam room is a vr cockpit - every instrument in reach of the spawn, a session that ends in a fanfare The brief: "make sure every instrument is reachable standing at the spawn (play.spawn); music preset studio stays OFF (the room IS the music)". 30's layout was a semicircle for a desktop view: in VR the piano sat on the FLOOR (keys at 0.08 m) and the drums were two metres from anyone. - THE COCKPIT (scripts/author-templates.cjs): a U of stands around the spawn at [1, 0, -1] facing -Z - the piano straight ahead on a keyboard stand (keys at 0.88 m), the drum machine on the left desk, the sampler and the mixer on the right desk, the transport on its stand behind-left, each turned to face the player; the pedal chain on a board beyond the right desk and the two speakers on stands behind the piano (above the keys, so the laser and the sound clear it). The furniture is select-through. The stage spot, the view and the card camera re-aim at the cockpit. - a VR-ONLY spawn: `play.spawn.vrOnly` (additive, kept by normalizeSpawn only when exactly true) - the headset stands in the cockpit; desktop Play and desktop Interact keep their own camera (playSpawn.desktopSpawn, used by spawnDesktopPlayer's default, objectActions' Interact fly-to and moduleSDK's respawn). A level desktop eye at the cockpit sees only the piano - measured: centre luminance 0.236 against the 0.25 floor with the flag ignored. - NO Game Music node, said in the def: the band is the music - how to play on the start screen, a VR line (hold the trigger and sweep), every button clicks, "Go!" when the count-in ends, and Session complete = a banner "8 bars at N BPM", confetti, the level-up fanfare and a success buzz Outside the lane (vr-modes' files, minimal): locomotionPolicy.normalizeSpawn keeps vrOnly; playSpawn.desktopSpawn; objectActions + moduleSDK read it. vitest locomotionPolicy +1. Suites: game-jam-room 37/37 (base 30; the cockpit's reach MEASURED - the nearest control of piano/drums/sampler/transport/mixer is 0.54-0.69 m from the feet axis at 0.82-0.92 m high, and all twelve devices have an unblocked laser line under 3.5 m); vr-sweep 10.x green on the new layout (keys and drum steps swept); music-template green. Counterfactuals (each red, then restored): - normalizeSpawn dropping vrOnly -> the VR-only spawn check and "desktop Play does not stand on the VR-only spawn" red (and the play frame's luminance fell under its floor) - the 30 scene (round-1 layout) -> the spawn, tip-reach and laser checks red - speakers on the floor behind the piano (the first cut) -> the laser check red: the keys block the line (key-62, key-69) Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 96 ++++++++++++++++++++++------- src/lib/locomotionPolicy.js | 6 +- src/lib/moduleSDK.js | 9 ++- src/lib/objectActions.js | 4 +- src/lib/playSpawn.js | 9 ++- tests/e2e/game-jam-room.test.cjs | 69 +++++++++++++++++++++ tests/unit/locomotionPolicy.test.js | 10 +++ 7 files changed, 175 insertions(+), 28 deletions(-) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index bfa58815..d2a06bf5 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -1781,8 +1781,37 @@ function jamGraph() { E('bquit', 'doquit', 'trigger'); E('bmenu', 'doquit', 'trigger'); E('bquit', 'quithide', 'trigger'); + + // ---- 30b: the shell sounds like a game — but never plays music over the band ---------- + N('click', 'gamesound', 'Button click', 280, 1360, { sound: 'click' }); + for (const b of ['bgo', 'bresume', 'brestart', 'breplay', 'bquit', 'bmenu']) E(b, 'click', 'trigger'); + N('saygo', 'announce', 'Say: go', 1000, 40, { text: 'Go!', sub: 'Press ▶ on the Transport — keep it going for ' + JAM_BARS + ' bars', seconds: 2, color: '#ffb060', decimals: 0 }); + E('countdone', 'saygo', 'trigger'); + N('saydone', 'announce', 'Say: session complete', 2440, 480, { text: 'Session complete!', sub: JAM_BARS + ' bars at {v} BPM', seconds: 2.6, color: '#ffb060', decimals: 0 }); + E('allwin', 'saydone', 'trigger'); + E('tbpm', 'saydone', 'value'); + N('donefx', 'effectburst', 'Confetti', 2440, 400, { kind: 'confetti', count: 96, lift: 0, color: '' }); + E('allwin', 'donefx', 'trigger'); + N('donesnd', 'gamesound', 'Fanfare', 2680, 400, { sound: 'levelup' }); + E('allwin', 'donesnd', 'trigger'); + N('donebuzz', 'hapticpulse', 'Buzz: done', 2680, 480, { pattern: 'success', hand: 'both' }); + E('allwin', 'donebuzz', 'trigger'); return g.done(); } +/** 30b: where a VR player stands in the Jam Room — the middle of the cockpit (feet) */ +const JAM_SPAWN = [1.0, 0, -1.0]; +/** 30b: a stand/desk under a device: a top slab and two end panels, dark wood, select-through + * @param {string} name @param {number[]} at the floor centre @param {number[]} size [w, top height, d] */ +const jamStand = (name, at, size) => { + const [w, h, d] = size; + const wood = { color: 0x3a2618, physical: true, roughness: 0.55, clearcoat: 0.3, pick: 'through' }; + const alongX = w >= d; + return [ + { type: 'box', name: name, size: [w, 0.05, d], pos: [at[0], h - 0.025, at[2]], bevel: 0.015, bevelSegments: 1, ...wood }, + { type: 'box', name: name + ' end A', size: alongX ? [0.05, h - 0.05, d * 0.9] : [w * 0.9, h - 0.05, 0.05], pos: alongX ? [at[0] - w / 2 + 0.06, (h - 0.05) / 2, at[2]] : [at[0], (h - 0.05) / 2, at[2] - d / 2 + 0.06], ...wood }, + { type: 'box', name: name + ' end B', size: alongX ? [0.05, h - 0.05, d * 0.9] : [w * 0.9, h - 0.05, 0.05], pos: alongX ? [at[0] + w / 2 - 0.06, (h - 0.05) / 2, at[2]] : [at[0], (h - 0.05) / 2, at[2] + d / 2 - 0.06], ...wood } + ]; +}; /** a warm lamp: a pole, a glowing shade and its light, as ONE group * @param {string} name @param {number[]} pos */ const jamLamp = (name, pos) => ({ @@ -1818,8 +1847,14 @@ const JAM_DEF = { sun: { color: '#ffe2c0', intensity: 1.6, dir: [0.4, 1, 0.7] } }, // no simulation (nothing here has a body); a FREE cursor, because you play the - // instruments by pointing at them — no pointer lock, the real cursor clicks keys and pads - physics: { play: { cursor: 'free', simOnPlay: false } }, + // instruments by pointing at them — no pointer lock, the real cursor clicks keys and pads. + // 30b: a VR-ONLY spawn INSIDE the band (see JAM_SPAWN): the headset stands at the + // cockpit, where the controller tip reaches the piano, the drums, the sampler, the + // transport and the mixer; a desktop keeps the overview it always had (a level eye at the + // cockpit would see only the piano). + physics: { play: { cursor: 'free', simOnPlay: false, spawn: { position: JAM_SPAWN, yaw: 0, vrOnly: true } } }, + // 30b: NO Game Music node, on purpose — the room IS the music (the transport, the drums and + // the piano are what you hear), so a procedural `studio` loop would only fight the band. post: { enabled: true, effects: [ @@ -1832,7 +1867,7 @@ const JAM_DEF = { changedAt: 0 }, // inside the room, a step back from the band: Play starts from the editor camera's spot - view: { pos: [1.0, 2.4, 2.4], target: [1.0, 0.8, -3.2] }, + view: { pos: [1.0, 2.5, 2.2], target: [1.0, 0.7, -1.4] }, thumb: { camera: 'Card camera' }, graphs: { scene: jamGraph() }, hud: { @@ -1848,10 +1883,13 @@ const JAM_DEF = { elements: [ { id: 'start-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 480, h: 360, z: 0, label: '', style: JAM_PANEL }, { id: 'start-title', kind: 'text', anchor: 'center', x: 0, y: -125, w: 420, h: 48, z: 1, label: 'JAM ROOM', style: { size: 38, weight: '700', color: '#ffb060', align: 'center' } }, - { id: 'start-sub', kind: 'text', anchor: 'center', x: 0, y: -72, w: 420, h: 44, z: 1, label: 'Press Start, then ▶ on the Transport, and keep the band going for eight bars.', style: { size: 14, color: '#f1e6dc', align: 'center' }, wrap: true }, - { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -24, w: 420, h: 24, z: 1, label: 'Your best tempo: 0 BPM', style: { size: 15, weight: '600', color: '#ffd9a8', align: 'center' } }, + // 30b: HOW TO PLAY + { id: 'howto-title', kind: 'text', anchor: 'center', x: 0, y: -94, w: 420, h: 18, z: 1, label: 'HOW TO PLAY', style: { size: 12, weight: '700', color: '#ffd9a8', align: 'center' } }, + { id: 'start-sub', kind: 'text', anchor: 'center', x: 0, y: -62, w: 440, h: 44, z: 1, label: 'Press Start, then ▶ on the Transport, and keep the band going for eight bars. Play the piano, the drum grid and the pads on top of it.', style: { size: 14, color: '#f1e6dc', align: 'center' }, wrap: true }, + { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -18, w: 420, h: 24, z: 1, label: 'Your best tempo: 0 BPM', style: { size: 15, weight: '600', color: '#ffd9a8', align: 'center' } }, { id: 'go-btn', kind: 'button', anchor: 'center', x: 0, y: 40, w: 240, h: 50, z: 1, label: 'Start jam', enabled: true, style: JAM_BTN }, - { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 122, w: 440, h: 40, z: 1, label: 'Click keys, pads and the drum grid · BPM −/+ on the Transport · P: menu', style: { size: 12, color: '#b8a594', align: 'center' }, wrap: true } + { id: 'start-hint', kind: 'text', anchor: 'center', x: 0, y: 108, w: 440, h: 22, z: 1, label: 'Click keys, pads and the drum grid · BPM −/+ on the Transport · P: menu', style: { size: 12, color: '#b8a594', align: 'center' }, wrap: true }, + { id: 'start-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 136, w: 440, h: 22, z: 1, label: 'VR: hold the trigger and sweep across keys and pads · Y: Edit mode', style: { size: 12, color: '#b8a594', align: 'center' }, wrap: true } ] }, { @@ -1928,7 +1966,7 @@ const JAM_DEF = { // warm light: two floor lamps and a spot on the beat lab jamLamp('Lamp left', [-4.1, 0, -5.4]), jamLamp('Lamp right', [6.3, 0, -5.4]), - { type: 'light', name: 'Stage spot', kind: 'spot', color: 0xffd9a8, intensity: 60, angle: 0.7, penumbra: 0.6, distance: 14, pos: [1, 5.2, 1.5], target: [0.9, 0, -2.6] }, + { type: 'light', name: 'Stage spot', kind: 'spot', color: 0xffd9a8, intensity: 60, angle: 0.7, penumbra: 0.6, distance: 14, pos: [1, 5.2, 1.5], target: [1.0, 0.6, -1.3] }, // props: an amp stack, a plant, a stool { type: 'group', name: 'Amp', pos: [-3.6, 0, -3.4], rot: [0, 0.5, 0], @@ -1955,22 +1993,38 @@ const JAM_DEF = { { type: 'cylinder', name: 'Stool foot', color: 0x2b2420, r: 0.2, h: 0.03, pos: [0, 0.015, 0], physical: true, metalness: 0.6, roughness: 0.35 } ] }, - { type: 'camera', name: 'Card camera', pos: [4.2, 2.6, 2.6], lookAt: [0.9, 0.5, -2.7], fov: 55 } + // 30b: the cockpit's furniture — what the devices stand on, at hand height. Select- + // through, so a press or the laser always reaches the device on top. + ...jamStand('Keyboard stand', [1.0, 0, -2.0], [1.84, 0.74, 1.0]), + ...jamStand('Drum desk', [0.1, 0, -0.85], [0.72, 0.78, 1.12]), + ...jamStand('Right desk', [1.75, 0, -0.85], [0.56, 0.78, 1.2]), + ...jamStand('Pedal board', [2.65, 0, -0.9], [0.36, 0.46, 1.9]), + // the speakers stand above the keys, so the laser (and the sound) clears the piano + ...jamStand('Speaker stand left', [0.1, 0, -3.1], [0.44, 0.75, 0.44]), + ...jamStand('Speaker stand right', [1.9, 0, -3.1], [0.44, 0.75, 0.44]), + { type: 'camera', name: 'Card camera', pos: [4.2, 2.6, 2.6], lookAt: [1.0, 0.7, -1.3], fov: 55 } ], - // a semicircle facing the spawn at the origin; both speakers turned to face the listener + // 30b: THE COCKPIT — a U of stands around the VR spawn (JAM_SPAWN, facing -Z): the piano + // straight ahead with its keys at hand height, the drum machine on the left desk, the + // sampler and the mixer on the right desk, the transport on its stand behind-left, all + // turned to face the player; the pedal chain on its board beyond the right desk and the + // two speakers on stands behind the piano, both in the laser's reach and line of sight. (30's + // semicircle faced a desktop view: in VR the piano sat on the FLOOR and the drums were + // two metres from anyone.) Each yaw turns the device's player side (+Z; the transport's + // panel is on -Z) toward the spawn. layout: [ - { kind: 'mod-music-lab-transport', pos: [-2.6, 0.5, -1.8] }, - { kind: 'mod-music-lab-piano', pos: [-1.3, 0, -2.4] }, - { kind: 'mod-music-lab-drums', pos: [0.3, 0.8, -2.8] }, - { kind: 'mod-music-lab-sampler', pos: [1.5, 0.8, -2.8] }, - { kind: 'mod-music-lab-speaker', index: 0, pos: [-1.0, 0.35, -4.4], yaw: Math.PI }, - { kind: 'mod-music-lab-speaker', index: 1, pos: [1.6, 0.35, -4.4], yaw: Math.PI }, - { kind: 'mod-music-fx-mixer', pos: [3.2, 0.8, -2.6] }, - { kind: 'mod-music-fx-filter', pos: [2.4, 0.5, -0.9] }, - { kind: 'mod-music-fx-distortion', pos: [3.0, 0.5, -0.9] }, - { kind: 'mod-music-fx-bitcrush', pos: [3.6, 0.5, -0.9] }, - { kind: 'mod-music-fx-delay', pos: [4.2, 0.5, -0.9] }, - { kind: 'mod-music-fx-reverb', pos: [4.8, 0.5, -0.9] } + { kind: 'mod-music-lab-piano', pos: [1.0, 0.8, -2.0], yaw: 0 }, + { kind: 'mod-music-lab-drums', pos: [0.1, 0.85, -0.85], yaw: Math.PI / 2 }, + { kind: 'mod-music-lab-transport', pos: [0.7, 0.5, -0.2], yaw: Math.atan2(-0.3, 0.8) }, + { kind: 'mod-music-lab-sampler', pos: [1.75, 0.85, -1.17], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-mixer', pos: [1.75, 0.85, -0.6], yaw: -Math.PI / 2 }, + { kind: 'mod-music-lab-speaker', index: 0, pos: [0.1, 1.1, -3.1], yaw: Math.PI }, + { kind: 'mod-music-lab-speaker', index: 1, pos: [1.9, 1.1, -3.1], yaw: Math.PI }, + { kind: 'mod-music-fx-filter', pos: [2.65, 0.5, -1.7], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-distortion', pos: [2.65, 0.5, -1.3], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-bitcrush', pos: [2.65, 0.5, -0.9], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-delay', pos: [2.65, 0.5, -0.5], yaw: -Math.PI / 2 }, + { kind: 'mod-music-fx-reverb', pos: [2.65, 0.5, -0.1], yaw: -Math.PI / 2 } ], generate: [ { menu: 'Music Lab: piano + speaker', moduleId: 'music-lab', waitMs: 1500 }, diff --git a/src/lib/locomotionPolicy.js b/src/lib/locomotionPolicy.js index 4643180f..81ccdbd3 100644 --- a/src/lib/locomotionPolicy.js +++ b/src/lib/locomotionPolicy.js @@ -61,7 +61,11 @@ export function normalizeSpawn(raw, yawArg) { const yaw = Number(yawRaw ?? 0); return { position: /** @type {[number, number, number]} */ (p), - yaw: Number.isFinite(yaw) ? yaw : 0 + yaw: Number.isFinite(yaw) ? yaw : 0, + // 30b (core-games): a VR-ONLY spawn — the headset stands on it, desktop Play and + // Interact keep their own camera (the Jam Room puts a VR player INSIDE the band, where + // a level desktop eye would see only the piano). Additive, kept only when true. + ...(!Array.isArray(raw) && raw?.vrOnly === true ? { vrOnly: true } : {}) }; } diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 68cdb7d2..9eacc612 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -60,7 +60,7 @@ import { announce as announceBanner, clearAnnouncement } from './gameAnnounce'; /** the ping chimes `api.playSound` still reaches (pingAudio's PING_SOUNDS ids) */ const PING_NAMES = new Set(['ding', 'chime', 'pluck', 'bell']); import { runtimeSpawn, setRuntimeSpawn } from './playSettings'; // 30b P4 (a leaf) -import { spawnDesktopPlayer, currentSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (a leaf) +import { spawnDesktopPlayer, currentSpawn, desktopSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (a leaf) /** modules already told they hit the storage cap this session (ONE toast each, never * one per write — a game saving every frame would otherwise bury the screen) */ @@ -387,9 +387,12 @@ function respawnPlayerNow() { if (get(editorMode) !== 'interact') return false; return !!vrControlsRef?.spawnPlayer?.(); } - if (get(isLocked) === true) return spawnDesktopPlayer(spawn); + // 30b (core-games): a VR-only spawn leaves the desktop view where it is + const desk = desktopSpawn(); + if (!desk) return false; + if (get(isLocked) === true) return spawnDesktopPlayer(desk); if (get(editorMode) === 'interact') { - const { eye, lookAt } = spawnEyePose(spawn); + const { eye, lookAt } = spawnEyePose(desk); objectActionsRef?.flyTo?.(eye, lookAt); return !!objectActionsRef; } diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index c757e6d9..b43c33bf 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -39,7 +39,7 @@ import { stripEditOverlays, isEditOverlay } from './editOverlays'; import { markTransient } from './transientObjects'; // 30 P3: a LEAF (THREE + stores), so a static import here closes no cycle import { clearModuleSelection } from './moduleContent'; -import { currentSpawn, spawnEyePose } from './playSpawn'; // 30b P4 +import { desktopSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (+ vrOnly: core-games) // D2: a LEAF (svelte stores + THREE), so a static import here closes no cycle import { shareDuplicatedMaterials, linkMaterials } from './materialSharing'; import { @@ -244,7 +244,7 @@ export function setEditorMode(mode) { // 30b P4: a game that names a spawn puts the desktop view there on the way in (VR // does the same for the rig — vrControls follows this store; Play's camera spawns in // PointerLockControls). No spawn, no move: the view stays where the author left it. - const spawn = currentSpawn(); + const spawn = desktopSpawn(); if (spawn && !get(isVRMode) && get(isLocked) !== true) { const { eye, lookAt } = spawnEyePose(spawn); flyTo(eye, lookAt); diff --git a/src/lib/playSpawn.js b/src/lib/playSpawn.js index bf1a72e6..f6263635 100644 --- a/src/lib/playSpawn.js +++ b/src/lib/playSpawn.js @@ -20,6 +20,13 @@ export function currentSpawn() { return resolvePlaySettings(get(globalScene)).spawn; } +/** the spawn a DESKTOP view honours — the one in force unless it is VR-only (30b core-games) + * @returns {{position: [number, number, number], yaw: number} | null} */ +export function desktopSpawn() { + const spawn = currentSpawn(); + return spawn && !(/** @type {any} */ (spawn).vrOnly) ? spawn : null; +} + /** * Where an EYE stands on a spawn, and a point straight ahead of it at eye height (what * flyTo and lookAt want). Pure. @param {{position: number[], yaw: number}} spawn @@ -38,7 +45,7 @@ export function spawnEyePose(spawn) { * @param {{position: number[], yaw: number} | null} [spawn] defaults to the one in force * @returns {boolean} whether the camera moved */ -export function spawnDesktopPlayer(spawn = currentSpawn()) { +export function spawnDesktopPlayer(spawn = desktopSpawn()) { /** @type {any} */ const cam = get(playerCam); if (!spawn || !cam?.isObject3D) return false; diff --git a/tests/e2e/game-jam-room.test.cjs b/tests/e2e/game-jam-room.test.cjs index 6b836029..03873279 100644 --- a/tests/e2e/game-jam-room.test.cjs +++ b/tests/e2e/game-jam-room.test.cjs @@ -94,6 +94,61 @@ h.run(async () => { h.check(st.cursor.setting === 'free', `a free-cursor game (${JSON.stringify(st.cursor)})`); h.check(!st.transport.playing, 'the transport is stopped on load (nothing plays until you do)'); + // 1c — 30b: THE COCKPIT. Standing on the VR spawn, the controller TIP reaches a control on + // every instrument (piano, drums, sampler, transport, mixer) at hand height, and the laser + // has a clear line to every pedal and speaker. Measured from the scene, not believed. + const reach = await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + let scene, group; + s.globalScene.subscribe((v) => (scene = v))(); + s.objectsGroup.subscribe((v) => (group = v))(); + group.updateMatrixWorld(true); + const spawn = s.playSettings.resolvePlaySettings(scene).spawn; + if (!spawn) return { spawn: null }; + const [fx, fy, fz] = spawn.position; + const head = new THREE.Vector3(fx, fy + 1.6, fz); + const devices = []; + group.traverse((o) => { if (o.userData?.device?.kind) devices.push(o); }); + const out = {}; + for (const d of devices) { + const kind = d.userData.device.kind.replace(/^mod-music-(lab|fx)-/, ''); + let best = null; + d.traverse((m) => { + if (!m.isMesh || m === d) return; + if (!s.gameKit.vrGameInput.clickTargetOf({ object: m, point: new THREE.Vector3(), distance: 0 })) return; + if (/^vrpatch/.test(m.name)) return; // a cable jack is not the instrument + const b = new THREE.Box3().setFromObject(m); + const c = b.getCenter(new THREE.Vector3()); + // horizontal distance from the feet axis to the nearest point of the control + const nx = Math.max(b.min.x, Math.min(fx, b.max.x)); + const nz = Math.max(b.min.z, Math.min(fz, b.max.z)); + const flat = Math.hypot(nx - fx, nz - fz); + // the laser: nothing opaque between the head and the control's centre + const dir = c.clone().sub(head); + const len = dir.length(); + const ray = new THREE.Raycaster(head, dir.normalize(), 0, len - 0.02); + const blocker = ray.intersectObjects(group.children, true).find((hit) => { + let n = hit.object; + while (n && n !== group) { + if (n === d || n.userData?.pick === 'through') return false; + n = n.parent; + } + return true; + }); + const row = { flat: +flat.toFixed(2), y: +c.y.toFixed(2), laser: +len.toFixed(2), blocked: blocker ? blocker.object.name : null }; + if (!best || row.flat < best.flat) best = row; + }); + (out[kind] ??= []).push(best); + } + return { spawn, out }; + }); + h.check(reach.spawn?.vrOnly === true && reach.spawn.position.join() === '1,0,-1', `30b: a VR-only spawn in the middle of the cockpit (${JSON.stringify(reach.spawn)})`); + const tipReach = ['piano', 'drums', 'sampler', 'transport', 'mixer'].map((k) => [k, reach.out?.[k]?.[0]]); + h.check(tipReach.every(([, r]) => r && r.flat <= 0.85 && r.y >= 0.6 && r.y <= 1.6), `30b: the tip reaches every instrument at hand height from the spawn (${JSON.stringify(Object.fromEntries(tipReach))})`); + const laserReach = Object.entries(reach.out ?? {}).flatMap(([k, rows]) => rows.map((r) => [k, r])); + h.check(laserReach.length === 12 && laserReach.every(([, r]) => r && !r.blocked && r.laser <= 3.5), `30b: the laser has a clear line to all twelve devices (${JSON.stringify(laserReach.filter(([, r]) => !r || r.blocked || r.laser > 3.5))})`); + // 2 — Play: no pointer lock, the start screen await page.evaluate(() => window.__stores.isLocked.set(true)); await page.waitForTimeout(1500); @@ -101,6 +156,18 @@ h.run(async () => { const lockEl = await page.evaluate(() => !!document.pointerLockElement); h.check(st.cursor.free === true && !lockEl, `in Play the cursor stays free (free ${st.cursor.free}, lock ${lockEl})`); h.check(/JAM ROOM/.test(await hud()) && /Start jam/.test(await hud()) && /Your best tempo: 0 BPM/.test(await hud()), 'the start screen: title, Start jam, this device\'s best (none yet)'); + // 30b: how to play, on a desktop and in VR; no music over the band; a desktop keeps its view + h.check(/HOW TO PLAY/.test(await hud()) && /VR: hold the trigger and sweep/.test(await hud()), '30b: the start screen says how to play, VR included'); + const quiet = await page.evaluate(() => { + const s = window.__stores; + let m, cam; + s.gameKit.gameMusic.gameMusicState.subscribe((v) => (m = v))(); + s.playerCam.subscribe((v) => (cam = v))(); + const p = cam.getWorldPosition(new s.THREE.Vector3()); + return { music: m, musicNodes: s.allNodes().filter((n) => n.type === 'gamemusic').length, eye: [p.x, p.y, p.z].map((n) => +n.toFixed(2)) }; + }); + h.check(quiet.music === null && quiet.musicNodes === 0, `30b: no game music — the room IS the music (${JSON.stringify(quiet.music)}, ${quiet.musicNodes} nodes)`); + h.check(Math.hypot(quiet.eye[0] - 1, quiet.eye[2] + 1) > 0.5, `30b: desktop Play does not stand on the VR-only spawn (eye ${quiet.eye})`); // 3 — Start: the count-in, then the goal, which waits for the transport await clickBtn('Start jam'); @@ -131,6 +198,8 @@ h.run(async () => { await h.eventually(() => snap().then((v) => v.state), (v) => v === 'over', 'eight bars complete the session (over)', 14000); await h.eventually(async () => await hud(), (t) => /SESSION COMPLETE/.test(t) && /8 bars at 240 BPM/.test(t) && /Your best tempo: 240 BPM/.test(t), 'Session complete names the tempo and the best', 4000); h.check((await stored())['jam-best-bpm'] === 240, 'the best tempo is saved on this device (jam-best-bpm 240)'); + const done = await page.evaluate(() => window.__stores.gameKit.gameFeelActions.gameFeelActionsDebug()); + h.check(done.last.some((e) => e.type === 'announce' && e.text === 'Session complete!' && e.sub === '8 bars at 240 BPM') && done.last.some((e) => e.type === 'effectburst' && e.kind === 'confetti') && (done.sounds.levelup ?? 0) >= 1, '30b: the session ends with a banner (8 bars at 240 BPM), confetti and the fanfare'); // 5 — Play again: a fresh count-in, and the bars count from where the count-in ENDED — // the transport is still running at beat 30-something diff --git a/tests/unit/locomotionPolicy.test.js b/tests/unit/locomotionPolicy.test.js index 081b7316..e702d5d6 100644 --- a/tests/unit/locomotionPolicy.test.js +++ b/tests/unit/locomotionPolicy.test.js @@ -115,3 +115,13 @@ describe('vrSpawnOffsets', () => { expect(turn.orientation.w).toBeCloseTo(Math.cos(0.5)); }); }); + +// 30b (core-games): a VR-only spawn keeps its flag through the one normalizer (absent = none) +describe('a VR-only spawn', () => { + it('keeps vrOnly only when it is exactly true', () => { + expect(normalizeSpawn({ position: [1, 0, -1], yaw: 0, vrOnly: true })).toEqual({ position: [1, 0, -1], yaw: 0, vrOnly: true }); + expect(normalizeSpawn({ position: [1, 0, -1], yaw: 0, vrOnly: 'yes' })).toEqual({ position: [1, 0, -1], yaw: 0 }); + expect(normalizeSpawn({ position: [1, 0, -1] })).toEqual({ position: [1, 0, -1], yaw: 0 }); + expect(normalizeSpawn([1, 0, -1], 0.5)).toEqual({ position: [1, 0, -1], yaw: 0.5 }); + }); +}); From 9fb5c7cd738c75aa23ff33f058b9408ddddde0dd Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 20:43:55 +0300 Subject: [PATCH 18/18] [fix] 30b core-games P3: what the shots showed - no banner over a results panel, a ring burst that reads in daylight, the score read in VR Reading the after-30b screenshots (1540x774, GPU ANGLE Vulkan) before calling it done: - a round's END banner ("Every star lit!", "Time's up!", "All stars collected!", "Session complete!") sat on top of the Round over / Session complete panel that says the same thing, and read twice (jam-room-4-later). The end is confetti + a cheer/fanfare/whistle + a buzz now; its words are the panel's. Start/free-play/milestone banners stay (no panel under them). - the ring sparkle is ADDITIVE and near-invisible against Towers' daylight sky: rings 1-3 add a small confetti puff at the ring (normal blending; the gold ring's burst was confetti). - the stars/jam suites now read the VR wrist card / top strip lines (vr-play's overlay split): "Lit: 1 / 24" + the clock, and "Bar N / 8" + the beat, with no board screen while playing. Suites (restaged scenes): game-towers 52/52, game-stars-room 57/57, game-jam-room 39/39, vr-game-panel 35/35 on the staged Towers (13.x: Start pressed by laser on the VR board, the HUD on the wrist/strip). Counterfactual: the jam session re-authored WITH its end banner -> "no banner over the Session complete panel" red. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/author-templates.cjs | 28 ++++++++++++++-------------- tests/e2e/game-jam-room.test.cjs | 14 +++++++++++++- tests/e2e/game-stars-room.test.cjs | 16 +++++++++++++++- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index d2a06bf5..b8503fd9 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -348,6 +348,13 @@ function towersGraph() { N('fx' + i, 'effectburst', 'Burst at ring ' + i, 2920, y, { kind: top ? 'confetti' : 'sparkle', count: top ? 96 : 56, lift: 0, color: '' }); E('first' + i, 'fx' + i, 'trigger'); E('selr' + i, 'fx' + i, 'at'); + // an additive sparkle alone is faint against a daylight sky: a small confetti puff + // beside it reads at any exposure (the top ring's burst IS confetti already) + if (!top) { + N('puff' + i, 'effectburst', 'Puff at ring ' + i, 2920, y + 70, { kind: 'confetti', count: 36, lift: 0.1, color: '' }); + E('first' + i, 'puff' + i, 'trigger'); + E('selr' + i, 'puff' + i, 'at'); + } N('chime' + i, 'gamesound', top ? 'Fanfare at the top' : 'Chime at ring ' + i, 3160, y, { sound: top ? 'levelup' : 'ring' }); E('first' + i, 'chime' + i, 'trigger'); E('selr' + i, 'chime' + i, 'at'); @@ -409,19 +416,16 @@ function towersGraph() { // Every menu button clicks. N('click', 'gamesound', 'Button click', 280, 2480, { sound: 'click' }); for (const b of ['bstart', 'bagain', 'bresume', 'brestart', 'bquit', 'breplay']) E(b, 'click', 'trigger'); - // A round starts with a whistle and a banner; it ends with one of two. + // A round starts with a whistle and a banner. It ENDS without one: the Round over panel is + // the result (a banner over it read twice), so the end is a cheer or a whistle + confetti. N('saygo', 'announce', 'Say: build!', 1720, 1640, { text: 'Build!', sub: 'Stack crates on the glowing pad — reach the gold ring', seconds: 2.2, color: '#ffd45e', decimals: 0 }); E('onround', 'saygo', 'trigger'); N('whistle', 'gamesound', 'Round start whistle', 1720, 1720, { sound: 'whistle' }); E('onround', 'whistle', 'trigger'); - N('saywin', 'announce', 'Say: all stars', 1240, 2000, { text: 'All stars collected!', sub: '', seconds: 2.4, color: '#ffc640', decimals: 0 }); - E('allwin', 'saywin', 'trigger'); N('cheer', 'gamesound', 'Cheer', 1240, 2080, { sound: 'cheer' }); E('allwin', 'cheer', 'trigger'); N('confetti', 'effectburst', 'Confetti for everyone', 1240, 2160, { kind: 'confetti', count: 96, lift: 0, color: '' }); E('allwin', 'confetti', 'trigger'); - N('sayup', 'announce', "Say: time's up", 1240, 2300, { text: "Time's up!", sub: '', seconds: 2.2, color: '#e5e9f0', decimals: 0 }); - E('alltime', 'sayup', 'trigger'); N('upwhistle', 'gamesound', 'Final whistle', 1240, 2380, { sound: 'whistle' }); E('alltime', 'upwhistle', 'trigger'); // Each crate: a pop when a player lifts it (On Grab) and a knock when it lands on @@ -977,16 +981,13 @@ function starsGraph() { E('onround', 'whistle', 'trigger'); N('sayfree', 'announce', 'Say: free play', 520, 110, { text: 'Free play', sub: 'Knock the stars around — P or the menu to start a round', seconds: 2, color: '#9ee6ff', decimals: 0 }); E('bfree', 'sayfree', 'trigger'); - N('saywin', 'announce', 'Say: all lit', 2920, 2150, { text: 'Every star lit!', sub: '', seconds: 2.4, color: '#ffe08a', decimals: 0 }); - E('allwin', 'saywin', 'trigger'); N('fanfare', 'gamesound', 'Win fanfare', 2920, 2230, { sound: 'levelup' }); E('allwin', 'fanfare', 'trigger'); - N('sayup', 'announce', "Say: time's up", 1240, 1840, { text: "Time's up!", sub: '', seconds: 2.2, color: '#e5e9f0', decimals: 0 }); - E('alltime', 'sayup', 'trigger'); N('upwhistle', 'gamesound', 'Final whistle', 1240, 1920, { sound: 'whistle' }); E('alltime', 'upwhistle', 'trigger'); // the round's end, whichever way: confetti in front of every player and a cheer, with the - // results on the Round over screen (the VR board in a headset) + // results on the Round over screen (the VR board in a headset) — NO banner at the end: it + // read twice over the results panel N('onover', 'ongamestate', 'When the round ends', 3160, 2700, { state: 'over', edge: 'enter', pulse: 0.3 }); N('confetti', 'effectburst', 'Confetti', 3400, 2700, { kind: 'confetti', count: 96, lift: 0, color: '' }); E('onover', 'confetti', 'trigger'); @@ -1782,14 +1783,13 @@ function jamGraph() { E('bmenu', 'doquit', 'trigger'); E('bquit', 'quithide', 'trigger'); - // ---- 30b: the shell sounds like a game — but never plays music over the band ---------- + // ---- 30b: the shell sounds like a game — but never plays music over the band. The end + // of a session is confetti, the fanfare and a buzz; its words are the Session complete + // panel (a banner over the panel read twice) ----------------------------------------------- N('click', 'gamesound', 'Button click', 280, 1360, { sound: 'click' }); for (const b of ['bgo', 'bresume', 'brestart', 'breplay', 'bquit', 'bmenu']) E(b, 'click', 'trigger'); N('saygo', 'announce', 'Say: go', 1000, 40, { text: 'Go!', sub: 'Press ▶ on the Transport — keep it going for ' + JAM_BARS + ' bars', seconds: 2, color: '#ffb060', decimals: 0 }); E('countdone', 'saygo', 'trigger'); - N('saydone', 'announce', 'Say: session complete', 2440, 480, { text: 'Session complete!', sub: JAM_BARS + ' bars at {v} BPM', seconds: 2.6, color: '#ffb060', decimals: 0 }); - E('allwin', 'saydone', 'trigger'); - E('tbpm', 'saydone', 'value'); N('donefx', 'effectburst', 'Confetti', 2440, 400, { kind: 'confetti', count: 96, lift: 0, color: '' }); E('allwin', 'donefx', 'trigger'); N('donesnd', 'gamesound', 'Fanfare', 2680, 400, { sound: 'levelup' }); diff --git a/tests/e2e/game-jam-room.test.cjs b/tests/e2e/game-jam-room.test.cjs index 03873279..1e942d69 100644 --- a/tests/e2e/game-jam-room.test.cjs +++ b/tests/e2e/game-jam-room.test.cjs @@ -60,6 +60,15 @@ h.run(async () => { }); const hud = async () => (await page.locator('#hud-layer').textContent()) ?? ''; const clickBtn = (text) => page.getByRole('button', { name: text, exact: true }).click(); + // 30b: what the VR wrist card / top strip would SAY right now (vr-play's overlay split) + const vrLines = () => + page.evaluate(() => { + const s = window.__stores; + const k = s.gameKit.vrGamePanel; + let rt; s.hudDocs.hudRuntime.subscribe((v) => (rt = v))(); + const { panel, overlay } = k.vrScreens(); + return { panel: panel.map((p) => p.screen.id), lines: overlay.flatMap((o) => k.overlayLines(o.screen.elements, rt)) }; + }); const stored = () => page.evaluate(() => { const out = {}; for (let i = 0; i < localStorage.length; i++) { @@ -195,11 +204,14 @@ h.run(async () => { await page.evaluate(() => { const m = window.__stores.musicClock; m.setBpm(240); m.playTransport(); }); await h.eventually(async () => await hud(), (t) => /Keep the band going\s+·\s+240 BPM/.test(t), 'the hint follows the transport (playing, 240 BPM)', 4000); await h.eventually(async () => await hud(), (t) => /Bar [1-7] \/ 8/.test(t), 'the bars count up', 6000); + const vr = await vrLines(); + h.check(vr.panel.length === 0 && vr.lines.some((l) => /^Bar [1-7] \/ 8$/.test(l)) && vr.lines.some((l) => /^Beat [1-4]$/.test(l)), `30b: the bars and the beat read on the VR wrist/strip (${JSON.stringify(vr)})`); await h.eventually(() => snap().then((v) => v.state), (v) => v === 'over', 'eight bars complete the session (over)', 14000); await h.eventually(async () => await hud(), (t) => /SESSION COMPLETE/.test(t) && /8 bars at 240 BPM/.test(t) && /Your best tempo: 240 BPM/.test(t), 'Session complete names the tempo and the best', 4000); h.check((await stored())['jam-best-bpm'] === 240, 'the best tempo is saved on this device (jam-best-bpm 240)'); const done = await page.evaluate(() => window.__stores.gameKit.gameFeelActions.gameFeelActionsDebug()); - h.check(done.last.some((e) => e.type === 'announce' && e.text === 'Session complete!' && e.sub === '8 bars at 240 BPM') && done.last.some((e) => e.type === 'effectburst' && e.kind === 'confetti') && (done.sounds.levelup ?? 0) >= 1, '30b: the session ends with a banner (8 bars at 240 BPM), confetti and the fanfare'); + h.check(done.last.some((e) => e.type === 'effectburst' && e.kind === 'confetti') && (done.sounds.levelup ?? 0) >= 1 && done.last.some((e) => e.type === 'hapticpulse' && e.pattern === 'success'), '30b: the session ends in confetti, the fanfare and a success buzz'); + h.check(!done.last.some((e) => e.type === 'announce' && /complete/i.test(e.text)), '30b: and no banner over the Session complete panel (it read twice)'); // 5 — Play again: a fresh count-in, and the bars count from where the count-in ENDED — // the transport is still running at beat 30-something diff --git a/tests/e2e/game-stars-room.test.cjs b/tests/e2e/game-stars-room.test.cjs index dd82a81a..617ee7e3 100644 --- a/tests/e2e/game-stars-room.test.cjs +++ b/tests/e2e/game-stars-room.test.cjs @@ -179,6 +179,15 @@ h.run(async () => { return { music: music?.preset ?? null, ann: ann?.text ?? null, last: d.last, fired: d.fired, sounds: d.sounds }; }); const resetFeel = () => page.evaluate(() => window.__stores.gameKit.gameFeelActions.resetGameFeelActionsDebug()); + // 30b: what the VR wrist card / top strip would SAY right now (vr-play's overlay split) + const vrLines = () => + page.evaluate(() => { + const s = window.__stores; + const k = s.gameKit.vrGamePanel; + let rt; s.hudDocs.hudRuntime.subscribe((v) => (rt = v))(); + const { panel, overlay } = k.vrScreens(); + return { panel: panel.map((p) => p.screen.id), lines: overlay.flatMap((o) => k.overlayLines(o.screen.elements, rt)) }; + }); // 2 — entering play starts the sim (free play, no round) await page.evaluate(() => window.__stores.isLocked.set(true)); @@ -264,6 +273,10 @@ h.run(async () => { await resetFeel(); await knockStar('Star 3', 3, 'q'); await h.eventually(async () => await hud(), (t) => /Lit: 1 \/ 24/.test(t), 'one hit lights one star (Lit: 1 / 24)', 8000); + // 30b: the score is readable in VR — the round HUD is an OVERLAY, so the wrist card and the + // top strip carry "Lit: 1 / 24" and the clock (the menu screens go to the VR board) + const vr = await vrLines(); + h.check(vr.panel.length === 0 && vr.lines.some((l) => /^Lit: 1 \/ 24$/.test(l)) && vr.lines.some((l) => /\d+s left/.test(l)), `30b: the score and the clock read on the VR wrist/strip (${JSON.stringify(vr)})`); // 30b: LIGHTING a star pays a coin chime at it and a tap in VR — once per star per round await h.eventually(() => feel(), (f) => f.last.filter((e) => e.sound === 'coin' && e.spatial).length === 1 && (f.fired.hapticpulse ?? 0) === 1, '30b: lighting Star 3 plays one coin at it and asks one tap', 4000); await page.waitForTimeout(1500); // let it drift, then knock it again inside the same round @@ -323,7 +336,8 @@ h.run(async () => { await h.eventually(litThisRound, (n) => n >= 24, 'all 24 latches were set this round (hit stamps at or after startedAt)', 10000); await h.eventually(() => snap().then((v) => v.state), (v) => v === 'over', 'every star lit ends the round (over)', 10000); // 30b: the round ends in confetti and a cheer, and the win is announced - await h.eventually(() => feel(), (f) => f.fired.effectburst > 0 && f.last.some((e) => e.type === 'effectburst' && e.kind === 'confetti') && f.last.some((e) => e.sound === 'cheer') && f.last.some((e) => e.type === 'announce' && e.text === 'Every star lit!'), '30b: over = confetti, a cheer and "Every star lit!"', 4000); + await h.eventually(() => feel(), (f) => f.last.some((e) => e.type === 'effectburst' && e.kind === 'confetti') && f.last.some((e) => e.sound === 'cheer') && (f.sounds.levelup ?? 0) === 1, '30b: over = confetti, a cheer and the win fanfare', 4000); + h.check(!(await feel()).last.some((e) => e.type === 'announce' && /lit!|up!/i.test(e.text)), '30b: and no banner over the results panel (it read twice)'); const coins = (await feel()).sounds.coin ?? 0; h.check(coins === 24, `30b: the sweep paid exactly one coin per star lit (${coins} / 24)`); await h.eventually(async () => await hud(), (t) => /EVERY STAR LIT/.test(t) && /Every star lit in \d+s/.test(t) && /Your best: 24 \/ 24/.test(t), 'the over screen names the time and the best, from storage');