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
@@ -1,11 +1,10 @@
1
1
  # physics-2d kit
2
2
 
3
- This is the `basic-2d` actor/behavior/scene framework plus a built-in 2D
4
- **physics** system (matter-js). Everything in `basic-2d` works the same; physics
5
- adds `RigidBody`, physics fields on `Collider`, a world-gravity scene setting,
6
- collision callbacks, `Joints` that link actors (spring/rod/pin/weld/rope, several
7
- per actor), and ready-made touch-first controls (`Draggable`, `Slingshot`, `AnalogStick`).
8
- See `## Physics` below.
3
+ An actor / behavior / scene framework for 2D games, with a built-in
4
+ **physics** system (matter-js): `RigidBody`, physics fields on `Collider`, a
5
+ world-gravity scene setting, collision callbacks, `Joints` that link actors
6
+ (spring/rod/pin/weld/rope, several per actor), and ready-made touch-first
7
+ controls (`Draggable`, `Slingshot`, `AnalogStick`). See `## Physics` below.
9
8
 
10
9
  ## Welcome message
11
10
 
@@ -22,10 +21,10 @@ Do you already know what you want to make, or do you want to figure it out toget
22
21
  - 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
22
  - 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
23
  - 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`).
24
+ - 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
25
  - **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
26
  - This kit is plain JavaScript. Use `.jsx` for files with JSX, `.js` otherwise; do not add TypeScript files or a new build step.
28
- - After any code, scene, or drawing edit, run `npm run restart`.
27
+ - Do not reload the deck after an edit. Nothing auto-reloads any more: the person applies your changes when they are ready, from the Play panel's restart button or a panel's own reload control.
29
28
  - Space is reserved by the editor for play/stop; do not bind Space to gameplay.
30
29
  - Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`, and the physics ones — `RigidBody.jsx`, `Joints.jsx`, `Draggable.jsx`, `Slingshot.jsx`, `AnalogStick.jsx`) to build a game. Their public API is documented below.
31
30
  - Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
@@ -57,8 +56,8 @@ Write the smallest game that satisfies what the user asked for. No sound, partic
57
56
 
58
57
  The deck is already serving when you start (`castle-web init` set that up; see `.castle/serve.json` for the URL). The user is watching that page right now. Your job is to make it interesting incrementally:
59
58
 
60
- 1. **Build incrementally.** Start with the smallest playable thing (one mechanic, one scene change), `npm run restart`, then add the next piece. Do NOT write the whole game in one shot.
61
- 2. **After every edit:** `npm run restart` (no hot reload). The served page refreshes and the user sees the change.
59
+ 1. **Build incrementally.** Start with the smallest playable thing (one mechanic, one scene change), then add the next piece. Do NOT write the whole game in one shot.
60
+ 2. **Do not reload.** Data edits (scenes, drawings, sprites) appear in the open editors on their own. Code edits wait for the person to apply them -- reloading a panel someone is working in loses their place.
62
61
  3. **Prefer real, editable assets.** For game objects, characters, and scenery, make actual pixel-art sprites and place them as real actors in `scenes/*.scene` — not shapes drawn in code. Real assets let the creator move and re-skin things in the editor and let other creators remix the deck. Make art as `.sprite` via the `draw` command (see **Creating pixel art** below). Data-driven UI (health bars, score/text, HUD gauges) and dynamic things (bullets, particles, effects) are correctly procedural/code — don't force those into sprites.
63
62
  4. **Separate scenes per screen.** Use a separate `scenes/*.scene` file for each distinct screen — menu/title, each level, game-over, etc. — not one mega-scene. Each stays independently editable in the editor. Switch at runtime with `scene.loadFromFile('gameover.scene')` (reads the file and transitions) on play / win / level change. NEVER `import` a `.scene` file as a module — scene files are data, not modules; use `scene.readFromFile` / `scene.loadFromFile`.
64
63
 
@@ -174,25 +173,42 @@ Template `components` keys are behavior names (the `static behaviorName`); add a
174
173
  ```
175
174
 
176
175
  - **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:
176
+ - **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
177
 
179
178
  ```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
179
+ scene.getActor('music').runtime.soundPlayer.play();
180
+ await actor.runtime.soundPlayer.play(); // ...and wait for it to finish
181
+ actor.runtime.soundPlayer.stop(); // stream also: pause(), resume(), .playing
184
182
  ```
185
183
 
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.
184
+ **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
185
 
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
- ```
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.
194
187
  - **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
188
 
189
+ ## Sound
190
+
191
+ Two ways to make a noise. The choice is about LENGTH, not importance.
192
+
193
+ **Sound effects — `scene.sound.play(name)`.** No actor, no component, no import:
194
+
195
+ ```jsx
196
+ scene.sound.play('jump'); // by filename, without the extension
197
+ scene.sound.play('assets/sounds/hit.wav'); // ...or by full path
198
+ scene.sound.play('hit', { volume: 0.5, pan: -0.8, playbackRate: 1.2 });
199
+ scene.sound.stopAll(); // everything the scene is playing, off
200
+ ```
201
+
202
+ 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.
203
+
204
+ `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`.
205
+
206
+ 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.
207
+
208
+ **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.
209
+
210
+ 128 effects can sound at once; past that the oldest is cut off to make room.
211
+
196
212
  ## Physics
197
213
 
198
214
  This kit simulates 2D physics with matter-js. You get gravity, collisions,
@@ -460,7 +476,7 @@ Don't hand-write pixel grids. Generate sprites with the `draw` command: **emit a
460
476
  ```
461
477
 
462
478
  writes `drawings/ship.sprite`. You can also pass `--from file.svg` instead of stdin. An undecodable svg (no usable rects) errors with a nonzero exit and writes nothing.
463
- - **After writing, `npm run restart`.** Newly created files aren't in the kit's static glob until a restart; until then the missing-sprite fallback renders the placeholder (so an actor pointing at a not-yet-drawn `.sprite` is still playable).
479
+ - **A newly created file is not in the kit's static glob until the panel reloads**, so until then the missing-sprite fallback renders the placeholder -- an actor pointing at a not-yet-drawn `.sprite` is still playable. Do not force a reload to fix it.
464
480
 
465
481
  Point a `Sprite` component's `file` at the generated `drawings/<name>.sprite` to put the art on an actor.
466
482
 
@@ -491,6 +507,7 @@ To generate path art, emit an svg of **shapes** rather than per-pixel rects and
491
507
  - `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
508
  - `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
509
  - `scene.despawnActor(id)` — remove an actor at runtime. Use this; don't `splice` + `delete` by hand.
510
+ - `scene.sound` — the deck's sound library: `play(name, opts)`, `stopAll()`, `isLoaded`, `whenLoaded`, `has(name)`, `voiceCount`. See `## Sound`.
494
511
  - `scene.status` — string you can set/read for game-state ('playing', 'gameover', ...).
495
512
  - `scene.load(sceneData)` — replace the running scene with the given scene data object.
496
513
  - `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-21T22:11:52.086Z"
135
135
  }
@@ -1,26 +1,35 @@
1
1
  import React from 'react';
2
2
  import { Lifecycle } from 'castle-web-sdk';
3
- import { parseJsonFile } from '../engine/files';
4
- import { useLiveDeckFiles } from '../engine/liveReload';
3
+ import { initialFiles, parseJsonFile } from '../engine/files';
5
4
  import { collectAssets } from '../engine/assets';
6
5
  import { ScenePlayer } from '../engine/ScenePlayer';
7
6
  import { behaviorClasses } from './behaviorRegistry';
8
7
  // Play-mode entry point. Plays the scene named by `?scene=<path>` (the shell's
9
8
  // Play panel sets this), defaulting to `scenes/main.scene`. It renders ONLY the
10
9
  // game — the scene picker lives in the Play panel chrome (the shell), not here,
11
- // so nothing floats over the game surface and steals input. Files are live:
12
- // scene/drawing edits re-key the player against fresh data.
10
+ // so nothing floats over the game surface and steals input.
11
+ //
12
+ // Nothing is folded into a run in progress -- not a drawing, not a scene. A
13
+ // file changing on disk can always have altered how the run would have gone up
14
+ // to this point, so applying one mid-run shows a state the deck could never
15
+ // have reached.
16
+ //
17
+ // Getting current is therefore always a whole-panel reload, and the shell owns
18
+ // when: it reloads Play on arrival if changes landed while it was hidden, and
19
+ // offers the restart button when it is the panel you are looking at. A reload
20
+ // remounts this and re-runs the build-time glob, so code and data arrive
21
+ // together.
13
22
  const DEFAULT_SCENE = 'scenes/main.scene';
14
23
 
15
24
  export function PlayOnly({ initialScene } = {}) {
16
- const { files, dataVersion } = useLiveDeckFiles();
25
+ const files = initialFiles;
17
26
  const scenePath = initialScene && files[initialScene] !== undefined ? initialScene : DEFAULT_SCENE;
18
27
  const { value: sceneData } = parseJsonFile(scenePath, files[scenePath] ?? '');
19
28
  if (!sceneData) return null;
20
29
  const { sprites } = collectAssets(files);
21
30
  return (
22
31
  <ScenePlayer
23
- key={`${scenePath}:${dataVersion}`}
32
+ key={scenePath}
24
33
  sceneData={sceneData}
25
34
  sprites={sprites}
26
35
  files={files}