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} 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/Scene.svelte b/src/components/Scene.svelte index dde54080..36d17b7d 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -54,7 +54,10 @@ // 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 { gameFeelActive } from '$lib/gameFeel'; import { vrKeyboardTarget } from '$lib/vrKeyboard'; import { measureMode, measureClick } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; @@ -1386,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 @@ -1419,7 +1429,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 +1446,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/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/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/effectsBurst.js b/src/lib/effectsBurst.js new file mode 100644 index 00000000..02d98344 --- /dev/null +++ b/src/lib/effectsBurst.js @@ -0,0 +1,315 @@ +// 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, globalRenderer, globalCamera } 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 + }; +} + +/** + * 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++; + 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/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/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/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..51b0bcfc --- /dev/null +++ b/src/lib/gameKit.js @@ -0,0 +1,12 @@ +// 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'; +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/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/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 dff5a4d6..c6084ed8 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -51,6 +51,14 @@ 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'; +// 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']); /** 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) */ @@ -81,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. */ @@ -104,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 */ @@ -117,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)); } @@ -176,6 +199,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(); @@ -253,6 +279,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)); @@ -273,6 +301,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 -------- @@ -561,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)); }, @@ -896,8 +933,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, @@ -1020,10 +1069,61 @@ 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'. + */ + /** + * 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 ?? {}), + 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/src/lib/vrControls.js b/src/lib/vrControls.js index eef68025..26a76ccc 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 { @@ -381,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) { @@ -391,6 +402,8 @@ export function vrModuleTriggerStart(index) { return false; } }); + triggerClaims[index] = claimed; + return claimed; } /** @param {number} index @returns {boolean} */ export function vrModuleTriggerEnd(index) { @@ -926,24 +939,74 @@ 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 + * @param {boolean=} force pulse even in Edit (the mode-switch tick only) */ -export function hapticPulse(intensity = 0.5, durationMs = 50, hand = undefined) { +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; + } 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 +2354,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..ec0ff9c8 --- /dev/null +++ b/src/lib/vrGameInput.js @@ -0,0 +1,432 @@ +// 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, gameClickMode } from './gameFeel'; +import { sceneHits } from './scenePick'; +import { pickStack, primaryIndex } from './selectThrough'; +import { topLevelObjectOf } from './objectActions'; +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'; + +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, sweepClicks: 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, sweepClicks: number, hover: (string | null)[], sweeps: boolean[]}} */ +export function vrGameInputDebug() { + return { ...stats, hover: [...hoverKey], sweeps: sweeps.map(Boolean) }; +} + +/* --------------------------------------------------------------- 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; + +/* ---------------------------------------------------------------- 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(); + +/** + * 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 ? 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; + } + 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 = []; + +/** 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) { + // 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); + sweepFrame(index); + } + }), + registerVRTriggerHooks({ + start: (/** @type {number} */ index) => triggerStart(index), + end: (/** @type {number} */ index) => triggerEnd(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; +} + +export function stopVrGameInput() { + for (const off of offs) off(); + offs = []; + hoverKey[0] = hoverKey[1] = null; + sweeps[0] = sweeps[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/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} 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/e2e/game-effects.test.cjs b/tests/e2e/game-effects.test.cjs new file mode 100644 index 00000000..3103f850 --- /dev/null +++ b/tests/e2e/game-effects.test.cjs @@ -0,0 +1,255 @@ +// 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; + 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; + 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(), + 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(() => { + 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); + 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)); + 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 }; + }); + // 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); + 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 () => { + 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); +}); diff --git a/tests/e2e/game-haptics.test.cjs b/tests/e2e/game-haptics.test.cjs new file mode 100644 index 00000000..18faa841 --- /dev/null +++ b/tests/e2e/game-haptics.test.cjs @@ -0,0 +1,174 @@ +// 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') })); + // 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.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(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'); + + 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.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.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) + ')'); + + await h.finish(browser); +}); 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); +}); 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); +}); 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); + }); +}); 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); + }); +});