diff --git a/MODULES.md b/MODULES.md
index 3c527c4d..047148c9 100644
--- a/MODULES.md
+++ b/MODULES.md
@@ -536,6 +536,30 @@ The things 1.18's round found, in the order they cost:
- **Lights and shadow casters are draw calls**: each shadow-casting light draws every caster
again; transmission (glass) renders the scene an extra time per camera, per eye.
+### Functional pack items: doors, lids, levers — `api.behavior` (1.19, roadmap 33)
+
+A pack item can be FUNCTIONAL: a door that opens on a click, a chest lid, a lever, a fan
+(the `behavior` field, see PACKS.md). Core runs them. Nothing plays in Edit; a click, a
+knock or walking up triggers them in Interact/Play; the state replicates; a door's
+collider follows its leaf. A module can drive them too:
+
+```js
+if (api.behavior) {
+ for (const item of api.behavior.list()) { // [{uuid, type, trigger, open}]
+ if (item.type === 'door' && !item.open) api.behavior.trigger(item.uuid, true); // open it
+ }
+ api.behavior.state(uuid); // {on, at, n} or null (never triggered)
+ api.behavior.trigger(uuid); // toggle, like a player's click
+}
+```
+
+`trigger` is REPLICATED (one `behavior` message; every peer poses the door from the same
+session-clock stamp), so call it on ONE peer: the authority, or the peer that saw the
+cause. It returns false when nothing changed (opening an open door). The state is
+runtime: it is never saved, and a peer in Edit shows the rest pose whatever it says. The
+item sounds are core game sounds: `door`, `gate`, `slide`, `lever`, `lid` (plus `click`),
+which `api.playSound` can play too.
+
### Game feel: sound, music, haptics, effects, banners (1.17, roadmap 30b)
Everything here is LOCAL to the device it runs on — broadcast your own op
diff --git a/PACKS.md b/PACKS.md
index 7152b904..3f1c17de 100644
--- a/PACKS.md
+++ b/PACKS.md
@@ -52,6 +52,56 @@ CC0, OpenGameArt CC0). Keep each file under the 5 MB share cap so it round-trips
peers. Installed audio items appear in the Explorer library and can be assigned to
a **Sound** node (spatial) or the **Scene music** channel (global).
+### Animated, functional items — `behavior` (roadmap 33, P2)
+
+An item row in a default pack's item list (`default.json`) MAY carry a `behavior`: a
+door you click open, a chest lid, a lever, a fan. The GLB holds the clips; the row says
+how they are used:
+
+```jsonc
+{
+ "name": "WoodDoor", "label": "Wooden door", "screenshot": "screenshot/screenshot.webp",
+ "variants": { "glTF-Binary": "WoodDoor.glb" },
+ "behavior": {
+ "type": "door", // door | toggle | oneshot | loop
+ "clip": "open", // the clip a trigger plays (its time 0 is the REST pose)
+ "closeClip": "close", // optional (door/toggle): played to close; absent = `clip` backwards
+ "trigger": "click", // click | proximity | knock
+ "autoplay": false, // honoured ONLY for type "loop" (ambient: a fan, a flag, a flame)
+ "sound": "door", // optional: a game-sound name (door gate slide lever lid click …)
+ "collider": "follow" // optional; the default for a door
+ }
+}
+```
+
+**The rule.** Nothing plays on placement, on load, or in Edit, and nothing loops unless
+it is a `loop` with `autoplay: true`. A peer in Edit always sees the rest pose. A trigger
+works in Interact or Play: `door`/`toggle` alternate open ⇄ closed, `oneshot` plays once
+per trigger, and a `loop` without autoplay starts and stops. The Animation panel previews
+a clip in Edit, locally: nothing is sent or saved, and the item goes back to rest.
+
+- **Triggers.** `click` is the desktop click, the Play tap, and the VR laser, trigger and
+ poke. `proximity` opens when the player comes within 1.5 m and closes when they leave
+ (a click still works). `knock` is a VR hand moving into the part at ≥ 0.6 m/s (on a
+ desktop, a click).
+- **Shared state.** One `behavior` message per trigger, stamped with the session clock.
+ Every peer derives the pose from the stamp, so a door swings in step everywhere, and a
+ late joiner receives the state with the door. The state is runtime only: a scene saved
+ with a door open reopens shut.
+- **Frame and moving parts.** A door ALWAYS ships with its frame. The nodes the
+ behavior's clips animate are the MOVING parts (put the hinge pivot on the node's origin);
+ everything else is the static frame. With `collider: "follow"` the frame becomes slab
+ colliders with the opening cut out, and each moving part gets a box that follows it, so
+ you can walk through an open door and are stopped by a shut one (while a simulation
+ runs; that is when the walker collides at all).
+- **Authoring the GLB.** Use LINEAR TRS tracks on the moving nodes, with `clip` at time 0
+ being the closed pose. A static `idle` clip FIRST is harmless and keeps older app
+ builds (which autoplayed the first clip) still. `lods` files must keep the same node
+ names, hierarchy and clips. The same `behavior` object may also sit in the GLB's scene
+ extras (`scene.extras.behavior`); the pack row wins over it.
+- The Explorer marks such items with a small ▶ "animated" badge that says what they do
+ and what sets them off.
+
## Repo / .zip structure
```
diff --git a/src/App.svelte b/src/App.svelte
index 4d640087..ec2cbb33 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -494,9 +494,10 @@ import { startMusicToolbox } from './lib/musicToolbox'
import('./lib/moduleWorld'),
import('./lib/playSpawn'),
import('./lib/packRefs'),
- import('./lib/lod')
- ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib]) => {
- window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib }
+ import('./lib/lod'),
+ import('./lib/packBehavior')
+ ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib, packBehaviorLib]) => {
+ window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib, packBehavior: packBehaviorLib }
})
}
})
diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte
index b47e1714..b225a63a 100644
--- a/src/components/Scene.svelte
+++ b/src/components/Scene.svelte
@@ -40,6 +40,7 @@
import { moduleInteractiveGroups, fireClickMiss, runClickHandlers } from '$lib/moduleSDK';
import { updateSpatialAudio } from '$lib/voiceChat';
import { tickAnimatedMixers } from '$lib/animatedImports';
+ import { startPackBehaviors, tickPackBehaviors } from '$lib/packBehavior';
import { tickAnimationPreview, captureAutoKey, playheadOf } from '$lib/animationPreview';
import { drawMode, drawTool, strokePointFromRay, endStroke, setDrawScene } from '$lib/drawMode';
import { splinePlaceFromRay, splineToolActive, finishSpline } from '$lib/splineTool';
@@ -388,6 +389,7 @@
updateVRControls(); // also manages ray/hover visibility outside sessions
updateSpatialAudio(camera.current, scene); // voices follow avatars (throttled)
tickAnimatedMixers(); // imported clips run on the synced clock
+ tickPackBehaviors(); // 33 P2: doors/lids/levers pose from their shared state (rest in Edit)
tickAnimationPreview(); // Animation window: local transform preview (not synced)
tickMeshEdit(); // vertex handles follow the object if it moves (119)
tickEditWireframe(); // ...and the edit wireframe stays parented to it (faceEdit)
@@ -1435,6 +1437,7 @@
// A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses
startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticKnock });
startClap({ hands: handSnapshot }); // 31: the same hand seam
+ startPackBehaviors({ hands: handSnapshot }); // 33 P2: functional pack items (the knock reads hands)
// 30b: game feel in VR (a frame hook + a trigger hook through vrControls' registries)
startVrGameInput();
diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte
index f7829e7c..9087370b 100644
--- a/src/components/editors/AnimationWindow.svelte
+++ b/src/components/editors/AnimationWindow.svelte
@@ -32,7 +32,7 @@
// posed from the synced clock) — the window used to ignore them entirely, so
// a rigged model showed "no movements yet" and its own animations were
// reachable only from the Inspector.
- import { animatedObjects, setAnimationState, clipInfo } from '$lib/animatedImports';
+ import { animatedObjects, setAnimationState, clipInfo, behaviorOf } from '$lib/animatedImports';
import {
SkipBack, SkipForward, StepBack, StepForward, Play, Pause, Square, Rewind, ZoomIn, ZoomOut, Maximize2,
SquareDashed, Lasso, Ghost
@@ -142,6 +142,13 @@
$animatedObjects;
return target ? clipInfo(target.uuid) : [];
});
+ // 33 P2: a FUNCTIONAL item (a door, a lid) has no transport: its clip buttons PREVIEW
+ // locally (never sent, never saved, back to rest at the end) — it plays for real only
+ // on its trigger, in Interact or Play
+ const behavior = $derived.by(() => {
+ $animatedObjects;
+ return target ? behaviorOf(target.uuid) : null;
+ });
// transport is per object now (N objects can play at once); the playhead time
// comes from the per-frame `playheads` store, the transport state does not.
@@ -1863,7 +1870,7 @@
{#each clips as clip (clip.name)}
target && setAnimationState(target.uuid, { clip: clip.name, playing: true })}
>
{clip.name}
@@ -1871,6 +1878,13 @@
{/each}
+ {#if behavior}
+
+ {behavior.type === 'loop' && behavior.autoplay ? 'Ambient loop' : 'Functional item'} — runs in
+ Interact or Play{behavior.type === 'loop' && behavior.autoplay ? '' : ' on its ' + behavior.trigger}.
+ Here ▶ previews it once; nothing is sent or saved.
+
+ {/if}
- import { Box, Boxes, Download, ExternalLink, Folder, FolderTree, Gift, Globe, HardDrive, House, LayoutGrid, List, LoaderCircle, PackageOpen, RefreshCw, Save, Share2, X } from '@lucide/svelte';
+ import { Box, Boxes, Download, ExternalLink, Folder, FolderTree, Gift, Globe, HardDrive, House, LayoutGrid, List, LoaderCircle, PackageOpen, Play, RefreshCw, Save, Share2, X } from '@lucide/svelte';
import Icon from '../ui/Icon.svelte';
// Explorer (95, tree v2 in 106): dockable asset browser — real file-manager
// tree on the left (inline create/rename, expand/collapse, drag re-parent,
@@ -962,6 +962,26 @@
});
// pack-item thumbnail: imported items carry a dataURL; default items resolve
// webp -> png -> screenshot via the onerror cursor, else a placeholder icon
+ // 33 P2: the "animated" badge's words — what the item does and what sets it off, so a
+ // door reads differently from a fan before anyone places it
+ const BEHAVIOR_WORDS: Record = {
+ door: 'Opens and closes',
+ toggle: 'Switches on and off',
+ oneshot: 'Plays once',
+ loop: 'Loops'
+ };
+ const TRIGGER_WORDS: Record = {
+ click: 'on a click',
+ proximity: 'when you walk up to it',
+ knock: 'when you knock on it'
+ };
+ function behaviorTitle(spec: any): string {
+ if (!spec) return '';
+ const what = BEHAVIOR_WORDS[spec.type] ?? 'Animated';
+ const when = spec.type === 'loop' && spec.autoplay ? 'by itself' : (TRIGGER_WORDS[spec.trigger] ?? 'on a click');
+ return 'Animated — ' + what.toLowerCase() + ' ' + when + ' in Interact or Play (never in Edit)';
+ }
+
function packThumb(item: any): string | null {
if (item.resolvedThumb) return item.resolvedThumb; // P2: cached resolution
if (item.thumbnail) return item.thumbnail;
@@ -5292,7 +5312,9 @@
kind: item.kind,
name: item.name,
prefabId: item.prefabId ?? null,
- url: item.glbUrl ?? null
+ url: item.glbUrl ?? null,
+ // 33 P2: a functional pack item's spec rides the drag to the drop
+ ...(item.behavior ? { behavior: item.behavior } : {})
};
}
/**
@@ -5703,7 +5725,8 @@
}
// 30c: the placed piece carries its pack reference (packRefs.js)
await importFile(new File([await res.blob()], item.name + '.glb'), item.name, 'glb', undefined, undefined, {
- packRef: packRefFromUrl(item.glbUrl, { pack: item.packName, item: item.name })
+ packRef: packRefFromUrl(item.glbUrl, { pack: item.packName, item: item.name }),
+ behavior: item.behavior ?? null // 33 P2
});
dismiss();
} catch {
@@ -6366,6 +6389,13 @@
+ {#if !isFolder && item.behavior}
+
+ {/if}
{#if !isFolder}
{#if item.volumeItem}
{/if}
{/if}
+ {#if item.behavior}
+
+
+ {/if}
{#if item.packEntry}
{#if packThumb(item)}
diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js
index 31bb6077..fbc6dfed 100644
--- a/src/lib/animatedImports.js
+++ b/src/lib/animatedImports.js
@@ -8,6 +8,7 @@ import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { registerHistoryKind, recordEntry } from './history';
import { runtimeNow } from './moduleSDK';
+import { normalizeBehavior } from './behaviorCore';
// Animated imports (GLTF/GLB and, since 17-D2, FBX). Rigs cannot survive the
// per-node GLTF sync (bones are children and the exporter round-trip is lossy),
@@ -30,6 +31,70 @@ const fileKinds = new Map();
/** @type {Map, durations: Record}>} */
const mixers = new Map();
+// ---- 33 P2: animated FUNCTIONAL items (a door, a chest, a lever, a fan) ---------------
+// An import whose root carries a `behavior` (stamped from the pack item's row, or from the
+// GLB's own scene extras) is driven by packBehavior.js, never by the transport below: the
+// transport is a looping clip on the synced clock, which is exactly what a door must NOT do.
+// The spec travels WITH the bytes (objectfile / save entry / undo entry) because it lives
+// in the pack row, not in the file. packBehavior reaches in through the two hooks.
+/** @type {Map} rootUuid -> spec */
+const behaviors = new Map();
+/** @type {{state: ((uuid: string) => any) | null, apply: ((uuid: string, state: any) => void) | null, preview: ((uuid: string, clip?: string) => void) | null, registered: ((uuid: string) => void) | null}} */
+const behaviorHooks = { state: null, apply: null, preview: null, registered: null };
+
+/** packBehavior plugs in here (it imports this module, so the edge cannot run the other way)
+ * @param {Partial} hooks */
+export function setBehaviorHooks(hooks) {
+ Object.assign(behaviorHooks, hooks);
+}
+
+/** @param {string} uuid @returns {import('./behaviorCore').BehaviorSpec | null} */
+export function behaviorOf(uuid) {
+ return behaviors.get(uuid) ?? null;
+}
+
+/** every root driven by a behavior @returns {string[]} */
+export function behaviorUuids() {
+ return [...behaviors.keys()];
+}
+
+/** the live registry, for the per-frame tick (iterate, never mutate — no copy per frame)
+ * @returns {ReadonlyMap} */
+export function behaviorRegistry() {
+ return behaviors;
+}
+
+/** the live mixer record (packBehavior poses through it) @param {string} uuid */
+export function animatedRecordOf(uuid) {
+ return mixers.get(uuid) ?? null;
+}
+
+/** the spec + live state a message/save carries for this root (both absent = none) @param {string} uuid */
+function behaviorFields(uuid) {
+ const spec = behaviors.get(uuid);
+ if (!spec) return {};
+ const state = behaviorHooks.state?.(uuid) ?? null;
+ return { behavior: spec, ...(state ? { behaviorState: state } : {}) };
+}
+
+/**
+ * 33 (for 33-lod-editor): the root's LOD group (`userData.lod`, plain JSON validated by
+ * lodGroupCore on the reading side) travels beside the bytes for the same reason the
+ * behavior spec does — the peer RE-PARSES the file, so anything stamped on the root after
+ * the parse is otherwise lost on peers, after a reload and across an undo. Absent = none,
+ * so every message without one is byte-identical.
+ * @param {any} root @returns {{lod?: any}}
+ */
+function rootDataFields(root) {
+ const lod = root?.userData?.lod;
+ if (!lod || typeof lod !== 'object') return {};
+ try {
+ return { lod: JSON.parse(JSON.stringify(lod)) };
+ } catch {
+ return {};
+ }
+}
+
/** GLTFLoader with draco + meshopt decoders wired */
export function createGltfLoader() {
const loader = new GLTFLoader();
@@ -93,7 +158,11 @@ export function animatedImportPayload(uuid) {
}
/**
- * Track an imported animated root: keep bytes, build the mixer, autoplay.
+ * Track an imported animated root: keep bytes, build the mixer. 33 P2: NOTHING AUTOPLAYS
+ * any more — placing an animated GLB used to start (and loop) its FIRST clip at once, which
+ * is how a door swung forever the moment it landed. The clip is selected, not playing; the
+ * Animation panel's play button (or a Play Animation node) starts it. A root carrying a
+ * `behavior` is handed to packBehavior instead (see the block above).
* @param {any} root @param {any[]} animations @param {ArrayBuffer} bytes
* @param {'gltf'|'fbx'} [kind] which parser those bytes need (default gltf)
*/
@@ -111,12 +180,21 @@ export function registerAnimatedImport(root, animations, bytes, kind = 'gltf') {
});
mixers.set(root.uuid, { mixer, actions, durations });
root.userData.animatedClips = animations.map((c) => c.name);
- const first = animations[0]?.name;
+ const spec = normalizeBehavior(root.userData.behavior);
+ if (spec && !actions[spec.clip]) {
+ // a behavior naming a clip the file does not have drives nothing — say so once
+ console.log('behavior clip "' + spec.clip + '" not in', root.name, Object.keys(actions));
+ }
+ if (spec && actions[spec.clip]) {
+ root.userData.behavior = spec;
+ behaviors.set(root.uuid, spec);
+ } else delete root.userData.behavior;
+ const first = spec && actions[spec.clip] ? spec.clip : animations[0]?.name;
animatedObjects.update((map) => ({
...map,
- [root.uuid]: { clips: animations.map((c) => c.name), clip: first, playing: true, speed: 1 }
+ [root.uuid]: { clips: animations.map((c) => c.name), clip: first, playing: false, speed: 1 }
}));
- if (first) actions[first].play();
+ if (behaviors.has(root.uuid)) behaviorHooks.registered?.(root.uuid);
}
/** Per-frame from the scene loop: pose = pure function of the synced clock */
@@ -124,6 +202,7 @@ export function tickAnimatedMixers() {
const states = get(animatedObjects);
const time = runtimeNow();
mixers.forEach(({ mixer, durations }, uuid) => {
+ if (behaviors.has(uuid)) return; // 33 P2: packBehavior poses these
const state = states[uuid];
if (!state || !state.playing || !state.clip) return;
const duration = durations[state.clip] || 1;
@@ -139,12 +218,26 @@ export function setAnimationState(uuid, next, replicate = true) {
const entry = get(animatedObjects)[uuid];
const record = mixers.get(uuid);
if (!entry || !record) return;
+ // 33 P2: a functional item has no transport. Pressing play on one in the Animation panel
+ // or the Inspector is a LOCAL PREVIEW — never sent, never saved, back to rest at the end
+ // (the user's rule: previewing a door in Edit must not leave it open in the file)
+ if (behaviors.has(uuid)) {
+ if (replicate && (next.playing || next.clip)) behaviorHooks.preview?.(uuid, next.clip ?? entry.clip);
+ return;
+ }
const state = { ...entry, ...next };
animatedObjects.update((map) => ({ ...map, [uuid]: state }));
if (next.clip && next.clip !== entry.clip) {
Object.values(record.actions).forEach((action) => action.stop());
record.actions[state.clip]?.play();
}
+ // 33 P2: nothing autoplays at registration any more, so the FIRST play is what schedules
+ // the action — without this a press (or a restored `playing: true`) changed the state and
+ // moved nothing
+ if (state.playing && state.clip && !record.actions[state.clip]?.isScheduled()) {
+ Object.values(record.actions).forEach((action) => action.stop());
+ record.actions[state.clip]?.play();
+ }
if (!state.playing && next.playing === false) record.mixer.setTime(0);
if (replicate) {
/** @type {any} */
@@ -175,11 +268,13 @@ export async function applyObjectFile(data) {
// animation on every walk-in for no gain.
if (!data.override) return;
if (data.name) held.name = data.name;
+ if (data.lod && typeof data.lod === 'object') held.userData.lod = data.lod;
if (data.pos) held.position.fromArray(data.pos);
if (data.rot) held.rotation.set(data.rot[0], data.rot[1], data.rot[2]);
if (data.scale) held.scale.fromArray(data.scale);
pokeScene();
- if (data.anim) setAnimationState(data.uuid, data.anim, false);
+ if (data.behaviorState && behaviors.has(data.uuid)) behaviorHooks.apply?.(data.uuid, data.behaviorState);
+ else if (data.anim) setAnimationState(data.uuid, data.anim, false);
return;
}
try {
@@ -188,13 +283,20 @@ export async function applyObjectFile(data) {
const { root, animations } = await parseAnimatedBytes(bytes, data.kind);
root.uuid = data.uuid;
root.name = data.name ?? 'Animated import';
+ // 33 P2: the spec rides the message (it lives in the pack row, not the file); absent =
+ // whatever the file's own extras say, or nothing
+ const spec = normalizeBehavior(data.behavior);
+ if (spec) root.userData.behavior = spec;
+ if (data.lod && typeof data.lod === 'object') root.userData.lod = data.lod;
if (data.pos) root.position.fromArray(data.pos);
if (data.rot) root.rotation.set(data.rot[0], data.rot[1], data.rot[2]);
if (data.scale) root.scale.fromArray(data.scale);
group.add(root);
pokeScene();
registerAnimatedImport(root, animations, bytes, data.kind === 'fbx' ? 'fbx' : 'gltf');
- if (data.anim) setAnimationState(data.uuid, data.anim, false);
+ if (behaviors.has(data.uuid)) {
+ if (data.behaviorState) behaviorHooks.apply?.(data.uuid, data.behaviorState);
+ } else if (data.anim) setAnimationState(data.uuid, data.anim, false);
} catch (error) {
console.log('objectfile parse failed', error);
showToast('Could not load an animated model from a peer');
@@ -219,7 +321,10 @@ export function sendAnimatedImport(conn, root, opts = {}) {
pos: root.position.toArray(),
rot: [root.rotation.x, root.rotation.y, root.rotation.z],
scale: root.scale.toArray(),
- anim: state ? { clip: state.clip, playing: state.playing, speed: state.speed } : null
+ anim: state ? { clip: state.clip, playing: state.playing, speed: state.speed } : null,
+ // 33 P2: additive — absent for every import without a behavior / a LOD group
+ ...behaviorFields(root.uuid),
+ ...rootDataFields(root)
});
}
@@ -286,6 +391,10 @@ export function animatedImportsSnapshot(group) {
rot: [child.rotation.x, child.rotation.y, child.rotation.z],
scale: child.scale.toArray(),
anim: state ? { clip: state.clip, playing: state.playing, speed: state.speed } : null,
+ // 33 P2: the SPEC is saved (it is not in the bytes); the open/shut state is runtime
+ // and never is — a scene saved with its door open reopens shut
+ ...(behaviors.has(child.uuid) ? { behavior: behaviors.get(child.uuid) } : {}),
+ ...rootDataFields(child),
bytes: bytesToBase64(bytes)
});
}
@@ -320,7 +429,9 @@ export async function animatedImportsRestore(entries, replicate = true) {
pos: entry.pos,
rot: entry.rot,
scale: entry.scale,
- anim: entry.anim
+ anim: entry.anim,
+ behavior: entry.behavior,
+ lod: entry.lod
});
const root = get(objectsGroup)?.getObjectByProperty('uuid', entry.uuid);
if (root && replicate && peer) sendAnimatedImport(peer, root); // peers reparse the same file
@@ -335,6 +446,7 @@ export async function animatedImportsRestore(entries, replicate = true) {
/** Cleanup when the object is removed @param {string} uuid */
export function dropAnimatedImport(uuid) {
+ behaviors.delete(uuid);
fileBytes.delete(uuid);
fileKinds.delete(uuid);
mixers.delete(uuid);
@@ -347,6 +459,7 @@ export function dropAnimatedImport(uuid) {
/** Scene was wiped — forget every registry entry */
export function dropAllAnimatedImports() {
+ behaviors.clear();
fileBytes.clear();
fileKinds.clear();
mixers.clear();
@@ -371,7 +484,9 @@ registerHistoryKind('animimport', (entry, state) => {
// `fileKind` — reusing `kind` would break the registry dispatch.
kind: entry.fileKind,
buffer: entry.buffer,
- pos: entry.pos
+ pos: entry.pos,
+ behavior: entry.behavior,
+ lod: entry.lod
}).then(() => {
const root = get(objectsGroup)?.getObjectByProperty('uuid', entry.uuid);
if (root && peer) sendAnimatedImport(peer, root); // peer.send broadcasts
@@ -397,6 +512,8 @@ export function recordAnimatedImport(root) {
fileKind: animatedImportKind(root.uuid),
buffer: fileBytes.get(root.uuid),
pos: root.position.toArray(),
+ ...(behaviors.has(root.uuid) ? { behavior: behaviors.get(root.uuid) } : {}),
+ ...rootDataFields(root),
before: { present: false },
after: { present: true }
});
diff --git a/src/lib/behaviorCore.js b/src/lib/behaviorCore.js
new file mode 100644
index 00000000..d3640826
--- /dev/null
+++ b/src/lib/behaviorCore.js
@@ -0,0 +1,263 @@
+// 33 P2 — ANIMATED, FUNCTIONAL PACK ITEMS: the pure half (imports NOTHING, so vitest
+// covers it with no browser). The runtime that drives the mixer, the wire, the triggers
+// and the colliders is packBehavior.js.
+//
+// THE CONTRACT (roadmap 33, P2): a pack item MAY carry
+// behavior: {type: 'door'|'toggle'|'oneshot'|'loop', clip, closeClip?, trigger:
+// 'click'|'proximity'|'knock', autoplay: false, sound?, collider?: 'follow'}
+// and the rule the user asked for: NOTHING plays on placement, on load or in Edit, and
+// nothing loops unless it is an ambient `loop` with `autoplay: true`.
+//
+// THE STATE is runtime and shared: `{on, at, from, n}` — `on` is open/running, `at` the
+// SESSION ms of the last trigger (sessionNow, so every peer evaluates the same moment),
+// `from` where the movement started in OPEN-clip seconds (a door closed half-way opens
+// from where it is, not from shut), `n` the trigger count (a oneshot's identity, and the
+// tie-break for two triggers in one millisecond). Every pose is a pure function of
+// (spec, state, durations, now) — determinism is the netcode, so a toggle is ONE message.
+
+export const BEHAVIOR_TYPES = ['door', 'toggle', 'oneshot', 'loop'];
+export const BEHAVIOR_TRIGGERS = ['click', 'proximity', 'knock'];
+/** the proximity trigger's radius (metres from the object's box) */
+export const PROXIMITY_RADIUS = 1.5;
+/** the slowest hand that counts as a knock (m/s) */
+export const KNOCK_SPEED = 0.6;
+const NAME_MAX = 64;
+
+/**
+ * @typedef {{type: 'door'|'toggle'|'oneshot'|'loop', clip: string, closeClip: string | null,
+ * trigger: 'click'|'proximity'|'knock', autoplay: boolean, sound: string | null,
+ * collider: 'follow' | null}} BehaviorSpec
+ * @typedef {{on: boolean, at: number, from: number, n: number}} BehaviorState
+ */
+
+/** @param {any} v */
+function clipName(v) {
+ return typeof v === 'string' && v.trim() ? v.trim().slice(0, NAME_MAX) : null;
+}
+
+/**
+ * The ONE boundary: a pack row, GLB extras or a wire message in, a clean spec or null out.
+ * Unknown keys are dropped (the spec is small and fully understood); a missing `clip` is
+ * null — there is nothing to drive. `autoplay` is honoured ONLY for `loop` (the rule).
+ * `collider` defaults to 'follow' for a door (a door you cannot walk through when it is open
+ * is not a door) and to none otherwise.
+ * @param {any} raw @returns {BehaviorSpec | null}
+ */
+export function normalizeBehavior(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+ const type = BEHAVIOR_TYPES.includes(raw.type) ? raw.type : null;
+ const clip = clipName(raw.clip);
+ if (!type || !clip) return null;
+ const trigger = BEHAVIOR_TRIGGERS.includes(raw.trigger) ? raw.trigger : 'click';
+ const closeClip = type === 'door' || type === 'toggle' ? clipName(raw.closeClip) : null;
+ const collider =
+ raw.collider === 'follow' || (raw.collider === undefined && type === 'door') ? 'follow' : null;
+ return {
+ type,
+ clip,
+ closeClip: closeClip && closeClip !== clip ? closeClip : null,
+ trigger,
+ autoplay: type === 'loop' && raw.autoplay === true,
+ sound: clipName(raw.sound),
+ collider
+ };
+}
+
+/** @returns {BehaviorState} the state before anything happened */
+export function restState() {
+ return { on: false, at: 0, from: 0, n: 0 };
+}
+
+/** @param {any} raw @returns {BehaviorState | null} a wire/save state, checked */
+export function normalizeState(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+ const at = Number(raw.at);
+ const from = Number(raw.from);
+ const n = Number(raw.n);
+ if (!Number.isFinite(at) || !Number.isFinite(from) || !Number.isFinite(n)) return null;
+ return { on: raw.on === true, at, from: Math.max(0, from), n: Math.max(0, Math.floor(n)) };
+}
+
+/**
+ * Latest wins: the newer stamp, then the higher count (two triggers inside one ms).
+ * @param {BehaviorState | null} mine @param {BehaviorState} theirs @returns {boolean}
+ */
+export function stateIsNewer(mine, theirs) {
+ if (!mine) return true;
+ if (theirs.at !== mine.at) return theirs.at > mine.at;
+ return theirs.n > mine.n;
+}
+
+/** @param {number} v @param {number} lo @param {number} hi */
+function clamp(v, lo, hi) {
+ return Math.min(hi, Math.max(lo, v));
+}
+
+/**
+ * How far along the OPEN clip a door/toggle is now (seconds, 0 = shut, `open` = full).
+ * Moves at clip speed from `from` toward the end the state points at.
+ * @param {BehaviorState} state @param {number} openDur @param {number} nowMs
+ */
+export function openPosition(state, openDur, nowMs) {
+ const dur = Math.max(1e-3, openDur);
+ if (!state.n) return 0;
+ const elapsed = Math.max(0, (nowMs - state.at) / 1000);
+ const from = clamp(state.from, 0, dur);
+ return state.on ? Math.min(dur, from + elapsed) : Math.max(0, from - elapsed);
+}
+
+/**
+ * THE POSE: which clip to show and at what time. `active` false is Edit (the user's rule:
+ * the rest pose, whatever the shared state says) — and the rest pose is the OPEN clip at 0.
+ * `durations` maps clip name -> seconds. A `closeClip` is played while closing, mapped so a
+ * half-open door closes from half-way: progress through it = 1 - openPosition / openDur.
+ * @param {BehaviorSpec} spec @param {BehaviorState} state
+ * @param {Record} durations @param {number} nowMs @param {boolean} active
+ * @returns {{clip: string, time: number, moving: boolean}}
+ */
+export function poseAt(spec, state, durations, nowMs, active) {
+ const dur = Math.max(1e-3, durations[spec.clip] ?? 1);
+ const rest = { clip: spec.clip, time: 0, moving: false };
+ if (!active) return rest;
+ const elapsed = Math.max(0, (nowMs - state.at) / 1000);
+ if (spec.type === 'loop') {
+ const running = spec.autoplay || state.on;
+ if (!running) return rest;
+ // ambient loops share the session clock's phase; a triggered one starts at its stamp
+ const t = spec.autoplay ? nowMs / 1000 : elapsed;
+ return { clip: spec.clip, time: ((t % dur) + dur) % dur, moving: true };
+ }
+ if (spec.type === 'oneshot') {
+ if (!state.n) return rest;
+ const time = Math.min(dur, elapsed);
+ return { clip: spec.clip, time, moving: time < dur };
+ }
+ // door / toggle
+ const pos = openPosition(state, dur, nowMs);
+ const moving = state.n > 0 && (state.on ? pos < dur : pos > 0);
+ if (!state.on && spec.closeClip && durations[spec.closeClip] && state.n) {
+ const cdur = Math.max(1e-3, durations[spec.closeClip]);
+ return { clip: spec.closeClip, time: (1 - pos / dur) * cdur, moving };
+ }
+ return { clip: spec.clip, time: pos, moving };
+}
+
+/**
+ * The next state after a trigger at `nowMs` (the press, a knock, the proximity edge).
+ * `want` forces a direction (proximity: true on the rising edge, false on the falling one);
+ * undefined toggles. A oneshot always restarts; a loop starts/stops. Returns null when
+ * nothing would change (a proximity open on an already-open door).
+ * @param {BehaviorSpec} spec @param {BehaviorState} state @param {Record} durations
+ * @param {number} nowMs @param {boolean} [want] @returns {BehaviorState | null}
+ */
+export function nextState(spec, state, durations, nowMs, want) {
+ const n = state.n + 1;
+ if (spec.type === 'oneshot') return { on: true, at: nowMs, from: 0, n };
+ if (spec.type === 'loop') {
+ if (spec.autoplay) return null; // ambient: nothing to trigger
+ const on = want === undefined ? !state.on : want;
+ return on === state.on && state.n ? null : { on, at: nowMs, from: 0, n };
+ }
+ const on = want === undefined ? !state.on : want;
+ if (on === state.on && state.n) return null;
+ const dur = Math.max(1e-3, durations[spec.clip] ?? 1);
+ return { on, at: nowMs, from: openPosition(state, dur, nowMs), n };
+}
+
+/**
+ * A box MINUS a box, as slabs: the part of `outer` not covered by `hole`, in up to six
+ * axis-aligned pieces (below/above the hole on x, then y, then z, each later axis inside
+ * the earlier ones' range). Slabs thinner than `minSize` are dropped — along a door's
+ * thickness the hole spans (nearly) the whole frame, so those slabs would be slivers.
+ * Boxes are [minx, miny, minz, maxx, maxy, maxz]. Used to cut a doorway out of a frame
+ * whose mesh is one piece (two posts and a lintel make ONE box, which would block the
+ * opening forever).
+ * @param {number[]} outer @param {number[]} hole @param {number} [minSize]
+ * @returns {number[][]}
+ */
+export function boxMinusBox(outer, hole, minSize = 0.01) {
+ const h = [0, 1, 2].map((a) => [clamp(hole[a], outer[a], outer[a + 3]), clamp(hole[a + 3], outer[a], outer[a + 3])]);
+ if (h.some(([lo, hi]) => hi - lo <= 0)) return [outer.slice()]; // no overlap
+ /** @type {number[][]} */
+ const out = [];
+ const cur = outer.slice();
+ for (let a = 0; a < 3; a++) {
+ const [lo, hi] = h[a];
+ if (lo - cur[a] >= minSize) {
+ const s = cur.slice();
+ s[a + 3] = lo;
+ out.push(s);
+ }
+ if (cur[a + 3] - hi >= minSize) {
+ const s = cur.slice();
+ s[a] = hi;
+ out.push(s);
+ }
+ cur[a] = lo;
+ cur[a + 3] = hi;
+ }
+ return out;
+}
+
+/** does box `a` overlap box `b` (strictly, by more than `eps`)? @param {number[]} a @param {number[]} b */
+export function boxesOverlap(a, b, eps = 1e-4) {
+ for (let i = 0; i < 3; i++) if (a[i + 3] - eps <= b[i] || b[i + 3] - eps <= a[i]) return false;
+ return true;
+}
+
+/**
+ * The frame's collider pieces: each static box, minus every moving part's REST box it
+ * overlaps (a moving part that sits inside a static mesh's box is a doorway, a lid, a
+ * hatch — the opening must stay open). Pure over boxes in the object's local frame.
+ * @param {number[][]} staticBoxes @param {number[][]} movingBoxes @returns {number[][]}
+ */
+export function frameSlabs(staticBoxes, movingBoxes) {
+ /** @type {number[][]} */
+ let pieces = staticBoxes.map((b) => b.slice());
+ for (const hole of movingBoxes) {
+ /** @type {number[][]} */
+ const next = [];
+ for (const piece of pieces) {
+ if (boxesOverlap(piece, hole)) next.push(...boxMinusBox(piece, passageThrough(piece, hole)));
+ else next.push(piece);
+ }
+ pieces = next;
+ }
+ return pieces;
+}
+
+/** a frame no deeper than this is a wall/doorway the opening passes THROUGH */
+const PASSAGE_DEPTH = 0.6;
+
+/**
+ * A door leaf is thinner than the frame it hangs in (the frame is the wall's depth), so
+ * cutting just the leaf's box leaves a sliver in front of and behind it that still blocks
+ * the doorway. When the moving part's THIN axis lies inside a shallow static box, the hole
+ * runs through that box's whole depth on that axis. A lid sitting ON a chest never enters
+ * here (they only touch), and a deep box keeps its exact hole.
+ * @param {number[]} piece @param {number[]} hole @returns {number[]}
+ */
+export function passageThrough(piece, hole) {
+ let thin = 0;
+ for (let a = 1; a < 3; a++) if (hole[a + 3] - hole[a] < hole[thin + 3] - hole[thin]) thin = a;
+ const depth = piece[thin + 3] - piece[thin];
+ const inside = hole[thin] >= piece[thin] - 1e-4 && hole[thin + 3] <= piece[thin + 3] + 1e-4;
+ if (depth > PASSAGE_DEPTH || !inside) return hole;
+ const out = hole.slice();
+ out[thin] = piece[thin];
+ out[thin + 3] = piece[thin + 3];
+ return out;
+}
+
+/** the 8 corners of a box as a flat xyz list (a custom collider piece is a hull of points)
+ * @param {number[]} b @returns {number[]} */
+export function boxCorners(b) {
+ const out = [];
+ for (const x of [b[0], b[3]]) for (const y of [b[1], b[4]]) for (const z of [b[2], b[5]]) out.push(x, y, z);
+ return out;
+}
+
+/** The sound a trigger plays when the item names none, per type. @param {BehaviorSpec} spec */
+export function defaultSound(spec) {
+ return spec.type === 'door' ? 'door' : spec.type === 'oneshot' ? 'lever' : spec.type === 'toggle' ? 'click' : null;
+}
diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js
index b3de320d..776f65ca 100644
--- a/src/lib/explorerDrop.js
+++ b/src/lib/explorerDrop.js
@@ -166,7 +166,7 @@ function spreadPoint(point, i, count) {
* shape, so this is the only consumer that had to learn anything. The raycast happens
* ONCE and every item is placed relative to that one point: raycasting per item would
* ask the scene where to put an object while the previous ones were still arriving.
- * @param {{id?: string, kind: string, name: string, prefabId?: string | null, url?: string | null, items?: any[]}} payload
+ * @param {{id?: string, kind: string, name: string, prefabId?: string | null, url?: string | null, behavior?: any, items?: any[]}} payload
* @param {number} clientX @param {number} clientY
*/
export async function dropExplorerItem(payload, clientX, clientY) {
@@ -209,7 +209,8 @@ async function placeExplorerPayload(payload, target) {
// 30c: the placed piece carries its pack reference, so a save and the wire write it
// as a small stub instead of the whole model (packRefs.js)
importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined, undefined, {
- packRef: packRefFromUrl(payload.url, { item: name })
+ packRef: packRefFromUrl(payload.url, { item: name }),
+ behavior: payload.behavior ?? null // 33 P2
});
dismiss();
} catch {
diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js
index 2a085ab8..b01b69b6 100644
--- a/src/lib/fileHandler.svelte.js
+++ b/src/lib/fileHandler.svelte.js
@@ -331,10 +331,15 @@ export async function loadFile(url, name) {
* message (rigs cannot survive the per-node pipeline).
* @param {any} result @param {ArrayBuffer} buffer @param {string=} name
* @param {'gltf'|'fbx'=} kind which parser the bytes need on the other side (17-D2)
+ * @param {number[]=} position 33 P2: the drop point
*/
-function addAnimatedImport(result, buffer, name, kind) {
+function addAnimatedImport(result, buffer, name, kind, position) {
const root = result.scene;
root.name = name ?? 'Animated import';
+ // 33 P2: an animated model lands where it was dropped, like every other import (the
+ // position used to be ignored on this path, so a door dropped at the cursor appeared at
+ // the origin). Set BEFORE the sync so peers receive the placed transform.
+ if (position) root.position.fromArray(position);
sceneObjects.add(root);
pokeScene();
controls.attach(root);
@@ -345,7 +350,12 @@ function addAnimatedImport(result, buffer, name, kind) {
// 15-K3: the selection SET drives the outline + Ctrl+D — keep it in sync
selectedObjects.set([root.uuid]);
peer.send({ type: 'lock', uuid: root.uuid, peerId: peer.peer.id });
- showToast('Animated model: ' + result.animations.length + ' clip(s), playing the first');
+ // 33 P2: nothing autoplays any more (registerAnimatedImport); say what does play it
+ showToast(
+ root.userData.behavior
+ ? 'Placed "' + root.name + '" — it opens with a click in Interact or Play'
+ : 'Animated model: ' + result.animations.length + ' clip(s) — play them from the Animation panel'
+ );
}
/** Shared tail for every import format: add to the scene, select, replicate.
@@ -668,7 +678,8 @@ function defaultImportName(extension, name) {
* @param {any} file @param {string=} name @param {string=} ext - explicit extension when the blob has no name (Library)
* @param {number[]=} position - world drop point (Explorer drag-out, 96)
* @param {any[]=} extras - companion files picked/dropped alongside (.mtl + its textures)
- * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null}} [opts]
+ * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null, behavior?: any}} [opts]
+ * `behavior` (33 P2): the pack item's functional spec — a door, a lid, a fan
* 26-F: `reduce` imports REDUCED. 30c: `packRef` names the PACK ITEM this file is — the
* placed root then carries the reference (packRefs.js), so a save and the wire write it
* as a small stub. Ignored for an animated or a reduced import (neither IS the file).
@@ -711,7 +722,10 @@ export async function importFile(file, name, ext, position, extras, opts = {}) {
// 30c: stamp the pack reference BEFORE addImported, which is what replicates it
if (opts.packRef && !parsed.animated && typeof file?.arrayBuffer === 'function')
stampPackRef(parsed.root, opts.packRef, await hashBytes(await file.arrayBuffer()));
- if (parsed.animated) addAnimatedImport(parsed.animated.result, parsed.animated.buffer, label, parsed.animated.kind);
+ // 33 P2: a pack item's `behavior` (its row) wins over one in the file's own extras
+ if (opts.behavior && parsed.animated) parsed.root.userData.behavior = opts.behavior;
+ if (parsed.animated)
+ addAnimatedImport(parsed.animated.result, parsed.animated.buffer, label, parsed.animated.kind, position);
else addImported(parsed.root, label, position);
for (const note of parsed.notes) showToast(note);
return parsed.root.uuid;
diff --git a/src/lib/gameSfx.js b/src/lib/gameSfx.js
index 7fd164f5..7f643a0c 100644
--- a/src/lib/gameSfx.js
+++ b/src/lib/gameSfx.js
@@ -1,4 +1,4 @@
-// 30b (vr-play) C5 — THE GAME SOUND SET. Twenty procedural WebAudio effects a game can
+// 30b (vr-play) C5 — THE GAME SOUND SET. Twenty (33 P2: twenty-five) procedural WebAudio effects a game can
// name (`api.playSound('coin')`), with NO asset files: every sound is oscillators, a
// shared noise buffer, filters and envelopes, built fresh per play on the app's one
// AudioContext (audioEngine — a second context would have its own listener and clock).
@@ -46,7 +46,14 @@ export const GAME_SOUNDS = [
'ring',
'sparkle',
'hurt',
- 'portal'
+ 'portal',
+ // 33 P2: the sounds a functional pack item names (a door, an iron gate, a sci-fi slide,
+ // a lever, a chest lid) — `click` covers buttons and plates
+ 'door',
+ 'gate',
+ 'slide',
+ 'lever',
+ 'lid'
];
const SOUND_SET = new Set(GAME_SOUNDS);
@@ -282,6 +289,37 @@ export function buildGameSound(ctx, dest, name, t0) {
tone(ctx, dest, { freq: 270, to: 1950, type: 'triangle', t0: t0 + 0.05, dur: 0.85, peak: 0.08, attack: 0.3 });
noise(ctx, dest, { t0, dur: 0.9, peak: 0.08, attack: 0.4, filter: 'bandpass', freq: 800, to: 4000, q: 2 });
return 0.95;
+ // 33 P2 — the functional-item set
+ case 'door':
+ // wood: a creak (a slow sawtooth wobble through a narrow band) then a soft thud
+ tone(ctx, dest, { freq: 210, to: 260, type: 'sawtooth', t0, dur: 0.5, peak: 0.06, attack: 0.08, vibrato: { rate: 23, depth: 18 } });
+ noise(ctx, dest, { t0, dur: 0.45, peak: 0.08, attack: 0.1, filter: 'bandpass', freq: 900, q: 6, flutter: 17 });
+ tone(ctx, dest, { freq: 110, to: 60, t0: t0 + 0.5, dur: 0.18, peak: 0.35, attack: 0.003 });
+ noise(ctx, dest, { t0: t0 + 0.5, dur: 0.08, peak: 0.2, freq: 700, to: 200, attack: 0.002 });
+ return 0.72;
+ case 'gate':
+ // iron: an inharmonic clank and a short scrape
+ [310, 310 * 2.32, 310 * 4.1].forEach((f, i) =>
+ tone(ctx, dest, { freq: f, t0, dur: 0.6 - i * 0.15, peak: 0.14 - i * 0.03, attack: 0.002 })
+ );
+ noise(ctx, dest, { t0: t0 + 0.04, dur: 0.35, peak: 0.12, attack: 0.02, filter: 'bandpass', freq: 2400, q: 3 });
+ return 0.65;
+ case 'slide':
+ // sci-fi: a pneumatic hiss over a gliding hum
+ noise(ctx, dest, { t0, dur: 0.6, peak: 0.25, attack: 0.03, filter: 'highpass', freq: 3500, to: 1500 });
+ tone(ctx, dest, { freq: 140, to: 220, type: 'triangle', t0, dur: 0.55, peak: 0.12, attack: 0.05 });
+ return 0.65;
+ case 'lever':
+ // a ratchet of clicks into a heavy clunk
+ [0, 0.05, 0.1].forEach((d) => noise(ctx, dest, { t0: t0 + d, dur: 0.03, peak: 0.15, filter: 'highpass', freq: 2500, attack: 0.001 }));
+ tone(ctx, dest, { freq: 130, to: 70, type: 'square', t0: t0 + 0.16, dur: 0.16, peak: 0.18, attack: 0.002 });
+ noise(ctx, dest, { t0: t0 + 0.16, dur: 0.1, peak: 0.25, freq: 900, to: 250, attack: 0.002 });
+ return 0.36;
+ case 'lid':
+ // a chest lid: a short high creak and a light knock
+ tone(ctx, dest, { freq: 420, to: 520, type: 'sawtooth', t0, dur: 0.3, peak: 0.05, attack: 0.05, vibrato: { rate: 31, depth: 25 } });
+ tone(ctx, dest, { freq: 180, to: 90, t0: t0 + 0.3, dur: 0.12, peak: 0.25, attack: 0.002 });
+ return 0.45;
default:
return -1;
}
diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js
index 6dde8928..105c7918 100644
--- a/src/lib/moduleSDK.js
+++ b/src/lib/moduleSDK.js
@@ -264,6 +264,7 @@ function removeSceneRootGroup(name) {
/** @type {any} */ let jointsRef = null;
/** @type {any} */ let objectActionsRef = null;
/** @type {any} */ let pingAudioRef = null;
+/** @type {any} */ let packBehaviorRef = null; // 33 P2 (api.behavior)
/** @type {any} */ let audioEngineRef = null;
/** @type {any} */ let musicClockRef = null;
/** @type {any} */ let audioDevicesRef = null;
@@ -298,6 +299,7 @@ if (typeof window !== 'undefined') {
import('./joints').then((m) => (jointsRef = m));
import('./objectActions').then((m) => (objectActionsRef = m));
import('./pingAudio').then((m) => (pingAudioRef = m));
+ import('./packBehavior').then((m) => (packBehaviorRef = m));
// 23-A5: the audio stack — engine (a leaf, but kept dynamic with its siblings so the
// four resolve together), clock, devices, patch, and the sample loader
import('./audioEngine').then((m) => (audioEngineRef = m));
@@ -1440,6 +1442,36 @@ function makeApi(moduleId, moduleName = moduleId) {
onDispose(() => handle.remove());
return handle;
},
+ /**
+ * 33 P2: FUNCTIONAL PACK ITEMS — a door, a chest lid, a lever, a fan placed from a pack
+ * (an item carrying a `behavior`). `list()` -> `[{uuid, type, trigger, open}]`;
+ * `state(uuid)` -> `{on, at, n}` or null before anything triggered it;
+ * `trigger(uuid, open?)` toggles it (or forces `open` true/false) exactly as a player's
+ * click would — REPLICATED (one `behavior` message, every peer poses it from the same
+ * stamp), so call it on ONE peer. Returns whether anything changed. Nothing here moves in
+ * Edit: a peer in Edit renders the rest pose whatever the state says.
+ */
+ behavior: {
+ list() {
+ const ref = packBehaviorRef;
+ if (!ref) return [];
+ return ref.packBehaviorDebug().items.map((/** @type {any} */ it) => ({
+ uuid: it.uuid,
+ type: it.spec?.type,
+ trigger: it.spec?.trigger,
+ open: !!it.state?.on
+ }));
+ },
+ /** @param {string} uuid */
+ state(uuid) {
+ const s = packBehaviorRef?.behaviorState(uuid);
+ return s ? { on: s.on, at: s.at, n: s.n } : null;
+ },
+ /** @param {string} uuid @param {boolean} [open] */
+ trigger(uuid, open) {
+ return !!packBehaviorRef?.triggerBehavior(String(uuid), typeof open === 'boolean' ? open : undefined);
+ }
+ },
/**
* 31-perf K4: the ADAPTIVE QUALITY LEVEL on this device, so a module can cut its own
* effects/particles/draw distance when the frame rate cannot hold. `level` is 0 (best)
diff --git a/src/lib/packBehavior.js b/src/lib/packBehavior.js
new file mode 100644
index 00000000..720e5ab0
--- /dev/null
+++ b/src/lib/packBehavior.js
@@ -0,0 +1,610 @@
+// 33 P2 — ANIMATED, FUNCTIONAL PACK ITEMS: the runtime. A door you click open, a chest
+// lid, a lever, a fan. The pure rules (normalize, timing, the doorway maths) live in
+// behaviorCore.js; this file feeds them and acts on the answer.
+//
+// WHAT DRIVES WHAT
+// · The SPEC rides the placed root (`userData.behavior`, registered by animatedImports
+// from the pack row or the GLB's own extras). animatedImports skips these roots in its
+// looping transport; THIS file poses them, every frame, through the same mixer.
+// · The STATE `{on, at, from, n}` is runtime and SHARED: one `behavior` message per
+// trigger, latest-wins on a sessionNow() stamp, every pose a pure function of it — so a
+// door swings in phase on every peer with no stream. A late joiner gets it as an additive
+// `behaviorState` on the objectfile message (animatedImports' behaviorFields). It is
+// never saved and never undone: a scene saved with a door open reopens shut.
+// · THE USER'S RULE: nothing moves in EDIT. A peer in Edit renders the rest pose whatever
+// the shared state says (gameFeelActive is LOCAL), so authoring never sees a door swing,
+// and an ambient loop only runs in Interact/Play. The Animation panel can PREVIEW a clip
+// in Edit — local, never sent, back to rest at the end.
+//
+// TRIGGERS (Interact/Play only): click — every click path (the desktop Interact click, the
+// Play tap, the VR laser/trigger and the hold-and-sweep poke) reaches moduleSDK's ONE
+// dispatch, where a core handler is pushed; knock — a VR hand moving into a moving part at
+// >= KNOCK_SPEED (a desktop click still knocks); proximity — the local player within 1.5 m
+// opens on the rising edge, the falling edge closes, a click still toggles.
+//
+// COLLIDERS (`collider: 'follow'`): the frame is a CUSTOM compound collider of slab boxes
+// with the doorway cut out (behaviorCore.frameSlabs), stamped on `userData.physics` at
+// registration on every peer (deterministic from the file, so nothing travels), and each
+// moving node gets its own KINEMATIC box in the live rapier world that follows the node
+// every frame. physics.js skips colliders it does not own (physicsRuntime's contract), and
+// the walker's capsule collides with everything in the world — so an open door lets you
+// through and a shut one stops you. Only while a simulation runs, which is the only time
+// the walker collides with anything at all (charController's tiers, unchanged).
+import * as THREE from 'three';
+import { get } from 'svelte/store';
+import { objectsGroup, globalCamera } from '../stores/sceneStore';
+import { peers } from '../stores/appStore';
+import { sessionNow } from './sessionClock';
+import { gameFeelActive } from './gameFeel';
+import { behaviorOf, behaviorUuids, behaviorRegistry, animatedRecordOf, setBehaviorHooks } from './animatedImports';
+import {
+ normalizeState,
+ restState,
+ stateIsNewer,
+ poseAt,
+ nextState,
+ frameSlabs,
+ boxCorners,
+ defaultSound,
+ PROXIMITY_RADIUS,
+ KNOCK_SPEED
+} from './behaviorCore';
+import { playGameSound } from './gameSfx';
+import { moduleClickHandlers } from './moduleSDK';
+
+/** @type {Map} */
+const states = new Map();
+/** @type {Map} uuid -> the placed root (re-found when a registration repeats) */
+const roots = new Map();
+/** @type {Map} uuid -> when we last looked and found NO root (a deleted item:
+ * the registry keeps it, and a tree search per frame per ghost would be a per-frame cost) */
+const missingAt = new Map();
+/** @type {Map} uuid -> the pose last written ("clip@time"), so an idle door costs nothing */
+const lastPose = new Map();
+/** @type {Map} uuid -> the action last driven (switching clip stops it) */
+const lastAction = new Map();
+/** @type {Map} LOCAL Animation-panel previews */
+const previews = new Map();
+/** @type {Map} proximity: was the player inside last frame */
+const near = new Map();
+/** @type {Map} knock cooldown: uuid -> performance.now() it may fire again */
+const knockReady = new Map();
+/** @type {Map} world boxes for proximity, refreshed */
+const boxCache = new Map();
+const stats = { triggers: 0, received: 0, stale: 0, poses: 0, sounds: 0, knocks: 0, colliderBuilds: 0, clicks: 0 };
+/** the last click the handler heard and what it made of it (the suites' diagnosis) */
+let lastClick = /** @type {{mesh: string, result: string} | null} */ (null);
+/** @param {any} mesh @param {string} result @returns {false} */
+function noteClick(mesh, result) {
+ lastClick = { mesh: String(mesh?.name ?? mesh?.type ?? '?'), result };
+ return false;
+}
+
+/** the leftover after a clip end before a preview hands the pose back */
+const PREVIEW_HOLD = 0.4;
+const KNOCK_COOLDOWN_MS = 800;
+const BOX_REFRESH_MS = 500;
+
+/** @type {((hand: 'left'|'right') => any) | null} */
+let hands = null;
+/** @type {any} */ let physicsRef = null;
+/** @type {any} */ let flowRef = null;
+let started = false;
+
+/* ------------------------------------------------------------------ helpers ------ */
+
+/** the placed root for a uuid (cached, re-validated: an undo re-adds a NEW object) @param {string} uuid */
+function rootOf(uuid) {
+ const cached = roots.get(uuid);
+ if (cached && cached.parent) return cached;
+ const now = performance.now();
+ if (now - (missingAt.get(uuid) ?? -Infinity) < 1000) return null;
+ const found = get(objectsGroup)?.getObjectByProperty('uuid', uuid) ?? null;
+ if (found) {
+ roots.set(uuid, found);
+ missingAt.delete(uuid);
+ } else {
+ roots.delete(uuid);
+ missingAt.set(uuid, now);
+ }
+ return found;
+}
+
+/** the behavior root a clicked mesh belongs to, or null @param {any} mesh */
+export function behaviorRootOf(mesh) {
+ for (let node = mesh; node; node = node.parent) if (node.uuid && behaviorOf(node.uuid)) return node;
+ return null;
+}
+
+/** the nodes a clip animates (the MOVING parts), by the track names @param {any} root @param {any[]} clips */
+function movingNodesOf(root, clips) {
+ /** @type {Set} */
+ const out = new Set();
+ for (const clip of clips) {
+ for (const track of clip?.tracks ?? []) {
+ // a weights-only track (a banner's morph) moves no collider
+ if (track.name.endsWith('.morphTargetInfluences')) continue;
+ const parsed = THREE.PropertyBinding.parseTrackName(track.name);
+ const node = parsed.nodeName ? THREE.PropertyBinding.findNode(root, parsed.nodeName) : null;
+ if (node && node !== root) out.add(node);
+ }
+ }
+ return [...out];
+}
+
+/** the spec's clips as AnimationClips @param {string} uuid */
+function specClips(uuid) {
+ const spec = behaviorOf(uuid);
+ const record = animatedRecordOf(uuid);
+ if (!spec || !record) return [];
+ /** @type {string[]} */
+ const names = spec.closeClip ? [spec.clip, spec.closeClip] : [spec.clip];
+ return names.map((name) => record.actions[name]?.getClip?.()).filter(Boolean);
+}
+
+/**
+ * Write a pose: `clip` at `time` seconds. The action is played, its time SET, and the
+ * mixer updated by 0 — an exact evaluation with no accumulated drift and no loop wrap.
+ * @param {string} uuid @param {string} clip @param {number} time
+ */
+function writePose(uuid, clip, time) {
+ const record = animatedRecordOf(uuid);
+ const action = record?.actions?.[clip];
+ if (!action) return;
+ const dur = action.getClip().duration;
+ const t = Math.min(dur, Math.max(0, time));
+ const key = clip + '@' + t.toFixed(4);
+ if (lastPose.get(uuid) === key) return;
+ const previous = lastAction.get(uuid);
+ if (previous && previous !== action) previous.stop();
+ if (!action.isScheduled() || !action.enabled) {
+ action.reset();
+ action.setLoop(THREE.LoopOnce, 1);
+ action.clampWhenFinished = true;
+ action.play();
+ }
+ action.paused = false;
+ action.time = t;
+ record.mixer.update(0);
+ lastAction.set(uuid, action);
+ lastPose.set(uuid, key);
+ stats.poses++;
+}
+
+/** play the item's sound at the object @param {string} uuid */
+function soundFor(uuid) {
+ const spec = behaviorOf(uuid);
+ const name = spec?.sound ?? (spec ? defaultSound(spec) : null);
+ if (!name) return;
+ const root = rootOf(uuid);
+ const at = root ? root.getWorldPosition(new THREE.Vector3()).toArray() : null;
+ if (playGameSound(name, at)) stats.sounds++;
+}
+
+/* ------------------------------------------------------------------ the state ---- */
+
+/** the current shared state (a copy) @param {string} uuid */
+export function behaviorState(uuid) {
+ const s = states.get(uuid);
+ return s ? { ...s } : null;
+}
+
+/**
+ * TRIGGER: the click, the knock, the proximity edge. Applies locally, sends ONE message,
+ * plays the sound here (every receiver plays its own). `want` forces a direction.
+ * @param {string} uuid @param {boolean} [want] @returns {boolean} did anything change
+ */
+export function triggerBehavior(uuid, want) {
+ const spec = behaviorOf(uuid);
+ const record = animatedRecordOf(uuid);
+ if (!spec || !record) return false;
+ const next = nextState(spec, states.get(uuid) ?? restState(), record.durations, sessionNow(), want);
+ if (!next) return false;
+ states.set(uuid, next);
+ stats.triggers++;
+ soundFor(uuid);
+ /** @type {any} */
+ const peer = get(peers);
+ peer?.send?.({ type: 'behavior', uuid, on: next.on, at: next.at, from: next.from, n: next.n });
+ return true;
+}
+
+/**
+ * Receive side of `behavior` (and of a late joiner's `behaviorState`). Latest wins; a
+ * FRESH remote trigger also sounds here (a stale one — a joiner's history — does not).
+ * @param {any} data @param {{sound?: boolean}} [opts] @returns {boolean} applied
+ */
+export function applyBehaviorState(data, opts = {}) {
+ const uuid = data?.uuid;
+ const state = normalizeState(data);
+ if (typeof uuid !== 'string' || !state) return false;
+ if (!stateIsNewer(states.get(uuid) ?? null, state)) {
+ stats.stale++;
+ return false;
+ }
+ states.set(uuid, state);
+ stats.received++;
+ if (opts.sound !== false && behaviorOf(uuid) && Math.abs(sessionNow() - state.at) < 1500) soundFor(uuid);
+ return true;
+}
+
+/** the wire applier (peerHandler) @param {any} data */
+export function applyRemoteBehavior(data) {
+ return applyBehaviorState(data);
+}
+
+/**
+ * LOCAL preview from the Animation panel / Inspector play button: plays `clip` once and
+ * hands the pose back. Never sent, never saved, works in Edit (that is what it is for).
+ * @param {string} uuid @param {string} [clip]
+ */
+export function previewBehavior(uuid, clip) {
+ const spec = behaviorOf(uuid);
+ const record = animatedRecordOf(uuid);
+ if (!spec || !record) return false;
+ const name = clip && record.actions[clip] ? clip : spec.clip;
+ previews.set(uuid, { clip: name, start: performance.now() });
+ return true;
+}
+
+/** stop a preview at once @param {string} uuid */
+export function stopBehaviorPreview(uuid) {
+ previews.delete(uuid);
+}
+
+/* ------------------------------------------------------------------ colliders ---- */
+
+/** local box of `object`'s meshes in the frame of `frame` (scale removed with it) @param {any} object @param {any} frame @param {(m: any) => boolean} [filter] */
+function boxInFrame(object, frame, filter) {
+ frame.updateWorldMatrix(true, true);
+ const inv = new THREE.Matrix4().copy(frame.matrixWorld).invert();
+ const box = new THREE.Box3();
+ const m = new THREE.Matrix4();
+ const piece = new THREE.Box3();
+ object.traverse((/** @type {any} */ node) => {
+ if (!node.isMesh || !node.geometry) return;
+ if (filter && !filter(node)) return;
+ if (!node.geometry.boundingBox) node.geometry.computeBoundingBox();
+ piece.copy(node.geometry.boundingBox).applyMatrix4(m.multiplyMatrices(inv, node.matrixWorld));
+ box.union(piece);
+ });
+ return box;
+}
+
+/** @param {THREE.Box3} b @returns {number[]} */
+const boxArray = (b) => [b.min.x, b.min.y, b.min.z, b.max.x, b.max.y, b.max.z];
+
+/**
+ * The frame's collider: static meshes' boxes minus the moving parts' REST boxes, as a
+ * custom compound collider on `userData.physics` (local to the root, the customPieces
+ * convention: scale is baked by physics). Never overrides a collider the user chose.
+ * @param {string} uuid
+ */
+function stampFrameCollider(uuid) {
+ const spec = behaviorOf(uuid);
+ const root = rootOf(uuid);
+ if (!spec || spec.collider !== 'follow' || !root) return;
+ if (root.userData.physics?.collider && !root.userData.physics.__behaviorFrame) return;
+ const moving = movingNodesOf(root, specClips(uuid));
+ if (!moving.length) return;
+ const movingSet = new Set();
+ for (const node of moving) node.traverse((/** @type {any} */ n) => movingSet.add(n));
+ /** @type {number[][]} */
+ const statics = [];
+ root.traverse((/** @type {any} */ node) => {
+ if (!node.isMesh || movingSet.has(node)) return;
+ const b = boxInFrame(node, root);
+ if (!b.isEmpty()) statics.push(boxArray(b));
+ });
+ const holes = moving.map((node) => boxArray(boxInFrame(node, root))).filter((b) => b[3] > b[0]);
+ const slabs = frameSlabs(statics, holes).slice(0, 50); // CUSTOM_MAX_FLOATS / 24
+ if (!slabs.length) return;
+ /** @type {number[]} */
+ const verts = [];
+ /** @type {number[][]} */
+ const pieces = [];
+ for (const s of slabs) {
+ pieces.push([verts.length, 24]);
+ verts.push(...boxCorners(s).map((v) => Math.round(v * 1e5) / 1e5));
+ }
+ root.userData.physics = {
+ ...(root.userData.physics ?? {}),
+ collider: 'custom',
+ colliderVerts: verts,
+ colliderPieces: pieces,
+ // marks the collider as OURS, so a re-registration may restamp it and a user's own
+ // pick is never overwritten
+ __behaviorFrame: true
+ };
+}
+
+/** @type {{world: any, parts: {uuid: string, node: any, body: any, last?: {pos: THREE.Vector3, quat: THREE.Quaternion}}[]} | null} */
+let built = null;
+
+/** the live rapier world, or null @returns {any} */
+function runtime() {
+ try {
+ return physicsRef?.physicsRuntime?.() ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/** (re)build a kinematic box per moving node of every follow-item into `rt.world` @param {any} rt */
+function buildPartColliders(rt) {
+ const { world, RAPIER } = rt;
+ built = { world, parts: [] };
+ stats.colliderBuilds++;
+ const pos = new THREE.Vector3();
+ const quat = new THREE.Quaternion();
+ const scale = new THREE.Vector3();
+ for (const uuid of behaviorUuids()) {
+ const spec = behaviorOf(uuid);
+ const root = rootOf(uuid);
+ if (!spec || spec.collider !== 'follow' || !root) continue;
+ for (const node of movingNodesOf(root, specClips(uuid))) {
+ const local = boxInFrame(node, node);
+ if (local.isEmpty()) continue;
+ node.updateWorldMatrix(true, false);
+ node.matrixWorld.decompose(pos, quat, scale);
+ const size = local.getSize(new THREE.Vector3()).multiply(scale).multiplyScalar(0.5);
+ const centre = local.getCenter(new THREE.Vector3()).multiply(scale);
+ try {
+ const body = world.createRigidBody(
+ RAPIER.RigidBodyDesc.kinematicPositionBased()
+ .setTranslation(pos.x, pos.y, pos.z)
+ .setRotation({ x: quat.x, y: quat.y, z: quat.z, w: quat.w })
+ );
+ world.createCollider(
+ RAPIER.ColliderDesc.cuboid(Math.max(0.01, size.x), Math.max(0.01, size.y), Math.max(0.01, size.z)).setTranslation(
+ centre.x,
+ centre.y,
+ centre.z
+ ),
+ body
+ );
+ built.parts.push({ uuid, node, body });
+ } catch (error) {
+ console.log('behavior collider failed', error);
+ }
+ }
+ }
+}
+
+// per-frame temps (no allocation in the hot path — MODULES.md's performance rule)
+const _pos = new THREE.Vector3();
+const _quat = new THREE.Quaternion();
+const _scale = new THREE.Vector3();
+/** set when a registration (or a removal) changes what must be followed */
+let collidersDirty = true;
+
+/** one frame of the followed colliders */
+function tickColliders() {
+ const rt = runtime();
+ if (!rt) {
+ built = null; // the world is gone with its bodies (stopSimulation frees it)
+ return;
+ }
+ let stale = !built || built.world !== rt.world || collidersDirty;
+ if (!stale && built) for (const part of built.parts) if (!part.node.parent || !rootOf(part.uuid)) stale = true;
+ let moved = false;
+ if (stale) {
+ collidersDirty = false;
+ if (built && built.world === rt.world)
+ for (const part of built.parts) {
+ try {
+ rt.world.removeRigidBody(part.body);
+ } catch {}
+ }
+ buildPartColliders(rt);
+ }
+ if (!built) return;
+ for (const part of built.parts) {
+ part.node.updateWorldMatrix(true, false);
+ part.node.matrixWorld.decompose(_pos, _quat, _scale);
+ // a part that has not moved since the last write costs nothing
+ if (part.last && _pos.equals(part.last.pos) && _quat.equals(part.last.quat)) continue;
+ part.last ??= { pos: new THREE.Vector3(), quat: new THREE.Quaternion() };
+ part.last.pos.copy(_pos);
+ part.last.quat.copy(_quat);
+ try {
+ // rapier copies vectors in: THREE's own {x,y,z(,w)} shapes are accepted as-is
+ part.body.setNextKinematicTranslation(_pos);
+ part.body.setNextKinematicRotation(_quat);
+ // the walker queries colliders between steps: teleport too, so a query this frame
+ // already sees the leaf where it is drawn
+ part.body.setTranslation(_pos, true);
+ part.body.setRotation(_quat, true);
+ moved = true;
+ } catch {}
+ }
+ if (moved) {
+ try {
+ rt.world.propagateModifiedBodyPositionsToColliders?.();
+ } catch {}
+ }
+}
+
+/* ------------------------------------------------------------------ triggers ----- */
+
+const _player = new THREE.Vector3();
+
+/** world box of a root, cached briefly (proximity) @param {string} uuid @param {any} root */
+function worldBox(uuid, root) {
+ const now = performance.now();
+ const cached = boxCache.get(uuid);
+ if (cached && now - cached.at < BOX_REFRESH_MS) return cached.box;
+ const box = new THREE.Box3().setFromObject(root);
+ boxCache.set(uuid, { box, at: now });
+ return box;
+}
+
+/** hand samples, reused every frame: `now` is this frame's, `prev` the one before */
+const handSamples = {
+ left: { now: { pos: new THREE.Vector3(), t: 0, ok: false }, prev: { pos: new THREE.Vector3(), t: 0, ok: false } },
+ right: { now: { pos: new THREE.Vector3(), t: 0, ok: false }, prev: { pos: new THREE.Vector3(), t: 0, ok: false } }
+};
+const _knockBox = new THREE.Box3();
+const HAND_LIST = [handSamples.left, handSamples.right];
+const HAND_NAMES = /** @type {const} */ (['left', 'right']);
+
+/** one frame of the proximity and knock triggers @param {string} uuid @param {any} spec @param {any} root */
+function tickTriggers(uuid, spec, root) {
+ if (spec.trigger === 'proximity') {
+ const camera = /** @type {any} */ (get(globalCamera));
+ if (!camera) return;
+ camera.getWorldPosition(_player);
+ const inside = worldBox(uuid, root).distanceToPoint(_player) <= PROXIMITY_RADIUS;
+ const was = near.get(uuid) ?? false;
+ if (inside !== was) {
+ near.set(uuid, inside);
+ triggerBehavior(uuid, spec.type === 'oneshot' ? undefined : inside);
+ }
+ return;
+ }
+ if (spec.trigger === 'knock' && hands) {
+ const now = performance.now();
+ if ((knockReady.get(uuid) ?? 0) > now) return;
+ _knockBox.copy(worldBox(uuid, root)).expandByScalar(0.05);
+ for (const sample of HAND_LIST) {
+ const { now: cur, prev } = sample;
+ if (!cur.ok || !prev.ok || cur.t <= prev.t) continue;
+ const speed = (cur.pos.distanceTo(prev.pos) / (cur.t - prev.t)) * 1000;
+ // the ENTRY edge: inside now, outside last frame, moving fast enough
+ if (_knockBox.containsPoint(cur.pos) && !_knockBox.containsPoint(prev.pos) && speed >= KNOCK_SPEED) {
+ knockReady.set(uuid, now + KNOCK_COOLDOWN_MS);
+ stats.knocks++;
+ triggerBehavior(uuid);
+ return;
+ }
+ }
+ }
+}
+
+/** this frame's hand poses (the previous frame's become `prev`) — no allocation */
+function sampleHands() {
+ if (!hands) return;
+ const t = performance.now();
+ for (const hand of HAND_NAMES) {
+ const sample = handSamples[hand];
+ sample.prev.pos.copy(sample.now.pos);
+ sample.prev.t = sample.now.t;
+ sample.prev.ok = sample.now.ok;
+ const snap = hands(hand);
+ sample.now.ok = !!snap?.position;
+ if (sample.now.ok) {
+ sample.now.pos.fromArray(snap.position);
+ sample.now.t = t;
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ the frame ---- */
+
+/** Per frame from Scene's loop (beside tickAnimatedMixers). Cheap when nothing moves. */
+export function tickPackBehaviors() {
+ const registry = behaviorRegistry();
+ if (!registry.size) {
+ if (built) built = null;
+ return;
+ }
+ const active = gameFeelActive();
+ const now = sessionNow();
+ if (active && hands) sampleHands();
+ for (const [uuid, spec] of registry) {
+ const record = animatedRecordOf(uuid);
+ const root = rootOf(uuid);
+ if (!spec || !record || !root) continue;
+ if (active && spec.trigger !== 'click') tickTriggers(uuid, spec, root);
+ if (!active) near.delete(uuid);
+ const preview = previews.get(uuid);
+ if (preview) {
+ const dur = record.durations[preview.clip] ?? 1;
+ const t = (performance.now() - preview.start) / 1000;
+ if (t <= dur + PREVIEW_HOLD) {
+ writePose(uuid, preview.clip, t);
+ continue;
+ }
+ previews.delete(uuid);
+ }
+ const pose = poseAt(spec, states.get(uuid) ?? restState(), record.durations, now, active);
+ writePose(uuid, pose.clip, pose.time);
+ }
+ if (physicsRef) tickColliders();
+}
+
+/* ------------------------------------------------------------------ the click ---- */
+
+/**
+ * The core click handler in moduleSDK's ONE dispatch (pushed raw, so it hears every mode;
+ * it gates itself). Consumes the click it acts on, then pulses the object's On Click so an
+ * author can still hang a sound or a counter on the door.
+ * @param {any} mesh @returns {boolean}
+ */
+function onBehaviorClick(mesh) {
+ stats.clicks++;
+ if (!gameFeelActive()) return noteClick(mesh, 'edit');
+ const root = behaviorRootOf(mesh);
+ if (!root) return noteClick(mesh, 'not-functional');
+ const spec = behaviorOf(root.uuid);
+ if (!spec || (spec.type === 'loop' && spec.autoplay)) return noteClick(mesh, 'ambient');
+ // proximity doors still answer a click (a toggle by hand); knock doors take a desktop
+ // click as the knock (a desktop has no hands)
+ if (!triggerBehavior(root.uuid)) return noteClick(mesh, 'unchanged');
+ noteClick(mesh, 'toggled');
+ try {
+ flowRef?.fireObjectClick?.(root.uuid);
+ } catch {}
+ return true;
+}
+
+/**
+ * Start once (Scene.svelte, beside the knock). `hands` is vrControls.handSnapshot, passed
+ * in so this file never imports vrControls.
+ * @param {{hands?: (hand: 'left'|'right') => any}} [options]
+ */
+export function startPackBehaviors(options = {}) {
+ if (options.hands) hands = options.hands;
+ if (started) return;
+ started = true;
+ setBehaviorHooks({
+ state: (uuid) => behaviorState(uuid),
+ apply: (uuid, state) => applyBehaviorState({ ...state, uuid }, { sound: false }),
+ preview: (uuid, clip) => previewBehavior(uuid, clip),
+ registered: (uuid) => {
+ roots.delete(uuid);
+ missingAt.delete(uuid);
+ lastPose.delete(uuid);
+ lastAction.delete(uuid);
+ const record = animatedRecordOf(uuid);
+ const spec = behaviorOf(uuid);
+ // the rest pose NOW, so the frame collider is measured with the part shut
+ if (record && spec) writePose(uuid, spec.clip, 0);
+ stampFrameCollider(uuid);
+ collidersDirty = true; // a new part to follow: rebuild against the live world
+ }
+ });
+ // a registration that happened before we started (a restore during boot)
+ for (const uuid of behaviorUuids()) stampFrameCollider(uuid);
+ moduleClickHandlers.push(onBehaviorClick);
+ // primed (physics and flowRuntime sit in history's cycle family)
+ import('./physics').then((m) => (physicsRef = m)).catch(() => {});
+ import('./flowRuntime').then((m) => (flowRef = m)).catch(() => {});
+}
+
+/** the suites' view @returns {any} */
+export function packBehaviorDebug() {
+ return {
+ ...stats,
+ lastClick,
+ items: behaviorUuids().map((uuid) => ({
+ uuid,
+ spec: behaviorOf(uuid),
+ state: behaviorState(uuid),
+ pose: lastPose.get(uuid) ?? null,
+ preview: previews.has(uuid),
+ physics: rootOf(uuid)?.userData?.physics ?? null
+ })),
+ colliders: built ? built.parts.map((p) => ({ uuid: p.uuid, node: p.node.name, handle: p.body.handle })) : []
+ };
+}
diff --git a/src/lib/packs.js b/src/lib/packs.js
index 2ea82efe..34db621e 100644
--- a/src/lib/packs.js
+++ b/src/lib/packs.js
@@ -2,6 +2,7 @@ import { writable, get } from 'svelte/store';
import { contentBase } from './contentBase';
import { addItemFromBytes, createFolder, explorerFolders } from './explorer';
import { safeStorage } from './safeStorage';
+import { normalizeBehavior } from './behaviorCore';
// N6 (roadmap 7 / ship-qa D1): object packs. Two sources, one normalized model:
// - DEFAULT packs from static/libraryList.json (bundled today; the model bytes
@@ -288,7 +289,10 @@ export async function loadPackItems(pack) {
glbUrl: /^https?:\/\//.test(glb) ? glb : `${pack.base}/${o.name}/glTF-Binary/${glb}`,
thumbs: thumbCandidates(pack, o),
resolvedThumb: cachedThumb(pack.name, o.name), // P2: skip re-probing if known
- packName: pack.name
+ packName: pack.name,
+ // 33 P2: a functional item (door, lid, lever, fan) — null for a plain prop.
+ // Normalized HERE so the Explorer badge and the placement agree on one spec.
+ behavior: normalizeBehavior(o.behavior)
};
});
}
diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js
index 6001d0ed..0f0d2a5f 100644
--- a/src/lib/peerHandler.svelte.js
+++ b/src/lib/peerHandler.svelte.js
@@ -95,6 +95,7 @@ import { applySessionProposal, applySessionAnswer, deferUntilShareChoice, localS
import { applyRemoteGeometry } from '$lib/geometryEdit';
import { applyLightTarget } from '$lib/lightParams';
import { applyObjectFile } from '$lib/animatedImports';
+import { applyRemoteBehavior } from '$lib/packBehavior';
import { lockedObjects, selectedObject, peerHands, objectsGroup, pokeScene } from '../stores/sceneStore';
import { addMessage, peers, userdata, pendingApprovals, waitingForApproval, showToast } from '../stores/appStore';
import { get } from 'svelte/store';
@@ -936,6 +937,10 @@ export class PeerConnection {
});
} else if(data.type == 'objectfile') {
applyObjectFile(data);
+ } else if(data.type == 'behavior') {
+ // 33 P2: a functional pack item was triggered (a door, a lid, a lever).
+ // Latest-wins on the sessionNow stamp; every peer derives the pose itself.
+ applyRemoteBehavior(data);
} else if(data.type == 'object') {
createObject(data, data.uuids, data.override, data.groupuuid, data.pos, data.rot, data.scale);
} else if(data.type == 'objectParameters') {
diff --git a/src/lib/peerScenes.js b/src/lib/peerScenes.js
index edc0e033..73c66a19 100644
--- a/src/lib/peerScenes.js
+++ b/src/lib/peerScenes.js
@@ -615,7 +615,7 @@ export function roomsOfSession(map, mine, host) {
export const ROOM_SCOPED = new Set([
// object lifecycle & geometry
'create', 'light', 'group', 'object', 'objectfile', 'duplicate', 'delete', 'name',
- 'move', 'throw', 'hit', 'simulate', 'color', 'objectParameters', 'geometry', 'lighttarget',
+ 'move', 'throw', 'hit', 'behavior', 'simulate', 'color', 'objectParameters', 'geometry', 'lighttarget',
'verts', 'meshgeo', 'uvpaint', 'uvpaintend', 'splineedit', 'drawlive', 'drawend',
'clearscene', 'loading',
// flow
diff --git a/src/lib/physics.js b/src/lib/physics.js
index 14f0268b..83daa5d5 100644
--- a/src/lib/physics.js
+++ b/src/lib/physics.js
@@ -826,7 +826,11 @@ async function startSimulation() {
);
if (dynamicUuids.length === 0) {
const selected = get(selectedObject);
- if (selected && group.getObjectByProperty('uuid', selected.uuid) && !locked.includes(selected.uuid)) {
+ // 33 P2: never a FUNCTIONAL pack item (a door, a chest): its colliders are the frame
+ // slabs + the followed moving parts, and as the fallback body it fell over and took
+ // every click as a carry. `selectedObject` is sticky, so the door just placed (and
+ // deselected) was exactly what this picked.
+ if (selected && !selected.userData?.behavior && group.getObjectByProperty('uuid', selected.uuid) && !locked.includes(selected.uuid)) {
params[selected.uuid] = { ...(params[selected.uuid] ?? {}), mass: 1 };
delete params[selected.uuid].forceStatic;
dynamicUuids = [selected.uuid];
diff --git a/src/lib/vrGameInput.js b/src/lib/vrGameInput.js
index aa8c0e9e..dd6b4f30 100644
--- a/src/lib/vrGameInput.js
+++ b/src/lib/vrGameInput.js
@@ -75,7 +75,8 @@ export function clickTargetOf(hit) {
const top = topLevelObjectOf(mesh);
if (!top) return null;
if (deviceBetween(mesh, top)) return { kind: 'object', key: mesh.uuid, mesh, top, point: hit.point, distance: hit.distance ?? 0, group: null };
- if (top.userData?.clickable === true || objectHasOnClick(top.uuid))
+ // 33 P2: a functional pack item (a door, a lever) is pressed like a button
+ if (top.userData?.clickable === true || top.userData?.behavior || objectHasOnClick(top.uuid))
return { kind: 'object', key: top.uuid, mesh, top, point: hit.point, distance: hit.distance ?? 0, group: null };
return null;
}
diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js
index 93fb3a4a..bd66ffc9 100644
--- a/src/lib/wireValidate.js
+++ b/src/lib/wireValidate.js
@@ -142,6 +142,15 @@ export const VALIDATORS = {
triggers: (d) => !!d.triggers && typeof d.triggers === 'object',
peervars: (d) => typeof d.peerId === 'string',
playmode: (d) => typeof d.peerId === 'string',
+ // 33 P2: a functional pack item was triggered (a door opened). The state is evaluated
+ // as a POSE every frame, so a non-finite stamp would put a door at NaN forever.
+ behavior: (d) =>
+ isUuid(d.uuid) &&
+ typeof d.on === 'boolean' &&
+ Number.isFinite(d.at) &&
+ Number.isFinite(d.from) &&
+ Number.isInteger(d.n) &&
+ d.n >= 0,
camera: (d) => typeof d.peerId === 'string' && isVec3(d.position) && isFiniteArray(d.rotation, 3)
};
diff --git a/tests/e2e/animated-models.test.cjs b/tests/e2e/animated-models.test.cjs
index 1d3d008a..76fb7c05 100644
--- a/tests/e2e/animated-models.test.cjs
+++ b/tests/e2e/animated-models.test.cjs
@@ -1,4 +1,5 @@
-// Phase 51: animated model import — clips play on the synced clock everywhere,
+// Phase 51: animated model import — clips play on the synced clock everywhere (33 P2: once
+// PLAYED — placement no longer autoplays),
// raw-bytes replication (incl. late joiners), pause/clip state syncs, undo works.
const h = require('./helpers.cjs');
@@ -61,7 +62,15 @@ h.run(async () => {
await h.eventually(() => animState(A.page), (s) => s?.clips?.includes('slide'), 'animated import registered on A');
const { uuid } = await animState(A.page);
- // the clip actually animates on A
+ // 33 P2: NOTHING AUTOPLAYS on placement any more — the clip is selected, not playing,
+ // and the model stands still until somebody presses play
+ h.check((await animState(A.page)).playing === false, '33: the import is NOT playing on placement');
+ const s1 = await moverX(A.page, uuid);
+ await A.page.waitForTimeout(400);
+ h.check(s1 === (await moverX(A.page, uuid)), '33: ...and the model stands still');
+ await A.page.evaluate((uuid) => window.__stores.animatedImports.setAnimationState(uuid, { playing: true }), uuid);
+
+ // the clip actually animates on A once played
const a1 = await moverX(A.page, uuid);
await A.page.waitForTimeout(400);
const a2 = await moverX(A.page, uuid);
diff --git a/tests/e2e/animation-persist.test.cjs b/tests/e2e/animation-persist.test.cjs
index 8c4fd492..0346e349 100644
--- a/tests/e2e/animation-persist.test.cjs
+++ b/tests/e2e/animation-persist.test.cjs
@@ -75,7 +75,10 @@ h.run(async () => {
// import it, and author a movement track on a second object
await A.page.evaluate(async (bytes) => {
const w = window.__stores;
- await w.fileHandler.importFile(new File([new Uint8Array(bytes)], 'rig.glb'), 'Rig');
+ const rig = await w.fileHandler.importFile(new File([new Uint8Array(bytes)], 'rig.glb'), 'Rig');
+ // 33 P2: placement no longer autoplays — press play, as a user would, so the save
+ // below carries a PLAYING transport (what this suite round-trips)
+ w.animatedImports.setAnimationState(rig, { playing: true });
await new Promise((r) => setTimeout(r, 600));
w.commandsHandler.sceneCommand('/create Box 1 1 1');
await new Promise((r) => setTimeout(r, 300));
@@ -182,6 +185,7 @@ h.run(async () => {
if (m?.parameter === 'animation') window.__sent.push(m.playing);
return orig(m);
};
+ // the clip is PLAYING here (pressed at import, restored playing), so one press pauses
document.querySelector('#clip-play')?.click();
await new Promise((r) => setTimeout(r, 400));
const imported = await new Promise((r) => w.animatedImports.animatedObjects.subscribe(r)());
diff --git a/tests/e2e/fixtures/build-test-door.mjs b/tests/e2e/fixtures/build-test-door.mjs
new file mode 100644
index 00000000..8249bd0f
--- /dev/null
+++ b/tests/e2e/fixtures/build-test-door.mjs
@@ -0,0 +1,91 @@
+// Builds tests/e2e/fixtures/test-door.glb: the contract-P2 test door the pack-behavior suite places.
+// A FRAME (one mesh, two posts + a lintel, so the collider has to cut the doorway out of it)
+// and a LEAF node whose origin is the hinge (vertices offset to +x), with three clips:
+// `idle` (static, FIRST - what anim-kit ships), `open` (0 -> -90 deg about Y over 1 s) and
+// `close` (the reverse). The doorway is x in [-0.5, 0.5], y in [0, 2], z in [-0.05, 0.05].
+//
+// Run (gltf-transform is not a core dependency; any checkout of it works):
+// GLTF_TRANSFORM=/path/to/node_modules/@gltf-transform/core node tests/e2e/fixtures/build-test-door.mjs
+import { writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const corePath = process.env.GLTF_TRANSFORM;
+if (!corePath) throw new Error('set GLTF_TRANSFORM to @gltf-transform/core');
+const { Document, NodeIO } = await import(join(corePath, 'dist/index.js'));
+
+const doc = new Document();
+const buffer = doc.createBuffer();
+
+/** axis-aligned boxes merged into one primitive: [[minx,miny,minz,maxx,maxy,maxz], ...] */
+function boxesPrim(boxes, material) {
+ const pos = [];
+ const nrm = [];
+ const idx = [];
+ for (const [x0, y0, z0, x1, y1, z1] of boxes) {
+ const faces = [
+ [[1, 0, 0], [[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]]],
+ [[-1, 0, 0], [[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]]],
+ [[0, 1, 0], [[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]]],
+ [[0, -1, 0], [[x0, y0, z1], [x0, y0, z0], [x1, y0, z0], [x1, y0, z1]]],
+ [[0, 0, 1], [[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]]],
+ [[0, 0, -1], [[x1, y0, z0], [x0, y0, z0], [x0, y1, z0], [x1, y1, z0]]]
+ ];
+ for (const [n, quad] of faces) {
+ const base = pos.length / 3;
+ for (const v of quad) {
+ pos.push(...v);
+ nrm.push(...n);
+ }
+ idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
+ }
+ }
+ const prim = doc
+ .createPrimitive()
+ .setAttribute('POSITION', doc.createAccessor().setType('VEC3').setArray(new Float32Array(pos)).setBuffer(buffer))
+ .setAttribute('NORMAL', doc.createAccessor().setType('VEC3').setArray(new Float32Array(nrm)).setBuffer(buffer))
+ .setIndices(doc.createAccessor().setType('SCALAR').setArray(new Uint16Array(idx)).setBuffer(buffer))
+ .setMaterial(material);
+ return prim;
+}
+
+const wood = doc.createMaterial('Wood').setBaseColorFactor([0.55, 0.36, 0.2, 1]).setRoughnessFactor(0.8);
+const stone = doc.createMaterial('Stone').setBaseColorFactor([0.6, 0.6, 0.62, 1]).setRoughnessFactor(0.9);
+
+const frameMesh = doc.createMesh('Frame').addPrimitive(
+ boxesPrim(
+ [
+ [-0.65, 0, -0.1, -0.5, 2.15, 0.1], // left post
+ [0.5, 0, -0.1, 0.65, 2.15, 0.1], // right post
+ [-0.5, 2.0, -0.1, 0.5, 2.15, 0.1] // lintel
+ ],
+ stone
+ )
+);
+const leafMesh = doc.createMesh('Leaf').addPrimitive(boxesPrim([[0.02, 0.01, -0.04, 0.98, 1.98, 0.04]], wood));
+
+const frame = doc.createNode('Frame').setMesh(frameMesh);
+const leaf = doc.createNode('Leaf').setMesh(leafMesh).setTranslation([-0.5, 0, 0]);
+const root = doc.createNode('TestDoor').addChild(frame).addChild(leaf);
+doc.createScene('Scene').addChild(root);
+
+/** a rotation clip about Y on the leaf @param {string} name @param {number[]} times @param {number[]} degs */
+function yClip(name, times, degs) {
+ const quats = [];
+ for (const d of degs) {
+ const h = (d * Math.PI) / 360;
+ quats.push(0, Math.sin(h), 0, Math.cos(h));
+ }
+ const input = doc.createAccessor().setType('SCALAR').setArray(new Float32Array(times)).setBuffer(buffer);
+ const output = doc.createAccessor().setType('VEC4').setArray(new Float32Array(quats)).setBuffer(buffer);
+ const sampler = doc.createAnimationSampler().setInput(input).setOutput(output).setInterpolation('LINEAR');
+ const channel = doc.createAnimationChannel().setTargetNode(leaf).setTargetPath('rotation').setSampler(sampler);
+ doc.createAnimation(name).addSampler(sampler).addChannel(channel);
+}
+yClip('idle', [0, 1], [0, 0]);
+yClip('open', [0, 1], [0, -90]);
+yClip('close', [0, 1], [-90, 0]);
+
+const out = join(dirname(fileURLToPath(import.meta.url)), 'test-door.glb');
+writeFileSync(out, await new NodeIO().writeBinary(doc));
+console.log('wrote', out);
diff --git a/tests/e2e/fixtures/test-door.glb b/tests/e2e/fixtures/test-door.glb
new file mode 100644
index 00000000..2eafd061
Binary files /dev/null and b/tests/e2e/fixtures/test-door.glb differ
diff --git a/tests/e2e/game-audio.test.cjs b/tests/e2e/game-audio.test.cjs
index 67d29852..1bcbcb16 100644
--- a/tests/e2e/game-audio.test.cjs
+++ b/tests/e2e/game-audio.test.cjs
@@ -29,11 +29,11 @@ h.run(async () => {
for (const name of k.GAME_SOUNDS) out.push(await k.renderGameSound(name));
return out;
});
- h.check(renders.length === 20, '1.1 the set holds the 20 contract names (' + renders.length + ')');
+ h.check(renders.length === 25, '1.1 the set holds the 25 contract names (20 + the 33 P2 item sounds) (' + renders.length + ')');
const quiet = renders.filter((r) => !r || r.rms < 0.004 || r.peak < 0.02);
h.check(quiet.length === 0, '1.2 every name renders energy (quiet: ' + JSON.stringify(quiet.map((r) => r && r.name)) + ')');
const distinct = new Set(renders.map((r) => r && Math.round(r.rms * 1e5)));
- h.check(distinct.size >= 18, '1.3 the sounds are distinct from each other (' + distinct.size + ' distinct levels)');
+ h.check(distinct.size >= 22, '1.3 the sounds are distinct from each other (' + distinct.size + ' distinct levels)');
const unknownRender = await page.evaluate(() => window.__stores.gameKit.gameSfx.renderGameSound('nope'));
h.check(unknownRender && unknownRender.seconds === -1 && unknownRender.rms === 0, '1.4 an unknown name builds nothing');
diff --git a/tests/e2e/pack-behavior.test.cjs b/tests/e2e/pack-behavior.test.cjs
new file mode 100644
index 00000000..2f1b748f
--- /dev/null
+++ b/tests/e2e/pack-behavior.test.cjs
@@ -0,0 +1,428 @@
+// 33 P2 (contract P2): ANIMATED, FUNCTIONAL PACK ITEMS. A door placed from a pack is STILL
+// (nothing autoplays on placement, on load or in Edit), opens on a click in Interact, its
+// leaf collider follows the swing so the walker capsule passes an open door and is stopped
+// by a shut one, every peer sees the same state (a late joiner too), Edit keeps the closed
+// pose, and the Animation panel previews a clip locally without touching the shared state.
+// The door is tests/e2e/fixtures/test-door.glb (gltf-transform: a one-piece frame + a
+// hinged `Leaf` + idle/open/close clips), served as a routed pack so the Explorer path —
+// badge, double-click place — is the real one.
+const fs = require('fs');
+const path = require('path');
+const h = require('./helpers.cjs');
+
+const DOOR_GLB = fs.readFileSync(path.join(__dirname, 'fixtures', 'test-door.glb'));
+const PACK = 'https://anim-pack.invalid';
+const DOOR_BEHAVIOR = { type: 'door', clip: 'open', closeClip: 'close', trigger: 'click', autoplay: false, sound: 'door', collider: 'follow' };
+
+/** route the test pack: an item list with one door, and its glb @param {any} page */
+async function routePack(page) {
+ await page.route(PACK + '/**', (route) => {
+ const url = route.request().url();
+ if (url.endsWith('/default.json'))
+ return route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify([
+ { name: 'TestDoor', label: 'Test door', variants: { 'glTF-Binary': 'TestDoor.glb' }, behavior: DOOR_BEHAVIOR },
+ { name: 'PlainCrate', label: 'Plain crate', variants: { 'glTF-Binary': 'TestDoor.glb' } }
+ ])
+ });
+ if (url.endsWith('.glb')) return route.fulfill({ contentType: 'model/gltf-binary', body: DOOR_GLB });
+ return route.fulfill({ status: 404, body: '' });
+ });
+}
+
+/** the door's live numbers on a page @param {any} page */
+const doorOf = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ const dbg = s.packBehavior.packBehaviorDebug();
+ const item = dbg.items[0];
+ if (!item) return null;
+ let root = null;
+ s.objectsGroup.subscribe((g) => (root = g?.getObjectByProperty('uuid', item.uuid)))();
+ const leaf = root?.getObjectByName('Leaf');
+ return {
+ uuid: item.uuid,
+ name: root?.name,
+ state: item.state,
+ spec: item.spec,
+ qy: leaf ? leaf.quaternion.y : null,
+ pos: root ? root.position.toArray() : null,
+ physics: item.physics,
+ colliders: dbg.colliders.length,
+ sounds: dbg.sounds,
+ triggers: dbg.triggers,
+ preview: item.preview
+ };
+ });
+
+const OPEN_QY = -Math.SQRT1_2; // -90 deg about Y
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+ const B = await h.setupPage(browser, 'B');
+ await routePack(A.page);
+ await h.connect(B, A);
+
+ console.log('\n=== 1. the pack row carries the spec; the Explorer badges it ===');
+ await A.page.locator('#explorer-slot').click();
+ await A.page.waitForTimeout(800);
+ await A.page.evaluate((base) => {
+ const s = window.__stores;
+ s.packs.packs.update((list) => [
+ ...list.filter((p) => p.name !== 'anim-test'),
+ { name: 'anim-test', title: 'Anim test', source: 'default', base, listUrl: base + '/default.json', zip: '', attributionUrl: '', sourceUrl: '', copyright: '', license: 'CC0-1.0' }
+ ]);
+ }, PACK);
+ await A.page.locator('#packs-folder').dblclick();
+ await A.page.waitForTimeout(300);
+ await A.page.locator('#explorer-list [data-pack="anim-test"]').click();
+ await h.eventually(
+ () => A.page.locator('#explorer-list .explorer-card').count(),
+ (n) => n === 2,
+ '1.1 the routed pack lists its two items'
+ );
+ const badges = await A.page.evaluate(() =>
+ [...document.querySelectorAll('#explorer-list .explorer-card')].map((c) => ({
+ text: c.textContent.trim(),
+ badge: c.querySelector('.explorer-animated')?.getAttribute('data-behavior') ?? null,
+ title: c.querySelector('.explorer-animated')?.getAttribute('title') ?? ''
+ }))
+ );
+ const doorCard = badges.find((b) => /TestDoor/.test(b.text));
+ const crateCard = badges.find((b) => /PlainCrate/.test(b.text));
+ h.check(doorCard?.badge === 'door', '1.2 the door card wears the "animated" badge (' + JSON.stringify(doorCard) + ')');
+ h.check(/opens and closes on a click/.test(doorCard?.title ?? ''), '1.3 the badge says what it does and what sets it off');
+ h.check(crateCard && crateCard.badge === null, '1.4 a plain item has no badge');
+
+ console.log('\n=== 2. placed from the Explorer: still, and nothing plays ===');
+ await A.page.locator('#explorer-list .explorer-card', { hasText: 'TestDoor' }).dblclick();
+ await h.eventually(() => doorOf(A.page), (d) => !!d?.uuid, '2.1 double-click places the door (the real pack path)', 15000);
+ let door = await doorOf(A.page);
+ h.check(door.spec?.type === 'door' && door.spec?.collider === 'follow', '2.2 the placed root carries the spec from the pack row');
+ const animState = await A.page.evaluate((uuid) => {
+ let st = null;
+ window.__stores.animatedImports.animatedObjects.subscribe((m) => (st = m[uuid]))();
+ return st;
+ }, door.uuid);
+ h.check(animState && animState.playing === false, '2.3 the transport is NOT playing after placement (' + JSON.stringify(animState) + ')');
+ const q0 = door.qy;
+ await A.page.waitForTimeout(800);
+ door = await doorOf(A.page);
+ h.check(Math.abs(q0) < 1e-4 && Math.abs(door.qy) < 1e-4, '2.4 the leaf is shut and stays shut (qy ' + q0 + ' -> ' + door.qy + ')');
+ h.check(door.state === null, '2.5 no shared state until something triggers it');
+ await A.page.evaluate((uuid) => {
+ const s = window.__stores;
+ let root = null;
+ s.objectsGroup.subscribe((g) => (root = g?.getObjectByProperty('uuid', uuid)))();
+ root.position.set(0, 0, -3);
+ root.updateMatrixWorld(true);
+ s.peers.subscribe((p) => p.send({ type: 'move', uuid, pos: [0, 0, -3], rot: [0, 0, 0, 'XYZ'], scale: [1, 1, 1] }))();
+ }, door.uuid);
+ await h.eventually(() => doorOf(B.page), (d) => d?.uuid === door.uuid && Math.abs(d.pos[2] + 3) < 1e-3, '2.6 B received the door (objectfile + the move)', 15000);
+ h.check((await doorOf(B.page)).spec?.type === 'door', '2.7 the spec rode the objectfile to B');
+
+ console.log('\n=== 3. the frame collider has the doorway cut out ===');
+ door = await doorOf(A.page);
+ const pieces = door.physics?.colliderPieces?.length ?? 0;
+ h.check(door.physics?.collider === 'custom' && pieces >= 3, '3.1 the frame is a custom compound of ' + pieces + ' slabs');
+ const opening = await A.page.evaluate((uuid) => {
+ const s = window.__stores;
+ let root = null;
+ s.objectsGroup.subscribe((g) => (root = g?.getObjectByProperty('uuid', uuid)))();
+ const v = root.userData.physics.colliderVerts;
+ const bad = [];
+ for (const [start, count] of root.userData.physics.colliderPieces) {
+ let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
+ for (let i = start; i < start + count; i += 3) {
+ minx = Math.min(minx, v[i]); maxx = Math.max(maxx, v[i]);
+ miny = Math.min(miny, v[i + 1]); maxy = Math.max(maxy, v[i + 1]);
+ }
+ // anything spanning the doorway's middle below the lintel blocks it
+ if (minx < 0 && maxx > 0 && miny < 1 && maxy > 1) bad.push([minx, maxx, miny, maxy]);
+ }
+ return bad;
+ }, door.uuid);
+ h.check(opening.length === 0, '3.2 no frame slab crosses the doorway (' + JSON.stringify(opening) + ')');
+
+ console.log('\n=== 4. Edit: a click selects, never opens ===');
+ const editClick = await A.page.evaluate((uuid) => {
+ const s = window.__stores;
+ let root = null;
+ s.objectsGroup.subscribe((g) => (root = g?.getObjectByProperty('uuid', uuid)))();
+ return s.moduleSDK.runClickHandlers(root.getObjectByName('Leaf'), 'edit');
+ }, door.uuid);
+ h.check(editClick === false && (await doorOf(A.page)).state === null, '4.1 the click handler stands down in Edit');
+
+ console.log('\n=== 5. Interact: a REAL click opens it, with its sound ===');
+ await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact'));
+ await A.page.evaluate(() => window.__stores.objectActions.deselectObject?.());
+ await A.page.waitForTimeout(300);
+ const pt = await h.projectPoint(A.page, [0.2, 1.2, -3]);
+ const under = await A.page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, pt);
+ h.check(under === 'CANVAS', '5.1 the aim point is the viewport (' + under + ')');
+ // once open, the leaf swings OUT of that point: later toggles aim at the right POST,
+ // which belongs to the same functional root
+ const framePt = await h.projectPoint(A.page, [0.58, 1.2, -3]);
+ const soundsBefore = door.sounds;
+ await A.page.mouse.click(pt.x, pt.y);
+ await h.eventually(() => doorOf(A.page), (d) => d?.state?.on === true, '5.2 the click opened it (state on)');
+ await h.eventually(() => doorOf(A.page), (d) => Math.abs(d.qy - OPEN_QY) < 0.01, '5.3 the leaf swung to 90 degrees on A');
+ h.check((await doorOf(A.page)).sounds > soundsBefore, '5.4 the door sound played');
+
+ console.log('\n=== 6. B sees it open (in Interact) and shut (in Edit) ===');
+ await h.eventually(() => doorOf(B.page), (d) => d?.state?.on === true, '6.1 the state reached B');
+ await B.page.waitForTimeout(1300);
+ h.check(Math.abs((await doorOf(B.page)).qy) < 1e-4, '6.2 B in EDIT renders the closed pose (the rule)');
+ await B.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact'));
+ await h.eventually(() => doorOf(B.page), (d) => Math.abs(d.qy - OPEN_QY) < 0.01, '6.3 B in Interact shows it open');
+
+ console.log('\n=== 7. the leaf collider follows: open passes, shut stops ===');
+ // THE FALLBACK GUARD: with nothing dynamic, a sim start simulates the (sticky) selected
+ // object — which used to be the door just placed, so it fell and every click carried it
+ await A.page.evaluate((uuid) => {
+ const s = window.__stores;
+ s.objectActions.selectObject(uuid);
+ s.objectActions.deselectObject(); // selectedObject stays the door (sticky)
+ }, door.uuid);
+ await A.page.evaluate(() => window.__stores.physics.toggleSimulation());
+ await A.page.waitForTimeout(1500);
+ const fallback = await A.page.evaluate((uuid) => {
+ const p = window.__stores.physics;
+ const running = !!p.physicsRuntime();
+ const dynamicDoor = running && p.physicsDebug().some((b) => b.uuid === uuid);
+ if (running) p.stopSimulation();
+ return { running, dynamicDoor };
+ }, door.uuid);
+ h.check(!fallback.dynamicDoor, '7.0a a sim start never makes the functional door the fallback dynamic body (' + JSON.stringify(fallback) + ')');
+ // a real dynamic body elsewhere, so a simulation runs
+ const crate = await A.page.evaluate(async () => {
+ const s = window.__stores;
+ s.commandsHandler.sceneCommand('/create Box 1 1 1');
+ await new Promise((r) => setTimeout(r, 800));
+ let g = null;
+ s.objectsGroup.subscribe((v) => (g = v))();
+ const box = [...g.children].reverse().find((c) => c.isMesh && !c.userData.behavior);
+ box.position.set(6, 3, 6);
+ box.updateMatrixWorld(true);
+ s.physics.setPhysicsFor(box.uuid, { mode: 'dynamic', mass: 1 });
+ s.objectActions.deselectObject();
+ return box.uuid;
+ });
+ await A.page.evaluate(() => window.__stores.physics.toggleSimulation());
+ await h.eventually(() => A.page.evaluate(() => !!window.__stores.physics.physicsRuntime()), (v) => v, '7.0 a simulation runs on A', 15000);
+ const bodies = await A.page.evaluate(() => {
+ const p = window.__stores.physics;
+ return {
+ dynamic: p.physicsDebug().map((b) => b.uuid),
+ fixed: p.physicsWorldDebug().fixed.find((f) => f.name === 'TestDoor') ?? null
+ };
+ });
+ h.check(
+ !bodies.dynamic.includes(door.uuid) && bodies.fixed && bodies.fixed.colliders >= 3,
+ '7.0b the door is a FIXED body of frame slabs, never the fallback dynamic one (' + JSON.stringify({ crate: bodies.dynamic.includes(crate), fixed: bodies.fixed }) + ')'
+ );
+ await A.page.waitForTimeout(400);
+ /** walk the capsule from in front of the door straight through the doorway */
+ const walk = () =>
+ A.page.evaluate(() => {
+ const cc = window.__stores.charController;
+ cc.resetWalker();
+ let p = { x: 0, y: 0.05, z: -1.4 };
+ let source = '';
+ for (let i = 0; i < 90; i++) {
+ const r = cc.resolveWalk(p, 1.7, 1 / 60, { dx: 0, dz: -0.05 }, { gravity: false });
+ p = { x: p.x + r.dx, y: r.feet, z: p.z + r.dz };
+ source = r.source;
+ }
+ return { z: p.z, source };
+ });
+ h.check((await doorOf(A.page)).colliders === 1, '7.1 one kinematic leaf collider follows the Leaf node');
+ // charController primes its physics import LAZILY on the first call, so the very first
+ // walk resolves on the plane tier — prime it, then measure
+ await walk();
+ await A.page.waitForTimeout(300);
+ let w = await walk();
+ h.check(w.source === 'rapier' && w.z < -4, '7.2 OPEN: the capsule walks through the doorway (z ' + w.z.toFixed(2) + ', ' + w.source + ')');
+ await A.page.mouse.click(framePt.x, framePt.y); // shut it — a click on the frame toggles the same root
+ await h.eventually(() => doorOf(A.page), (d) => d?.state?.on === false, '7.3 a second click shuts it');
+ await h.eventually(() => doorOf(A.page), (d) => Math.abs(d.qy) < 0.01, '7.4 the leaf swung back');
+ await A.page.waitForTimeout(200);
+ w = await walk();
+ h.check(w.z > -2.9, '7.5 SHUT: the leaf stops the capsule (z ' + w.z.toFixed(2) + ')');
+ // counterfactual: the whole-door box (no doorway cut) blocks even an OPEN door
+ const diagBefore = await A.page.evaluate(({ x, y }) => ({ under: document.elementFromPoint(x, y)?.tagName, dbg: window.__stores.packBehavior.packBehaviorDebug().clicks }), framePt);
+ await A.page.mouse.click(framePt.x, framePt.y);
+ await A.page.waitForTimeout(300);
+ const diag = await A.page.evaluate(() => {
+ const d = window.__stores.packBehavior.packBehaviorDebug();
+ let pi = null;
+ window.__stores.playInteract.playInteractState?.subscribe?.((v) => (pi = v))();
+ return { clicks: d.clicks, lastClick: d.lastClick, state: d.items[0]?.state, pi };
+ });
+ console.log(' 7.6 diagnosis: before ' + JSON.stringify(diagBefore) + ' after ' + JSON.stringify(diag));
+ await h.eventually(() => doorOf(A.page), (d) => Math.abs(d.qy - OPEN_QY) < 0.01, '7.6 reopened');
+ await A.page.evaluate(async (uuid) => {
+ const s = window.__stores;
+ s.physics.stopSimulation();
+ let root = null;
+ s.objectsGroup.subscribe((g) => (root = g?.getObjectByProperty('uuid', uuid)))();
+ root.userData.__savedPhysics = root.userData.physics;
+ delete root.userData.physics;
+ await s.physics.toggleSimulation();
+ }, door.uuid);
+ await A.page.waitForTimeout(600);
+ w = await walk();
+ h.check(w.z > -2.9, '7.7 COUNTERFACTUAL: without the cut frame the open doorway is a wall (z ' + w.z.toFixed(2) + ')');
+ await A.page.evaluate((uuid) => {
+ const s = window.__stores;
+ s.physics.stopSimulation();
+ let root = null;
+ s.objectsGroup.subscribe((g) => (root = g?.getObjectByProperty('uuid', uuid)))();
+ root.userData.physics = root.userData.__savedPhysics;
+ delete root.userData.__savedPhysics;
+ }, door.uuid);
+
+ console.log('\n=== 8. Edit keeps the closed pose; a save carries the spec, never the state ===');
+ await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit'));
+ await h.eventually(() => doorOf(A.page), (d) => Math.abs(d.qy) < 1e-4, '8.1 back in Edit the leaf rests shut while the shared state says open');
+ h.check((await doorOf(A.page)).state?.on === true, '8.2 ...and the shared state is untouched');
+ const saved = await A.page.evaluate(() => {
+ const s = window.__stores;
+ let g = null;
+ s.objectsGroup.subscribe((v) => (g = v))();
+ return s.animatedImports.animatedImportsSnapshot(g).map((e) => ({ behavior: e.behavior, state: e.behaviorState ?? null }));
+ });
+ h.check(saved.length === 1 && saved[0].behavior?.type === 'door' && saved[0].state === null, '8.3 the save entry has the spec and no open/shut state');
+
+ console.log('\n=== 9. a late joiner gets the state with the door ===');
+ const C = await h.setupPage(browser, 'C');
+ await h.connect(C, A);
+ await h.eventually(() => doorOf(C.page), (d) => d?.uuid === door.uuid && d.state?.on === true, '9.1 C holds the door, open, from the handshake', 20000);
+ const [aState, cState] = [(await doorOf(A.page)).state, (await doorOf(C.page)).state];
+ h.check(aState.at === cState.at && aState.n === cState.n, '9.2 C has the SAME stamp and count as A');
+ h.check(Math.abs((await doorOf(C.page)).qy) < 1e-4, '9.3 C is in Edit, so it shows the door shut');
+
+ console.log('\n=== 10. Animation panel preview: local, unsent, back to rest ===');
+ const sent = await A.page.evaluate(async (uuid) => {
+ const s = window.__stores;
+ let peer = null;
+ s.peers.subscribe((p) => (peer = p))();
+ const types = [];
+ const orig = peer.send.bind(peer);
+ peer.send = (msg) => (types.push(msg?.type), orig(msg));
+ s.animatedImports.setAnimationState(uuid, { playing: true, clip: 'open' });
+ let root = null;
+ s.objectsGroup.subscribe((g) => (root = g?.getObjectByProperty('uuid', uuid)))();
+ // sample the whole preview window (1 s clip + the hold): one instant read on a loaded
+ // page can land before the first preview frame
+ let mid = 0;
+ for (let i = 0; i < 30; i++) {
+ await new Promise((r) => setTimeout(r, 50));
+ mid = Math.min(mid, root.getObjectByName('Leaf').quaternion.y);
+ }
+ peer.send = orig;
+ return { types, mid };
+ }, door.uuid);
+ h.check(sent.mid < -0.2, '10.1 the preview moves the leaf in Edit (deepest qy ' + sent.mid.toFixed(3) + ')');
+ h.check(!sent.types.includes('objectParameters') && !sent.types.includes('behavior'), '10.2 nothing was sent (' + JSON.stringify(sent.types) + ')');
+ await h.eventually(() => doorOf(A.page), (d) => Math.abs(d.qy) < 1e-4 && !d.preview, '10.3 the preview hands the pose back to rest', 4000);
+ h.check((await doorOf(A.page)).state?.n === aState.n, '10.4 the shared state did not change');
+
+ console.log('\n=== 11. the wire shape is validated ===');
+ const valid = await A.page.evaluate((uuid) => {
+ const v = window.__stores.wireValidate.validateWireMessage;
+ return {
+ good: v({ type: 'behavior', uuid, on: true, at: 1, from: 0, n: 1 }),
+ nan: v({ type: 'behavior', uuid, on: true, at: NaN, from: 0, n: 1 }),
+ str: v({ type: 'behavior', uuid, on: 'yes', at: 1, from: 0, n: 1 }),
+ neg: v({ type: 'behavior', uuid, on: true, at: 1, from: 0, n: -1 })
+ };
+ }, door.uuid);
+ h.check(valid.good && !valid.nan && !valid.str && !valid.neg, '11.1 behavior: good passes, NaN/string/negative refused (' + JSON.stringify(valid) + ')');
+ const stale = await B.page.evaluate((uuid) => window.__stores.packBehavior.applyBehaviorState({ uuid, on: false, at: 1, from: 0, n: 1 }), door.uuid);
+ h.check(stale === false && (await doorOf(B.page)).state?.on === true, '11.2 an older state is refused (latest wins)');
+
+ console.log('\n=== 12. a GLB with no behavior no longer autoplays ===');
+ const plain = await A.page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ const s = window.__stores;
+ const THREE = s.THREE;
+ const root = new THREE.Group();
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial());
+ mesh.name = 'mover';
+ root.add(mesh);
+ const track = new THREE.VectorKeyframeTrack('mover.position', [0, 1, 2], [0, 0, 0, 2, 0, 0, 0, 0, 0]);
+ new s.GLTFExporterModule.GLTFExporter().parse(
+ root,
+ async (buffer) => {
+ const uuid = await s.fileHandler.importFile(new File([buffer], 'slide.glb'), 'Slider');
+ let g = null;
+ s.objectsGroup.subscribe((v) => (g = v))();
+ const node = () => g.getObjectByProperty('uuid', uuid)?.getObjectByName('mover')?.position.x;
+ const x1 = node();
+ await new Promise((r) => setTimeout(r, 600));
+ let st = null;
+ s.animatedImports.animatedObjects.subscribe((m) => (st = m[uuid]))();
+ resolve({ x1, x2: node(), playing: st?.playing, behavior: s.animatedImports.behaviorOf(uuid) });
+ },
+ () => resolve(null),
+ { binary: true, animations: [new THREE.AnimationClip('slide', 2, [track])] }
+ );
+ })
+ );
+ h.check(plain && plain.playing === false && plain.x1 === plain.x2, '12.1 placing an animated GLB plays nothing (' + JSON.stringify(plain) + ')');
+ h.check(plain && plain.behavior === null, '12.2 ...and it is not a functional item');
+
+ console.log('\n=== 13. the module seam: api.behavior ===');
+ const sdk = await A.page.evaluate(async (uuid) => {
+ const s = window.__stores;
+ await s.moduleSDK.initModules([
+ { id: 'behavior33', name: 'Behavior test', version: '1.0.0', description: '33 P2 seam', register(api) { window.__bapi = api; } }
+ ]);
+ await new Promise((r) => setTimeout(r, 300));
+ const api = window.__bapi;
+ const list = api.behavior.list();
+ const before = api.behavior.state(uuid);
+ const changed = api.behavior.trigger(uuid, false);
+ const again = api.behavior.trigger(uuid, false); // already shut: nothing to do
+ return { list, before, changed, again, after: api.behavior.state(uuid) };
+ }, door.uuid);
+ h.check(sdk.list.length === 1 && sdk.list[0].type === 'door' && sdk.list[0].open === true, '13.1 api.behavior.list() names the open door (' + JSON.stringify(sdk.list) + ')');
+ h.check(sdk.changed === true && sdk.again === false && sdk.after.on === false, '13.2 trigger(uuid, false) shuts it once and is a no-op after');
+ await h.eventually(() => doorOf(B.page), (d) => d?.state?.on === false, '13.3 the module trigger replicated to B');
+
+ console.log('\n=== 14. a knock door: a fast hand opens it, a slow one does not ===');
+ const knock = await A.page.evaluate(async (bytes) => {
+ const s = window.__stores;
+ const behavior = { type: 'door', clip: 'open', closeClip: 'close', trigger: 'knock', sound: 'door' };
+ const uuid = await s.fileHandler.importFile(new File([new Uint8Array(bytes)], 'knock.glb'), 'Knock door', 'glb', [3, 0, -3], undefined, { behavior });
+ // a fake hand seam (no headset headless): the frame loop samples it every frame
+ window.__hand = null;
+ s.packBehavior.startPackBehaviors({ hands: (hand) => (hand === 'right' && window.__hand ? { position: window.__hand } : null) });
+ const frames = (n) => new Promise((r) => { let i = 0; const f = () => (++i >= n ? r() : requestAnimationFrame(f)); requestAnimationFrame(f); });
+ const stateOf = () => s.packBehavior.behaviorState(uuid);
+ s.objectActions.setEditorMode('interact');
+ // SLOW: creep in at ~1 mm per frame (~0.06 m/s) — touching is not knocking
+ for (let z = -2.5; z >= -3.0; z -= 0.001) {
+ window.__hand = [3, 1.2, z];
+ await frames(1);
+ }
+ const slow = stateOf();
+ window.__hand = [3, 1.2, -2.0];
+ await frames(3);
+ // FAST: out to in, in one frame
+ window.__hand = [3, 1.2, -3.0];
+ await frames(3);
+ const fast = stateOf();
+ window.__hand = null;
+ s.packBehavior.startPackBehaviors({ hands: s.vrControls.handSnapshot });
+ return { uuid, slow, fast, knocks: s.packBehavior.packBehaviorDebug().knocks };
+ }, Array.from(DOOR_GLB));
+ h.check(knock.slow === null, '14.1 a hand creeping into the door does not knock (' + JSON.stringify(knock.slow) + ')');
+ h.check(knock.fast?.on === true && knock.knocks >= 1, '14.2 a hand arriving fast knocks it open (' + JSON.stringify(knock.fast) + ')');
+
+ await h.finish(browser);
+});
diff --git a/tests/unit/behaviorCore.test.js b/tests/unit/behaviorCore.test.js
new file mode 100644
index 00000000..a0426e65
--- /dev/null
+++ b/tests/unit/behaviorCore.test.js
@@ -0,0 +1,141 @@
+// @ts-nocheck — assertions on nullable returns; the module itself is fully typed
+import { describe, it, expect } from 'vitest';
+import {
+ normalizeBehavior,
+ normalizeState,
+ restState,
+ stateIsNewer,
+ poseAt,
+ nextState,
+ openPosition,
+ boxMinusBox,
+ frameSlabs,
+ boxCorners
+} from '../../src/lib/behaviorCore.js';
+
+// 33 P2: the pure half of animated pack items.
+const DOOR = normalizeBehavior({ type: 'door', clip: 'open', closeClip: 'close', trigger: 'click', autoplay: false, sound: 'door' });
+const DUR = { idle: 1, open: 1, close: 1 };
+
+describe('normalizeBehavior', () => {
+ it('keeps the contract fields and defaults a door collider to follow', () => {
+ expect(DOOR).toEqual({ type: 'door', clip: 'open', closeClip: 'close', trigger: 'click', autoplay: false, sound: 'door', collider: 'follow' });
+ });
+ it('refuses an unknown type or a missing clip', () => {
+ expect(normalizeBehavior({ type: 'spin', clip: 'a' })).toBeNull();
+ expect(normalizeBehavior({ type: 'door' })).toBeNull();
+ expect(normalizeBehavior(null)).toBeNull();
+ });
+ it('honours autoplay ONLY for a loop (nothing else may play on its own)', () => {
+ expect(normalizeBehavior({ type: 'door', clip: 'open', autoplay: true }).autoplay).toBe(false);
+ expect(normalizeBehavior({ type: 'oneshot', clip: 'pull', autoplay: true }).autoplay).toBe(false);
+ expect(normalizeBehavior({ type: 'loop', clip: 'loop', autoplay: true }).autoplay).toBe(true);
+ expect(normalizeBehavior({ type: 'loop', clip: 'loop' }).autoplay).toBe(false);
+ });
+ it('defaults an unknown trigger to click and drops a closeClip equal to clip', () => {
+ const b = normalizeBehavior({ type: 'toggle', clip: 'on', closeClip: 'on', trigger: 'laser' });
+ expect(b.trigger).toBe('click');
+ expect(b.closeClip).toBeNull();
+ expect(b.collider).toBeNull();
+ });
+});
+
+describe('poses', () => {
+ it('rests at the open clip t=0 before any trigger and in Edit whatever the state', () => {
+ expect(poseAt(DOOR, restState(), DUR, 5000, true)).toEqual({ clip: 'open', time: 0, moving: false });
+ const open = { on: true, at: 0, from: 0, n: 1 };
+ expect(poseAt(DOOR, open, DUR, 5000, false)).toEqual({ clip: 'open', time: 0, moving: false });
+ });
+ it('a door opens at clip speed and holds open', () => {
+ const s = nextState(DOOR, restState(), DUR, 1000);
+ expect(s).toEqual({ on: true, at: 1000, from: 0, n: 1 });
+ expect(poseAt(DOOR, s, DUR, 1500, true).time).toBeCloseTo(0.5);
+ expect(poseAt(DOOR, s, DUR, 9000, true)).toEqual({ clip: 'open', time: 1, moving: false });
+ });
+ it('closing half-way through opening starts the close clip from half-way', () => {
+ const opening = nextState(DOOR, restState(), DUR, 0);
+ const closing = nextState(DOOR, opening, DUR, 500);
+ expect(closing.on).toBe(false);
+ expect(closing.from).toBeCloseTo(0.5);
+ const p = poseAt(DOOR, closing, DUR, 600, true);
+ expect(p.clip).toBe('close');
+ expect(p.time).toBeCloseTo(0.6); // 1 - 0.4 open
+ expect(poseAt(DOOR, closing, DUR, 5000, true)).toEqual({ clip: 'close', time: 1, moving: false });
+ });
+ it('without a closeClip a door plays the open clip backwards', () => {
+ const spec = normalizeBehavior({ type: 'toggle', clip: 'open' });
+ const open = { on: true, at: 0, from: 0, n: 1 };
+ const shut = nextState(spec, open, DUR, 3000);
+ expect(poseAt(spec, shut, DUR, 3250, true)).toEqual({ clip: 'open', time: 0.75, moving: true });
+ });
+ it('a oneshot plays once per trigger and stops at its end', () => {
+ const spec = normalizeBehavior({ type: 'oneshot', clip: 'pull' });
+ const s1 = nextState(spec, restState(), { pull: 2 }, 0);
+ expect(poseAt(spec, s1, { pull: 2 }, 500, true).time).toBeCloseTo(0.5);
+ expect(poseAt(spec, s1, { pull: 2 }, 9000, true)).toEqual({ clip: 'pull', time: 2, moving: false });
+ const s2 = nextState(spec, s1, { pull: 2 }, 10000);
+ expect(s2.n).toBe(2);
+ expect(poseAt(spec, s2, { pull: 2 }, 10100, true).time).toBeCloseTo(0.1);
+ });
+ it('an ambient loop runs on the session phase only while active; a plain loop waits for its trigger', () => {
+ const amb = normalizeBehavior({ type: 'loop', clip: 'loop', autoplay: true });
+ expect(poseAt(amb, restState(), { loop: 2 }, 5500, true).time).toBeCloseTo(1.5);
+ expect(poseAt(amb, restState(), { loop: 2 }, 5500, false).time).toBe(0);
+ expect(nextState(amb, restState(), { loop: 2 }, 0)).toBeNull();
+ const plain = normalizeBehavior({ type: 'loop', clip: 'loop' });
+ expect(poseAt(plain, restState(), { loop: 2 }, 5500, true).moving).toBe(false);
+ const on = nextState(plain, restState(), { loop: 2 }, 5000);
+ expect(poseAt(plain, on, { loop: 2 }, 7500, true).time).toBeCloseTo(0.5);
+ });
+ it('a forced direction that changes nothing returns null (proximity on an open door)', () => {
+ const open = nextState(DOOR, restState(), DUR, 0);
+ expect(nextState(DOOR, open, DUR, 100, true)).toBeNull();
+ expect(nextState(DOOR, open, DUR, 100, false).on).toBe(false);
+ });
+ it('openPosition clamps a stale from', () => {
+ expect(openPosition({ on: true, at: 0, from: 9, n: 1 }, 1, 0)).toBe(1);
+ });
+});
+
+describe('state merge', () => {
+ it('latest stamp wins, the count breaks a tie', () => {
+ const a = { on: true, at: 100, from: 0, n: 1 };
+ expect(stateIsNewer(a, { on: false, at: 99, from: 0, n: 5 })).toBe(false);
+ expect(stateIsNewer(a, { on: false, at: 100, from: 0, n: 2 })).toBe(true);
+ expect(stateIsNewer(null, a)).toBe(true);
+ });
+ it('normalizeState rejects non-finite input', () => {
+ expect(normalizeState({ on: true, at: 'x', from: 0, n: 1 })).toBeNull();
+ expect(normalizeState({ on: 1, at: 5, from: -2, n: 2.7 })).toEqual({ on: false, at: 5, from: 0, n: 2 });
+ });
+});
+
+describe('the doorway', () => {
+ // the fixture door: a one-piece frame box over x [-0.65, 0.65], y [0, 2.15], z [-0.1, 0.1]
+ const frame = [-0.65, 0, -0.1, 0.65, 2.15, 0.1];
+ const leaf = [-0.48, 0.01, -0.04, 0.48, 1.98, 0.04];
+ it('cutting the leaf out of the frame box leaves posts and a lintel, and nothing in the opening', () => {
+ const slabs = frameSlabs([frame], [leaf]);
+ // left post, right post, threshold sliver dropped? (0.01 tall = kept at minSize 0.01), lintel
+ const inOpening = slabs.filter((s) => s[0] < 0 && s[3] > 0 && s[1] < 1 && s[4] > 1);
+ expect(inOpening).toEqual([]);
+ expect(slabs.some((s) => s[3] <= -0.48 + 1e-9)).toBe(true); // a left post
+ expect(slabs.some((s) => s[0] >= 0.48 - 1e-9)).toBe(true); // a right post
+ expect(slabs.some((s) => s[1] >= 1.98 - 1e-9)).toBe(true); // a lintel
+ });
+ it('a lid resting ON a chest leaves the chest body whole; a trapdoor opens through a floor ring', () => {
+ const body = [0, 0, 0, 1, 0.5, 0.6];
+ expect(frameSlabs([body], [[0, 0.5, 0, 1, 0.56, 0.6]])).toEqual([body]);
+ const ring = [-1, -0.2, -1, 1, 0, 1];
+ const slabs = frameSlabs([ring], [[-0.5, -0.1, -0.5, 0.5, -0.05, 0.5]]);
+ expect(slabs.filter((s) => s[0] < 0 && s[3] > 0 && s[2] < 0 && s[5] > 0)).toEqual([]);
+ expect(slabs.length).toBe(4);
+ });
+ it('no overlap = the box unchanged; a hole covering it = nothing left', () => {
+ expect(boxMinusBox([0, 0, 0, 1, 1, 1], [2, 2, 2, 3, 3, 3])).toEqual([[0, 0, 0, 1, 1, 1]]);
+ expect(boxMinusBox([0, 0, 0, 1, 1, 1], [-1, -1, -1, 2, 2, 2])).toEqual([]);
+ });
+ it('boxCorners gives 8 points', () => {
+ expect(boxCorners([0, 0, 0, 1, 2, 3]).length).toBe(24);
+ });
+});