diff --git a/src/App.svelte b/src/App.svelte index f93a67e8..40cf9592 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,6 +2,10 @@ import { safeStorage } from '$lib/safeStorage'; import { onMount } from 'svelte' import { Canvas } from '@threlte/core' + // 30 P0: threlte's Canvas defaults `shadows` to PCFSoftShadowMap, which three 0.185 + // deprecates and silently downgrades to PCFShadowMap on the first shadow render (with a + // console warning). Asking for PCF directly is the identical picture, minus the warning. + import { PCFShadowMap } from 'three' import Scene from './components/Scene.svelte' import Menu from './components/Menu.svelte' import ConfirmModal from './components/menu/ConfirmModal.svelte' @@ -102,6 +106,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { loadProjectManifest } from '$lib/projectManifest' import { startSharedLibrary } from '$lib/sharedLibrary' import { startSceneIdentity } from '$lib/sceneIdentity' + import GameChip from './components/hud/GameChip.svelte' import HudLayer from './components/hud/HudLayer.svelte' import HudEditor from './components/editors/HudEditor.svelte' import { importFile, load } from '$lib/fileHandler.svelte' @@ -471,9 +476,11 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/qualityGovernor'), import('./lib/importBudget'), import('./lib/importGate'), - import('./lib/decimate') - ]).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]) => { - 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 } + 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 } }) } }) @@ -568,6 +575,10 @@ import { startMusicToolbox } from './lib/musicToolbox' above (a game HUD that dies when you press play is no HUD at all). --z-hud, no new tier: it beats the camera PiP and loses to modal/toast/menu. --> + +
- +
diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 9ddf8a1d..a7f71a2a 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -211,6 +211,9 @@ ongamestate: AnimationNode, setvariable: AnimationNode, getvariable: AnimationNode, + // 30 P4: the device-local pair (the second registry — the catalog is the first) + storevalue: AnimationNode, + storedvalue: AnimationNode, gametime: AnimationNode, // 21-F3's `collectcount` card MOVED to the collectible module (R3a) — an old // scene's node renders as UnknownNode until the module is installed, honestly diff --git a/src/components/hud/GameChip.svelte b/src/components/hud/GameChip.svelte new file mode 100644 index 00000000..b967327d --- /dev/null +++ b/src/components/hud/GameChip.svelte @@ -0,0 +1,111 @@ + + +{#if visible} +
+ Game · {stateLabel} + + +
+{/if} + + diff --git a/src/components/hud/HudLayer.svelte b/src/components/hud/HudLayer.svelte index 260619ca..c1659958 100644 --- a/src/components/hud/HudLayer.svelte +++ b/src/components/hud/HudLayer.svelte @@ -12,7 +12,7 @@ // phase (the AnnotationPins / AnnotationMarkers split). import { onMount } from 'svelte'; import HudElement from './HudElement.svelte'; - import { hudDocs, hudRuntime, hudScreenOverride, hudPreviewInViewport, visibleScreen, activeHudKeys, setHudValue, hudValueOf } from '$lib/hudDocs'; + import { hudDocs, hudRuntime, hudScreenOverride, hudPreviewInViewport, hudIsGame, visibleScreen, activeHudKeys, setHudValue, hudValueOf } from '$lib/hudDocs'; import { isVRMode, isLocked, playPointerFree } from '../../stores/sceneStore'; import { hudEditorClose } from '../../stores/appStore.js'; import { viewportOverrides, renderLayer } from '$lib/viewportOverrides'; @@ -62,6 +62,17 @@ // authoring. `=== false` would read the transient value and flip back a moment later. const playing = $derived($isLocked === true); + // 30 P1: THE EDITOR STOPS MASQUERADING AS THE GAME. Outside Play a GAME's screens (the + // `hudIsGame` rule: a screen bound to a game state) are not drawn at all — the menu of + // a Games-tab game used to sit over the scene being built, with a LIVE Start that began + // the round for every peer with nobody in Play. The game chip (GameChip.svelte) stands + // in for them. The HUD editor's preview eye still draws them for authoring, and then + // every control is INERT (`editor` on HudElement — the artboard's own swallow), so + // laying out a menu can never fire the game. A HUD with no state-bound screen is a + // plain overlay and keeps its old behaviour exactly. + const gameInEditor = $derived(!playing && $hudIsGame); + const gameHidden = $derived(gameInEditor && !$hudPreviewInViewport); + // BOTH stores are read as dependencies. `visibleScreen` reaches the override through // `get()`, and a `get()` inside a $derived registers NOTHING — so with only $hudDocs // here, showing a screen wrote the store and the layer never re-rendered. It looked @@ -103,7 +114,7 @@ // dropdowns, and under pointer lock the ring is the only hand a player has. A // disabled control is skipped the way it ignores presses. const focusables = $derived(elements.filter((el) => isInteractiveKind(el.kind) && el.enabled !== false)); - const anyVisible = $derived(elements.length > 0 && !$isVRMode && layerAllowed && !authoringHidden); + const anyVisible = $derived(elements.length > 0 && !$isVRMode && layerAllowed && !authoringHidden && !gameHidden); // ---- 21-E3: the MENU SUBSTATE - this component is the SINGLE WRITER ------------ // Visibility IS the state: any visible screen marked input:'menu' while playing @@ -286,7 +297,8 @@ // Claim only while a screen with focusables is actually up, and release the moment it // is not — a claim left standing pauses editor fly for good. $effect(() => { - const wants = anyVisible && focusables.length > 0; + // 30 P1: an INERT preview has nothing to drive, so it must not pause editor flight + const wants = anyVisible && focusables.length > 0 && !gameInEditor; if (!wants) return; claimInput('keys'); return () => releaseInput('keys'); @@ -332,7 +344,7 @@ {#if anyVisible} -
+
{#each elements as el (el.__key + ':' + el.id)}
- +
{/each}
@@ -368,6 +380,12 @@ .hud-slot { position: absolute; } + /* 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) */ + .hud-inert :global(*) { + pointer-events: none !important; + } /* the keyboard ring, so a player under pointer lock can see where they are */ .hud-focused { outline: 2px solid var(--accent, #ef562f); diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 9adc16ed..5f21b136 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -39,6 +39,9 @@ import { visibleDockKey, dockOccupants, bottomInset, FLOW_FAMILY, armDockMode, DOCK_TITLES } from '$lib/bottomDock'; import { togglePanel } from '$lib/panelToggles'; import { requestPlay, willEnterXR, willEnterAR, vrSupported, arSupported, xrSessionFailed } from '$lib/playMode'; + // 30 P2: Test play — the play button's right-click row beside the game chip's button + import { testPlay } from '$lib/gamePresence'; + import { hudIsGame } from '$lib/hudDocs'; import { DOCK_VIEWS } from '$lib/dockMenu'; import { safeStorage } from '$lib/safeStorage'; import { VRButton, XRButton } from '@threlte/xr' @@ -916,6 +919,18 @@ requestPlay(); } }, + // 30 P2: TEST PLAY — back to the menu, into Play, the Start screen. Offered only + // where it means something: a scene with no game shell has no menu to start from. + ...($hudIsGame + ? [ + { section: 'Game' }, + { + label: 'Test play (start from the menu)', + tooltip: 'Reset the game to its menu, enter Play, and start from the Start screen', + action: () => testPlay() + } + ] + : []), // the FAB is a toolbar cell like any other, so it carries the same tail — // minus "Hide button" (there is no toolbar without a way to press play) ...toolbarTail(null) diff --git a/src/components/play/PlayReticle.svelte b/src/components/play/PlayReticle.svelte index 993276a6..e8db4c9f 100644 --- a/src/components/play/PlayReticle.svelte +++ b/src/components/play/PlayReticle.svelte @@ -5,6 +5,7 @@ // playInteract.js. import { isLocked, isVRMode } from '../../stores/sceneStore'; import { playInteractState } from '$lib/playInteract'; + import { playCursorFree } from '$lib/playCursor'; import { safeStorage } from '$lib/safeStorage'; // the scroll hint is worth exactly one showing, so it is a LOCAL pref and @@ -14,7 +15,10 @@ ); const reticle = $derived($playInteractState); - const visible = $derived($isLocked && !$isVRMode && reticle.mode !== 'off'); + // 30 P3: a free-cursor game aims with the real cursor, so there is no crosshair to draw. + // $playInteractState is the dependency that re-reads it (it moves on every aim change, + // and playCursorFree reads its stores through get()). + const visible = $derived($isLocked && !$isVRMode && reticle.mode !== 'off' && !playCursorFree()); const carrying = $derived(reticle.mode === 'carrying'); $effect(() => { diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index 018c6484..faa9a53a 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -7,6 +7,8 @@ import { userdata, peers } from '../../stores/appStore' import { dungeonData, slideMove, spawnPointFor } from '$lib/dungeonPlay' import { resolvePlaySettings } from '$lib/playSettings' + // 30 P3: free-cursor games never take the pointer — the real cursor aims + import { playCursorFree } from '$lib/playCursor' import { inputClaims, getGamepadAxes } from '$lib/inputRuntime' import { gamepadPrefs } from '$lib/gamepadPrefs' import { coarsePointer } from '$lib/inputDevice' @@ -111,7 +113,7 @@ if (document.pointerLockElement === domElement) document.exitPointerLock() } else if (!free && wasFree) { wasFree = false - if (!noPointerLock && $isLocked === true && document.pointerLockElement !== domElement) { + if (!noPointerLock && !playCursorFree() && $isLocked === true && document.pointerLockElement !== domElement) { const again: any = domElement.requestPointerLock({ unadjustedMovement: true }) again?.catch?.(() => {}) } @@ -160,6 +162,10 @@ */ function requestLock(first: boolean) { if (noPointerLock) return cancelLockRetry() // W4: nothing to lock on a touch device + // 30 P3: a FREE-CURSOR game (`play.cursor: 'free'`) enters play without a lock: the + // cursor stays visible and aims (playCursor.playAimNdc), movement keys still walk or + // fly, and Escape still leaves — PLC's Escape branch already handles "no lock held" + if (playCursorFree()) return cancelLockRetry() if (get(isLocked) !== true) return cancelLockRetry() if (get(playPointerFree)) return cancelLockRetry() // 21-E3: the menu owns the pointer if (document.pointerLockElement === domElement) return cancelLockRetry() @@ -527,6 +533,7 @@ // a programmatic exit, so this stays idle.) function onCanvasPointerDown() { if (noPointerLock) return // W4: a touch tap is the look/interact gesture, not a re-lock + if (playCursorFree()) return // 30 P3: a click in a free-cursor game is the game's if ($isLocked === true && !$playPointerFree && document.pointerLockElement !== domElement) { const again: any = domElement.requestPointerLock({ unadjustedMovement: true }) again?.catch?.(() => {}) diff --git a/src/extensions/Grid.svelte b/src/extensions/Grid.svelte index 8fb6b9af..c07a0725 100644 --- a/src/extensions/Grid.svelte +++ b/src/extensions/Grid.svelte @@ -1,7 +1,11 @@ - {#if showGrid} + {#if showGrid && ($isLocked !== true || $helpersInPlay)} flowRuntime -> @@ -1115,6 +1117,40 @@ function updateLeaderboardNodes(time, ctx) { /** @type {Map} */ const hudSetActed = new Map(); +/** 30 P4: a Store Value / Stored Value key in THIS scene's namespace + * (`tp:scene::`). The name is the scene's own (levels' + * `currentLevel`), so a best score belongs to its game. @param {string} key */ +function storedKey(key) { + let name = null; + try { + name = levelsRef ? get(levelsRef.currentLevel)?.name ?? null : null; + } catch {} + return sceneStorageKey(name, key); +} + +/** 30 P4: the Store Value write. `set` keeps whatever arrived (a number or text); `max`, + * `min` and `add` are numeric against what this device already holds, and an unchanged + * best writes nothing. @param {any} data */ +function storeValue(data) { + const key = String(data.key ?? '').trim(); + if (!key) return; + const full = storedKey(key); + const mode = data.mode ?? 'set'; + if (mode === 'set') { + writeStored(full, typeof data.value === 'string' ? data.value : num(data.value ?? 0)); + return; + } + const value = num(data.value ?? 0); + const held = Number(readStored(full, undefined)); + const has = Number.isFinite(held); + const next = + mode === 'max' ? (has ? Math.max(held, value) : value) + : mode === 'min' ? (has ? Math.min(held, value) : value) + : mode === 'add' ? (has ? held : 0) + value + : value; + if (!has || next !== held) writeStored(full, next); +} + /** The element behind an id, across every HUD document. An input's OPTIONS and its * `shared` flag live on the element, and a node names only the id. @param {string} id */ function findHudElement(id) { @@ -1154,7 +1190,7 @@ function updateGameNodes(time, ctx) { // 1. the ACTIONS, on a fresh trigger stamp only for (const node of nodes) { const type = node.type; - if (type !== 'setgamestate' && type !== 'setcamera' && type !== 'setvariable' && type !== 'setlook' && type !== 'travel') + if (type !== 'setgamestate' && type !== 'setcamera' && type !== 'setvariable' && type !== 'setlook' && type !== 'travel' && type !== 'storevalue') continue; seeActionNode(node, time); const stamp = triggerStampFor(node.id, ctx); @@ -1197,6 +1233,12 @@ function updateGameNodes(time, ctx) { const hash = typeof data.level === 'string' ? data.level : ''; if (sceneName && levelsRef?.travelToScene) levelsRef.travelToScene(sceneName); else if (hash && levelsRef) levelsRef.travelToLevel(hash, String(data.levelName ?? '')); + } else if (type === 'storevalue') { + // 30 P4: a LOCAL write on the stamp edge, inside the actionSeenAt family above (a + // fresh node adopting an old stamp must not overwrite a best on connect). It + // sends NOTHING: every peer that sees the trigger acts for its own device, which + // is the whole semantics of "saved on this device". + storeValue(data); } else if (type === 'setcamera') { const uuid = typeof data.camera === 'string' ? data.camera : ''; if (uuid) lookThroughCamera(uuid); @@ -1828,6 +1870,7 @@ export const valueTypes = [ 'hudinput', // 21-D4: the HUD as a SOURCE - what the player set on a slider/toggle/etc // 21-D6 the game shell 'ongamestate', 'getvariable', 'gametime', + 'storedvalue', // 30 P4: what Store Value saved on this device // 24-A A4: `peervariable` was MISSING here since 21-G4, and the omission was silent in // every direction that is easy to look at — it has an OUTPUT type in flowSockets, an // evaluator case below, and the editor draws its source handle — but `resolveInputs` @@ -2368,6 +2411,16 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) { // LOCAL read of REPLICATED state, so every peer computes the same number and // nothing about the read goes on the wire return num(gameVar(String(d.name ?? ''), d.fallback ?? 0)); + // --- 30 P4: what THIS device saved (never replicated, legitimately per-peer) --- + case 'storedvalue': { + const key = String(d.key ?? '').trim(); + const held = key ? readStored(storedKey(key), undefined) : undefined; + if (d.output === 'text') { + if (held === undefined) return String(d.fallback ?? ''); + return typeof held === 'string' ? held : JSON.stringify(held); + } + return held === undefined ? num(d.fallback ?? 0) : num(held); + } // --- 21-G4: the PER-PLAYER half of the same idea --- case 'peervariable': { // Also a LOCAL read of REPLICATED state — every peer holds every peer's row — diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js index 96a5eaab..b2fb8e33 100644 --- a/src/lib/flowSockets.js +++ b/src/lib/flowSockets.js @@ -61,6 +61,9 @@ const OUTPUT = { ongamestate: 'event', getvariable: 'number', gametime: 'number', + // 30 P4: the readable half of Store Value (a number socket; `output: text` carries a + // string down the same channel, the hudtext precedent) + storedvalue: 'number', // 21-F3's `collectcount` MOVED to the collectible module (R3a); a module value // node's socket type comes from its registerValueNode `vtype`, so no entry here. // 21-G4: one player's own number (mine / a named peer / the sum / the max) @@ -168,6 +171,7 @@ const INPUT = { setcamera: { trigger: 'event', camera: 'object' }, setlook: { trigger: 'event', camera: 'object', on: 'boolean' }, setvariable: { trigger: 'event', value: 'number' }, + storevalue: { trigger: 'event', value: 'number' }, // 30 P4 gamestart: { camera: 'object' }, // 21-F4: travel fires on its trigger edge; allplayers takes each player's own // boolean answer (a Latch, a Gate, a Compare — anything true/false) diff --git a/src/lib/gamePresence.js b/src/lib/gamePresence.js index 3f38f0ca..3771d284 100644 --- a/src/lib/gamePresence.js +++ b/src/lib/gamePresence.js @@ -25,11 +25,15 @@ // history's import subtree, and nothing here registers a history kind. import { writable, get } from 'svelte/store'; -import { peers } from '../stores/appStore'; +import { peers, showToast } from '../stores/appStore'; import { isLocked } from '../stores/sceneStore'; import { rolesInfo } from './cloudHooks'; import { sessionHost } from './connectionState'; import { gameState, resetGame } from './gameState'; +// 30 P2: both leaves (hudDocs: svelte/store + gameState + hudKinds + safeStorage; playMode: +// sceneStore + appStore), so Test play lives here beside the reset permission it obeys +import { hudIsGame, hudScreenOverride } from './hudDocs'; +import { requestPlay } from './playMode'; /** The two modes. A third would be a protocol change, so the reader treats anything it * does not recognise as `editor` (the normalize-at-the-boundary rule). */ @@ -275,8 +279,11 @@ export function isSessionWriter() { */ export function tickAbandonWatch(now = Date.now()) { const game = get(gameState); - if (game.state !== 'playing') { - // nothing to abandon; forget the arming so the NEXT round starts clean + // 30 P2 (roadmap 30 fork 5): EVERY state but `menu` is a round a player can leave — a + // paused round and a finished one (`over`, the victory screen) used to linger on the + // editor forever, because only `playing` was watched. `menu` is the one state with + // nothing to abandon, and reaching it forgets the arming so the NEXT round starts clean. + if (game.state === 'menu') { armedRound = null; return 'idle'; } @@ -287,7 +294,11 @@ export function tickAbandonWatch(now = Date.now()) { } // a round nobody has entered yet is not an abandoned round if (armedRound !== game.round) return 'idle'; - if (now - lastPlayingAt < ABANDON_MS) return 'waiting'; + // 30 P2: ALONE, leaving IS abandoning — there is nobody whose reload or connect dance + // the ten-second window protects, and the round must not sit `playing` with its in-game + // HUD waiting for a player who is back in the editor. With peers the window stands. + const grace = livePeers().length ? ABANDON_MS : 0; + if (now - lastPlayingAt < grace) return 'waiting'; if (!isSessionWriter()) return 'notwriter'; // re-read through the single write path: `resetGame` is what the admin button calls, // so the two ways a game ends up back at its menu are literally one function @@ -342,6 +353,41 @@ export function requestResetGame() { /** @type {(()=>void)[]} */ let disposers = []; +/** was this peer in play at the last isLocked notification (the exit EDGE, not a state) */ +let wasPlaying = false; +/** @type {any} */ let pokeTimer = null; + +/** One deferred watch pass; several notifications in one task coalesce into it. */ +function pokeWatch() { + if (pokeTimer || typeof setTimeout === 'undefined') return; + pokeTimer = setTimeout(() => { + pokeTimer = null; + tickAbandonWatch(); + }, 0); +} + +/** + * 30 P2 — ▶ TEST PLAY (roadmap 30 fork 4). What an author means by "try my game": back to + * the menu, into Play, the Start screen in front of them. Reached from the game chip and + * from the play button's right-click menu. + * + * The reset goes through `requestResetGame`, so it obeys the same rule as the Users + * popover's admin entry: the host (or anyone alone) may, an admin under a roles plugin + * may, and anybody else JOINS the game as it is — told so, never silently. This peer's + * own screen overrides are cleared either way (LOCAL), so the state-bound screen decides + * what shows. Play itself is the ordinary `requestPlay`, synchronously inside the click, so + * an immersive session is still requested within the gesture that asked for it. + * @returns {{ok: boolean, reason?: string}} the reset's verdict + */ +export function testPlay() { + const game = get(gameState); + const pristine = game.state === 'menu' && !game.startedAt; + const verdict = pristine ? { ok: true } : requestResetGame(); + if (!verdict.ok) showToast((verdict.reason ?? 'The game cannot be reset.') + ' Joining the game as it is.'); + hudScreenOverride.set({}); + requestPlay(); + return verdict; +} /** Install the presence broadcast + the abandon watch. Idempotent. */ export function startGamePresence() { @@ -349,7 +395,26 @@ export function startGamePresence() { // the subscribe lives HERE and not at module scope: a module-level subscribe runs // its callback SYNCHRONOUSLY at module eval, and anything it reads that is declared // below TDZ-crashes the SSR prerender (the documented meshEdit/faceEdit trap) - disposers.push(isLocked.subscribe(() => publishPlayMode())); + disposers.push( + isLocked.subscribe((v) => { + publishPlayMode(); + // 30 P2: a screen THIS peer chose during play (the pause menu a P press opened, a + // hudscreen node's override) is play state — it must not follow the player back + // into the editor, or the next Play lands on a stale pause screen over a round + // that has since returned to its menu. Games only: a plain HUD's overrides are + // authored behaviour and keep their old lifetime. + if (v !== true && wasPlaying && get(hudIsGame)) hudScreenOverride.set({}); + wasPlaying = v === true; + pokeWatch(); + }) + ); + // 30 P2: the watch also runs on the EDGES, not only on its 1s beat: entering play arms + // the round even if the player leaves inside the first second, and the last player + // leaving resets a solo round on the spot. Deferred a macrotask, because a tick may + // WRITE the game state and these are store notifications (never write a store from + // inside a subscriber). + disposers.push(gameState.subscribe(() => pokeWatch())); + disposers.push(peerPlayModes.subscribe(() => pokeWatch())); watchTimer = setInterval(() => tickAbandonWatch(), WATCH_MS); disposers.push(() => { clearInterval(watchTimer); @@ -361,6 +426,9 @@ export function startGamePresence() { export function stopGamePresence() { for (const dispose of disposers) dispose(); disposers = []; + if (pokeTimer) clearTimeout(pokeTimer); + pokeTimer = null; + wasPlaying = false; } /** Test/debug view — `abandonWrites` is the only way to tell WHICH peer wrote a diff --git a/src/lib/gameStorage.js b/src/lib/gameStorage.js new file mode 100644 index 00000000..e68d6544 --- /dev/null +++ b/src/lib/gameStorage.js @@ -0,0 +1,197 @@ +// 30 P4 — WHAT A GAME REMEMBERS ON THIS DEVICE (roadmap 30 fork 7). +// +// Two consumers, one rule: a module's `api.storage` and the Store Value / Stored Value flow +// nodes. Both are LOCAL per device by design — a best score, unlocked levels, a "seen the +// tutorial" flag — so nothing here replicates, nothing enters a scene file and nothing is +// undone. A peer's best is theirs; the card says so ("Saved on this device only"). +// +// THE KEYS ARE A CONTRACT, byte for byte, because a module that must run on an older core +// (untangle's fallback for a 1.16 client) writes the SAME key itself so its data survives +// the upgrade: +// +// tp:mod:: a module's own namespace (api.storage) +// tp:scene:: a flow graph's namespace, per scene ('untitled' +// when the scene has no name) +// +// VALUES ARE JSON. `set(key, value)` stores `JSON.stringify(value)`; `get` parses it back +// and hands the fallback over on a missing key OR a value that does not parse (a hand-edited +// entry, a truncated write), because a broken save must never crash a game's boot. +// +// THE CAP is per MODULE (256 KB), measured as the JSON text of every entry in the namespace +// plus its key — what the browser actually spends. A write that would cross it returns +// false and nothing is written; the caller hears it once per session through `onOverCap` +// (moduleSDK turns that into ONE toast), never once per frame. +// +// A DELIBERATE LEAF: it imports safeStorage (itself a leaf) and nothing else, so moduleSDK, +// flowRuntime and a unit test can all reach it. safeStorage is what makes the promise +// honest: in Safari private mode, a sandboxed frame or a full quota the write falls back to +// memory for the session instead of throwing. + +import { safeStorage } from './safeStorage'; + +/** bytes (UTF-16 code units, i.e. JSON text length) one module may keep */ +export const MODULE_STORAGE_CAP = 256 * 1024; + +/** @param {string} moduleId */ +export function modulePrefix(moduleId) { + return 'tp:mod:' + String(moduleId) + ':'; +} + +/** @param {string|null|undefined} sceneName */ +export function scenePrefix(sceneName) { + const name = typeof sceneName === 'string' && sceneName.trim() ? sceneName.trim() : 'untitled'; + return 'tp:scene:' + name + ':'; +} + +/** The full key a module writes. @param {string} moduleId @param {string} key */ +export function moduleStorageKey(moduleId, key) { + return modulePrefix(moduleId) + String(key); +} + +/** The full key a Store Value node writes. @param {string|null|undefined} sceneName @param {string} key */ +export function sceneStorageKey(sceneName, key) { + return scenePrefix(sceneName) + String(key); +} + +/** Parsed-value cache keyed by the FULL key. Every write through this module keeps it + * current, so a Stored Value node read every frame costs a Map lookup, not a + * localStorage read and a JSON.parse. @type {Map} */ +const cache = new Map(); + +/** @param {string} full @returns {{text: string|null, value: any}} */ +function read(full) { + const hit = cache.get(full); + if (hit) return hit; + const text = safeStorage.getItem(full); + /** @type {any} */ + let value; + let ok = text !== null; + if (ok) { + try { + value = JSON.parse(/** @type {string} */ (text)); + } catch { + ok = false; + } + } + const entry = { text: ok ? text : null, value: ok ? value : undefined }; + cache.set(full, entry); + return entry; +} + +/** Read a full key: the parsed value, or `fallback` when it is missing or unreadable. + * @param {string} full @param {any} [fallback] */ +export function readStored(full, fallback = undefined) { + const entry = read(full); + return entry.text === null ? fallback : entry.value; +} + +/** Does a full key hold a readable value? @param {string} full */ +export function hasStored(full) { + return read(full).text !== null; +} + +/** Write a full key (JSON). `undefined` removes it. Returns the JSON text written, or + * null when the value cannot be serialized (a cycle, a BigInt). + * @param {string} full @param {any} value @returns {string|null} */ +export function writeStored(full, value) { + if (value === undefined) { + removeStored(full); + return ''; + } + /** @type {string} */ + let text; + try { + const out = JSON.stringify(value); + if (typeof out !== 'string') return null; // a function, a symbol + text = out; + } catch { + return null; + } + safeStorage.setItem(full, text); + cache.set(full, { text, value: JSON.parse(text) }); + return text; +} + +/** @param {string} full */ +export function removeStored(full) { + safeStorage.removeItem(full); + cache.delete(full); +} + +/** Every full key under a prefix. @param {string} prefix @returns {string[]} */ +function fullKeysUnder(prefix) { + return safeStorage.keys().filter((key) => key.startsWith(prefix)); +} + +/** The JSON bytes a namespace holds, optionally pretending one key held `replace` instead. + * @param {string} prefix @param {string} [except] a full key to leave out */ +function usedBytes(prefix, except) { + let total = 0; + for (const full of fullKeysUnder(prefix)) { + if (full === except) continue; + const text = safeStorage.getItem(full); + if (text !== null) total += full.length + text.length; + } + return total; +} + +/** + * The `api.storage` object for one module. + * @param {string} moduleId + * @param {{cap?: number, onOverCap?: (info: {key: string, bytes: number, cap: number}) => void}} [options] + */ +export function makeModuleStorage(moduleId, options = {}) { + const prefix = modulePrefix(moduleId); + const cap = options.cap ?? MODULE_STORAGE_CAP; + return { + /** @param {string} key @param {any} [fallback] */ + get(key, fallback = undefined) { + return readStored(prefix + String(key), fallback); + }, + /** @param {string} key @param {any} value @returns {boolean} written */ + set(key, value) { + const full = prefix + String(key); + if (value === undefined) { + removeStored(full); + return true; + } + /** @type {string|undefined} */ + let text; + try { + text = JSON.stringify(value); + } catch { + return false; + } + if (typeof text !== 'string') return false; + const bytes = usedBytes(prefix, full) + full.length + text.length; + if (bytes > cap) { + options.onOverCap?.({ key: String(key), bytes, cap }); + return false; + } + return writeStored(full, value) !== null; + }, + /** @param {string} key */ + remove(key) { + removeStored(prefix + String(key)); + }, + /** this module's keys, without the prefix, sorted @returns {string[]} */ + keys() { + return fullKeysUnder(prefix) + .map((full) => full.slice(prefix.length)) + .sort(); + }, + /** forget everything this module stored — the module's own reset */ + clear() { + for (const full of fullKeysUnder(prefix)) removeStored(full); + }, + /** how much of the cap is spent (JSON text of every entry + its key) */ + bytes() { + return usedBytes(prefix); + } + }; +} + +/** TEST SEAM: forget the parsed-value cache (a suite that writes localStorage directly). */ +export function debugResetGameStorage() { + cache.clear(); +} diff --git a/src/lib/hudActions.js b/src/lib/hudActions.js index 5579f1c1..efb51344 100644 --- a/src/lib/hudActions.js +++ b/src/lib/hudActions.js @@ -115,6 +115,10 @@ export const HUD_ACTIONS = [ // entry calls, so the two ways a game is reset are one function. { key: 'resetgame', label: 'Reset the game', group: 'Game', role: 'press', node: 'setgamestate', data: { state: 'menu', reset: true }, handle: 'trigger', hint: 'Back to the menu AND the round clock to zero — collectibles read un-collected again.' }, { key: 'setvar', label: 'Set a variable', group: 'Game', role: 'press', node: 'setvariable', data: { name: 'score', op: 'add', value: 1 }, handle: 'trigger', hint: 'Add to, subtract from or set a shared number.' }, + // 30 P4: keep the best on THIS device. The value is fed from the `score` variable by a + // `via` source (additive to the press role: absent everywhere else, so every other + // press action builds exactly what it built before). + { key: 'savebest', label: 'Save best score', group: 'Game', role: 'press', node: 'storevalue', data: { key: 'best', mode: 'max' }, handle: 'trigger', via: { node: 'getvariable', data: { name: 'score' }, handle: 'value' }, hint: 'Keeps the highest “score” seen on this device (Store Value, max) — each player keeps their own.' }, // 21-F4: LEVEL COMPLETE — travel to a level. The destination is the AUTHOR'S PICK // on the Travel card, deliberately not a "next by folder order": the Explorer // library is LOCAL, so two peers can hold different orders and a computed "next" @@ -309,6 +313,10 @@ export function describeNode(node, handle = null) { return 'Only once'; case 'getvariable': return 'Variable “' + (d.name ?? '') + '”'; + case 'storevalue': + return 'Save “' + (d.key ?? '') + '” on this device (' + (d.mode ?? 'set') + ')'; + case 'storedvalue': + return 'Saved “' + (d.key ?? '') + '” (this device)'; case 'gametime': return 'Round time (' + (d.read ?? 'elapsed') + ')'; case 'hudtext': @@ -521,6 +529,13 @@ export function addBinding(elementId, actionKey) { const actionNode = makeNode(action.node, baseX + 220, baseY, action.data); created.push(actionNode); createdEdges.push(makeEdge(press, actionNode, action.handle)); + // 30 P4: a press action may name the VALUE it acts on too (Save best score reads + // the score) — the drives role's `via`, one socket over + if (action.via) { + const source = makeNode(action.via.node, baseX, baseY + 120, action.via.data); + created.push(source); + createdEdges.push(makeEdge(source, actionNode, action.via.handle)); + } } } else { const displayType = /** @type {any} */ (DISPLAY_NODE)[String(action.role === 'drives' ? currentKindOf(elementId) : '')] ?? 'hudtext'; diff --git a/src/lib/hudDocs.js b/src/lib/hudDocs.js index 8b38b473..81c83f03 100644 --- a/src/lib/hudDocs.js +++ b/src/lib/hudDocs.js @@ -21,7 +21,7 @@ // computes the same string with no message of its own. Screen visibility is per-peer ON // PURPOSE — one player on the start menu while another plays. -import { writable, get } from 'svelte/store'; +import { writable, derived, get } from 'svelte/store'; import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares // 21-D1: the kind REGISTRY. hudKinds imports nothing, so this stays a leaf. import { HUD_KINDS as REGISTERED_KINDS, defaultsForKind, styleDefaultsForKind, kindDef } from './hudKinds'; @@ -75,6 +75,28 @@ export const activeHudDoc = writable(null); * @type {import('svelte/store').Writable>} */ export const hudScreenOverride = writable({}); +/** + * 30 P1: IS THIS SCENE A GAME? The rule, and why it is this one: a scene is a game when ANY + * of its HUD documents holds a screen bound to a game state (`showWhile` set). That binding + * is the one thing that makes the editor MASQUERADE as the running game — the shared state + * picks the screen, so a menu whose state is `menu` paints itself over the editor with + * nobody in Play. Every Games-tab template has one; a hand-made HUD with no state-bound + * screen is a plain overlay and keeps its old behaviour (drawn in the editor, buttons live). + * A `hudbutton -> setgamestate` wiring alone was considered and NOT taken: with no + * state-bound screen nothing masquerades, and reading the graph here would put a flow edge + * into a leaf that keeps none. + * @param {Record} docs @returns {boolean} + */ +export function isGameHud(docs) { + for (const doc of Object.values(docs ?? {})) + for (const screen of Array.isArray(doc?.screens) ? doc.screens : []) + if (typeof screen?.showWhile === 'string' && screen.showWhile) return true; + return false; +} + +/** 30 P1: the rule above as a store — HudLayer and the game chip both read it. */ +export const hudIsGame = derived(hudDocs, (docs) => isGameHud(docs)); + /** The editor's current selection, per document key -> element ids. LOCAL. * @type {import('svelte/store').Writable>} */ export const hudSelection = writable({}); diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index c81f3631..24ae0f2e 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -43,6 +43,12 @@ import { ndcFromClient } from './canvasRect'; // 27-B: recovery paths report through the diagnostics ring (hardening audit H4) import { log } from './diagnostics'; import { safeStorage } from './safeStorage'; +// 30 P4: api.storage — a LEAF (safeStorage only), shared with the Store Value flow node +import { makeModuleStorage } from './gameStorage'; + +/** 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) */ +const storageCapWarned = new Set(); // Module SDK v1 — in-repo modules under src/modules// register through // the api object passed to their register(api). See MODULES.md for the guide. @@ -1014,6 +1020,24 @@ function makeApi(moduleId, moduleName = moduleId) { * immune to the shared-scope add race. Rows replicate on the presence channel, * late joiners converge, a row drops with its owner's disconnect. */ + /** + * 30 P4 (roadmap 30 fork 7): what this module remembers ON THIS DEVICE — a best + * score, unlocked levels, a settings choice. JSON values under + * `tp:mod::` through safeStorage (never throws: a private window or a + * full quota falls back to memory for the session), 256 KB per module (a `set` over + * it returns false and says so ONCE), LOCAL: never replicated, never in a scene + * file, never undone — and deliberately NOT cleared when the module is disabled or + * removed (a reinstall keeps your progress; `clear()` is the module's own reset). + * `get(key, fallback)` · `set(key, value) -> bool` · `remove(key)` · `keys()` · + * `clear()` · `bytes()`. + */ + storage: makeModuleStorage(moduleId, { + onOverCap: () => { + if (storageCapWarned.has(moduleId)) return; + storageCapWarned.add(moduleId); + showToast(`"${moduleName}" hit its 256 KB storage limit on this device — that value was not saved.`); + } + }), peerVars: { /** Write MY OWN row. @param {string} name @param {number} value */ setMine(name, value) { diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index a06ac847..ab712820 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -214,6 +214,33 @@ export const nodeCatalog = [ defaults: { name: 'score', fallback: 0 }, params: [{ key: 'name', kind: 'text', placeholder: 'score', maxLength: 40 }] }, + // 30 P4 (roadmap 30 fork 7): what a game remembers ON THIS DEVICE — a best score, + // a level reached. Keyed `tp:scene::` so a best belongs to its + // game. LOCAL by design: Store Value writes this peer's browser and sends + // nothing, so a REPLICATED trigger (an ordinary On Click) makes every peer save + // its own copy, while a per-player trigger saves only for the one who earned it. + { + type: 'storevalue', + label: 'Store Value', + defaults: { key: 'best', mode: 'max', value: 0 }, + inputs: ['trigger', 'value'], + inputLabels: { value: 'value — wire a Counter, a variable, a score' }, + params: [ + { key: 'key', kind: 'text', placeholder: 'best', maxLength: 60 }, + { key: 'mode', kind: 'select', options: ['set', 'max', 'min', 'add'] } + ], + note: 'Saved on this device only — never sent to other players, never in the scene file.' + }, + { + type: 'storedvalue', + label: 'Stored Value', + defaults: { key: 'best', output: 'number', fallback: 0 }, + params: [ + { key: 'key', kind: 'text', placeholder: 'best', maxLength: 60 }, + { key: 'output', kind: 'select', options: ['number', 'text'] } + ], + note: 'Reads what THIS device saved — each player sees their own.' + }, // the round clock, derived from the shared startedAt stamp — no clock of its own { type: 'gametime', diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index df0afa02..d93cf64d 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -35,6 +35,8 @@ export const NODE_DOCS = { allplayers: "Iterates the connected players so a per-player value can be read, or a HUD row shown, for each of them.", setvariable: "Stores a named value in the replicated scene variables when a pulse arrives - shared state without an object.", getvariable: "Reads a named scene variable as a value - the read side of Set Variable, live on every peer.", + storevalue: "Saves a value on THIS device when a pulse arrives (set, keep the max or min, or add) - a best score or a level reached that survives a reload; never sent to other players.", + storedvalue: "Reads what Store Value saved on this device, as a number or text - the read side of a best score; each player sees their own.", gametime: "Seconds since the game (or the round) started, the same on every peer - a clock that pauses with the game.", peervariable: "A value kept PER PLAYER (score, lives, team) - each peer reads its own row, or a named player's.", charcontroller: "Turns the connected object into a walking, jumping character driven by the player's movement input.", @@ -70,6 +72,7 @@ export const NODE_DOCS = { onclick: "Fires a short pulse when its object is clicked - the bridge from user input into the graph.", keypress: "Fires a pulse while a keyboard key is pressed - the bridge from your keyboard into the graph.", onimpact: "Fires a pulse when a physics simulation lands the connected object on the ground or another object.", + onhit: "Fires a pulse when a hand or a walking player knocks the object - with how hard it was hit (speed) and whether you did it (byMe).", onenter: "Fires a pulse when something enters a trigger volume - the checkpoint, doorway and pressure-plate node.", onexit: "Fires a pulse when something leaves a trigger volume - the other half of On Enter.", onrest: "Fires a pulse when a physics body has finished moving - the counterpart to On Impact, which fires when it starts.", diff --git a/src/lib/physics.js b/src/lib/physics.js index 1ed12567..14f0268b 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -148,10 +148,34 @@ const IMPACT_MIN_DOWN_VY = 1.2; // m/s downward (pre-step) for a contact to coun export async function warmup() { if (RAPIER) return; const module = await import('@dimforge/rapier3d-compat'); - await module.init(); + await initQuietly(module); RAPIER = module; } +// 30 P0: rapier-compat's own `init()` hands wasm-bindgen's init a bare byte array — the +// pre-0.2.93 calling convention — and wasm-bindgen answers with "using deprecated +// parameters for the initialization function; pass a single object instead". The call +// is INSIDE rapier's bundle (`init` takes no arguments and the inner init is not +// exported), so there is no single-object form for us to pass, and rapier is a FROZEN +// dependency (solver behaviour). So the one warning is filtered for the length of that +// call and nothing else: an exact-prefix match, restored in `finally`, every other +// console.warn passes straight through. +const RAPIER_INIT_WARNING = 'using deprecated parameters for the initialization function'; + +/** @param {any} module */ +async function initQuietly(module) { + const warn = console.warn; + console.warn = (/** @type {any[]} */ ...args) => { + if (typeof args[0] === 'string' && args[0].startsWith(RAPIER_INIT_WARNING)) return; + warn.apply(console, args); + }; + try { + await module.init(); + } finally { + console.warn = warn; + } +} + /** @param {any} object */ function transformOf(object) { return { diff --git a/src/lib/playCursor.js b/src/lib/playCursor.js new file mode 100644 index 00000000..533c69f7 --- /dev/null +++ b/src/lib/playCursor.js @@ -0,0 +1,77 @@ +// 30 P3 — FREE-CURSOR GAMES (roadmap 30 fork 6): WHERE PLAY MODE AIMS. +// +// Play mode had exactly one aim: NDC (0, 0), the crosshair, because under pointer lock the +// cursor is pinned to the centre of the canvas and does not exist for the player. A board +// game, a puzzle or an instrument wants the opposite — the real cursor, visible, aiming +// wherever it is — and `scenePhysics.play.cursor: 'free'` asks for that (absent = 'locked', +// today, byte for byte). A module may publish it too, field by field, through the +// `userData.play` contract (`resolvePlaySettings`). +// +// THIS LEAF IS THE ONE ANSWER to "where is the player aiming right now", so the pieces that +// ask cannot disagree: +// · playInteract's tap and grab (the crosshair ray before this), +// · PointerLockControls, which must not ask for a lock in free mode, +// · PlayReticle, which has no crosshair to draw in free mode, +// · the module SDK's `pointerRay()` (lane 30-core-modes reads `playAimNdc()` in play: +// the cursor when free, the crosshair under a lock — which is also the fix for the +// stale-mouse ray it returned under a lock). +// +// The cursor is recorded in CLIENT pixels and converted to NDC against the canvas at read +// time (the W9 rule `pointerRay` already keeps: the viewport can change with the pointer +// perfectly still). LOCAL and unreplicated — an aim is a fact about this screen. +// +// Imports stores and leaves only (sceneStore, scenePhysics via playSettings, canvasRect), +// so playInteract, PointerLockControls and moduleSDK can all reach it without a cycle. + +import { get } from 'svelte/store'; +import { isLocked, isVRMode, globalScene } from '../stores/sceneStore'; +import { resolvePlaySettings } from './playSettings'; +import { ndcFromClient } from './canvasRect'; + +/** the last pointer position over the page, CLIENT pixels */ +const cursor = { x: 0, y: 0, seen: false }; + +if (typeof window !== 'undefined') { + // capture phase + passive: we only read coordinates, and a panel that stops the event + // on its way up (the documented delegated-handler trap) must not hide the cursor from us + const note = (/** @type {PointerEvent} */ event) => { + cursor.x = event.clientX; + cursor.y = event.clientY; + cursor.seen = true; + }; + window.addEventListener('pointermove', note, { capture: true, passive: true }); + window.addEventListener('pointerdown', note, { capture: true, passive: true }); +} + +/** 'free' | 'locked' — the scene's play cursor, publishers included. Readable in the + * editor too (it is authored data), so the Inspector can show it. @returns {'free'|'locked'} */ +export function playCursorSetting() { + return resolvePlaySettings(get(globalScene)).cursor === 'free' ? 'free' : 'locked'; +} + +/** Is play running with a FREE cursor right now? Desktop play only: a headset has no + * cursor to free, and outside play there is no aim at all. */ +export function playCursorFree() { + if (get(isLocked) !== true || get(isVRMode)) return false; + return playCursorSetting() === 'free'; +} + +/** The last cursor position in client pixels, or null before the first pointer event. */ +export function cursorClient() { + return cursor.seen ? { x: cursor.x, y: cursor.y } : null; +} + +/** + * WHERE PLAY MODE AIMS, in NDC: the cursor in free-cursor play (the centre until the first + * pointer event), the crosshair (0, 0) otherwise. + * @returns {{x: number, y: number}} + */ +export function playAimNdc() { + if (!playCursorFree() || !cursor.seen) return { x: 0, y: 0 }; + return ndcFromClient(cursor.x, cursor.y); +} + +/** test/debug view */ +export function playCursorDebug() { + return { setting: playCursorSetting(), free: playCursorFree(), cursor: cursorClient(), ndc: playAimNdc() }; +} diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js index 88102534..1ace69f9 100644 --- a/src/lib/playInteract.js +++ b/src/lib/playInteract.js @@ -18,6 +18,8 @@ import { velocityFromSamples } from './throwVelocity'; import { resolvePlaySettings } from './playSettings'; import { nameOf } from './lockControl'; import { moduleClickHandlers, moduleInteractiveGroups, fireClickMiss } from './moduleSDK'; +// 30 P3: where play mode aims — the crosshair under a lock, the cursor in a free-cursor game +import { playAimNdc, playCursorFree } from './playCursor'; // 21-B B3: play mode becomes INTERACT mode — a crosshair grab at distance, // scroll to push and pull, and a release that throws with the velocity you @@ -113,8 +115,13 @@ function dynamicUuids() { /** @param {any} camera */ function aimFrom(camera) { camera.getWorldPosition(camPos); - camera.getWorldDirection(camDir); + // 30 P3: the aim is the crosshair (NDC 0,0) under a lock and the CURSOR in a + // free-cursor game; `camDir` then follows the ray rather than the view axis, which is + // what makes a carried object follow the cursor + const aim = playAimNdc(); + centre.set(aim.x, aim.y); raycaster.setFromCamera(centre, camera); + camDir.copy(raycaster.ray.direction); return sceneHits(raycaster, {}); // no tinyProxies: a proxy carries no `face` // and is a SELECTION affordance — grabbing an invisible speck is not a feature } @@ -218,6 +225,11 @@ function sendThrow(object, velocity, throwIt) { /** @param {PointerEvent} event */ function onPointerDown(event) { if (event.button !== 0) return; + // 30 P3: with a FREE cursor a press anywhere on the page reaches this window listener — + // a HUD button, a toast, the ✕ — so only a press on the VIEWPORT is a world gesture. + // Under a lock the target is the locked canvas anyway, which is why this is scoped to + // free mode (a synthesized window-level press keeps working there, as it always has). + if (playCursorFree() && !isViewportTarget(event)) return; const mode = interactionMode(); if (mode === 'off' || !activeCamera) return; const hits = aimFrom(activeCamera); @@ -245,6 +257,13 @@ function onPointerDown(event) { beginGrab(target, activeCamera); } +/** Is this event aimed at the 3D viewport (the renderer's canvas)? @param {Event} event */ +function isViewportTarget(event) { + /** @type {any} */ + const target = event.target; + return !!target && target.tagName === 'CANVAS' && !!target.closest?.('.viewport'); +} + /** @param {PointerEvent} event */ function onPointerUp(event) { if (event.button !== 0) return; @@ -338,7 +357,11 @@ export function tickPlayInteract(delta, camera) { return; } camera.getWorldPosition(camPos); - camera.getWorldDirection(camDir); + // 30 P3: along the AIM ray, so a free-cursor carry follows the cursor + const aim = playAimNdc(); + centre.set(aim.x, aim.y); + raycaster.setFromCamera(centre, camera); + camDir.copy(raycaster.ray.direction); targetPos.copy(camPos).addScaledVector(camDir, carryDistance); // dt-based, so a throttled tab does not change the feel const k = Math.min(SPRING_K_MAX, Math.max(SPRING_K_MIN, SPRING_K / Math.sqrt(Math.max(grab.mass, 1)))); diff --git a/src/lib/playMode.js b/src/lib/playMode.js index 6f573a36..a19f886b 100644 --- a/src/lib/playMode.js +++ b/src/lib/playMode.js @@ -3,6 +3,12 @@ import { isLocked, isVRMode, vrOverride, vrPassthrough } from '../stores/sceneSt // appStore is a LEAF (svelte/store and nothing else — sceneStore already pulls in more // than it does), so this does not widen the cycle surface the note below is about. import { showToast } from '../stores/appStore'; +// 30 P0: the back-button marker goes through SvelteKit's shallow-routing entry, not a raw +// `history.pushState` — the router patches the raw call in dev with a "will conflict with +// SvelteKit's router" warning, and it is right: its own popstate handler reads the index +// keys only ITS pushState writes. `$app/navigation` is SSR-safe to IMPORT (only a CALL on +// the server throws, and the marker is only ever pushed from a browser play press). +import { pushState } from '$app/navigation'; // THE PLAY STATE MACHINE, lifted out of Controls.svelte so the play FAB, the FAB's // right-click mode menu and (next) a keyboard shortcut all press the same button. @@ -154,20 +160,38 @@ export function exitPlay() { // no history to go back to, pushing and popping our own entry is invisible. // // It is safe to pop because the marker is ALWAYS the top entry when we hold one: this -// app keeps no state in the URL and nothing else in it calls pushState (grep), so -// there is no router to fight and nothing of the user's can be underneath ours. The -// flag is the whole guard — see `consumePlayMarker` for the double-pop it prevents. +// app keeps no state in the URL and nothing else in it pushes history entries (grep), so +// nothing of the user's can be underneath ours. The flag is the whole guard — see +// `consumePlayMarker` for the double-pop it prevents. +// +// 30 P0: the entry is a SvelteKit SHALLOW entry (`pushState` from $app/navigation) — +// same url, same navigation index, so the router's popstate handler treats the Back +// that spends it as a state change and never navigates; our listener below still sees +// the popstate. The state object lands under the router's `sveltekit:states` key, which +// is where `playMarkerState()` reads it. let backMarker = false; function pushPlayMarker() { if (backMarker || typeof history === 'undefined') return; try { - // the SAME url with a state object: no navigation, nothing for a router to - // resolve, and `history.state.tpPlay` is a readable answer to "is the marker up?" - history.pushState({ tpPlay: true }, ''); + // the SAME url ('' resolves to the current one) with a state object: no + // navigation, nothing to load, and `playMarkerState()` is a readable answer to + // "is the marker up?" + pushState('', { tpPlay: true }); backMarker = true; } catch { - /* a sandboxed frame can refuse pushState; play simply keeps its normal exits */ + /* a sandboxed frame can refuse pushState (and the router refuses before it has + started); play simply keeps its normal exits */ + } +} + +/** the marker's state object as the router stored it on the CURRENT entry (test/debug view) */ +export function playMarkerState() { + try { + const st = /** @type {any} */ (typeof history === 'undefined' ? null : history.state); + return !!st?.['sveltekit:states']?.tpPlay; + } catch { + return false; } } diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 57784b0d..2f739586 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -41,7 +41,11 @@ export function playPublishers(scene) { * The effective play settings: the scene's shared `play` block, overridden * FIELD BY FIELD by each publisher (a module only overrides what it declares). * @param {any} scene - * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number}} + * 30 P3: `cursor` too — 'free' (the real cursor aims, no pointer lock) or 'locked' (the + * crosshair, today). A module publishing `userData.play.cursor` overrides the scene's, the + * way `grounded` does, which is how a board-game module asks for it without an authored + * scene field. + * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number, cursor: 'free'|'locked'}} */ export function resolvePlaySettings(scene) { const base = get(scenePlay); @@ -49,7 +53,8 @@ export function resolvePlaySettings(scene) { const out = { interaction: base.interaction, grounded: base.grounded, - eyeHeight: DEFAULT_EYE_HEIGHT + eyeHeight: DEFAULT_EYE_HEIGHT, + cursor: base.cursor === 'free' ? 'free' : 'locked' }; const publishers = playPublishers(scene); if (publishers.length > 1 && !warnedMultiple) { @@ -67,6 +72,7 @@ export function resolvePlaySettings(scene) { out.interaction = play.interaction; if (typeof play.grounded === 'boolean') out.grounded = play.grounded; if (typeof play.eyeHeight === 'number') out.eyeHeight = play.eyeHeight; + if (play.cursor === 'free' || play.cursor === 'locked') out.cursor = play.cursor; } return out; } diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 8992b6c7..0bc8c1d5 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -43,6 +43,10 @@ export const DEFAULT_SCENE_PHYSICS = Object.freeze({ }); const BOUNDS_ACTIONS = ['freeze', 'respawn', 'delete']; +// 30 P3: the play cursor. 'locked' (the crosshair under pointer lock) is the default and +// is NEVER written — a normalized play block carries `cursor` only when it is 'free' — so +// every scene saved before this, and every scene that does not use it, stays byte-identical. +const PLAY_CURSORS = ['free', 'locked']; const INTERACTIONS = ['grab', 'click', 'off']; /** @param {any} v @param {number} lo @param {number} hi @param {number} fallback */ @@ -142,9 +146,11 @@ export function normalizeScenePhysics(raw) { { interaction: pick(playRaw.interaction, INTERACTIONS, d.play.interaction), grounded: bool(playRaw.grounded, d.play.grounded), - simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay) + simOnPlay: bool(playRaw.simOnPlay, d.play.simOnPlay), + // 30 P3: present only when free (see PLAY_CURSORS) + ...(pick(playRaw.cursor, PLAY_CURSORS, 'locked') === 'free' ? { cursor: 'free' } : {}) }, - ['interaction', 'grounded', 'simOnPlay'] + ['interaction', 'grounded', 'simOnPlay', 'cursor'] ), // A1: the 20 ceiling is throwVelocity's MAX_LINVEL, restated rather than imported — // this module is store-only and the response clamps through clampThrow anyway @@ -189,7 +195,7 @@ scenePhysicsState_.subscribe((s) => sceneGravity.set(s.gravity)); export const scenePhysicsGround = derived(scenePhysicsState_, (s) => s.ground); /** out-of-bounds config. NOT named `sceneBounds` — that is sceneBounds.js */ export const scenePhysicsBounds = derived(scenePhysicsState_, (s) => s.bounds); -/** play-mode block ({interaction, grounded, simOnPlay}) */ +/** play-mode block ({interaction, grounded, simOnPlay, cursor?: 'free'}) */ export const scenePlay = derived(scenePhysicsState_, (s) => s.play); /** A1: the knock block ({enabled, gain, maxSpeed, minSpeed, radius, spin, predict}) */ export const sceneKnock = derived(scenePhysicsState_, (s) => s.knock); diff --git a/tests/e2e/console-hygiene.test.cjs b/tests/e2e/console-hygiene.test.cjs new file mode 100644 index 00000000..10ae74ce --- /dev/null +++ b/tests/e2e/console-hygiene.test.cjs @@ -0,0 +1,121 @@ +// 30 P0 — CONSOLE HYGIENE. Three warnings every session printed, each a real fault in +// miniature and each reported from the diagnostics log of a user's Quest: +// +// 1. `WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead.` +// threlte's defaults `shadows` to PCFSoftShadowMap; three 0.185 downgrades +// it on the first shadow render. App.svelte asks for PCFShadowMap directly now — +// the identical picture, and this suite also asserts the renderer's type IS PCF. +// 2. `Avoid using history.pushState(...)` — SvelteKit's router, answering the play +// marker's raw pushState. playMode.js pushes a SvelteKit SHALLOW entry instead. +// 3. `using deprecated parameters for the initialization function` — wasm-bindgen, +// answering rapier-compat's own init(). physics.js filters exactly that line for +// the length of the call (the call is inside rapier, which is a frozen dependency). +// +// The console is read from BEFORE the first navigation (helpers.setupPage attaches its +// listener ahead of page.goto), because the PCF warning fires on the very first frame +// with a shadow-casting light — a listener attached after boot would never see it and +// this suite would pass over the bug. +const h = require('./helpers.cjs'); + +const BANNED = [ + { id: 'pcf', re: /PCFSoftShadowMap has been deprecated/ }, + { id: 'pushState', re: /Avoid using `history\.pushState/ }, + { id: 'wasm-init', re: /using deprecated parameters for the initialization function/ } +]; + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + /** @param {string} id */ + const seen = (id) => { + const rule = BANNED.find((b) => b.id === id); + return (page.__console ?? []).filter((m) => rule.re.test(m.text)); + }; + + h.check(Array.isArray(page.__console), 'the console is captured from before the first navigation'); + + // a lit, shadow-casting scene: a box under the default environment's sun + await page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 800)); + }); + await page.waitForTimeout(1500); + + // ---- 1. the shadow map -------------------------------------------------------- + const shadow = await page.evaluate(() => { + const THREE = window.__stores.THREE; + let renderer; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + return { + enabled: !!renderer?.shadowMap?.enabled, + type: renderer?.shadowMap?.type, + pcf: THREE.PCFShadowMap, + soft: THREE.PCFSoftShadowMap, + lights: (() => { + let n = 0; + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + scene?.traverse((o) => { + if (o.isLight && o.castShadow) n++; + }); + return n; + })() + }; + }); + h.check(shadow.lights > 0, `premise: a shadow-casting light is in the scene (${shadow.lights})`); + h.check(shadow.enabled, 'shadows stay enabled'); + h.check(shadow.type === shadow.pcf, `the renderer asks for PCFShadowMap (type ${shadow.type}, PCF ${shadow.pcf}, soft ${shadow.soft})`); + h.check(seen('pcf').length === 0, `no PCFSoftShadowMap deprecation warning (${seen('pcf').length})`); + + // ---- 2 + 3. enter play (pushes the back marker, warms rapier), exit --------------- + await page.evaluate(async () => { + const s = window.__stores; + await s.physics.warmup(); + s.playMode.requestPlay(); + }); + await h.eventually( + () => page.evaluate(() => window.__stores.playMode.playBackMarker()), + (v) => v === true, + 'entering play pushes the back marker', + 6000 + ); + const marker = await page.evaluate(() => window.__stores.playMode.playMarkerState?.() ?? false); + h.check(marker === true, 'the marker is a SvelteKit shallow entry (sveltekit:states.tpPlay)'); + await page.evaluate(() => window.__stores.playMode.exitPlay()); + await h.eventually( + () => page.evaluate(() => window.__stores.playMode.playBackMarker()), + (v) => v === false, + 'leaving play spends the marker', + 6000 + ); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.isLocked.subscribe((x) => (v = x))(); return v; }), + (v) => v === null, + 'and play settles back to the editor', + 6000 + ); + await page.waitForTimeout(600); + const rapierReady = await page.evaluate(async () => { + try { + await window.__stores.physics.warmup(); + return true; + } catch { + return false; + } + }); + h.check(rapierReady, 'premise: rapier initialised (its init is what prints the wasm warning)'); + h.check(seen('pushState').length === 0, `no SvelteKit router pushState warning (${seen('pushState').length})`); + h.check(seen('wasm-init').length === 0, `no wasm "deprecated parameters" warning (${seen('wasm-init').length})`); + + // the filter must not eat anything ELSE: an ordinary warning still gets through + await page.evaluate(() => console.warn('console-hygiene: an ordinary warning')); + await page.waitForTimeout(200); + h.check( + (page.__console ?? []).some((m) => /console-hygiene: an ordinary warning/.test(m.text)), + 'an ordinary console.warn still reaches the console (the filter is scoped to one call)' + ); + + await h.finish(browser); +}); diff --git a/tests/e2e/game-dungeon-realms.test.cjs b/tests/e2e/game-dungeon-realms.test.cjs index cbb17627..c5a02156 100644 --- a/tests/e2e/game-dungeon-realms.test.cjs +++ b/tests/e2e/game-dungeon-realms.test.cjs @@ -255,7 +255,7 @@ h.run(async () => { const dungeonNodes = await nodesOf(A.page, 'dkdungeon'); h.check(dungeonNodes.length === 1 && dungeonNodes[0].data.apply === true && dungeonNodes[0].data.seed === SEED, '1.7 ONE Dungeon node owns the recipe (apply on, seed ' + SEED + ')'); h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.8 the game shell starts in menu with the menu screen'); - h.check(/DUNGEON REALMS/.test(await hudText(A.page)), '1.9 the HUD menu screen renders its title'); + await h.eventually(() => A.page.evaluate(() => ({ chip: !!document.querySelector('#game-chip'), buttons: document.querySelectorAll('#hud-layer button').length })), (v) => v.chip && v.buttons === 0, '1.9 in the editor the game chip stands in for the menu (30 P1: no live menu over the editor)', 6000); h.check(a1.resolvedGrounded === true && a1.grounded === true, '1.10 grounded resolves TRUE from the Kit contract (playSettings)'); // ---- 2. B receives it over the handshake --------------------------------------------------- @@ -276,6 +276,7 @@ h.run(async () => { await A.page.locator('#play-button').click(); await B.page.locator('#play-button').click(); await h.eventually(() => snap(A.page), (s) => s.menu, '3.1 A: the module start menu appears in play mode'); + await h.eventually(async () => (await hudText(A.page)) ?? '', (t) => /DUNGEON REALMS/.test(t), '3.1b in play the HUD menu screen renders its title'); await h.eventually(() => snap(B.page), (s) => s.menu, '3.2 B: the module start menu appears'); h.check(await clickMenu(A.page, 'join-p1'), '3.3 A joins as Player 1 (module menu)'); h.check(await clickMenu(B.page, 'join-p2'), '3.4 B joins as Player 2'); diff --git a/tests/e2e/game-editor-flow.test.cjs b/tests/e2e/game-editor-flow.test.cjs new file mode 100644 index 00000000..213ae7e1 --- /dev/null +++ b/tests/e2e/game-editor-flow.test.cjs @@ -0,0 +1,214 @@ +// 30 P1/P2 — GAMES START ON START: the editor stops masquerading as the running game. +// +// Before: loading a Games-tab game painted its MENU over the editor ("TOWERS / Start +// round" on top of the scene being built) with LIVE buttons — a Start press in the editor +// started the round for every peer with nobody in Play — and leaving Play left the round +// `playing` with its in-game HUD on the editor. +// +// After: a GAME's screens are not drawn outside Play (the rule, and why it is the rule: +// hudDocs `isGameHud` — a scene is a game when one of its HUD screens is bound to a game +// state with `showWhile`), the HUD editor's preview eye still shows them for authoring +// with every button INERT, and a small chip stands in for them: "Game · " plus +// ▶ Test play. +// +// Loaded from the REAL Towers .tpscene with the REAL collectible zip, skip-never-fail when +// either is missing (authored content must never turn a bare checkout red). +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 TPSCENE = + process.env.TOWERS_TPSCENE || (SCENES_REPO && path.join(SCENES_REPO, 'games/towers/scene.tpscene')); + +/** @param {any} page */ +async function loadTowers(page) { + const bytes = Array.from(fs.readFileSync(TPSCENE)); + 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(2000); +} + +/** @param {any} page */ +const snap = (page) => + page.evaluate(() => { + const s = window.__stores; + /** @param {any} st */ + const g = (st) => { + let v; + st.subscribe((/** @type {any} */ x) => (v = x))(); + return v; + }; + const layer = document.querySelector('#hud-layer'); + const chip = document.querySelector('#game-chip'); + return { + state: g(s.gameState.gameState)?.state ?? null, + locked: g(s.isLocked), + isGame: g(s.hudDocs.hudIsGame), + screen: s.hudDocs.visibleScreen('scene')?.id ?? null, + layer: !!layer, + layerText: layer?.textContent ?? '', + buttons: layer ? layer.querySelectorAll('button').length : 0, + chip: !!chip, + chipText: chip?.textContent?.replace(/\s+/g, ' ').trim() ?? '', + preview: g(s.hudDocs.hudPreviewInViewport) + }; + }); + +h.run(async () => { + if (!TPSCENE || !fs.existsSync(TPSCENE)) { + console.log('SKIP: no sibling scenes checkout with games/towers/scene.tpscene (or TOWERS_TPSCENE)'); + return; + } + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { + context: { viewport: { width: 1280, height: 720 } }, + storage: { hudPreviewInViewport: 'false' } + }); + const page = A.page; + if (!(await h.installModule(A, 'collectible'))) { + console.log('SKIP: no collectible.zip in the sibling modules checkout (npm run pack -- collectible)'); + await h.finish(browser); + return; + } + await loadTowers(page); + + // ===================================================================== + // 1. EDIT MODE SHOWS THE CHIP, NOT THE MENU + // ===================================================================== + let st = await snap(page); + h.check(st.state === 'menu' && st.screen === 'menu', `premise: the game sits on its menu (${st.state}/${st.screen})`); + h.check(st.locked !== true, 'premise: we are in the editor, not in Play'); + h.check(st.isGame === true, 'Towers reads as a GAME (a showWhile-bound screen)'); + h.check(st.buttons === 0, `no HUD button exists in the editor (${st.buttons})`); + h.check(!/TOWERS/.test(st.layerText), 'and the menu title is not painted over the editor'); + h.check(st.chip, 'the game chip stands in for it'); + h.check(/Game\s*·\s*menu/.test(st.chipText), `reading "Game · menu" (${st.chipText})`); + + // A REAL click where the Start button used to be changes nothing. The spot is computed + // from the element's own anchored rect, the rule HudLayer places it by. + const start = await page.evaluate(() => { + const s = window.__stores; + const doc = s.hudDocs.hudDocOf('scene'); + const menu = doc.screens.find((sc) => sc.id === 'menu'); + const el = menu.elements.find((e) => e.kind === 'button' && /start/i.test(String(e.label ?? e.text ?? ''))); + if (!el) return null; + const r = s.hudDocs.rectInFrame(el, window.innerWidth, window.innerHeight); + return { x: r.left + r.w / 2, y: r.top + r.h / 2, id: el.id }; + }); + h.check(!!start, `premise: the Start button is in the document (${start?.id})`); + if (start) { + const under = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? null, start); + h.check(under !== 'BUTTON', `nothing button-shaped is under that spot in the editor (${under})`); + await page.mouse.click(start.x, start.y); + await page.waitForTimeout(900); + st = await snap(page); + h.check(st.state === 'menu', `clicking where Start used to be leaves the game on its menu (${st.state})`); + } + + // ===================================================================== + // 2. THE PREVIEW EYE: the author can still SEE the screens — inert + // ===================================================================== + await page.locator('#game-chip-preview').click(); + await h.eventually(() => snap(page), (v) => v.buttons > 0 && /TOWERS/.test(v.layerText), 'the preview eye paints the menu over the editor', 5000); + st = await snap(page); + h.check(st.preview === true, 'the eye IS the HUD editor\'s preview store (one switch, two surfaces)'); + const inert = await page.evaluate(() => { + const b = [...document.querySelectorAll('#hud-layer button')].find((x) => /start/i.test(x.textContent ?? '')); + if (!b) return null; + const r = b.getBoundingClientRect(); + const x = r.left + r.width / 2; + const y = r.top + r.height / 2; + const under = document.elementFromPoint(x, y); + return { tab: b.tabIndex, x, y, clickThrough: !b.contains(under), under: under?.tagName ?? null }; + }); + h.check(!!inert && inert.tab === -1, `the previewed Start is out of the tab order (${JSON.stringify(inert)})`); + h.check(!!inert && inert.clickThrough, `and the preview is a PICTURE of the menu: a click there reaches the viewport (${inert?.under})`); + // a real press on the previewed button's own pixel + if (inert) await page.mouse.click(inert.x, inert.y); + await page.waitForTimeout(900); + st = await snap(page); + h.check(st.state === 'menu', `pressing the PREVIEWED Start does nothing — buttons are inert outside Play (${st.state})`); + await page.locator('#game-chip-preview').click(); + await h.eventually(() => snap(page), (v) => v.buttons === 0, 'the eye off hides the screens again', 5000); + + // ===================================================================== + // 3. ▶ TEST PLAY (solo): back to the menu, into Play, the Start screen + // ===================================================================== + // leave the round somewhere Test play must undo: mid-game, with a local screen override + await page.evaluate(() => { + const s = window.__stores; + s.gameState.setGameState('playing'); + s.hudDocs.showHudScreen('scene', 'pause'); + }); + await h.eventually(() => snap(page), (v) => v.state === 'playing', 'premise: the round is running before Test play', 4000); + await page.locator('#game-chip-test').click(); + await h.eventually(() => snap(page), (v) => v.locked === true, 'Test play enters Play', 6000); + st = await snap(page); + h.check(st.state === 'menu', `...with the game reset to its menu (${st.state})`); + h.check(st.screen === 'menu', `...and the MENU screen showing, not the stale pause override (${st.screen})`); + await h.eventually(() => snap(page), (v) => /Start round/.test(v.layerText) && v.buttons > 0, 'the Start screen is in front of the player', 6000); + h.check(!st.chip, 'the chip is editor chrome — gone in Play'); + await page.locator('#hud-layer button', { hasText: 'Start round' }).click(); + await h.eventually(() => snap(page), (v) => v.state === 'playing' && v.screen === 'hud', 'Start (in Play) starts the round', 8000); + await page.keyboard.press('Escape'); + await h.eventually(() => snap(page), (v) => v.locked !== true, 'Escape returns to the editor', 6000); + await h.eventually(() => snap(page), (v) => v.state === 'menu', 'ALONE, leaving play resets the round to its menu at once', 3000); + st = await snap(page); + h.check(st.buttons === 0 && !/Stack on the glowing pad/.test(st.layerText), `and no in-game HUD is left on the editor (${st.buttons} buttons)`); + h.check(/Game\s*·\s*menu/.test(st.chipText), `the chip reads the menu again (${st.chipText})`); + + // the play button's right-click menu carries the same row + await page.locator('#play-button').click({ button: 'right' }); + const row = page.locator('[role=menuitem]', { hasText: 'Test play (start from the menu)' }); + await h.eventually(() => row.count(), (n) => n === 1, 'the play button right-click menu offers "Test play (start from the menu)"', 4000); + await row.first().click(); + await h.eventually(() => snap(page), (v) => v.locked === true && v.state === 'menu', 'and the row enters Play on the menu', 6000); + await page.keyboard.press('Escape'); + await h.eventually(() => snap(page), (v) => v.locked !== true, 'back to the editor', 6000); + + // ===================================================================== + // 4. TWO PEERS: the round ends when the LAST player leaves + // ===================================================================== + const B = await h.setupPage(browser, 'B', { context: { viewport: { width: 1280, height: 720 } } }); + if (!(await h.installModule(B, 'collectible'))) { + console.log('SKIP (section 4): no collectible.zip for the second peer'); + await h.finish(browser); + return; + } + await h.connect(B, A); + await h.eventually(() => snap(B.page), (v) => v.isGame === true, 'B received the game (its HUD document)', 20000); + const both = async () => ({ a: await snap(page), b: await snap(B.page) }); + // A (the host) Test-plays and starts; B joins play + await page.locator('#game-chip-test').click(); + await h.eventually(() => snap(page), (v) => v.locked === true, 'A enters Play through Test play', 6000); + await page.locator('#hud-layer button', { hasText: 'Start round' }).click(); + await h.eventually(() => both(), (v) => v.a.state === 'playing' && v.b.state === 'playing', 'Start in A\'s Play starts the round for both', 8000); + await B.page.evaluate(() => window.__stores.playMode.requestPlay()); + await h.eventually( + () => page.evaluate(() => window.__stores.gamePresence.gamePresenceDebug().peers), + (m) => Object.values(m).includes('playing'), + 'A sees B in play', + 8000 + ); + // A leaves while B plays: the round stays + await page.keyboard.press('Escape'); + await h.eventually(() => snap(page), (v) => v.locked !== true, 'A back in the editor', 6000); + await page.waitForTimeout(12000); // past the ten-second window: B is still playing + let v2 = await both(); + h.check(v2.a.state === 'playing' && v2.b.state === 'playing', `A left while B plays: the round is still on for both (${v2.a.state}/${v2.b.state})`); + // B leaves too: nobody is in play, so the host commits the menu after the window + await B.page.keyboard.press('Escape'); + await h.eventually(() => snap(B.page), (v) => v.locked !== true, 'B back in the editor', 6000); + await h.eventually(() => both(), (v) => v.a.state === 'menu' && v.b.state === 'menu', 'the LAST player leaving resets the round to menu on BOTH (the host writes it)', 20000); + const writes = await Promise.all([page, B.page].map((p) => p.evaluate(() => window.__stores.gamePresence.gamePresenceDebug().abandonWrites))); + h.check(writes[0] >= 1 && writes[1] === 0, `written by the host alone (A=${writes[0]}, B=${writes[1]})`); + + await h.finish(browser); +}); diff --git a/tests/e2e/game-loop.test.cjs b/tests/e2e/game-loop.test.cjs index cc023995..a0d14096 100644 --- a/tests/e2e/game-loop.test.cjs +++ b/tests/e2e/game-loop.test.cjs @@ -82,7 +82,10 @@ h.run(async () => { ...withStates, screens: withStates.screens.map((sc) => ({ ...sc, - showWhile: sc.id === hudScreen ? 'playing' : 'menu' + showWhile: sc.id === hudScreen ? 'playing' : 'menu', + // 30 P1: the menu is pressed IN PLAY now, so it frees the pointer the way every + // Games-tab menu does (21-E3's menu substate) + ...(sc.id === hudScreen ? {} : { input: 'menu' }) })) }); await new Promise((r) => setTimeout(r, 600)); @@ -168,6 +171,10 @@ h.run(async () => { // close the editor so the HUD renders in the viewport (D5's authoring rule) window.__stores.hudEditorClose.set(true); }); + // 30 P1: a GAME's screens are drawn in PLAY, never over the editor (game-editor-flow), + // so both players press play first — which is also the only honest version of "the + // user's scenario": Start is a button inside the game, not a switch in the editor. + for (const p of [A, B]) await p.page.evaluate(() => window.__stores.playMode.requestPlay()); await page.waitForTimeout(1200); const beforeA = await onScreen(A); const beforeB = await onScreen(B); @@ -238,10 +245,17 @@ h.run(async () => { h.check(scoredB === 'Gems: 7', `and the peer derives the same string with no runtime message (${JSON.stringify(scoredB)})`); // ---- 8. a LATE JOINER walks into a running game ------------------------ + // A approves from the EDITOR (a peer cannot approve while in play); B stays in play, + // so the round keeps a player and the abandon watch leaves it running + await page.evaluate(() => window.__stores.playMode.exitPlay()); + await page.waitForTimeout(600); const C = await h.setupPage(browser, 'C'); await C.page.waitForFunction(() => !!window.__stores?.gameState, { timeout: 30000 }); await h.connect(C, A); await C.page.waitForTimeout(3200); + // the joiner looks at the game the way a player does: from inside Play + await C.page.evaluate(() => window.__stores.playMode.requestPlay()); + await C.page.waitForTimeout(1200); const lateState = await gstate(C); h.check(lateState.state === 'playing', `a late joiner arrives mid-game (${lateState.state})`); h.check(lateState.vars?.gems === 7, `with the score already right (${lateState.vars?.gems})`); @@ -286,6 +300,8 @@ h.run(async () => { ); // ---- 9. the game ENDS, and everyone follows ---------------------------- + await page.evaluate(() => window.__stores.playMode.requestPlay()); + await page.waitForTimeout(600); const ended = await page.evaluate(async () => { const s = window.__stores; s.gameState.setGameState('over', { outcome: 'won' }); diff --git a/tests/e2e/game-towers.test.cjs b/tests/e2e/game-towers.test.cjs index ae72bd1f..e2a9ee2c 100644 --- a/tests/e2e/game-towers.test.cjs +++ b/tests/e2e/game-towers.test.cjs @@ -65,11 +65,13 @@ h.run(async () => { h.check(crates.length === 9, `9 pre-placed dynamic crates (${crates.length})`); h.check(st.play?.simOnPlay === true && st.play?.interaction === 'grab', 'play block: grab + simOnPlay'); h.check(st.state === 'menu' && st.screen === 'menu', `starts on the menu screen (${st.state}/${st.screen})`); - h.check(/TOWERS/.test(await hud()), 'the menu renders (TOWERS)'); // 2 — entering play starts the sim (simOnPlay honoured from the file) await page.evaluate(() => window.__stores.isLocked.set(true)); await h.eventually(() => snap().then((v) => v.sim), (v) => v === true, 'entering play starts the sim', 10000); + // 30 P1: a game's menu is drawn in PLAY, not over the editor (game-editor-flow covers + // the editor half), so the menu check lives on this side of the play press now + h.check(/TOWERS/.test(await hud()), 'the menu renders in play (TOWERS)'); // 3 — the Start button flips to playing and swaps the menu for the HUD await clickBtn('Start round'); diff --git a/tests/e2e/game-untangle.test.cjs b/tests/e2e/game-untangle.test.cjs index e45c2f8c..dc636cd0 100644 --- a/tests/e2e/game-untangle.test.cjs +++ b/tests/e2e/game-untangle.test.cjs @@ -203,7 +203,7 @@ h.run(async () => { let a1 = await snap(A.page); h.check(a1.dots === 5 + TEMPLATE_LEVEL, '1.7 level ' + TEMPLATE_LEVEL + ' has ' + (5 + TEMPLATE_LEVEL) + ' dots (' + a1.dots + ')'); h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.8 the game shell starts in menu with the menu screen'); - h.check(/UNTANGLE/.test(await hudText(A.page)) && /Start/.test(await hudText(A.page)), '1.9 the menu renders its title and Start'); + await h.eventually(() => A.page.evaluate(() => ({ chip: !!document.querySelector('#game-chip'), buttons: document.querySelectorAll('#hud-layer button').length })), (v) => v.chip && v.buttons === 0, '1.9 in the editor the game chip stands in for the menu (30 P1: no live menu over the editor)', 6000); // 1c — the SAME file loaded AGAIN (Templates modal, twice): the node's data did not change, so // only a node re-armed by the scene clear applies its level again. Without that the second load @@ -236,6 +236,7 @@ h.run(async () => { await A.page.locator('#play-button').click(); await B.page.locator('#play-button').click(); await A.page.waitForTimeout(500); + await h.eventually(async () => (await hudText(A.page)) ?? '', (t) => /UNTANGLE/.test(t) && /Start/.test(t), '4.0 in play the menu renders its title and Start'); await hudButton(A.page, 'Start').click(); await h.eventually(() => gameStateOf(A.page), (v) => v === 'playing', '4.1 Start flips the shell to playing'); await h.eventually(() => screenOf(A.page), (v) => v === 'hud', '4.2 A sees the HUD screen', 6000); diff --git a/tests/e2e/game-waves.test.cjs b/tests/e2e/game-waves.test.cjs index 9755d98f..b6a18f6d 100644 --- a/tests/e2e/game-waves.test.cjs +++ b/tests/e2e/game-waves.test.cjs @@ -296,7 +296,7 @@ h.run(async () => { h.check(phys.play?.interaction === 'grab' && phys.play?.grounded === true && phys.play?.simOnPlay === true, '1.7 play block: grab, grounded, sim on play (' + JSON.stringify(phys.play) + ')'); h.check(phys.knock?.enabled === true && phys.knock?.maxSpeed === 10, '1.8 the knock block is ON (a hand knocks an enemy) (' + JSON.stringify(phys.knock) + ')'); h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.9 the game shell starts in menu with the menu screen'); - h.check(/WAVES/.test(await hudText(A.page)) && /Start/.test(await hudText(A.page)), '1.10 the menu renders its title and Start'); + await h.eventually(() => A.page.evaluate(() => ({ chip: !!document.querySelector('#game-chip'), buttons: document.querySelectorAll('#hud-layer button').length })), (v) => v.chip && v.buttons === 0, '1.10 in the editor the game chip stands in for the menu (30 P1: no live menu over the editor)', 6000); await h.eventually(() => snap(A.page), (s) => !!s && s.enemies.length === 4 && s.wave === 1 && !s.running && !!s.goal && s.spawns === 3 && s.waves === 3, '1.11 waves derives from the file: 4 enemies, wave 1 of 3, idle, a goal, three spawn points', 10000); const a1 = await snap(A.page); h.check(a1.enemies.map((e) => e.label).join() === ENEMIES.join(), '1.12 enemy order is by name (' + a1.enemies.map((e) => e.label).join(', ') + ')'); @@ -316,6 +316,7 @@ h.run(async () => { await h.eventually(() => simOf(A.page), (v) => v.own === true, '3.1 A simulates on entering play (simOnPlay)', 15000); await B.page.locator('#play-button').click(); await A.page.waitForTimeout(500); + await h.eventually(async () => (await hudText(A.page)) ?? '', (t) => /WAVES/.test(t) && /Start/.test(t), '3.1b in play the menu renders its title and Start'); await hudButton(A.page, 'Start').click(); await h.eventually(() => gameStateOf(A.page), (v) => v === 'playing', '3.2 Start flips the shell to playing on A'); await h.eventually(() => gameStateOf(B.page), (v) => v === 'playing', '3.3 ...and on B (the replicated state)'); diff --git a/tests/e2e/helpers.cjs b/tests/e2e/helpers.cjs index ca8d20c0..28a62fae 100644 --- a/tests/e2e/helpers.cjs +++ b/tests/e2e/helpers.cjs @@ -119,6 +119,12 @@ async function setupPage(browser, name, options = {}) { page.__errors.push(err.message ?? String(err)); console.log(`[${name} pageerror] ` + err.stack); }); + // 30 P0: every console message, from BEFORE the first navigation — a warning printed on + // the first frame (three's shadow-map deprecation) is invisible to a listener attached + // after boot. `{type, text}`; `console-hygiene` reads it. + /** @type {{type: string, text: string}[]} */ + page.__console = []; + page.on('console', (message) => page.__console.push({ type: message.type(), text: message.text() })); // 27-D: `options.hash` loads the app WITH a hash (`{ hash: '#safe' }`). It has to be // on the initial navigation, not set afterwards: safe mode is read once during // onMount, so a hash assigned to a live page arrives long after the decision. diff --git a/tests/e2e/module-storage.test.cjs b/tests/e2e/module-storage.test.cjs new file mode 100644 index 00000000..a2ef2b58 --- /dev/null +++ b/tests/e2e/module-storage.test.cjs @@ -0,0 +1,274 @@ +// 30 P4 — GAMES REMEMBER THINGS ON THIS DEVICE (roadmap 30 fork 7). +// +// Two surfaces, one leaf (gameStorage.js): +// · `api.storage` for modules — JSON under `tp:mod::` through safeStorage, +// 256 KB per module (over it: `false` and ONE toast), LOCAL, surviving a reload. +// · the Store Value / Stored Value flow nodes — `tp:scene::`, +// acting on the trigger's stamp edge, sending NOTHING. +// +// Modules are driven INLINE through `moduleSDK.initModules` (the post-backends precedent: the +// real makeApi path, no zip). The key format is asserted byte for byte against raw +// localStorage, because a module that must also run on an older core (untangle's fallback) +// writes the very same key itself. +const h = require('./helpers.cjs'); + +/** register two inline modules that park their api.storage on window @param {any} page */ +const registerModules = (page) => + page.evaluate(async () => { + const sdk = window.__stores.moduleSDK; + /** @param {string} id */ + const make = (id) => ({ + id, + name: 'Storage test ' + id, + version: '1.0.0', + description: 'inline', + register(api) { + window['__' + id] = api.storage; + } + }); + await sdk.initModules([make('stA'), make('stB')]); + return !!window.__stA && !!window.__stB; + }); + +/** @param {any} page */ +const toastsMatching = (page, re) => + page.evaluate((src) => { + let list = []; + window.__stores.toastStore.subscribe((v) => (list = v ?? []))(); + const r = new RegExp(src); + return list.filter((t) => r.test(String(t?.message ?? t?.text ?? t ?? ''))).length; + }, re.source); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + // ===================================================================== 1. the api + h.check(await registerModules(page), 'two inline modules received api.storage'); + const basics = await page.evaluate(() => { + const a = window.__stA; + const out = {}; + out.setN = a.set('score', 42); + out.setO = a.set('progress', { mode2d: { unlocked: 3, solved: [1, 2] } }); + out.setT = a.set('name', 'Ada'); + out.getN = a.get('score'); + out.getO = a.get('progress'); + out.getT = a.get('name'); + out.missing = a.get('nothing', 'fallback'); + out.keys = a.keys(); + out.raw = localStorage.getItem('tp:mod:stA:progress'); + a.remove('name'); + out.afterRemove = a.keys(); + out.bytes = a.bytes(); + return out; + }); + h.check(basics.setN && basics.setO && basics.setT, 'set returns true for JSON values'); + h.check(basics.getN === 42 && basics.getT === 'Ada', `get round-trips a number and text (${basics.getN}, ${basics.getT})`); + h.check(JSON.stringify(basics.getO) === '{"mode2d":{"unlocked":3,"solved":[1,2]}}', 'and an object'); + h.check(basics.missing === 'fallback', 'a missing key hands back the fallback'); + h.check(JSON.stringify(basics.keys) === '["name","progress","score"]', `keys() lists this module's keys, sorted (${basics.keys})`); + h.check( + basics.raw === '{"mode2d":{"unlocked":3,"solved":[1,2]}}', + `THE KEY CONTRACT: localStorage["tp:mod:stA:progress"] holds the JSON (${basics.raw})` + ); + h.check(JSON.stringify(basics.afterRemove) === '["progress","score"]', 'remove() drops one key'); + h.check(basics.bytes > 0, `bytes() measures what the module spends (${basics.bytes})`); + + // ===================================================================== 2. two modules never meet + const apart = await page.evaluate(() => { + const a = window.__stA; + const b = window.__stB; + b.set('score', 7); + const view = { a: a.get('score'), b: b.get('score'), bKeys: b.keys() }; + b.clear(); + view.afterClear = { a: a.get('score'), bKeys: b.keys() }; + return view; + }); + h.check(apart.a === 42 && apart.b === 7, `the same key in two modules holds two values (${apart.a}, ${apart.b})`); + h.check(JSON.stringify(apart.bKeys) === '["score"]', "a module's keys() never lists another module's"); + h.check(apart.afterClear.a === 42 && apart.afterClear.bKeys.length === 0, "clear() empties this module and leaves the other alone"); + + // ===================================================================== 3. the cap + const cap = await page.evaluate(() => { + const a = window.__stA; + const big = 'x'.repeat(300 * 1024); + const first = a.set('huge', big); + const second = a.set('huge2', big); + return { first, second, has: a.get('huge', null) !== null, cap: window.__stores.gameStorage.MODULE_STORAGE_CAP }; + }); + h.check(cap.first === false && cap.second === false, 'a write over 256 KB returns false'); + h.check(!cap.has, 'and writes nothing'); + h.check(cap.cap === 262144, `the cap is 256 KB (${cap.cap})`); + await page.waitForTimeout(300); + const capToasts = await toastsMatching(page, /storage limit/); + h.check(capToasts === 1, `ONE toast per module per session, not one per write (${capToasts})`); + + // ===================================================================== 4. a failing localStorage + const failing = await page.evaluate(() => { + const a = window.__stA; + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function () { + throw new DOMException('quota', 'QuotaExceededError'); + }; + let threw = false; + let ok = false; + let back = null; + try { + ok = a.set('offline', { still: 'here' }); + back = a.get('offline'); + } catch { + threw = true; + } + Storage.prototype.setItem = original; + return { threw, ok, back, raw: localStorage.getItem('tp:mod:stA:offline') }; + }); + h.check(!failing.threw, 'a throwing localStorage never throws out of api.storage'); + h.check(failing.ok && failing.back?.still === 'here', 'the value applies for the session (memory fallback)'); + h.check(failing.raw === null, 'and truly never reached localStorage'); + h.check(h.pageErrors(A).length === 0, `no page error (${h.pageErrors(A).length})`); + + // ===================================================================== 5. the flow nodes + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + const built = await page.evaluate(async () => { + const s = window.__stores; + s.levels.currentLevel.set({ hash: '', name: 'Arcade', unsaved: true }); + const nodes = [ + { id: 'svKey', type: 'keypress', position: { x: 0, y: 0 }, data: { type: 'keypress', code: 'KeyK', edge: 'down' }, class: 'w-[150px]' }, + { id: 'svCount', type: 'counter', position: { x: 0, y: 200 }, data: { type: 'counter', op: 'up', step: 1 }, class: 'w-[150px]' }, + { id: 'svStore', type: 'storevalue', position: { x: 300, y: 0 }, data: { type: 'storevalue', key: 'best', mode: 'max', value: 0 }, class: 'w-[150px]' }, + { id: 'svRead', type: 'storedvalue', position: { x: 600, y: 0 }, data: { type: 'storedvalue', key: 'best', output: 'number', fallback: -1 }, class: 'w-[150px]' } + ]; + const edges = [ + { id: 'e-svKey-svCount.pulse', source: 'svKey', target: 'svCount', targetHandle: 'pulse' }, + { id: 'e-svKey-svStore.trigger', source: 'svKey', target: 'svStore', targetHandle: 'trigger' }, + { id: 'e-svCount-svStore.value', source: 'svCount', target: 'svStore', targetHandle: 'value' } + ]; + s.flowGraphs.update((g) => ({ ...g, scene: { nodes, edges } })); + s.flowNodes.set(nodes); + s.flowEdges.set(edges); + await new Promise((r) => setTimeout(r, 800)); // past the nodes' first-seen (actionSeenAt) + return true; + }); + h.check(built, 'a Counter -> Store Value (max) graph'); + // count the wire while the node writes: Store Value must send NOTHING of its own + await page.evaluate(() => { + let peer; + window.__stores.peers.subscribe((v) => (peer = v))(); + window.__sentTypes = []; + const send = peer.send.bind(peer); + peer.send = (msg) => { + window.__sentTypes.push(msg?.type); + return send(msg); + }; + }); + for (let i = 0; i < 3; i++) { + // a LOCAL pulse (this player's own key press): the counter and the store both act + await page.evaluate(() => window.__stores.flowRuntime.applyNodeTrigger('svKey', (Date.now() % 86400000) / 1000, false)); + await page.waitForTimeout(700); + } + const flow = await page.evaluate(() => ({ + raw: localStorage.getItem('tp:scene:Arcade:best'), + sent: window.__sentTypes + })); + h.check(flow.raw === '3', `three presses keep the best (3) under tp:scene:Arcade:best (${flow.raw})`); + h.check(!flow.sent.some((t) => /stor/i.test(String(t))), `Store Value put nothing on the wire (${JSON.stringify([...new Set(flow.sent)])})`); + // a LOWER value must not overwrite the max + await page.evaluate(() => { + const s = window.__stores; + s.flowGraphs.update((g) => ({ + ...g, + scene: { ...g.scene, nodes: g.scene.nodes.map((n) => (n.id === 'svCount' ? { ...n, data: { ...n.data, step: -5 } } : n)) } + })); + }); + await page.evaluate(() => window.__stores.flowRuntime.applyNodeTrigger('svKey', (Date.now() % 86400000) / 1000, false)); + await page.waitForTimeout(700); + h.check((await page.evaluate(() => localStorage.getItem('tp:scene:Arcade:best'))) === '3', 'max mode never lowers the best'); + const readNow = await page.evaluate(() => { + let v = {}; + window.__stores.flowValues.subscribe((x) => (v = x))(); + return v.svRead; + }); + h.check(readNow === 3, `Stored Value reads it back (${readNow})`); + const onB = await B.page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('tp:scene:'))); + h.check(onB.length === 0, `the second peer received NOTHING (${JSON.stringify(onB)})`); + + // ===================================================================== 6. a reload keeps both halves + await h.freshReload(A); + await page.waitForFunction(() => window.__stores && !!window.__stores.moduleSDK, { timeout: 30000 }); + await registerModules(page); + const after = await page.evaluate(async () => { + const s = window.__stores; + s.levels.currentLevel.set({ hash: '', name: 'Arcade', unsaved: true }); + const nodes = [ + { id: 'svRead2', type: 'storedvalue', position: { x: 0, y: 0 }, data: { type: 'storedvalue', key: 'best', output: 'number', fallback: -1 }, class: 'w-[150px]' }, + { id: 'svText', type: 'storedvalue', position: { x: 0, y: 200 }, data: { type: 'storedvalue', key: 'best', output: 'text', fallback: '' }, class: 'w-[150px]' } + ]; + s.flowGraphs.update((g) => ({ ...g, scene: { nodes, edges: [] } })); + s.flowNodes.set(nodes); + s.flowEdges.set([]); + await new Promise((r) => setTimeout(r, 900)); + let v = {}; + s.flowValues.subscribe((x) => (v = x))(); + return { score: window.__stA.get('score'), num: v.svRead2, text: v.svText }; + }); + h.check(after.score === 42, `a module's value survives a reload (${after.score})`); + h.check(after.num === 3, `Stored Value reads the saved best after a reload (${after.num})`); + h.check(after.text === '3', `and as text (${JSON.stringify(after.text)})`); + const otherScene = await page.evaluate(async () => { + const s = window.__stores; + s.levels.currentLevel.set({ hash: '', name: 'Other game', unsaved: true }); + await new Promise((r) => setTimeout(r, 500)); + let v = {}; + s.flowValues.subscribe((x) => (v = x))(); + return v.svRead2; + }); + h.check(otherScene === -1, `another scene's namespace does not see it (fallback ${otherScene})`); + + // ===================================================================== 7. "Save best score" on a HUD button + const bind = await page.evaluate(async () => { + const s = window.__stores; + s.hudDocs.setHudDocFor('scene', { + active: 'main', + screens: [{ id: 'main', name: 'Main', elements: [{ id: 'saveBtn', kind: 'button', text: 'Save', anchor: 'center', x: 0, y: 0, w: 120, h: 40 }] }] + }); + await new Promise((r) => setTimeout(r, 300)); + const offered = s.hudActions.actionsForKind('button').some((a) => a.key === 'savebest'); + const res = s.hudActions.addBinding('saveBtn', 'savebest'); + await new Promise((r) => setTimeout(r, 900)); // past the fresh nodes' first-seen + let g; + s.flowGraphs.subscribe((v) => (g = v))(); + const nodes = g.scene.nodes; + const edges = g.scene.edges; + const store = nodes.find((n) => n.type === 'storevalue'); + const src = nodes.find((n) => n.type === 'getvariable'); + const press = nodes.find((n) => n.type === 'hudbutton'); + return { + offered, + ok: res.ok, + store: store?.data ?? null, + src: src?.data?.name ?? null, + trig: edges.some((e) => e.source === press?.id && e.target === store?.id && e.targetHandle === 'trigger'), + val: edges.some((e) => e.source === src?.id && e.target === store?.id && e.targetHandle === 'value'), + labels: s.hudActions.bindingsFor('saveBtn').map((b) => b.label) + }; + }); + h.check(bind.offered, 'a HUD button is offered "Save best score"'); + h.check(bind.ok && bind.store?.key === 'best' && bind.store?.mode === 'max', `which builds a Store Value (best, max) (${JSON.stringify(bind.store)})`); + h.check(bind.src === 'score' && bind.val, 'fed by the score variable on its value socket'); + h.check(bind.trig, 'and pressed by the button on its trigger socket'); + h.check(bind.labels.some((l) => /Save .best. on this device/.test(l)), `the Actions pane says what it does (${JSON.stringify(bind.labels)})`); + await page.evaluate(() => { + window.__stores.gameState.setGameVar('score', 12); + window.__stores.flowRuntime.fireHudButton('saveBtn'); + }); + await h.eventually( + () => page.evaluate(() => localStorage.getItem('tp:scene:Other game:best')), + (v) => v === '12', + 'pressing it saves the score as this device\'s best', + 4000 + ); + + await h.finish(browser); +}); diff --git a/tests/e2e/play-free-cursor.test.cjs b/tests/e2e/play-free-cursor.test.cjs new file mode 100644 index 00000000..4cd3b8fd --- /dev/null +++ b/tests/e2e/play-free-cursor.test.cjs @@ -0,0 +1,255 @@ +// 30 P3 — FREE-CURSOR GAMES + NO EDITOR GRID IN PLAY (roadmap 30 fork 6). +// +// `scenePhysics.play.cursor: 'free'` enters play WITHOUT a pointer lock: the real cursor +// stays visible and AIMS — a tap fires On Click at the object under the cursor, a +// press-drag carries a dynamic body along the cursor ray — while the crosshair has nothing +// to draw. The field is OMITTED at its default, so a scene that never uses it saves +// byte-identically. Independently, the editor grid is never drawn in play (either cursor). +// +// Real input throughout: page.mouse at an object's PROJECTED pixel, with each target placed +// OFF the view centre, so the crosshair ray (NDC 0,0) would miss it — a pass here cannot be +// the old aim getting lucky. +const h = require('./helpers.cjs'); + +/** @param {any} page @param {string} body */ +const sp = (page, body) => page.evaluate((b) => new Function('sp', b)(window.__stores.scenePhysics), body); + +/** place an object in front of the ACTIVE camera, offset sideways (camera right) and up + * @param {any} page @param {string} uuid @param {number} ahead @param {number} right @param {number} [up] */ +const placeInView = (page, uuid, ahead, right, up = 0) => + page.evaluate( + ([uuid, ahead, right, up]) => { + const THREE = window.__stores.THREE; + let camera = null; + let group = null; + window.__stores.globalCamera.subscribe((v) => (camera = v))(); + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const pos = camera.getWorldPosition(new THREE.Vector3()); + const dir = camera.getWorldDirection(new THREE.Vector3()); + const side = new THREE.Vector3().crossVectors(dir, camera.up).normalize(); + const upv = new THREE.Vector3().crossVectors(side, dir).normalize(); + const at = pos.addScaledVector(dir, ahead).addScaledVector(side, right).addScaledVector(upv, up); + const object = group.getObjectByProperty('uuid', uuid); + object.position.copy(at); + object.updateMatrixWorld(); + window.__stores.objectsGroup.update((v) => v); + return at.toArray(); + }, + [uuid, ahead, right, up] + ); + +/** @param {any} page @param {string} uuid */ +const posOf = (page, uuid) => + page.evaluate((uuid) => { + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + return group.getObjectByProperty('uuid', uuid)?.position.toArray() ?? null; + }, uuid); + +/** @param {any} page */ +const counter = (page) => + page.evaluate(() => { + let map = {}; + window.__stores.flowTriggers.subscribe((v) => (map = v))(); + return map.fcCount?.count ?? 0; + }); + +/** @param {any} page */ +const status = (page) => + page.evaluate(() => { + const s = window.__stores; + let locked; + let scene; + s.isLocked.subscribe((v) => (locked = v))(); + s.globalScene.subscribe((v) => (scene = v))(); + let grid = null; + scene?.traverse((o) => { + if (o.name === 'editor-grid') grid = o; + }); + return { + locked, + lockCalls: window.__lockCalls ?? 0, + lockElement: !!document.pointerLockElement, + reticle: !!document.querySelector('#play-reticle'), + free: s.playCursor.playCursorFree(), + grid: !!grid && grid.visible !== false + }; + }); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + { + // rapier's wasm is lazy; warm the vite dep cache on a throwaway page first + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(3000); + await warm.ctx.close(); + } + const A = await h.setupPage(browser, 'A', { + context: { viewport: { width: 1280, height: 720 } }, + storage: { showGrid: 'true', helpersInPlay: 'false' } + }); + const page = A.page; + // count every pointer-lock request the app makes, whatever element it asks for + await page.evaluate(() => { + window.__lockCalls = 0; + const original = Element.prototype.requestPointerLock; + Element.prototype.requestPointerLock = function (...args) { + window.__lockCalls++; + return original.apply(this, args); + }; + }); + + const ids = await page.evaluate(() => { + const cmd = window.__stores.commandsHandler.sceneCommand; + let group = null; + cmd('/create box'); + cmd('/create box'); + cmd('/create box'); + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const [target, crate, floor] = group.children.slice(-3); + target.name = 'Target'; + target.userData.physics = { mode: 'static' }; + crate.name = 'Crate'; + crate.userData.physics = { mode: 'dynamic', mass: 1 }; + floor.name = 'Floor'; + floor.scale.set(20, 0.2, 20); + floor.position.set(0, -0.1, 0); + floor.userData.physics = { mode: 'static' }; + window.__stores.objectsGroup.update((v) => v); + return { target: target.uuid, crate: crate.uuid }; + }); + await page.waitForTimeout(1500); + + // ===================================================================== 1. the field + const saved = await page.evaluate(() => { + const sp = window.__stores.scenePhysics; + sp.setScenePhysics({ gravity: -9 }); // a non-default scene, so a snapshot exists + const plain = sp.scenePhysicsSnapshot(); + sp.setScenePhysics({ play: { cursor: 'locked' } }); + const locked = sp.scenePhysicsSnapshot(); + sp.setScenePhysics({ play: { cursor: 'nonsense' } }); + const junk = sp.scenePhysicsSnapshot(); + sp.setScenePhysics({ play: { cursor: 'free' } }); + const free = sp.scenePhysicsSnapshot(); + return { plain: plain?.play, locked: locked?.play, junk: junk?.play, free: free?.play }; + }); + h.check(!!saved.plain && !('cursor' in saved.plain), `a scene saved without the field has no cursor key (${JSON.stringify(saved.plain)})`); + h.check(!('cursor' in (saved.locked ?? {})), "'locked' is the default and is never written"); + h.check(!('cursor' in (saved.junk ?? {})), 'an unknown value normalizes to the default (not written)'); + h.check(saved.free?.cursor === 'free', `'free' is written (${JSON.stringify(saved.free)})`); + const resolved = await page.evaluate(() => window.__stores.playSettings.resolvePlaySettings(null).cursor); + h.check(resolved === 'free', `resolvePlaySettings carries it (${resolved})`); + + // ===================================================================== 2. the grid, editor side + let st = await status(page); + h.check(st.grid, 'premise: the editor grid is drawn in the editor'); + + // ===================================================================== 3. enter play, free + await page.evaluate(() => window.__stores.physics.toggleSimulation()); + await page.waitForTimeout(800); + await page.evaluate(() => window.__stores.playMode.requestPlay()); + await h.eventually(() => status(page), (v) => v.locked === true, 'play mode is on', 5000); + await page.waitForTimeout(1200); + st = await status(page); + h.check(st.free, 'the cursor is free'); + h.check(st.lockCalls === 0, `entering play asked for NO pointer lock (${st.lockCalls} requests)`); + h.check(!st.lockElement, 'and nothing holds one'); + h.check(!st.reticle, 'the crosshair is not drawn (the cursor aims)'); + h.check(!st.grid, 'the editor grid is hidden in play'); + + // ===================================================================== 4. a real click fires On Click + await page.evaluate((uuid) => { + const nodes = [ + { id: 'fcClick', type: 'onclick', position: { x: 0, y: 0 }, data: { type: 'onclick', pulse: 0.3 }, class: 'w-[150px]' }, + { id: 'fcSel', type: 'objectselector', position: { x: 300, y: 0 }, data: { type: 'objectselector', selected: uuid }, class: 'w-[150px]' }, + { id: 'fcCount', type: 'counter', position: { x: 0, y: 200 }, data: { type: 'counter', op: 'up', step: 1 }, class: 'w-[150px]' } + ]; + const edges = [ + { id: 'e-fcClick-fcSel', source: 'fcClick', target: 'fcSel' }, + { id: 'e-fcClick-fcCount', source: 'fcClick', target: 'fcCount' } + ]; + window.__stores.flowGraphs.update((graphs) => ({ ...graphs, scene: { nodes, edges } })); + window.__stores.flowNodes.set(nodes); + window.__stores.flowEdges.set(edges); + }, ids.target); + // off-centre: 1.6 m right of the view axis at 5 m, so the crosshair would miss it + const targetAt = await placeInView(page, ids.target, 5, 1.6); + await page.waitForTimeout(600); + const tp = await h.projectPoint(page, targetAt); + const under = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? null, tp); + h.check(under === 'CANVAS', `premise: the target's pixel is the viewport (${under})`); + h.check(Math.abs(tp.x - 640) > 60, `premise: the target is off the view centre (${Math.round(tp.x)}px)`); + const before = await counter(page); + await page.mouse.move(tp.x, tp.y); + await page.mouse.down(); + await page.waitForTimeout(90); + await page.mouse.up(); + await h.eventually(() => counter(page), (n) => n > before, `a real click at the target's pixel fires its On Click (${before} -> ?)`, 3000); + const debugClick = await page.evaluate(() => window.__stores.playInteract.playInteractDebug().lastUp); + h.check(debugClick === 'click', `recorded as a click on an object (${debugClick})`); + + // ===================================================================== 5. a real press-drag carries a body + const crateAt = await placeInView(page, ids.crate, 3, -0.9); + await page.waitForTimeout(500); + const cp = await h.projectPoint(page, crateAt); + await page.mouse.move(cp.x, cp.y); + await page.mouse.down(); + await page.waitForTimeout(250); + const carrying = await page.evaluate(() => window.__stores.playInteract.playInteractDebug().carrying); + h.check(carrying === ids.crate, `a press on the crate's pixel grabs it (${carrying === ids.crate})`); + // drag the cursor 260px to the right; the carried crate follows the cursor ray + const startPos = await posOf(page, ids.crate); + for (let i = 1; i <= 10; i++) { + await page.mouse.move(cp.x + i * 26, cp.y); + await page.waitForTimeout(40); + } + await page.waitForTimeout(700); + const draggedPos = await posOf(page, ids.crate); + const moved = Math.hypot(draggedPos[0] - startPos[0], draggedPos[2] - startPos[2]); + const shown = await h.projectPoint(page, draggedPos); + h.check(moved > 0.5, `the crate travelled with the cursor (${moved.toFixed(2)} m)`); + h.check(Math.abs(shown.x - (cp.x + 260)) < 90, `and sits under it on screen (${Math.round(shown.x)}px vs cursor ${Math.round(cp.x + 260)}px)`); + await page.mouse.up(); + await page.waitForTimeout(300); + const released = await page.evaluate(() => window.__stores.playInteract.playInteractDebug().carrying); + h.check(released === null, 'releasing lets go'); + + // a click on page CHROME is not a world gesture in a free-cursor game + const chromeClick = await page.evaluate(() => { + const before = window.__stores.playInteract.playInteractDebug().lastUp; + const b = document.createElement('button'); + b.textContent = 'chrome'; + b.style.cssText = 'position:fixed;left:4px;top:300px;z-index:99999'; + document.body.appendChild(b); + const r = b.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2, before }; + }); + const beforeChrome = await counter(page); + await page.mouse.click(chromeClick.x, chromeClick.y); + await page.waitForTimeout(400); + h.check((await counter(page)) === beforeChrome, 'a press on page chrome is not taken as a world tap'); + + // ===================================================================== 6. Escape leaves + await page.keyboard.press('Escape'); + await h.eventually(() => status(page), (v) => v.locked !== true, 'Escape leaves a free-cursor game', 5000); + await h.eventually(() => status(page), (v) => v.grid, 'the grid is back in the editor', 3000); + + // ===================================================================== 7. locked: the lock IS asked for, the grid still hides + await sp(page, 'sp.setScenePhysics({ play: { cursor: "locked" } })'); + await page.evaluate(() => { + window.__lockCalls = 0; + }); + await page.waitForTimeout(2200); // past the exit settle + await page.evaluate(() => window.__stores.playMode.requestPlay()); + await h.eventually(() => status(page), (v) => v.locked === true, 'play mode again, cursor locked', 5000); + await page.waitForTimeout(800); + st = await status(page); + h.check(st.lockCalls > 0, `a LOCKED game still asks for the pointer lock (${st.lockCalls}) — the spy works`); + h.check(!st.free, 'and reads as not free'); + h.check(!st.grid, 'the editor grid is hidden in play with a locked cursor too'); + await page.evaluate(() => window.__stores.playMode.exitPlay()); + await page.waitForTimeout(500); + + await h.finish(browser); +}); diff --git a/tests/e2e/touch-play.test.cjs b/tests/e2e/touch-play.test.cjs index 9fb0e0e9..3d0f1e72 100644 --- a/tests/e2e/touch-play.test.cjs +++ b/tests/e2e/touch-play.test.cjs @@ -94,7 +94,9 @@ const watchClaims = (page) => const historyState = (page) => page.evaluate(() => ({ - tpPlay: !!(history.state && history.state.tpPlay), + // 30 P0: the marker is a SvelteKit SHALLOW entry now, so its state object sits under + // the router's `sveltekit:states` key — playMarkerState() reads it there + tpPlay: window.__stores.playMode.playMarkerState(), marker: window.__stores.playMode.playBackMarker(), href: location.href, mounted: !!document.querySelector('#play-button') || !!document.querySelector('canvas') diff --git a/tests/unit/gameStorage.test.js b/tests/unit/gameStorage.test.js new file mode 100644 index 00000000..1e9bbe8e --- /dev/null +++ b/tests/unit/gameStorage.test.js @@ -0,0 +1,106 @@ +// 30 P4: the storage leaf with no browser — namespacing, JSON round trips, the cap, and a +// broken entry never throwing. vitest runs in node, where localStorage does not exist, so +// safeStorage serves every key from its in-memory fallback: the same path Safari private +// mode takes, which is exactly the one worth covering here. +import { describe, it, expect, beforeEach } from 'vitest'; +import { + makeModuleStorage, + moduleStorageKey, + sceneStorageKey, + readStored, + writeStored, + MODULE_STORAGE_CAP, + debugResetGameStorage +} from '../../src/lib/gameStorage.js'; +import { safeStorage, debugResetStorage } from '../../src/lib/safeStorage.js'; + +beforeEach(() => { + for (const key of safeStorage.keys()) safeStorage.removeItem(key); + debugResetStorage(); + debugResetGameStorage(); +}); + +describe('the key contract', () => { + it('is byte-for-byte what a module fallback writes', () => { + expect(moduleStorageKey('untangle', 'progress')).toBe('tp:mod:untangle:progress'); + expect(sceneStorageKey('Towers', 'best')).toBe('tp:scene:Towers:best'); + expect(sceneStorageKey('', 'best')).toBe('tp:scene:untitled:best'); + expect(sceneStorageKey(null, 'best')).toBe('tp:scene:untitled:best'); + }); +}); + +describe('makeModuleStorage', () => { + it('round-trips JSON values', () => { + const s = makeModuleStorage('a'); + expect(s.set('n', 3)).toBe(true); + expect(s.set('o', { unlocked: 4, solved: [1, 2] })).toBe(true); + expect(s.set('t', 'hi')).toBe(true); + expect(s.get('n')).toBe(3); + expect(s.get('o')).toEqual({ unlocked: 4, solved: [1, 2] }); + expect(s.get('t')).toBe('hi'); + expect(safeStorage.getItem('tp:mod:a:o')).toBe('{"unlocked":4,"solved":[1,2]}'); + }); + + it('hands back the fallback for a missing or unreadable key', () => { + const s = makeModuleStorage('a'); + expect(s.get('missing', 7)).toBe(7); + safeStorage.setItem('tp:mod:a:broken', '{not json'); + expect(s.get('broken', 'fb')).toBe('fb'); + }); + + it('keeps two modules apart', () => { + const a = makeModuleStorage('a'); + const b = makeModuleStorage('b'); + a.set('score', 1); + b.set('score', 2); + expect(a.get('score')).toBe(1); + expect(b.get('score')).toBe(2); + expect(a.keys()).toEqual(['score']); + a.clear(); + expect(a.keys()).toEqual([]); + expect(b.get('score')).toBe(2); + }); + + it('remove and keys', () => { + const s = makeModuleStorage('a'); + s.set('x', 1); + s.set('y', 2); + expect(s.keys()).toEqual(['x', 'y']); + s.remove('x'); + expect(s.keys()).toEqual(['y']); + expect(s.set('y', undefined)).toBe(true); + expect(s.keys()).toEqual([]); + }); + + it('refuses a write over the cap, once per call, and writes nothing', () => { + /** @type {any[]} */ + const over = []; + const s = makeModuleStorage('a', { cap: 100, onOverCap: (info) => over.push(info) }); + expect(s.set('small', 'x'.repeat(40))).toBe(true); + expect(s.set('big', 'x'.repeat(80))).toBe(false); + expect(over.length).toBe(1); + expect(s.get('big', null)).toBe(null); + // replacing a key measures the NEW value, not old + new + expect(s.set('small', 'y'.repeat(40))).toBe(true); + expect(MODULE_STORAGE_CAP).toBe(262144); + }); + + it('refuses what JSON cannot carry instead of throwing', () => { + const s = makeModuleStorage('a'); + /** @type {any} */ + const cyclic = {}; + cyclic.self = cyclic; + expect(s.set('c', cyclic)).toBe(false); + expect(s.set('f', () => 1)).toBe(false); + }); +}); + +describe('the scene half', () => { + it('writeStored/readStored share the cache with a direct read', () => { + const full = sceneStorageKey('Towers', 'best'); + writeStored(full, 12); + expect(readStored(full, 0)).toBe(12); + debugResetGameStorage(); + expect(readStored(full, 0)).toBe(12); + }); +});