castle-web-cli 0.4.128 → 0.4.129

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.
@@ -22,7 +22,7 @@ Do you already know what you want to make, or do you want to figure it out toget
22
22
  - Editable screens live in `scenes/*.scene` files (plain JSON). Use a separate scene file per distinct screen, and switch with `scene.loadFromFile('name.scene')`.
23
23
  - Every actor is an instance of a **blueprint** (`blueprints/*.scene`) — see `## Blueprints` below. Always author the blueprint file yourself and reference it via `"blueprint"`. Don't write inline actors (full `components`, no `blueprint` field): the editor auto-migrates each one into its own new blueprint file on open, one per actor with no dedup, which litters the deck with junk blueprints.
24
24
  - Real game objects and scenery should usually be editable sprites: generate `.sprite` with `npm run draw -- name`, then place it via a `Sprite` component pointing at `drawings/name.sprite`. Dynamic UI/effects stay procedural.
25
- - A deck can also hold uploaded media (the Files panel's Upload button): `Sprite` draws image files as well as `.sprite`, `Sound` and `Video` play audio and video files, and `Tone` synthesizes a note with no file at all. Point their `file` at the deck path (`assets/logo.png`).
25
+ - A deck can also hold uploaded media (the Files panel's Upload button): `Sprite` draws image files as well as `.sprite`, and `Video` plays video files. Point their `file` at the deck path (`assets/logo.png`). For audio see `## Sound` below -- sound effects need no component at all.
26
26
  - **Supported media formats are exactly**: `.png` `.jpg` `.jpeg` `.gif` `.webp` `.svg` images, `.mp3` `.wav` `.m4a` audio, `.mp4` (H.264) video. Nothing else — a published deck inlines every asset as a `data:` URI, where the browser trusts the declared type instead of sniffing the bytes, so a format that merely works while serving (`.mov` is the classic case) can be dead once published. Convert, don't improvise.
27
27
  - This kit is plain JavaScript. Use `.jsx` for files with JSX, `.js` otherwise; do not add TypeScript files or a new build step.
28
28
  - After any code, scene, or drawing edit, run `npm run restart`.
@@ -174,25 +174,42 @@ Template `components` keys are behavior names (the `static behaviorName`); add a
174
174
  ```
175
175
 
176
176
  - **Camera** — `{ target: actorId, followX, followY, roomWidth, roomHeight }`. Place on a dedicated actor; sets `scene.camera` clamped to the room. Omit entirely for a fixed view (no camera = no translation).
177
- - **Sound** — `{ file: "assets/hit.mp3", volume?: 1, playbackRate?: 1, pan?: 0, loop?: false, polyphonic?: false, playOnStart?: true }`. Plays an audio file (`.mp3`/`.wav`/`.m4a`) from an actor. `playbackRate` retunes it (2 = an octave up and twice as fast, 0.5 = an octave down); `pan` places it left (−1) to right (+1); `polyphonic` overlaps copies instead of cutting the last one off, which is what a sound effect fired twice in a row should do (leave it off for music). With `playOnStart` it starts with the scene; otherwise trigger it from code through the handle it puts on the actor:
177
+ - **SoundPlayer** — `{ file: "assets/music/theme.mp3", stream?: false, volume?: 1, playbackRate?: 1, pan?: 0, loop?: false, playOnStart?: true }`. Plays one audio file from an actor. `stream: false` (the default) fires it from memory — instant and overlapping, for effects; `stream: true` streams it from an `<audio>` element — for music and long ambience, whose decoded audio would be far too big to hold. `loop` applies to `stream: true` only. `playbackRate` retunes it (2 = an octave up and twice as fast, 0.5 = an octave down); `pan` places it left (−1) to right (+1). With `playOnStart` it starts with the scene; otherwise trigger it through the handle it puts on the actor:
178
178
 
179
179
  ```jsx
180
- scene.getActor('sfx').runtime.sound.play(); // restarts from the beginning
181
- await actor.runtime.sound.play(); // ...and wait for it to finish
182
- actor.runtime.sound.stop(); // also: pause(), resume(), .playing
183
- scene.sound.stopAll(); // everything the scene is playing, off
180
+ scene.getActor('music').runtime.soundPlayer.play();
181
+ await actor.runtime.soundPlayer.play(); // ...and wait for it to finish
182
+ actor.runtime.soundPlayer.stop(); // stream also: pause(), resume(), .playing
184
183
  ```
185
184
 
186
- Sound only plays during play, never in the editor, and everything stops on Stop / a scene change. Browsers refuse audio until the player has touched the page; a sound that starts before that is queued and begins on the first tap or key, so don't work around it.
187
- - **Tone** — `{ note?: 60, waveform?: 'square'|'sawtooth'|'sine'|'triangle'|'noise', attack?: 0, release?: 0.3, volume?: 0.5, pan?: 0, playOnStart?: false }`. Plays a synthesized note — no audio file, nothing downloaded, nothing stored. Reach for this before an audio file for blips, thuds and pickups. `note` is MIDI (60 = middle C, +12 an octave, +1 a semitone), and the note lasts `attack + release` seconds.
185
+ **For a one-off sound effect, don't place one of these** `scene.sound.play('hit')` needs no actor and no component. See `## Sound` below.
188
186
 
189
- ```jsx
190
- actor.runtime.tone.play(); // the note as configured
191
- actor.runtime.tone.play({ note: 72 }); // ...or override per play, e.g. a rising scale
192
- await actor.runtime.tone.play({ note: 48, waveform: 'noise' });
193
- ```
187
+ Sound only plays during play, never in the editor, and everything stops on Stop / a scene change. Browsers refuse audio until the player has touched the page; a sound that starts before that is queued and begins on the first tap or key, so don't work around it.
194
188
  - **Video** — `{ file: "assets/clip.mp4", mode?: 'cover'|'fit'|'stretch', playing?: true, loop?: true, muted?: true, volume?: 1 }`. Plays an `.mp4` (H.264) video inside the Layout box, drawn into the scene like a sprite: it moves with Layout, draws in `z` order, and can carry a Collider. `mode` frames it exactly as Sprite's does. In the editor it holds its first frame so you can place it. Leave `muted: true` unless the player has already interacted — an unmuted autoplaying video is blocked by browsers, a muted one isn't.
195
189
 
190
+ ## Sound
191
+
192
+ Two ways to make a noise. The choice is about LENGTH, not importance.
193
+
194
+ **Sound effects — `scene.sound.play(name)`.** No actor, no component, no import:
195
+
196
+ ```jsx
197
+ scene.sound.play('jump'); // by filename, without the extension
198
+ scene.sound.play('assets/sounds/hit.wav'); // ...or by full path
199
+ scene.sound.play('hit', { volume: 0.5, pan: -0.8, playbackRate: 1.2 });
200
+ scene.sound.stopAll(); // everything the scene is playing, off
201
+ ```
202
+
203
+ Effects are decoded into memory before the deck starts, so they fire instantly and overlap. **Put them in `assets/sounds/`** — that directory, and only that one, is preloaded. A sound kept elsewhere still plays, but its FIRST play is silent while it loads.
204
+
205
+ `play` returns `{ stop, finished }`, or `null` when nothing played (an unknown name, or a sound still loading — an effect that arrives late is worse than one that never arrives, so it is dropped rather than deferred). Also on `scene.sound`: `isLoaded`, `whenLoaded`, `has(name)`, `voiceCount`.
206
+
207
+ Name a sound by its filename without the extension, or by its full deck path. Two files sharing a filename (`assets/sounds/click.wav` and `assets/sounds/ui/click.wav`) make the bare name ambiguous, so it resolves to NEITHER — use the full path for those.
208
+
209
+ **Music and long ambience — a `SoundPlayer` component with `stream: true`.** Decoded audio is ~21MB per minute, so a track is streamed from an `<audio>` element instead of held in memory. Keep those in `assets/music/`, outside the preload directory.
210
+
211
+ 128 effects can sound at once; past that the oldest is cut off to make room.
212
+
196
213
  ## Physics
197
214
 
198
215
  This kit simulates 2D physics with matter-js. You get gravity, collisions,
@@ -491,6 +508,7 @@ To generate path art, emit an svg of **shapes** rather than per-pixel rects and
491
508
  - `scene.spawnActor({ components: { Layout: {...}, MyBehavior: {...} } })` — add a new actor at runtime with fully-specified components (no blueprint). Returns the actor (with auto-minted `id` and `runtime = {}`). Use this; don't push to `scene.data.actors` by hand.
492
509
  - `scene.spawnFromBlueprint('blueprints/enemy.scene', { components: { Layout: { x, y } } })` — spawn an instance of a blueprint, same merge semantics as a placed instance. Prefer this over `spawnActor` when you're spawning copies of something that has (or should have) a blueprint, e.g. `scene.spawnFromBlueprint(actor.blueprint, { components: { Layout: { x: actor.components.Layout.x, y: actor.components.Layout.y } } })` to spawn another of the same kind as an existing actor.
493
510
  - `scene.despawnActor(id)` — remove an actor at runtime. Use this; don't `splice` + `delete` by hand.
511
+ - `scene.sound` — the deck's sound library: `play(name, opts)`, `stopAll()`, `isLoaded`, `whenLoaded`, `has(name)`, `voiceCount`. See `## Sound`.
494
512
  - `scene.status` — string you can set/read for game-state ('playing', 'gameover', ...).
495
513
  - `scene.load(sceneData)` — replace the running scene with the given scene data object.
496
514
  - `scene.readFromFile(name)` — read and parse a scene file (`'gameover.scene'`, `'levels/2.scene'`), returning its scene data. Use this instead of importing a `.scene` file.
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from 'react';
2
- import { Panel, SelectField, NumberField, CheckboxField, Button } from '../engine/ui';
2
+ import { Panel, SelectField, NumberField, CheckboxField, Button, FileField } from '../engine/ui';
3
3
  import { centerOf, inActorWorldSpace } from '../engine/physics/controls';
4
4
  import { JOINT_TYPES } from '../engine/physics/joints';
5
5
  import { drawJointArt } from '../engine/physics/jointArt';
@@ -180,11 +180,12 @@ function JointEntry({ index, joint, spriteFiles, active, onPickToggle, onPatch,
180
180
  <SelectField label="Show in play" value={render} onChange={(v) => onPatch({ render: v })} options={RENDER_MODES} />
181
181
  {render === 'sprite' ? (
182
182
  <>
183
- <SelectField
183
+ <FileField
184
184
  label="Art"
185
185
  value={joint.sprite || ''}
186
186
  onChange={(v) => onPatch({ sprite: v })}
187
- options={['', ...spriteFiles]}
187
+ files={spriteFiles}
188
+ allowEmpty
188
189
  />
189
190
  <NumberField label="Thickness" value={joint.thickness ?? 12} min={1} onChange={(v) => onPatch({ thickness: v })} />
190
191
  <SelectField
@@ -0,0 +1,266 @@
1
+ import { resolveDeckFile } from 'castle-web-sdk';
2
+ import React from 'react';
3
+ import { mediaElement, playMedia, setMediaPan, setMediaPlaybackRate, stopMedia } from '../engine/media';
4
+ import { isPreloadedPath, playSound } from '../engine/sound';
5
+ import { mediaFilesOfKind } from '../engine/files';
6
+ import { FileField, Panel } from '../engine/ui';
7
+ import { AutoFields, overrideProps } from '../engine/autoInspector';
8
+ import { PAN, UNIT } from '../engine/propertyRanges';
9
+
10
+ // Plays one audio file (.mp3 / .wav / .m4a) from an actor.
11
+ //
12
+ // `stream` picks HOW it plays, which is the only real decision here:
13
+ //
14
+ // stream: false the sound is already decoded in memory, and plays instantly.
15
+ // Copies overlap. This is what a sound effect wants, and it is
16
+ // the default. Preloaded only if the file lives in
17
+ // `assets/sounds/` -- see engine/sound.js.
18
+ // stream: true an <audio> element, decoded as it plays. Nothing is held in
19
+ // memory, so this is what a music track or a long ambience
20
+ // wants -- a minute of audio is ~21MB decoded. One at a time:
21
+ // playing again restarts it rather than overlapping.
22
+ //
23
+ // Set `playOnStart` and it plays when the scene starts. Either way you can
24
+ // trigger it from a behavior, through the handle this puts on the actor:
25
+ //
26
+ // scene.getActor('music').runtime.soundPlayer.play();
27
+ // await actor.runtime.soundPlayer.play(); // ...and wait for it to end
28
+ // actor.runtime.soundPlayer.stop();
29
+ //
30
+ // To fire a sound effect that no actor owns -- most of them -- don't place one of
31
+ // these at all. `scene.sound.play('hit')` needs no actor and no import.
32
+ //
33
+ // Sound only plays during play, never in the editor: `update` is what wires it
34
+ // up, and the editor doesn't run it. Pressing Stop silences everything, as does
35
+ // `scene.sound.stopAll()` (see systems/media.js).
36
+ //
37
+ // Browsers refuse to play audio until the player has touched the page. A sound
38
+ // that starts before that isn't lost -- it's queued and starts on the first tap
39
+ // or key, which for a game is usually the first input anyway.
40
+ export class SoundPlayer {
41
+ static behaviorName = 'SoundPlayer';
42
+
43
+ static defaultProps = {
44
+ file: '',
45
+ stream: false,
46
+ volume: 1,
47
+ playbackRate: 1,
48
+ pan: 0,
49
+ loop: false,
50
+ playOnStart: true,
51
+ };
52
+
53
+ // Mirrors the clamps `readSettings` already applies at runtime, so the
54
+ // inspector can't author a value the sound would silently ignore -- including
55
+ // playbackRate's 0.01..10, where the classic editor's ceiling lives.
56
+ static propertyMeta = {
57
+ stream: { hint: streamHint },
58
+ volume: UNIT,
59
+ playbackRate: { min: 0.01, max: 10, step: 0.05 },
60
+ pan: PAN,
61
+ };
62
+
63
+ constructor(props) {
64
+ this.props = props;
65
+ }
66
+
67
+ update(actor) {
68
+ if (!actor.runtime || actor.runtime.collected) return;
69
+ const file = resolveDeckFile(this.props.file);
70
+ const settings = readSettings(this.props);
71
+ const previous = actor.runtime.soundPlayer;
72
+ const handle = settings.stream
73
+ ? streamHandleFor(actor.id, file, settings, previous)
74
+ : bufferHandleFor(file, settings, previous);
75
+ actor.runtime.soundPlayer = handle;
76
+ // Only a NEW handle starts itself: this runs every frame, and `playOnStart`
77
+ // means the start of the scene, not the start of each one.
78
+ if (handle && handle !== previous && this.props.playOnStart) void handle.play();
79
+ }
80
+
81
+ static Inspector({ component, setComponent, override }) {
82
+ // `loop` is an element's, not a buffer's -- a decoded one-shot has no loop
83
+ // in v0. Hiding it rather than showing a dead control is the difference
84
+ // between a prop that doesn't apply and one that looks broken.
85
+ const streaming = Boolean(component.stream ?? SoundPlayer.defaultProps.stream);
86
+ const fields = ['stream', 'volume', 'playbackRate', 'pan', 'loop', 'playOnStart'];
87
+ return (
88
+ <Panel title="SoundPlayer" overridden={override?.anyOverridden()}>
89
+ <FileField
90
+ label="File"
91
+ value={component.file}
92
+ onChange={(file) => setComponent({ file })}
93
+ files={mediaFilesOfKind('audio')}
94
+ allowEmpty
95
+ {...overrideProps(override, 'file')}
96
+ />
97
+ <AutoFields
98
+ defaultProps={SoundPlayer.defaultProps}
99
+ meta={SoundPlayer.propertyMeta}
100
+ component={component}
101
+ setComponent={setComponent}
102
+ only={streaming ? fields : fields.filter((key) => key !== 'loop')}
103
+ override={override}
104
+ />
105
+ </Panel>
106
+ );
107
+ }
108
+ }
109
+
110
+ // What `stream` means, in the inspector, for the setting it is actually on --
111
+ // a note describing both halves of the toggle at once would say less than the
112
+ // prop's own name does. The third case is the one that bites: a sound outside
113
+ // assets/sounds/ plays, but not the first time it is asked for, and nothing else
114
+ // in the editor would ever tell you that.
115
+ function streamHint(on, component) {
116
+ if (on) return 'Music and long sounds. Streamed, so nothing is held in memory.';
117
+ const file = resolveDeckFile(component?.file ?? '');
118
+ if (file && !isPreloadedPath(file)) {
119
+ return 'Short sfx. Move this file into assets/sounds/ to preload before first play, otherwise first play is silent.';
120
+ }
121
+ return 'Short sfx. Preloaded, so they fire instantly and overlap.';
122
+ }
123
+
124
+ function clamp(value, min, max, fallback) {
125
+ return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
126
+ }
127
+
128
+ function readSettings(props) {
129
+ return {
130
+ stream: Boolean(props.stream),
131
+ volume: clamp(props.volume, 0, 1, 1),
132
+ // The same ceiling the classic editor used: past 10x a sound is a click.
133
+ playbackRate: clamp(props.playbackRate, 0.01, 10, 1),
134
+ pan: clamp(props.pan, -1, 1, 0),
135
+ loop: Boolean(props.loop),
136
+ };
137
+ }
138
+
139
+ function applyStreamSettings(element, settings) {
140
+ element.volume = settings.volume;
141
+ // Not a plain assignment: an element time-stretches by default, so this is
142
+ // what makes `playbackRate` mean the same retune on both paths. See
143
+ // engine/media.js.
144
+ setMediaPlaybackRate(element, settings.playbackRate);
145
+ element.loop = settings.loop;
146
+ if (settings.pan !== 0) setMediaPan(element, settings.pan);
147
+ }
148
+
149
+ const warnedStreamOnly = new Set();
150
+ function streamOnly(method) {
151
+ if (warnedStreamOnly.has(method)) return;
152
+ warnedStreamOnly.add(method);
153
+ console.warn(
154
+ `[SoundPlayer] ${method}() needs "stream" on -- a decoded sound has no playhead to hold. Ignored.`
155
+ );
156
+ }
157
+
158
+ // Asking the pool for this actor's element EVERY frame is what keeps it alive:
159
+ // an entry nobody asks for is reaped as a despawned actor's leftovers (see
160
+ // engine/media.js). Skipping the ask on frames where the handle already matched
161
+ // meant a track went on sounding while its entry was reaped out from under the
162
+ // pool, past the reach of `stopAll`.
163
+ //
164
+ // A different file is a different pool entry, so comparing elements is also how
165
+ // a changed `file` prop gets a new handle.
166
+ function streamHandleFor(actorId, file, settings, previous) {
167
+ const element = mediaElement(actorId, 'audio', file);
168
+ if (!element) return null;
169
+ // Props are live: turning the volume down or the pitch up in the inspector
170
+ // takes effect on the sound already playing.
171
+ applyStreamSettings(element, settings);
172
+ if (previous && previous.element === element) {
173
+ previous.settings = settings;
174
+ return previous;
175
+ }
176
+ return makeStreamHandle(file, element, settings);
177
+ }
178
+
179
+ function bufferHandleFor(file, settings, previous) {
180
+ if (previous && !previous.stream && previous.file === file) {
181
+ previous.settings = settings;
182
+ return previous;
183
+ }
184
+ return makeBufferHandle(file, settings);
185
+ }
186
+
187
+ // The <audio> handle: one element, reused. `play()` resolves when the sound ends,
188
+ // so a behavior can `await` one before doing the next thing. A looping sound
189
+ // resolves right away rather than never -- awaiting something that by definition
190
+ // doesn't end would hang the caller forever, and that is never what was meant.
191
+ function makeStreamHandle(file, element, settings) {
192
+ const handle = {
193
+ file,
194
+ stream: true,
195
+ settings,
196
+ element,
197
+ play() {
198
+ applyStreamSettings(element, handle.settings);
199
+ try {
200
+ element.currentTime = 0;
201
+ } catch {
202
+ // Not seekable yet (still loading) -- it plays from the start anyway.
203
+ }
204
+ playMedia(element);
205
+ if (handle.settings.loop) return Promise.resolve();
206
+ return new Promise((resolve) => {
207
+ const done = () => resolve();
208
+ element.addEventListener('ended', done, { once: true });
209
+ element.addEventListener('error', done, { once: true });
210
+ });
211
+ },
212
+ resume() {
213
+ playMedia(element);
214
+ },
215
+ pause() {
216
+ element.pause();
217
+ },
218
+ stop() {
219
+ stopMedia(element);
220
+ },
221
+ get playing() {
222
+ return !element.paused;
223
+ },
224
+ };
225
+ return handle;
226
+ }
227
+
228
+ // The decoded handle: every `play()` is its own voice, so they overlap. `stop()`
229
+ // stops the one most recently started -- the others are already on their way out,
230
+ // and `scene.sound.stopAll()` is the blunt instrument for all of them at once.
231
+ //
232
+ // `play()` resolves immediately when nothing played: an unknown file, or a sound
233
+ // still decoding. Never hanging is the point -- see playSound in engine/sound.js.
234
+ function makeBufferHandle(file, settings) {
235
+ const handle = {
236
+ file,
237
+ stream: false,
238
+ settings,
239
+ element: null,
240
+ voice: null,
241
+ play() {
242
+ const s = handle.settings;
243
+ const voice = playSound(file, { volume: s.volume, pan: s.pan, playbackRate: s.playbackRate });
244
+ handle.voice = voice;
245
+ if (!voice) return Promise.resolve();
246
+ void voice.finished.then(() => {
247
+ if (handle.voice === voice) handle.voice = null;
248
+ });
249
+ return voice.finished;
250
+ },
251
+ resume() {
252
+ streamOnly('resume');
253
+ },
254
+ pause() {
255
+ streamOnly('pause');
256
+ },
257
+ stop() {
258
+ handle.voice?.stop();
259
+ handle.voice = null;
260
+ },
261
+ get playing() {
262
+ return handle.voice !== null;
263
+ },
264
+ };
265
+ return handle;
266
+ }
@@ -4,7 +4,7 @@ import { artFrameCount, isImageArt, renderArtFrame } from '../engine/art';
4
4
  import { isVectorRenderer } from '../engine/pxart';
5
5
  import { renderSmoothCompositeFrame, renderSmoothSpriteFrame } from '../engine/pxartSmooth';
6
6
  import { spriteDestRect } from '../engine/spriteGeometry';
7
- import { Panel, SelectField } from '../engine/ui';
7
+ import { FileField, Panel, SelectField } from '../engine/ui';
8
8
  import { AutoFields, overrideProps } from '../engine/autoInspector';
9
9
  import { parseTint, tintCanvas } from './tint';
10
10
 
@@ -163,11 +163,11 @@ export class Sprite {
163
163
  const spriteFiles = getSpriteFiles(sprites);
164
164
  return (
165
165
  <Panel title="Sprite" overridden={override?.anyOverridden()}>
166
- <SelectField
166
+ <FileField
167
167
  label="File"
168
168
  value={component.file}
169
169
  onChange={(file) => setComponent({ file })}
170
- options={spriteFiles}
170
+ files={spriteFiles}
171
171
  {...overrideProps(override, 'file')}
172
172
  />
173
173
  <SelectField
@@ -3,7 +3,7 @@ import React from 'react';
3
3
  import { mediaElement, playMedia, stopMedia } from '../engine/media';
4
4
  import { mediaFilesOfKind } from '../engine/files';
5
5
  import { spriteDestRect } from '../engine/spriteGeometry';
6
- import { Panel, SelectField } from '../engine/ui';
6
+ import { FileField, Panel, SelectField } from '../engine/ui';
7
7
  import { AutoFields, overrideProps } from '../engine/autoInspector';
8
8
  import { UNIT } from '../engine/propertyRanges';
9
9
 
@@ -89,11 +89,12 @@ export class Video {
89
89
  static Inspector({ component, setComponent, override }) {
90
90
  return (
91
91
  <Panel title="Video" overridden={override?.anyOverridden()}>
92
- <SelectField
92
+ <FileField
93
93
  label="File"
94
94
  value={component.file}
95
95
  onChange={(file) => setComponent({ file })}
96
- options={mediaFilesOfKind('video')}
96
+ files={mediaFilesOfKind('video')}
97
+ allowEmpty
97
98
  {...overrideProps(override, 'file')}
98
99
  />
99
100
  <SelectField
@@ -131,5 +131,5 @@
131
131
  "main": "main.jsx",
132
132
  "autoUpdateWhenImported": true,
133
133
  "title": "physics-2d",
134
- "publishedVersion": "2026-08-18T21:05:46.663Z"
134
+ "publishedVersion": "2026-08-20T22:15:34.812Z"
135
135
  }
@@ -32,3 +32,44 @@ export function resumeAudioContext() {
32
32
  });
33
33
  }
34
34
  }
35
+
36
+ // Where a gesture that could unlock audio might land. This deck's own window
37
+ // always -- plus the EDITOR's, because there the deck runs inside a panel iframe
38
+ // and a click on the editor's own chrome (its Play button, a file, an inspector
39
+ // field) is a gesture the browser grants this frame but that this frame never
40
+ // hears about. Without the parent, a deck's first sound stays queued until the
41
+ // player happens to click the game itself, which in the editor reads as sound
42
+ // being broken.
43
+ //
44
+ // Measured rather than assumed: a same-origin parent's pointerdown both fires
45
+ // here AND satisfies the autoplay policy, so the queued sound really does start.
46
+ //
47
+ // A published deck's parent is a Castle host on another origin. Touching it
48
+ // throws, there is nothing to listen to, and the deck's own window is the whole
49
+ // answer -- which is why this is a try/catch rather than a capability check.
50
+ export function unlockTargets() {
51
+ const targets = [window];
52
+ try {
53
+ const parent = window.parent;
54
+ if (parent && parent !== window && parent.document) targets.push(parent);
55
+ } catch {
56
+ // Cross-origin parent: embedded by a host rather than open in the editor.
57
+ }
58
+ return targets;
59
+ }
60
+
61
+ let resumeBound = false;
62
+
63
+ // Resume the context on the first gesture, wherever it lands. A deck that only
64
+ // plays buffers never goes through engine/media.js, so this is what makes its
65
+ // sound audible after the player finally taps: without it the context stays
66
+ // suspended until some later play happens to call resume again, losing the one
67
+ // in between. Idempotent, so callers need not track whether it ran.
68
+ export function bindAudioResume() {
69
+ if (resumeBound || typeof window === 'undefined') return;
70
+ resumeBound = true;
71
+ for (const target of unlockTargets()) {
72
+ target.addEventListener('pointerdown', resumeAudioContext, { passive: true });
73
+ target.addEventListener('keydown', resumeAudioContext, { passive: true });
74
+ }
75
+ }
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { CheckboxField, ColorField, NumberField, Panel, TextField, isHexColor } from './ui';
2
+ import { CheckboxField, ColorField, FieldNote, NumberField, Panel, TextField, isHexColor } from './ui';
3
3
  // Build the override-indicator field props (purple tint + "Default: X [Reset]")
4
4
  // for a single property from a behavior panel's override context. Returns an
5
5
  // empty object when there's no context (blueprint template editing) or the
@@ -17,6 +17,43 @@ export function overrideProps(override, prop) {
17
17
  // defaults -- which are an unbounded scrubber stepping by whole numbers, wrong
18
18
  // for anything that lives in 0..1. Declaring BOTH min and max makes it a true
19
19
  // slider (fill + thumb). Props that declare nothing are unchanged.
20
+ //
21
+ // Any prop can also declare `hint`, a line of explanation shown under the field.
22
+ // As a function it receives the prop's current value and the whole component, so
23
+ // a hint can say what the CURRENT setting means rather than describing both
24
+ // halves of a toggle at once -- which is the only way a one-line note beats the
25
+ // prop's own name.
26
+ function fieldFor({ label, current, set, sample, entry, ov }) {
27
+ if (typeof sample === 'number') {
28
+ const { min, max, step } = entry ?? {};
29
+ return (
30
+ <NumberField
31
+ label={label}
32
+ value={current}
33
+ onChange={set}
34
+ {...(min == null ? {} : { min })}
35
+ {...(max == null ? {} : { max })}
36
+ {...(step == null ? {} : { step })}
37
+ {...ov}
38
+ />
39
+ );
40
+ }
41
+ if (typeof sample === 'boolean') {
42
+ return <CheckboxField label={label} checked={current} onChange={set} {...ov} />;
43
+ }
44
+ if (isHexColor(sample)) {
45
+ return <ColorField label={label} value={current} onChange={set} {...ov} />;
46
+ }
47
+ return (
48
+ <TextField label={label} value={current == null ? '' : String(current)} onChange={set} {...ov} />
49
+ );
50
+ }
51
+
52
+ function hintFor(entry, current, component) {
53
+ if (typeof entry?.hint === 'function') return entry.hint(current, component);
54
+ return entry?.hint ?? null;
55
+ }
56
+
20
57
  export function AutoFields({ defaultProps, component, setComponent, only, exclude, override, meta }) {
21
58
  const keys = Object.keys(defaultProps).filter((key) => {
22
59
  if (only) return only.includes(key);
@@ -28,39 +65,19 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
28
65
  {keys.map((key) => {
29
66
  const fallback = defaultProps[key];
30
67
  const current = key in component ? component[key] : fallback;
31
- const set = (value) => setComponent({ [key]: value });
32
- const label = humanizeKey(key);
33
- const sample = fallback ?? current;
34
- const ov = overrideProps(override, key);
35
- if (typeof sample === 'number') {
36
- const { min, max, step } = meta?.[key] ?? {};
37
- return (
38
- <NumberField
39
- key={key}
40
- label={label}
41
- value={current}
42
- onChange={set}
43
- {...(min == null ? {} : { min })}
44
- {...(max == null ? {} : { max })}
45
- {...(step == null ? {} : { step })}
46
- {...ov}
47
- />
48
- );
49
- }
50
- if (typeof sample === 'boolean') {
51
- return <CheckboxField key={key} label={label} checked={current} onChange={set} {...ov} />;
52
- }
53
- if (isHexColor(sample)) {
54
- return <ColorField key={key} label={label} value={current} onChange={set} {...ov} />;
55
- }
68
+ const entry = meta?.[key];
56
69
  return (
57
- <TextField
58
- key={key}
59
- label={label}
60
- value={current == null ? '' : String(current)}
61
- onChange={set}
62
- {...ov}
63
- />
70
+ <React.Fragment key={key}>
71
+ {fieldFor({
72
+ label: humanizeKey(key),
73
+ current,
74
+ set: (value) => setComponent({ [key]: value }),
75
+ sample: fallback ?? current,
76
+ entry,
77
+ ov: overrideProps(override, key),
78
+ })}
79
+ <FieldNote>{hintFor(entry, current, component)}</FieldNote>
80
+ </React.Fragment>
64
81
  );
65
82
  })}
66
83
  </>