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.
Files changed (157) hide show
  1. package/dist/agent-prompts.js +4 -4
  2. package/dist/filesChanged.d.ts +2 -0
  3. package/dist/filesChanged.js +6 -2
  4. package/dist/ide.js +15 -4
  5. package/dist/importBrowse.js +1 -0
  6. package/dist/index.js +10 -4
  7. package/dist/init.js +63 -15
  8. package/dist/native/loop.js +1 -1
  9. package/dist/native/tools.js +1 -1
  10. package/dist/serve.d.ts +1 -0
  11. package/dist/serve.js +20 -1
  12. package/dist/shell/assets/index-BpOLUyVO.js +441 -0
  13. package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/physics-2d/CLAUDE.md +40 -23
  16. package/kits/physics-2d/behaviors/Joints.jsx +4 -3
  17. package/kits/physics-2d/behaviors/SoundPlayer.jsx +266 -0
  18. package/kits/physics-2d/behaviors/Sprite.jsx +3 -3
  19. package/kits/physics-2d/behaviors/Video.jsx +4 -3
  20. package/kits/physics-2d/castle.json +1 -1
  21. package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
  22. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  23. package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
  24. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  25. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  26. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  27. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  28. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  29. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  30. package/kits/physics-2d/engine/audioContext.js +41 -0
  31. package/kits/physics-2d/engine/autoInspector.jsx +50 -33
  32. package/kits/physics-2d/engine/blueprint.js +15 -3
  33. package/kits/physics-2d/engine/liveReload.js +33 -21
  34. package/kits/physics-2d/engine/media.js +29 -39
  35. package/kits/physics-2d/engine/sound.js +316 -0
  36. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  37. package/kits/physics-2d/engine/ui.jsx +274 -5
  38. package/kits/physics-2d/engine/ui.module.css +137 -16
  39. package/kits/physics-2d/scripts/testsounds.mjs +84 -0
  40. package/kits/physics-2d/systems/media.js +31 -15
  41. package/kits/physics-3d/CLAUDE.md +109 -0
  42. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  43. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  44. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  45. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  46. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  47. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  48. package/kits/physics-3d/behaviors/Portal.jsx +19 -0
  49. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  50. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  51. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  52. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  53. package/kits/physics-3d/blueprints/crate.scene +29 -0
  54. package/kits/physics-3d/blueprints/door.scene +35 -0
  55. package/kits/physics-3d/blueprints/gem.scene +26 -0
  56. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  57. package/kits/physics-3d/blueprints/platform.scene +23 -0
  58. package/kits/physics-3d/blueprints/player.scene +29 -0
  59. package/kits/physics-3d/blueprints/portal.scene +21 -0
  60. package/kits/physics-3d/blueprints/rock.scene +14 -0
  61. package/kits/physics-3d/blueprints/statue.scene +26 -0
  62. package/kits/physics-3d/blueprints/tree.scene +14 -0
  63. package/kits/physics-3d/blueprints/wall.scene +26 -0
  64. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  65. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  66. package/kits/physics-3d/drawings/crate.pxart +26 -0
  67. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  68. package/kits/physics-3d/drawings/door.pxart +27 -0
  69. package/kits/physics-3d/drawings/face.pxart +26 -0
  70. package/kits/physics-3d/drawings/floor.pxart +27 -0
  71. package/kits/physics-3d/drawings/platform.pxart +27 -0
  72. package/kits/physics-3d/drawings/statue.pxart +27 -0
  73. package/kits/physics-3d/drawings/wall.pxart +27 -0
  74. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
  75. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  76. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  77. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  78. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  79. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  80. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  81. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  82. package/kits/physics-3d/engine3d/materials.js +174 -0
  83. package/kits/physics-3d/engine3d/meshops.js +123 -0
  84. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  85. package/kits/physics-3d/engine3d/modelFiles.js +139 -0
  86. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  87. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  88. package/kits/physics-3d/engine3d/world3d.js +216 -0
  89. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  90. package/kits/physics-3d/index.html +20 -0
  91. package/kits/physics-3d/main.jsx +30 -0
  92. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  93. package/kits/physics-3d/models/player.pxmodel +222 -0
  94. package/kits/physics-3d/models/rock.pxmodel +30 -0
  95. package/kits/physics-3d/models/tree.pxmodel +10 -0
  96. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  97. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  98. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  99. package/kits/physics-3d/scenes/main.scene +414 -0
  100. package/kits/physics-3d/scenes/model-lab.scene +127 -0
  101. package/kits/physics-3d/systems/physics3d.js +356 -0
  102. package/package.json +5 -2
  103. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  104. package/kits/basic-2d/CLAUDE.md +0 -221
  105. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  106. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  107. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  108. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  109. package/kits/basic-2d/behaviors/tint.js +0 -47
  110. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  111. package/kits/basic-2d/docs/pxart-format.md +0 -377
  112. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  113. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  114. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  115. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  116. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  117. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  118. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  119. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  120. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  121. package/kits/basic-2d/editors/editorHistory.js +0 -157
  122. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  123. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  124. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  125. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  126. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  127. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  128. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  129. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  130. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  131. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  132. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  133. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  134. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  135. package/kits/basic-2d/engine/assets.js +0 -15
  136. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  137. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  138. package/kits/basic-2d/engine/blueprint.js +0 -557
  139. package/kits/basic-2d/engine/collider.js +0 -200
  140. package/kits/basic-2d/engine/files.js +0 -141
  141. package/kits/basic-2d/engine/liveReload.js +0 -88
  142. package/kits/basic-2d/engine/pxart.js +0 -1032
  143. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  144. package/kits/basic-2d/engine/scene.js +0 -696
  145. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  146. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  147. package/kits/basic-2d/engine/ui.jsx +0 -695
  148. package/kits/basic-2d/engine/ui.module.css +0 -2287
  149. package/kits/basic-2d/index.html +0 -24
  150. package/kits/basic-2d/main.jsx +0 -24
  151. package/kits/basic-2d/scenes/main.scene +0 -16
  152. package/kits/basic-2d/scripts/draw.mjs +0 -121
  153. package/kits/physics-2d/behaviors/Sound.jsx +0 -163
  154. package/kits/physics-2d/behaviors/Tone.jsx +0 -95
  155. package/kits/physics-2d/engine/tone.js +0 -112
  156. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  157. /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 (e.g. basic-2d); a kit adds a
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.toggle, checked && styles.toggleOn)}
631
- role="switch"
630
+ className={cx(styles.checkbox, checked && styles.checkboxOn)}
631
+ role="checkbox"
632
632
  aria-checked={!!checked}
633
633
  onClick={() => onChange(!checked)}>
634
- <span className={styles.toggleKnob} />
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 (an agent's CODE edit
724
- // triggers requestReload, which would otherwise reset every sheet to the
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
+ }