castle-web-cli 0.4.128 → 0.4.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.js +4 -4
- package/dist/filesChanged.d.ts +2 -0
- package/dist/filesChanged.js +6 -2
- package/dist/ide.js +15 -4
- package/dist/importBrowse.js +1 -0
- package/dist/index.js +10 -4
- package/dist/init.js +63 -15
- package/dist/native/loop.js +1 -1
- package/dist/native/tools.js +1 -1
- package/dist/serve.d.ts +1 -0
- package/dist/serve.js +20 -1
- package/dist/shell/assets/index-BpOLUyVO.js +441 -0
- package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +40 -23
- package/kits/physics-2d/behaviors/Joints.jsx +4 -3
- package/kits/physics-2d/behaviors/SoundPlayer.jsx +266 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +3 -3
- package/kits/physics-2d/behaviors/Video.jsx +4 -3
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
- package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
- package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
- package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
- package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
- package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
- package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
- package/kits/physics-2d/engine/audioContext.js +41 -0
- package/kits/physics-2d/engine/autoInspector.jsx +50 -33
- package/kits/physics-2d/engine/blueprint.js +15 -3
- package/kits/physics-2d/engine/liveReload.js +33 -21
- package/kits/physics-2d/engine/media.js +29 -39
- package/kits/physics-2d/engine/sound.js +316 -0
- package/kits/physics-2d/engine/systemRegistry.js +1 -1
- package/kits/physics-2d/engine/ui.jsx +274 -5
- package/kits/physics-2d/engine/ui.module.css +137 -16
- package/kits/physics-2d/scripts/testsounds.mjs +84 -0
- package/kits/physics-2d/systems/media.js +31 -15
- package/kits/physics-3d/CLAUDE.md +109 -0
- package/kits/physics-3d/behaviors/Body.jsx +51 -0
- package/kits/physics-3d/behaviors/Door.jsx +31 -0
- package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
- package/kits/physics-3d/behaviors/Model.jsx +14 -0
- package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
- package/kits/physics-3d/behaviors/Player.jsx +172 -0
- package/kits/physics-3d/behaviors/Portal.jsx +19 -0
- package/kits/physics-3d/behaviors/Shape.jsx +75 -0
- package/kits/physics-3d/behaviors/Solid.jsx +11 -0
- package/kits/physics-3d/behaviors/Transform.jsx +24 -0
- package/kits/physics-3d/blueprints/barrel.scene +15 -0
- package/kits/physics-3d/blueprints/crate.scene +29 -0
- package/kits/physics-3d/blueprints/door.scene +35 -0
- package/kits/physics-3d/blueprints/gem.scene +26 -0
- package/kits/physics-3d/blueprints/pillar.scene +25 -0
- package/kits/physics-3d/blueprints/platform.scene +23 -0
- package/kits/physics-3d/blueprints/player.scene +29 -0
- package/kits/physics-3d/blueprints/portal.scene +21 -0
- package/kits/physics-3d/blueprints/rock.scene +14 -0
- package/kits/physics-3d/blueprints/statue.scene +26 -0
- package/kits/physics-3d/blueprints/tree.scene +14 -0
- package/kits/physics-3d/blueprints/wall.scene +26 -0
- package/kits/{basic-2d → physics-3d}/castle.json +23 -5
- package/kits/physics-3d/docs/pxmodel-format.md +111 -0
- package/kits/physics-3d/drawings/crate.pxart +26 -0
- package/kits/physics-3d/drawings/door-edge.pxart +26 -0
- package/kits/physics-3d/drawings/door.pxart +27 -0
- package/kits/physics-3d/drawings/face.pxart +26 -0
- package/kits/physics-3d/drawings/floor.pxart +27 -0
- package/kits/physics-3d/drawings/platform.pxart +27 -0
- package/kits/physics-3d/drawings/statue.pxart +27 -0
- package/kits/physics-3d/drawings/wall.pxart +27 -0
- package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
- package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
- package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
- package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
- package/kits/physics-3d/engine3d/editor3dData.js +114 -0
- package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
- package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
- package/kits/physics-3d/engine3d/editorWorld.js +249 -0
- package/kits/physics-3d/engine3d/materials.js +174 -0
- package/kits/physics-3d/engine3d/meshops.js +123 -0
- package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
- package/kits/physics-3d/engine3d/modelFiles.js +139 -0
- package/kits/physics-3d/engine3d/pxmodel.js +265 -0
- package/kits/physics-3d/engine3d/thumbnails.js +137 -0
- package/kits/physics-3d/engine3d/world3d.js +216 -0
- package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
- package/kits/physics-3d/index.html +20 -0
- package/kits/physics-3d/main.jsx +30 -0
- package/kits/physics-3d/models/barrel.pxmodel +10 -0
- package/kits/physics-3d/models/player.pxmodel +222 -0
- package/kits/physics-3d/models/rock.pxmodel +30 -0
- package/kits/physics-3d/models/tree.pxmodel +10 -0
- package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
- package/kits/{basic-2d → physics-3d}/package.json +11 -9
- package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
- package/kits/physics-3d/scenes/main.scene +414 -0
- package/kits/physics-3d/scenes/model-lab.scene +127 -0
- package/kits/physics-3d/systems/physics3d.js +356 -0
- package/package.json +5 -2
- package/dist/shell/assets/index-DKu9ejyh.js +0 -436
- package/kits/basic-2d/CLAUDE.md +0 -221
- package/kits/basic-2d/behaviors/Camera.jsx +0 -43
- package/kits/basic-2d/behaviors/Collider.jsx +0 -213
- package/kits/basic-2d/behaviors/Layout.jsx +0 -53
- package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
- package/kits/basic-2d/behaviors/tint.js +0 -47
- package/kits/basic-2d/blueprints/cauldron.scene +0 -20
- package/kits/basic-2d/docs/pxart-format.md +0 -377
- package/kits/basic-2d/drawings/cauldron.pxart +0 -113
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
- package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
- package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
- package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
- package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
- package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
- package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
- package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
- package/kits/basic-2d/editors/editorHistory.js +0 -157
- package/kits/basic-2d/editors/inspectorSheet.js +0 -13
- package/kits/basic-2d/editors/pixelCanvas.js +0 -11
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
- package/kits/basic-2d/editors/pixelGeometry.js +0 -140
- package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
- package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
- package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
- package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
- package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
- package/kits/basic-2d/editors/pxArtTools.js +0 -232
- package/kits/basic-2d/editors/useArtboardFit.js +0 -105
- package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
- package/kits/basic-2d/engine/SceneUI.jsx +0 -59
- package/kits/basic-2d/engine/assets.js +0 -15
- package/kits/basic-2d/engine/autoInspector.jsx +0 -70
- package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
- package/kits/basic-2d/engine/blueprint.js +0 -557
- package/kits/basic-2d/engine/collider.js +0 -200
- package/kits/basic-2d/engine/files.js +0 -141
- package/kits/basic-2d/engine/liveReload.js +0 -88
- package/kits/basic-2d/engine/pxart.js +0 -1032
- package/kits/basic-2d/engine/pxartSmooth.js +0 -222
- package/kits/basic-2d/engine/scene.js +0 -696
- package/kits/basic-2d/engine/spriteGeometry.js +0 -32
- package/kits/basic-2d/engine/systemRegistry.js +0 -16
- package/kits/basic-2d/engine/ui.jsx +0 -695
- package/kits/basic-2d/engine/ui.module.css +0 -2287
- package/kits/basic-2d/index.html +0 -24
- package/kits/basic-2d/main.jsx +0 -24
- package/kits/basic-2d/scenes/main.scene +0 -16
- package/kits/basic-2d/scripts/draw.mjs +0 -121
- package/kits/physics-2d/behaviors/Sound.jsx +0 -163
- package/kits/physics-2d/behaviors/Tone.jsx +0 -95
- package/kits/physics-2d/engine/tone.js +0 -112
- /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
- /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// The deck's sound effects: audio files decoded into WebAudio buffers ahead of
|
|
2
|
+
// time, then fired as overlapping one-shots from anywhere.
|
|
3
|
+
//
|
|
4
|
+
// This is the SFX half of the kit's audio. The other half is `engine/media.js`,
|
|
5
|
+
// which plays an `<audio>` element -- the right thing for a long track, whose
|
|
6
|
+
// decoded PCM would be tens of megabytes held in memory for as long as the deck
|
|
7
|
+
// runs (a minute of 44.1kHz stereo is ~21MB). Short sounds go here, long ones
|
|
8
|
+
// stream there, and `behaviors/SoundPlayer.jsx` is where a deck chooses.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately decoupled from the rest of the kit: the file map arrives through
|
|
11
|
+
// `initSounds` rather than being imported, and nothing here knows about actors,
|
|
12
|
+
// scenes or blueprints. The one kit import is the shared AudioContext, which has
|
|
13
|
+
// to be shared -- a second context could not mix with the element path. So
|
|
14
|
+
// moving this into the SDK or a package other kits can use is a file move, not a
|
|
15
|
+
// rewrite.
|
|
16
|
+
//
|
|
17
|
+
// A context starts SUSPENDED until the page has been interacted with, but
|
|
18
|
+
// decoding does not need a running one, so preloading begins at load and only
|
|
19
|
+
// playback waits for the unlock (see engine/audioContext.js).
|
|
20
|
+
|
|
21
|
+
import { bindAudioResume, getAudioContext, resumeAudioContext } from './audioContext';
|
|
22
|
+
|
|
23
|
+
// Generous on purpose: a voice is ~3 nodes, so this is a backstop against a
|
|
24
|
+
// runaway loop, not a mixing decision. Sounds stack and clip if a deck asks them
|
|
25
|
+
// to -- that is the deck's business.
|
|
26
|
+
const MAX_VOICES = 128;
|
|
27
|
+
|
|
28
|
+
// Preloaded sounds live here, in the deck's own tree or an import's (so a kit
|
|
29
|
+
// can ship its own effects). Everything else in the deck is still playable and
|
|
30
|
+
// still listed in the inspector -- this decides what is held in memory, not what
|
|
31
|
+
// exists. Keeping it a DIRECTORY rather than a size cutoff means the answer is
|
|
32
|
+
// visible in the file tree instead of buried in a constant here.
|
|
33
|
+
const PRELOAD_DIR = /^(?:imports\/[^/]+\/)?assets\/sounds\//;
|
|
34
|
+
|
|
35
|
+
// Where an import's files live at the deck root, mirroring castle-web-sdk's
|
|
36
|
+
// IMPORTS_DIR. Spelled out rather than imported so this module keeps depending on
|
|
37
|
+
// nothing but the shared AudioContext.
|
|
38
|
+
const IMPORTS_PREFIX = 'imports/';
|
|
39
|
+
const isImported = (path) => path.startsWith(IMPORTS_PREFIX);
|
|
40
|
+
|
|
41
|
+
// A preloaded sound longer than this is probably meant to stream. Warned about,
|
|
42
|
+
// never excluded: what preloads is the file tree's business, and a rule that
|
|
43
|
+
// silently dropped a file would be exactly the hidden internal the directory
|
|
44
|
+
// convention exists to avoid.
|
|
45
|
+
const LONG_SOUND_SECONDS = 15;
|
|
46
|
+
|
|
47
|
+
const DEV = Boolean(import.meta.env?.DEV);
|
|
48
|
+
|
|
49
|
+
let media = {};
|
|
50
|
+
let stems = new Map();
|
|
51
|
+
const buffers = new Map();
|
|
52
|
+
const loading = new Map();
|
|
53
|
+
// Insertion-ordered, which is what makes the cap's "steal the oldest" a lookup
|
|
54
|
+
// rather than a search.
|
|
55
|
+
const voices = new Set();
|
|
56
|
+
const preloadSet = new Set();
|
|
57
|
+
const warned = new Set();
|
|
58
|
+
let master = null;
|
|
59
|
+
let ready = null;
|
|
60
|
+
let preloaded = false;
|
|
61
|
+
|
|
62
|
+
function warnOnce(key, message) {
|
|
63
|
+
if (!DEV || warned.has(key)) return;
|
|
64
|
+
warned.add(key);
|
|
65
|
+
console.warn(`[sound] ${message}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clamp(value, min, max, fallback) {
|
|
69
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Stems within one deck: `hit` for `assets/sounds/hit.wav`. A stem shared by two
|
|
73
|
+
// of that deck's files resolves to NEITHER -- picking one arbitrarily would make
|
|
74
|
+
// the winner a function of sort order, which the deck author cannot see. The full
|
|
75
|
+
// path always works and is how such a pair is told apart.
|
|
76
|
+
function uniqueStems(paths, ambiguous) {
|
|
77
|
+
const found = new Map();
|
|
78
|
+
for (const path of paths) {
|
|
79
|
+
const stem = path.slice(path.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '');
|
|
80
|
+
if (found.has(stem)) ambiguous.add(stem);
|
|
81
|
+
else found.set(stem, path);
|
|
82
|
+
}
|
|
83
|
+
for (const stem of ambiguous) found.delete(stem);
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Short names for sounds, resolved deck-first: a sound the deck owns beats one of
|
|
88
|
+
// the same name from an import, the same precedence editors/behaviorRegistry.js
|
|
89
|
+
// gives a deck's behaviors over a dependency's. Without that, a kit that shipped
|
|
90
|
+
// its own `click.wav` would make every importing deck's `play('click')` ambiguous
|
|
91
|
+
// -- a kit could break decks by adding a file.
|
|
92
|
+
function buildStems(paths) {
|
|
93
|
+
const ownAmbiguous = new Set();
|
|
94
|
+
const importAmbiguous = new Set();
|
|
95
|
+
const imported = uniqueStems(paths.filter(isImported), importAmbiguous);
|
|
96
|
+
const own = uniqueStems(paths.filter((path) => !isImported(path)), ownAmbiguous);
|
|
97
|
+
const merged = new Map([...imported, ...own]);
|
|
98
|
+
for (const stem of ownAmbiguous) {
|
|
99
|
+
merged.delete(stem);
|
|
100
|
+
warnOnce(`stem:${stem}`, `This deck has more than one sound named "${stem}" -- play it by full path instead.`);
|
|
101
|
+
}
|
|
102
|
+
for (const stem of importAmbiguous) {
|
|
103
|
+
if (own.has(stem)) continue;
|
|
104
|
+
merged.delete(stem);
|
|
105
|
+
warnOnce(`stem:${stem}`, `An import has more than one sound named "${stem}" -- play it by full path instead.`);
|
|
106
|
+
}
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolvePath(name) {
|
|
111
|
+
const key = typeof name === 'string' ? name.replace(/^\.\//, '') : '';
|
|
112
|
+
if (media[key]) return key;
|
|
113
|
+
return stems.get(key) ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Everything is mixed through one gain node, so a master volume and a fade on
|
|
117
|
+
// stop have somewhere to live later. Unity, and no compressor: a deck that
|
|
118
|
+
// stacks forty copies of one sample is meant to clip.
|
|
119
|
+
function masterGain() {
|
|
120
|
+
const ctx = getAudioContext();
|
|
121
|
+
if (!ctx) return null;
|
|
122
|
+
if (!master || master.context !== ctx) {
|
|
123
|
+
master = ctx.createGain();
|
|
124
|
+
master.gain.value = 1;
|
|
125
|
+
master.connect(ctx.destination);
|
|
126
|
+
}
|
|
127
|
+
return master;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function decode(path) {
|
|
131
|
+
const url = media[path];
|
|
132
|
+
const ctx = getAudioContext();
|
|
133
|
+
if (!url || !ctx) return null;
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetch(url);
|
|
136
|
+
const buffer = await ctx.decodeAudioData(await response.arrayBuffer());
|
|
137
|
+
buffers.set(path, buffer);
|
|
138
|
+
return buffer;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
warnOnce(`decode:${path}`, `Could not decode ${path}: ${error?.message ?? error}`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// One decode per file, however many callers ask at once -- two `play`s of an
|
|
146
|
+
// unloaded sound in the same frame must not decode it twice.
|
|
147
|
+
function load(path) {
|
|
148
|
+
if (buffers.has(path)) return Promise.resolve(buffers.get(path));
|
|
149
|
+
const inFlight = loading.get(path);
|
|
150
|
+
if (inFlight) return inFlight;
|
|
151
|
+
const started = decode(path).finally(() => loading.delete(path));
|
|
152
|
+
loading.set(path, started);
|
|
153
|
+
return started;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function preload(path) {
|
|
157
|
+
const buffer = await load(path);
|
|
158
|
+
if (buffer && buffer.duration > LONG_SOUND_SECONDS) {
|
|
159
|
+
warnOnce(
|
|
160
|
+
`long:${path}`,
|
|
161
|
+
`${path} is ${Math.round(buffer.duration)}s and is held decoded in memory. ` +
|
|
162
|
+
`Long tracks belong outside assets/sounds/, played by a SoundPlayer with stream on.`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether a deck path is one this library preloads. */
|
|
168
|
+
export function isPreloadedPath(path) {
|
|
169
|
+
return PRELOAD_DIR.test(String(path ?? ''));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Point the library at the deck's audio files and start preloading.
|
|
174
|
+
*
|
|
175
|
+
* @param audioFiles path -> URL, every audio file in the deck (see
|
|
176
|
+
* engine/files.js -- URLs, not paths, so this keeps working once the deck is
|
|
177
|
+
* bundled into a single file and the assets are `data:` URIs).
|
|
178
|
+
*/
|
|
179
|
+
export function initSounds(audioFiles) {
|
|
180
|
+
media = { ...audioFiles };
|
|
181
|
+
stems = buildStems(Object.keys(media));
|
|
182
|
+
preloadSet.clear();
|
|
183
|
+
for (const path of Object.keys(media)) {
|
|
184
|
+
if (PRELOAD_DIR.test(path)) preloadSet.add(path);
|
|
185
|
+
}
|
|
186
|
+
// No sounds to preload means no AudioContext either: a deck that makes no
|
|
187
|
+
// sound should not build an audio graph just by loading the kit.
|
|
188
|
+
if (preloadSet.size === 0) {
|
|
189
|
+
preloaded = true;
|
|
190
|
+
ready = Promise.resolve();
|
|
191
|
+
return ready;
|
|
192
|
+
}
|
|
193
|
+
ready = Promise.all([...preloadSet].map(preload)).then(() => {
|
|
194
|
+
preloaded = true;
|
|
195
|
+
});
|
|
196
|
+
return ready;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function stopVoice(voice) {
|
|
200
|
+
voices.delete(voice);
|
|
201
|
+
try {
|
|
202
|
+
voice.source.stop();
|
|
203
|
+
} catch {
|
|
204
|
+
// Already ended -- `ended` has fired or the source never started.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function startVoice(buffer, { volume, pan, playbackRate }) {
|
|
209
|
+
const ctx = getAudioContext();
|
|
210
|
+
const out = masterGain();
|
|
211
|
+
if (!ctx || !out) return null;
|
|
212
|
+
// The context starts suspended until the page has been interacted with. This
|
|
213
|
+
// asks now, and binds a listener so the next gesture asks again -- a deck with
|
|
214
|
+
// no streaming sound has nothing else that would.
|
|
215
|
+
bindAudioResume();
|
|
216
|
+
resumeAudioContext();
|
|
217
|
+
if (voices.size >= MAX_VOICES) stopVoice(voices.values().next().value);
|
|
218
|
+
|
|
219
|
+
const source = ctx.createBufferSource();
|
|
220
|
+
source.buffer = buffer;
|
|
221
|
+
source.playbackRate.value = clamp(playbackRate, 0.01, 10, 1);
|
|
222
|
+
const gain = ctx.createGain();
|
|
223
|
+
gain.gain.value = clamp(volume, 0, 1, 1);
|
|
224
|
+
source.connect(gain);
|
|
225
|
+
let tail = gain;
|
|
226
|
+
const wanted = clamp(pan, -1, 1, 0);
|
|
227
|
+
if (wanted !== 0 && ctx.createStereoPanner) {
|
|
228
|
+
const panner = ctx.createStereoPanner();
|
|
229
|
+
panner.pan.value = wanted;
|
|
230
|
+
gain.connect(panner);
|
|
231
|
+
tail = panner;
|
|
232
|
+
}
|
|
233
|
+
tail.connect(out);
|
|
234
|
+
|
|
235
|
+
const voice = { source };
|
|
236
|
+
voices.add(voice);
|
|
237
|
+
const finished = new Promise((resolve) => {
|
|
238
|
+
source.addEventListener('ended', () => {
|
|
239
|
+
voices.delete(voice);
|
|
240
|
+
resolve();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
source.start();
|
|
244
|
+
return {
|
|
245
|
+
stop() {
|
|
246
|
+
stopVoice(voice);
|
|
247
|
+
},
|
|
248
|
+
finished,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Play a sound, overlapping whatever else is playing.
|
|
254
|
+
*
|
|
255
|
+
* `name` is a deck-relative path (`assets/sounds/hit.wav`) or the bare filename
|
|
256
|
+
* without its extension (`hit`).
|
|
257
|
+
*
|
|
258
|
+
* Returns `{ stop, finished }`, or null when nothing played -- an unknown name,
|
|
259
|
+
* or a sound whose buffer is not decoded yet. A play that lands before its sound
|
|
260
|
+
* is ready is DROPPED rather than deferred: a sound effect that arrives late is
|
|
261
|
+
* worse than one that never arrives, and deferring it would fire it at a moment
|
|
262
|
+
* that has already passed.
|
|
263
|
+
*/
|
|
264
|
+
export function playSound(name, { volume = 1, pan = 0, playbackRate = 1 } = {}) {
|
|
265
|
+
const path = resolvePath(name);
|
|
266
|
+
if (!path) {
|
|
267
|
+
warnOnce(`unknown:${name}`, `No sound named "${name}".`);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const buffer = buffers.get(path);
|
|
271
|
+
if (buffer) return startVoice(buffer, { volume, pan, playbackRate });
|
|
272
|
+
// Not decoded. Start it now so the NEXT play works -- which for a sound
|
|
273
|
+
// outside assets/sounds/ is what makes the first play the only silent one.
|
|
274
|
+
void load(path);
|
|
275
|
+
if (!preloadSet.has(path)) {
|
|
276
|
+
warnOnce(
|
|
277
|
+
`cold:${path}`,
|
|
278
|
+
`${path} is not preloaded, so this first play is silent. ` +
|
|
279
|
+
`Move it under assets/sounds/ to have it ready before the deck starts.`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Every sound effect, off. Long tracks are the element path's to stop. */
|
|
286
|
+
export function stopAllSounds() {
|
|
287
|
+
for (const voice of [...voices]) stopVoice(voice);
|
|
288
|
+
voices.clear();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The deck-facing library, also reachable as `scene.sound` (see systems/media.js)
|
|
292
|
+
// so a behavior needs no import to make a noise.
|
|
293
|
+
export const Sound = {
|
|
294
|
+
/** True once every preloaded sound has been decoded (or failed to). */
|
|
295
|
+
get isLoaded() {
|
|
296
|
+
return preloaded;
|
|
297
|
+
},
|
|
298
|
+
/** Resolves when preloading settles, so a caller need not poll `isLoaded`. */
|
|
299
|
+
get whenLoaded() {
|
|
300
|
+
return ready ?? Promise.resolve();
|
|
301
|
+
},
|
|
302
|
+
/** Whether a name resolves to a sound this deck has. */
|
|
303
|
+
has(name) {
|
|
304
|
+
return resolvePath(name) !== null;
|
|
305
|
+
},
|
|
306
|
+
/**
|
|
307
|
+
* How many voices are sounding right now. Worth watching if effects seem to
|
|
308
|
+
* cut each other off: at MAX_VOICES the oldest is stolen to make room, and
|
|
309
|
+
* nothing else says so.
|
|
310
|
+
*/
|
|
311
|
+
get voiceCount() {
|
|
312
|
+
return voices.size;
|
|
313
|
+
},
|
|
314
|
+
play: playSound,
|
|
315
|
+
stopAll: stopAllSounds,
|
|
316
|
+
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// SceneRuntime (see engine/scene.js `makeScene`) to register itself via
|
|
4
4
|
// `runtime.registerSystem(...)`. A system is a plain object with an optional
|
|
5
5
|
// `afterBehaviors(scene, dt)` hook, run once per frame after all behavior
|
|
6
|
-
// updates. Empty in a kit with no `systems/` dir
|
|
6
|
+
// updates. Empty in a kit with no `systems/` dir; a kit adds a
|
|
7
7
|
// system by dropping a file here -- no edits to the engine required. Symmetric
|
|
8
8
|
// with editors/behaviorRegistry.js.
|
|
9
9
|
// Root-anchored, deck + imports (see engine/files.js).
|
|
@@ -627,15 +627,35 @@ export function CheckboxField({ label, checked, onChange, overridden, defaultVal
|
|
|
627
627
|
<FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
|
|
628
628
|
<button
|
|
629
629
|
type="button"
|
|
630
|
-
className={cx(styles.
|
|
631
|
-
role="
|
|
630
|
+
className={cx(styles.checkbox, checked && styles.checkboxOn)}
|
|
631
|
+
role="checkbox"
|
|
632
632
|
aria-checked={!!checked}
|
|
633
633
|
onClick={() => onChange(!checked)}>
|
|
634
|
-
<
|
|
634
|
+
<svg className={styles.checkboxMark} viewBox="0 0 12 12" aria-hidden="true">
|
|
635
|
+
<path
|
|
636
|
+
d="M2 6.2L4.8 9 10 3"
|
|
637
|
+
fill="none"
|
|
638
|
+
stroke="currentColor"
|
|
639
|
+
strokeWidth="1.8"
|
|
640
|
+
strokeLinecap="round"
|
|
641
|
+
strokeLinejoin="round"
|
|
642
|
+
/>
|
|
643
|
+
</svg>
|
|
635
644
|
</button>
|
|
636
645
|
</FieldRow>
|
|
637
646
|
);
|
|
638
647
|
}
|
|
648
|
+
// Explanatory text under a field, for a prop whose name doesn't carry its
|
|
649
|
+
// meaning. Rendered inside the panel's field grid, so it lands under the control
|
|
650
|
+
// rather than under the label.
|
|
651
|
+
export function FieldNote({ children }) {
|
|
652
|
+
if (!children) return null;
|
|
653
|
+
return (
|
|
654
|
+
<div className={styles.fieldNote}>
|
|
655
|
+
<div className={styles.fieldNoteInner}>{children}</div>
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
639
659
|
export function ColorField({ label, value, onChange, overridden, defaultValue, onReset }) {
|
|
640
660
|
const hex = normalizeHex(value);
|
|
641
661
|
const alpha = hex.length === 9 ? hex.slice(7) : '';
|
|
@@ -720,8 +740,8 @@ function settleSheet(drag, setHeight, expandedRef) {
|
|
|
720
740
|
const settled = setHeight(drag.startHeight - (drag.lastY - drag.startY));
|
|
721
741
|
if (settled > peek + 2) expandedRef.current = settled;
|
|
722
742
|
}
|
|
723
|
-
// Persist a sheet's resting height across reloads (
|
|
724
|
-
//
|
|
743
|
+
// Persist a sheet's resting height across reloads (picking up a code change
|
|
744
|
+
// means reloading the panel, which would otherwise reset every sheet to the
|
|
725
745
|
// default size). Keyed per sheet via `storageKey`; sessionStorage-scoped and
|
|
726
746
|
// best-effort, the same degrade-to-memory-only contract as editorHistory.
|
|
727
747
|
function sheetHeightStorageKey(key) {
|
|
@@ -947,3 +967,252 @@ export function SelectField({ label, value, onChange, options, overridden, defau
|
|
|
947
967
|
</FieldRow>
|
|
948
968
|
);
|
|
949
969
|
}
|
|
970
|
+
|
|
971
|
+
function basenameOf(path) {
|
|
972
|
+
if (!path) return '';
|
|
973
|
+
return path.slice(path.lastIndexOf('/') + 1);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
let measureCanvasCtx;
|
|
977
|
+
function measureTextWidth(text, font) {
|
|
978
|
+
if (!measureCanvasCtx) {
|
|
979
|
+
measureCanvasCtx = document.createElement('canvas').getContext('2d');
|
|
980
|
+
}
|
|
981
|
+
measureCanvasCtx.font = font;
|
|
982
|
+
return measureCanvasCtx.measureText(text).width;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Keep the filename intact; the directory is what yields. Binary-search the
|
|
986
|
+
// prefix of the directory until `{prefix}…/{basename}` fits `maxWidth`.
|
|
987
|
+
function fitPathKeepBasename(path, maxWidth, font) {
|
|
988
|
+
const measure = (text) => measureTextWidth(text, font);
|
|
989
|
+
if (maxWidth <= 0 || measure(path) <= maxWidth) return path;
|
|
990
|
+
const slash = path.lastIndexOf('/');
|
|
991
|
+
const dir = slash > 0 ? path.slice(0, slash) : '';
|
|
992
|
+
const base = slash > 0 ? path.slice(slash + 1) : path;
|
|
993
|
+
const ellipsed = `…/${base}`;
|
|
994
|
+
if (!dir || measure(ellipsed) > maxWidth) return fitKeepEnd(base, maxWidth, measure);
|
|
995
|
+
let lo = 0;
|
|
996
|
+
let hi = dir.length;
|
|
997
|
+
let best = ellipsed;
|
|
998
|
+
while (lo <= hi) {
|
|
999
|
+
const mid = (lo + hi) >> 1;
|
|
1000
|
+
const candidate = `${dir.slice(0, mid)}…/${base}`;
|
|
1001
|
+
if (measure(candidate) <= maxWidth) {
|
|
1002
|
+
best = candidate;
|
|
1003
|
+
lo = mid + 1;
|
|
1004
|
+
} else {
|
|
1005
|
+
hi = mid - 1;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return best;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function fitKeepEnd(text, maxWidth, measure) {
|
|
1012
|
+
if (measure(text) <= maxWidth) return text;
|
|
1013
|
+
if (measure('…') > maxWidth) return '';
|
|
1014
|
+
let lo = 0;
|
|
1015
|
+
let hi = text.length;
|
|
1016
|
+
let best = '…';
|
|
1017
|
+
while (lo <= hi) {
|
|
1018
|
+
const mid = (lo + hi) >> 1;
|
|
1019
|
+
const candidate = `…${text.slice(text.length - mid)}`;
|
|
1020
|
+
if (measure(candidate) <= maxWidth) {
|
|
1021
|
+
best = candidate;
|
|
1022
|
+
lo = mid + 1;
|
|
1023
|
+
} else {
|
|
1024
|
+
hi = mid - 1;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return best;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function FilePathLabel({ path }) {
|
|
1031
|
+
const ref = useRef(null);
|
|
1032
|
+
const [shown, setShown] = useState(path);
|
|
1033
|
+
useLayoutEffect(() => {
|
|
1034
|
+
const node = ref.current;
|
|
1035
|
+
if (!node) return undefined;
|
|
1036
|
+
function fit() {
|
|
1037
|
+
const max = node.clientWidth;
|
|
1038
|
+
if (max <= 0) return;
|
|
1039
|
+
const cs = getComputedStyle(node);
|
|
1040
|
+
const font = cs.font && cs.font !== '0px' ? cs.font : `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
|
1041
|
+
setShown(fitPathKeepBasename(path, max, font));
|
|
1042
|
+
}
|
|
1043
|
+
fit();
|
|
1044
|
+
const observer = new ResizeObserver(fit);
|
|
1045
|
+
observer.observe(node);
|
|
1046
|
+
return () => observer.disconnect();
|
|
1047
|
+
}, [path]);
|
|
1048
|
+
return (
|
|
1049
|
+
<span ref={ref} className={styles.filePickerPath} title={path}>
|
|
1050
|
+
{shown}
|
|
1051
|
+
</span>
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function pickerRowChrome(list) {
|
|
1056
|
+
if (!list) return 26;
|
|
1057
|
+
const box = getComputedStyle(list);
|
|
1058
|
+
const opt = list.querySelector('button');
|
|
1059
|
+
const op = opt ? getComputedStyle(opt) : null;
|
|
1060
|
+
return (
|
|
1061
|
+
(parseFloat(box.paddingLeft) || 0) +
|
|
1062
|
+
(parseFloat(box.paddingRight) || 0) +
|
|
1063
|
+
(parseFloat(box.borderLeftWidth) || 0) +
|
|
1064
|
+
(parseFloat(box.borderRightWidth) || 0) +
|
|
1065
|
+
(op ? (parseFloat(op.paddingLeft) || 0) + (parseFloat(op.paddingRight) || 0) : 16)
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function longestPathWidth(list, paths) {
|
|
1070
|
+
if (!list || !paths.length) return 0;
|
|
1071
|
+
const probe = document.createElement('span');
|
|
1072
|
+
probe.style.cssText = 'position:absolute;left:-9999px;white-space:nowrap;pointer-events:none;';
|
|
1073
|
+
const opt = list.querySelector('button');
|
|
1074
|
+
if (opt) {
|
|
1075
|
+
const s = getComputedStyle(opt);
|
|
1076
|
+
probe.style.font = s.font && s.font !== '0px' ? s.font : `${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
|
|
1077
|
+
}
|
|
1078
|
+
list.appendChild(probe);
|
|
1079
|
+
let max = 0;
|
|
1080
|
+
for (const path of paths) {
|
|
1081
|
+
probe.textContent = path || 'None';
|
|
1082
|
+
max = Math.max(max, probe.offsetWidth);
|
|
1083
|
+
}
|
|
1084
|
+
probe.remove();
|
|
1085
|
+
return max;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// File path field. Closed chrome shows the basename; the open list shows full
|
|
1089
|
+
// paths. Callers pass the candidate paths — this does not decide what a sprite
|
|
1090
|
+
// or sound is. `allowEmpty` adds a None row that writes ''.
|
|
1091
|
+
export function FileField({
|
|
1092
|
+
label,
|
|
1093
|
+
value,
|
|
1094
|
+
onChange,
|
|
1095
|
+
files = [],
|
|
1096
|
+
allowEmpty = false,
|
|
1097
|
+
overridden,
|
|
1098
|
+
defaultValue,
|
|
1099
|
+
onReset,
|
|
1100
|
+
}) {
|
|
1101
|
+
const current = value ?? '';
|
|
1102
|
+
const [open, setOpen] = useState(false);
|
|
1103
|
+
const buttonRef = useRef(null);
|
|
1104
|
+
const listRef = useRef(null);
|
|
1105
|
+
const [pos, setPos] = useState(null);
|
|
1106
|
+
|
|
1107
|
+
const paths = [];
|
|
1108
|
+
if (allowEmpty) paths.push('');
|
|
1109
|
+
for (const path of files) {
|
|
1110
|
+
if (path && !paths.includes(path)) paths.push(path);
|
|
1111
|
+
}
|
|
1112
|
+
if (current && !paths.includes(current)) paths.splice(allowEmpty ? 1 : 0, 0, current);
|
|
1113
|
+
|
|
1114
|
+
useLayoutEffect(() => {
|
|
1115
|
+
if (!open || !buttonRef.current) return undefined;
|
|
1116
|
+
function place() {
|
|
1117
|
+
const anchor = buttonRef.current.getBoundingClientRect();
|
|
1118
|
+
const list = listRef.current;
|
|
1119
|
+
const margin = 8;
|
|
1120
|
+
const maxWidth = window.innerWidth - margin * 2;
|
|
1121
|
+
const needed = longestPathWidth(list, paths) + pickerRowChrome(list) + 4;
|
|
1122
|
+
const width = Math.min(Math.max(needed, anchor.width), maxWidth);
|
|
1123
|
+
// Grow left from the field if the paths need more than the control width.
|
|
1124
|
+
let left = anchor.right - width;
|
|
1125
|
+
if (left < margin) left = margin;
|
|
1126
|
+
if (left + width > window.innerWidth - margin) {
|
|
1127
|
+
left = Math.max(margin, window.innerWidth - width - margin);
|
|
1128
|
+
}
|
|
1129
|
+
const gap = 4;
|
|
1130
|
+
const height = list?.getBoundingClientRect().height ?? 0;
|
|
1131
|
+
let top = anchor.bottom + gap;
|
|
1132
|
+
if (height && top + height > window.innerHeight - margin) {
|
|
1133
|
+
top = Math.max(margin, anchor.top - gap - height);
|
|
1134
|
+
}
|
|
1135
|
+
setPos({ top, left, width });
|
|
1136
|
+
}
|
|
1137
|
+
place();
|
|
1138
|
+
window.addEventListener('resize', place);
|
|
1139
|
+
window.addEventListener('scroll', place, true);
|
|
1140
|
+
return () => {
|
|
1141
|
+
window.removeEventListener('resize', place);
|
|
1142
|
+
window.removeEventListener('scroll', place, true);
|
|
1143
|
+
};
|
|
1144
|
+
}, [open, paths.length]);
|
|
1145
|
+
|
|
1146
|
+
useEffect(() => {
|
|
1147
|
+
if (!open) return undefined;
|
|
1148
|
+
function onKeyDown(event) {
|
|
1149
|
+
if (event.key === 'Escape') setOpen(false);
|
|
1150
|
+
}
|
|
1151
|
+
function onPointerDown(event) {
|
|
1152
|
+
if (listRef.current?.contains(event.target) || buttonRef.current?.contains(event.target)) {
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
setOpen(false);
|
|
1156
|
+
}
|
|
1157
|
+
window.addEventListener('keydown', onKeyDown);
|
|
1158
|
+
window.addEventListener('pointerdown', onPointerDown);
|
|
1159
|
+
return () => {
|
|
1160
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
1161
|
+
window.removeEventListener('pointerdown', onPointerDown);
|
|
1162
|
+
};
|
|
1163
|
+
}, [open]);
|
|
1164
|
+
|
|
1165
|
+
return (
|
|
1166
|
+
<FieldRow
|
|
1167
|
+
label={label}
|
|
1168
|
+
overridden={overridden}
|
|
1169
|
+
defaultValue={defaultValue ? basenameOf(defaultValue) : defaultValue}
|
|
1170
|
+
onReset={onReset}>
|
|
1171
|
+
<button
|
|
1172
|
+
ref={buttonRef}
|
|
1173
|
+
type="button"
|
|
1174
|
+
className={cx(styles.select, styles.fileField)}
|
|
1175
|
+
aria-haspopup="listbox"
|
|
1176
|
+
aria-expanded={open}
|
|
1177
|
+
onClick={() => setOpen((next) => !next)}>
|
|
1178
|
+
<span className={cx(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
|
|
1179
|
+
{current ? basenameOf(current) : 'None'}
|
|
1180
|
+
</span>
|
|
1181
|
+
</button>
|
|
1182
|
+
{open
|
|
1183
|
+
? createPortal(
|
|
1184
|
+
<div
|
|
1185
|
+
ref={listRef}
|
|
1186
|
+
className={styles.filePicker}
|
|
1187
|
+
role="listbox"
|
|
1188
|
+
aria-label={label}
|
|
1189
|
+
style={pos ?? { visibility: 'hidden' }}>
|
|
1190
|
+
{paths.map((path) => {
|
|
1191
|
+
const selected = path === current;
|
|
1192
|
+
return (
|
|
1193
|
+
<button
|
|
1194
|
+
key={path || '__none'}
|
|
1195
|
+
type="button"
|
|
1196
|
+
role="option"
|
|
1197
|
+
aria-selected={selected}
|
|
1198
|
+
className={cx(
|
|
1199
|
+
styles.filePickerOption,
|
|
1200
|
+
selected && styles.filePickerOptionSelected,
|
|
1201
|
+
!path && styles.fileFieldEmpty
|
|
1202
|
+
)}
|
|
1203
|
+
title={path || 'None'}
|
|
1204
|
+
onClick={() => {
|
|
1205
|
+
onChange(path);
|
|
1206
|
+
setOpen(false);
|
|
1207
|
+
}}>
|
|
1208
|
+
{path ? <FilePathLabel path={path} /> : 'None'}
|
|
1209
|
+
</button>
|
|
1210
|
+
);
|
|
1211
|
+
})}
|
|
1212
|
+
</div>,
|
|
1213
|
+
document.body
|
|
1214
|
+
)
|
|
1215
|
+
: null}
|
|
1216
|
+
</FieldRow>
|
|
1217
|
+
);
|
|
1218
|
+
}
|