Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions MODULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions PACKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
7 changes: 4 additions & 3 deletions src/App.svelte

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();

Expand Down
18 changes: 16 additions & 2 deletions src/components/editors/AnimationWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1863,14 +1870,21 @@
{#each clips as clip (clip.name)}
<button
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1 text-left text-xs hover:bg-gray-700/60 {clipState?.clip === clip.name ? 'bg-primary-900/40 text-primary-200' : 'text-gray-300'}"
title="Play this clip — on every peer"
title={behavior ? 'Preview this clip here — not sent, not saved' : 'Play this clip — on every peer'}
onclick={() => target && setAnimationState(target.uuid, { clip: clip.name, playing: true })}
>
<span class="min-w-0 truncate">{clip.name}</span>
<span class="shrink-0 text-[10px] tabular-nums text-gray-500">{clip.duration.toFixed(2)}s</span>
</button>
{/each}
</div>
{#if behavior}
<p id="animation-behavior-note" class="px-2 pb-1 text-[10px] leading-snug text-amber-300/90">
{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.
</p>
{/if}
<div class="flex items-center gap-2 px-2 pb-1">
<button
id="clip-play"
Expand Down
46 changes: 43 additions & 3 deletions src/components/editors/Explorer.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
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,
Expand Down Expand Up @@ -962,6 +962,26 @@
});
// pack-item thumbnail: imported items carry a dataURL; default items resolve
// webp -> png -> screenshot via the <img> 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<string, string> = {
door: 'Opens and closes',
toggle: 'Switches on and off',
oneshot: 'Plays once',
loop: 'Loops'
};
const TRIGGER_WORDS: Record<string, string> = {
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;
Expand Down Expand Up @@ -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 } : {})
};
}
/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -6366,6 +6389,13 @@
<!-- the same status the card carries in its corners, folded into ONE inline dot:
a row has no corners, and four possible dots on one line is noise. The
precedence is the card's own, top to bottom. -->
{#if !isFolder && item.behavior}
<span
class="explorer-animated shrink-0 text-amber-300"
title={behaviorTitle(item.behavior)}
data-behavior={item.behavior.type}
><Play size={10} aria-hidden="true" /></span>
{/if}
{#if !isFolder}
{#if item.volumeItem}
<span
Expand Down Expand Up @@ -7479,6 +7509,16 @@
</span>
{/if}
{/if}
{#if item.behavior}
<!-- 33 P2: an ANIMATED, functional item (a door, a lid, a fan). Top LEFT: a
pack card has no scene dots, so that corner is free on every card that
can carry this. -->
<span
class="explorer-animated absolute left-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-gray-900/80 text-amber-300"
title={behaviorTitle(item.behavior)}
data-behavior={item.behavior.type}
><Play size={9} aria-hidden="true" /></span>
{/if}
{#if item.packEntry}
{#if packThumb(item)}
<!-- N6: lazily resolve webp -> png -> screenshot via onerror; P2: cache the winner -->
Expand Down
Loading
Loading