castle-web-cli 0.4.114 → 0.4.116

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 (95) hide show
  1. package/dist/agent-prompts.js +2 -2
  2. package/dist/agent.d.ts +9 -9
  3. package/dist/agent.js +590 -589
  4. package/dist/castleJson.d.ts +7 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.d.ts +34 -0
  7. package/dist/editorConfig.js +125 -0
  8. package/dist/ide.js +245 -120
  9. package/dist/imports.d.ts +10 -0
  10. package/dist/imports.js +149 -17
  11. package/dist/init.d.ts +3 -0
  12. package/dist/init.js +113 -88
  13. package/dist/install.d.ts +1 -1
  14. package/dist/install.js +26 -24
  15. package/dist/serve.js +16 -0
  16. package/dist/shell/assets/index-DiPlPGyg.js +144 -0
  17. package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
  18. package/dist/shell/index.html +2 -2
  19. package/dist/unsupportedMedia.d.ts +1 -0
  20. package/dist/unsupportedMedia.js +54 -0
  21. package/dist/vitePlugins.js +13 -20
  22. package/kits/basic-2d/castle.json +1 -1
  23. package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
  24. package/kits/physics-2d/CLAUDE.md +65 -34
  25. package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
  26. package/kits/physics-2d/behaviors/Collider.jsx +1 -1
  27. package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
  28. package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +5 -5
  29. package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
  30. package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
  31. package/kits/physics-2d/behaviors/Sound.jsx +152 -0
  32. package/kits/physics-2d/behaviors/Sprite.jsx +112 -42
  33. package/kits/physics-2d/behaviors/Tone.jsx +83 -0
  34. package/kits/physics-2d/behaviors/Video.jsx +111 -0
  35. package/kits/physics-2d/behaviors/tint.js +24 -9
  36. package/kits/physics-2d/blueprints/ball.scene +1 -1
  37. package/kits/physics-2d/blueprints/block.scene +1 -1
  38. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  39. package/kits/physics-2d/blueprints/crate.scene +1 -1
  40. package/kits/physics-2d/castle.json +74 -4
  41. package/kits/physics-2d/docs/pxart-format.md +537 -51
  42. package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
  43. package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
  44. package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
  45. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  46. package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
  47. package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
  48. package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
  49. package/kits/physics-2d/editors/brushFit.js +535 -0
  50. package/kits/physics-2d/editors/brushShapes.js +140 -0
  51. package/kits/physics-2d/editors/mediaFile.js +31 -0
  52. package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
  53. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  54. package/kits/physics-2d/editors/pathTools.js +1906 -0
  55. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  56. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  57. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  58. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  59. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  60. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  61. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  62. package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
  63. package/kits/physics-2d/engine/art.js +105 -0
  64. package/kits/physics-2d/engine/assets.js +12 -4
  65. package/kits/physics-2d/engine/audioContext.js +34 -0
  66. package/kits/physics-2d/engine/blueprint.js +3 -3
  67. package/kits/physics-2d/engine/collider.js +28 -17
  68. package/kits/physics-2d/engine/files.js +77 -1
  69. package/kits/physics-2d/engine/liveReload.js +1 -1
  70. package/kits/physics-2d/engine/media.js +212 -0
  71. package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
  72. package/kits/physics-2d/{physics → engine/physics}/jointArt.js +3 -4
  73. package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
  74. package/kits/physics-2d/engine/pxart.js +153 -35
  75. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  76. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  77. package/kits/physics-2d/engine/scene.js +12 -0
  78. package/kits/physics-2d/engine/tone.js +112 -0
  79. package/kits/physics-2d/engine/ui.jsx +22 -1
  80. package/kits/physics-2d/engine/ui.module.css +36 -12
  81. package/kits/physics-2d/package-lock.json +1 -1
  82. package/kits/physics-2d/scripts/draw.mjs +7 -7
  83. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  84. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  85. package/kits/physics-2d/systems/media.js +34 -0
  86. package/kits/physics-2d/systems/physics.js +12 -3
  87. package/package.json +1 -1
  88. package/dist/shell/assets/index-CqpY1xZR.js +0 -144
  89. package/dist/shell/assets/index-DCwVFL5u.css +0 -1
  90. package/kits/physics-2d/physics/index.js +0 -26
  91. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  92. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  93. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
  94. /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
  95. /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
@@ -9,7 +9,7 @@ See `## Physics` below.
9
9
 
10
10
  ## Welcome message
11
11
 
12
- Welcome to an early test of Castle's new engine, with real 2D physics! You're starting with Castle's art and scene editors. Open `cauldron.pxart` or `main.scene` to try them out, or I can start building something for you — a physics toy, a launcher game, a drag-and-fling puzzle, whatever you like.
12
+ Welcome to an early test of Castle's new engine, with real 2D physics! You're starting with Castle's art and scene editors. Open `cauldron.sprite` or `main.scene` to try them out, or I can start building something for you — a physics toy, a launcher game, a drag-and-fling puzzle, whatever you like.
13
13
 
14
14
  Do you already know what you want to make, or do you want to figure it out together?
15
15
 
@@ -21,11 +21,13 @@ Do you already know what you want to make, or do you want to figure it out toget
21
21
  - Game logic lives in `behaviors/*.jsx` classes. A behavior's `static behaviorName` must match the component key used in scene JSON.
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
- - Real game objects and scenery should usually be editable sprites: generate `.pxart` with `npm run draw -- name`, then place it via a `Sprite` component pointing at `drawings/name.pxart`. Dynamic UI/effects stay procedural.
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`).
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.
25
27
  - This kit is plain JavaScript. Use `.jsx` for files with JSX, `.js` otherwise; do not add TypeScript files or a new build step.
26
28
  - After any code, scene, or drawing edit, run `npm run restart`.
27
29
  - Space is reserved by the editor for play/stop; do not bind Space to gameplay.
28
- - Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`) to build a game. Their public API is documented below.
30
+ - 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.
29
31
  - Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
30
32
 
31
33
  ## Files and imports
@@ -33,11 +35,11 @@ Do you already know what you want to make, or do you want to figure it out toget
33
35
  One rule wherever a file is named -- JS imports, `"blueprint"` refs, `Sprite.file`,
34
36
  a joint's `sprite`, anything a behavior invents:
35
37
 
36
- - `drawings/ship.pxart` -- a file of the deck the reference is WRITTEN IN. In this
38
+ - `drawings/ship.sprite` -- a file of the deck the reference is WRITTEN IN. In this
37
39
  deck's own files that means this deck; in a file belonging to an import, that
38
- import. So a kit's blueprint saying `drawings/cauldron.pxart` keeps meaning the
40
+ import. So a kit's blueprint saying `drawings/cauldron.sprite` keeps meaning the
39
41
  kit's drawing once the kit is imported by someone else.
40
- - `@imports/<alias>/drawings/ship.pxart` -- a file of the deck imported under
42
+ - `@imports/<alias>/drawings/ship.sprite` -- a file of the deck imported under
41
43
  `<alias>`. This is the only way to name another deck's file, so cross-deck
42
44
  references are visible as such, and it means the same thing from any file at
43
45
  any depth.
@@ -57,7 +59,7 @@ The deck is already serving when you start (`castle-web init` set that up; see `
57
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.
59
61
  2. **After every edit:** `npm run restart` (no hot reload). The served page refreshes and the user sees the change.
60
- 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 `.pxart` 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.
62
+ 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.
61
63
  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`.
62
64
 
63
65
  Card size is **500 wide × 700 tall** (origin top-left, +y is down).
@@ -124,7 +126,7 @@ A **blueprint** is a `.scene` file under `blueprints/` whose single actor (`acto
124
126
  {
125
127
  "components": {
126
128
  "Layout": { "width": 50, "height": 25 },
127
- "Sprite": { "file": "drawings/brick.pxart" },
129
+ "Sprite": { "file": "drawings/brick.sprite" },
128
130
  "Collider": { "kind": "solid" },
129
131
  "Brick": {}
130
132
  }
@@ -143,8 +145,8 @@ Template `components` keys are behavior names (the `static behaviorName`); add a
143
145
  ## Built-in behaviors
144
146
 
145
147
  - **Layout** — `{ x, y, width, height, z?, rotation? }`. Every actor needs one. `z` orders draw (low first). `rotation` is degrees about the center.
146
- - **Sprite** — `{ file: "drawings/foo.pxart", tint?: "#rrggbbaa", playing?: true, tag?: "", mode?: 'cover'|'fit'|'stretch'|'tile', tileSize?: 50 }`. Renders a `.pxart` pixel-art sprite into the Layout box: scaled up preserving the art's aspect ratio to fill the whole box, cropping whatever overflows a mismatched box (`mode: 'cover'`, the default -- CSS object-fit: cover), scaled preserving the art's aspect ratio and centered so the whole sprite stays visible, letterboxing a mismatched box instead of cropping (`mode: 'fit'` -- CSS object-fit: contain), scaled to fill the box exactly and distorting the art when the aspect ratios don't match (`mode: 'stretch'`), or repeated (`mode: 'tile'`). `tint` multiplies; use white (`#ffffffff`) or omit for the original colors. Animated sprites play automatically; set `playing: false` to hold the first frame, or `tag` to play a named animation tag. Tile mode repeats the art at a fixed cell size (`tileSize` card units tall, width scaled by the art's aspect) instead of stretching it across the Layout box.
147
- - **Missing sprites fall back to a placeholder.** If `file` names a sprite that doesn't exist yet, it renders the fallback `drawings/cauldron.pxart` — so you can give an actor its REAL intended sprite name (`drawings/paddle.pxart`) right away and it shows the placeholder until that file is created. Reference real names from the start; don't wait for the art.
148
+ - **Sprite** — `{ file: "drawings/foo.sprite", tint?: "#rrggbbaa", playing?: true, tag?: "", mode?: 'cover'|'fit'|'stretch'|'tile', tileSize?: 50 }`. Renders the deck's 2D art into the Layout box. `file` is either a `.sprite` pixel-art sprite or an uploaded **image file** (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`) — every prop below works the same on both, except that an image is a single still (no frames or tags) and is drawn smoothed rather than nearest-neighbour, which is what you want for a photo and not for pixel art. Draw modes: scaled up preserving the art's aspect ratio to fill the whole box, cropping whatever overflows a mismatched box (`mode: 'cover'`, the default -- CSS object-fit: cover), scaled preserving the art's aspect ratio and centered so the whole sprite stays visible, letterboxing a mismatched box instead of cropping (`mode: 'fit'` -- CSS object-fit: contain), scaled to fill the box exactly and distorting the art when the aspect ratios don't match (`mode: 'stretch'`), or repeated (`mode: 'tile'`). `tint` multiplies; use white (`#ffffffff`) or omit for the original colors. Animated sprites play automatically; set `playing: false` to hold the first frame, or `tag` to play a named animation tag. Tile mode repeats the art at a fixed cell size (`tileSize` card units tall, width scaled by the art's aspect) instead of stretching it across the Layout box.
149
+ - **Missing sprites fall back to a placeholder.** If `file` names a sprite that doesn't exist yet, it renders the fallback `drawings/cauldron.sprite` — so you can give an actor its REAL intended sprite name (`drawings/paddle.sprite`) right away and it shows the placeholder until that file is created. Reference real names from the start; don't wait for the art.
148
150
  - **Fill the box, don't distort.** The default `mode: 'cover'` fills the Layout box with the art undistorted, cropping whatever overflows when the box aspect doesn't match the art's — so treat the Layout box like an image frame in a design tool: size it to frame what matters and keep the sprite's important content toward the center, since the edges may be cropped. Keeping an actor's Layout aspect ratio close to its sprite's native aspect ratio minimizes how much gets cropped. When the whole sprite must stay visible (nothing is safe to crop), use `mode: 'fit'`, which preserves the art and letterboxes the mismatched box instead. Avoid `mode: 'stretch'` for pixel art — filling a long/tall box by distorting wrecks the pixels (a brick sprite stretched into a wall ruins the bricks). For long surfaces (walls, floors, platforms), use `mode: 'tile'` on one actor to repeat the art at a fixed cell size instead of stretching or cropping a single sprite.
149
151
  - **Collider** — `{ kind: 'solid'|'pickup', mode?: 'auto'|'manual', width, height, offsetX?, offsetY?, debug? }`. Just a rect; `offsetX`/`offsetY` nudge the collider from its default position (so `0,0` is the default). `mode: 'auto'` (the default) fits the rect to the actor's Sprite's opaque pixels, mapped through the Sprite's draw mode (`fit`'s letterboxed dest rect, `stretch`'s full Layout box, `cover`'s filled box clipped to the Layout box; `tile` mode and a missing/unresolved Sprite use the whole Layout box instead). `width`/`height` only apply in `mode: 'manual'` (a rect of that size, centered in the Layout box); they're ignored in `auto`. The framework does NOT auto-resolve collisions for you. Use it as data:
150
152
 
@@ -158,6 +160,24 @@ Template `components` keys are behavior names (the `static behaviorName`); add a
158
160
  ```
159
161
 
160
162
  - **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).
163
+ - **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:
164
+
165
+ ```jsx
166
+ scene.getActor('sfx').runtime.sound.play(); // restarts from the beginning
167
+ await actor.runtime.sound.play(); // ...and wait for it to finish
168
+ actor.runtime.sound.stop(); // also: pause(), resume(), .playing
169
+ scene.sound.stopAll(); // everything the scene is playing, off
170
+ ```
171
+
172
+ 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.
173
+ - **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.
174
+
175
+ ```jsx
176
+ actor.runtime.tone.play(); // the note as configured
177
+ actor.runtime.tone.play({ note: 72 }); // ...or override per play, e.g. a rising scale
178
+ await actor.runtime.tone.play({ note: 48, waveform: 'noise' });
179
+ ```
180
+ - **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.
161
181
 
162
182
  ## Physics
163
183
 
@@ -308,7 +328,7 @@ anchors.
308
328
  prototyping; you see your joints working.
309
329
  - `hidden` — nothing drawn in play (the joint is pure mechanics). It still shows
310
330
  as a faint line in the *editor* so you don't lose track of it.
311
- - `sprite` — draw art along the joint. Set `sprite` to a `.pxart`
331
+ - `sprite` — draw art along the joint. Set `sprite` to a `.sprite`
312
332
  (defaults to a built-in rope), `thickness` (px), and `fit`: `tile` (repeat a
313
333
  segment — rope, chain) or `stretch` (one image end-to-end — stick, beam). The
314
334
  art rotates to the joint's angle and scales to its live length, so a rope
@@ -349,31 +369,30 @@ draw(actor, scene, ctx) {
349
369
  - **A global max-speed clamp (`MAX_SPEED` in matterBridge)** backstops any joint
350
370
  blow-up so a body can't be flung off screen — normal motion never reaches it.
351
371
 
352
- ## Adding physics to another kit
372
+ ## Where physics lives in the kit
353
373
 
354
- The physics module (`physics/`) is self-contained and portable. To add it to a
355
- different kit:
374
+ Physics is a normal part of this kit, not a bolt-on module. It uses the same
375
+ directories as everything else, because the rest of the system only looks in
376
+ those places:
356
377
 
357
- 1. Copy the `physics/` folder into the kit.
358
- 2. Add `matter-js` to the kit's `package.json` dependencies.
359
- 3. In the kit's `engine/scene.js`: add a systems registry to `SceneRuntime`
360
- (`this.systems = []`, a `registerSystem(system)` method, at the end of
361
- `update(dt)` a `for (const s of this.systems) s.afterBehaviors?.(this, dt);`,
362
- and at the START of `load(sceneData)` a
363
- `for (const s of this.systems) s.reset?.(this);` so a reload/restart/scene
364
- transition resets the simulation to the authored layout instead of carrying
365
- over body positions + joints), then call `installPhysics(runtime)` from
366
- `makeScene` (and route `clone()` through `makeScene` so clones get it too).
367
- 4. In the kit's `editors/behaviorRegistry`, also glob
368
- `../physics/behaviors/*.jsx` so the physics behaviors auto-register.
378
+ - `behaviors/` `RigidBody`, `Joints`, `Draggable`, `Slingshot`, `AnalogStick`
379
+ sit alongside `Layout`/`Sprite`/`Collider`/`Camera`. This is the ONE directory
380
+ behaviors live in: the deck file map (`engine/files.js`), the editor's visible
381
+ paths (`castle.json`), the lint config, and the duplicate gate all key off it,
382
+ so a behavior kept anywhere else is invisible to all of them.
383
+ - `engine/physics/` the simulation itself (`PhysicsSystem.js`, `matterBridge.js`,
384
+ `joints.js`, `jointArt.js`, `controls.js`). Internal, like the rest of `engine/`.
385
+ - `systems/physics.js` the installer, auto-discovered by
386
+ `engine/systemRegistry.js` and invoked by `makeScene`, so the shared engine
387
+ never references physics directly.
369
388
 
370
- The module reuses the kit's `engine/collider.js` `getColliderRect(actor,
371
- sprites)` for shape geometry, so a kit needs that (both `basic-2d` and this kit
372
- have it).
389
+ That last seam is the generic one: any kit can add a per-frame system by dropping
390
+ a file in `systems/` that exports `installSystem(runtime)`, with no engine edits.
391
+ Physics is just this kit's use of it.
373
392
 
374
- ## Creating pixel art (`.pxart`)
393
+ ## Creating pixel art (`.sprite`)
375
394
 
376
- Don't hand-write pixel grids. Generate sprites with the `draw` command: **emit a terse svg-rect** — a tiny `<svg viewBox="0 0 16 16">` with **one `<rect>` per pixel** — and pipe it to `npm run draw`. It quantizes every fill to the agent palette and writes a single-frame `drawings/<name>.pxart`.
395
+ Don't hand-write pixel grids. Generate sprites with the `draw` command: **emit a terse svg-rect** — a tiny `<svg viewBox="0 0 16 16">` with **one `<rect>` per pixel** — and pipe it to `npm run draw`. It quantizes every fill to the agent palette and writes a single-frame `drawings/<name>.sprite`.
377
396
 
378
397
  - **Resolution is 16×16.** Use `viewBox="0 0 16 16"` and **one 1×1 `<rect>` per filled pixel** (`width="1" height="1"` at integer `x`/`y`). Leave background pixels out (omitted = transparent). Keep every rect 1×1 — the decoder infers cell size from the smallest rect, so mixing in larger blocks shrinks the whole grid.
379
398
  - **Palette is the agent 16-color subset** (a fixed subset of the editor's full Endesga-64 painting palette) — every `fill` must be one of:
@@ -387,10 +406,22 @@ Don't hand-write pixel grids. Generate sprites with the `draw` command: **emit a
387
406
  printf '<svg viewBox="0 0 16 16"><rect x="7" y="4" width="1" height="1" fill="#ffa214"/><rect x="8" y="4" width="1" height="1" fill="#ffa214"/><rect x="7" y="5" width="1" height="1" fill="#0098dc"/><rect x="8" y="5" width="1" height="1" fill="#0098dc"/></svg>' | npm run draw -- ship
388
407
  ```
389
408
 
390
- writes `drawings/ship.pxart`. You can also pass `--from file.svg` instead of stdin. An undecodable svg (no usable rects) errors with a nonzero exit and writes nothing.
391
- - **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 `.pxart` is still playable).
409
+ 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.
410
+ - **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).
411
+
412
+ Point a `Sprite` component's `file` at the generated `drawings/<name>.sprite` to put the art on an actor.
413
+
414
+ Older decks may still use the `.pxart` extension for the same format; the editor opens and saves both, but **create new art as `.sprite`**.
415
+
416
+ ## Path layers (vector source in `.sprite`)
417
+
418
+ Layers come in two kinds. A **pixel** layer is the grid of palette keys described above. A **path** layer also carries vector source: an ordered list of shapes, each a fill plus an optional stroke over subpaths of cubic segments. Shapes composite in painter's order, so a later shape overwrites an earlier one's pixels, and a shape's own multi-subpath fill resolves even-odd (which is how holes are punched).
419
+
420
+ Only **closed** subpaths bound a fill; an open one only strokes. That is how a shape draws its internal divisions — one outline around the silhouette plus an open subpath per division — rather than as two abutting shapes, which would carry two fill keys to keep in step and a seam where each strokes its own side of the shared border. A division that lies exactly on the outline is redundant, and the editor drops it on the next edit, so draw one only where it departs from the outline.
421
+
422
+ The editor always bakes a path layer's shapes down into the cell's `grid`, so **pixels stay the truth** for the runtime and for anything that doesn't know about paths. Path layers are fully editable in the app — pen, select, bend, punch, flip/rotate, z-order, recolor — and pixel layers behave exactly as they did. Converting a layer's kind is lossy in both directions: Pixel → Path throws away the pixels, Path → Pixel throws away the vector source and keeps the baked grid.
392
423
 
393
- Point a `Sprite` component's `file` at the generated `drawings/<name>.pxart` to put the art on an actor.
424
+ To generate path art, emit an svg of **shapes** rather than per-pixel rects and pipe it to `node scripts/import-svg.mjs <name>`; `scripts/svg-emission-guide.md` covers what the importer accepts. `docs/pxart-format.md` is normative for the on-disk form, the half-cell lattice path points are authored onto (guidance for placement, not a storage rule — coordinates are plain floats), the bake, and the sprite's finish fields.
394
425
 
395
426
  ## SceneRuntime API (what `scene` exposes to behaviors)
396
427
 
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
- import { Panel, SelectField } from '../../engine/ui';
3
- import { AutoFields, overrideProps } from '../../engine/autoInspector';
4
- import { analogVelocity, inActorWorldSpace, pressOnDraggable, stickVector } from '../controls';
2
+ import { Panel, SelectField } from '../engine/ui';
3
+ import { AutoFields, overrideProps } from '../engine/autoInspector';
4
+ import { analogVelocity, inActorWorldSpace, pressOnDraggable, stickVector } from '../engine/physics/controls';
5
5
 
6
6
  // AnalogStick: an on-screen virtual joystick that drives the actor it's on --
7
7
  // the look and feel of castle-client's Analog Stick. Press anywhere to place
@@ -88,7 +88,7 @@ export class Collider {
88
88
  // zone for pickup/goal logic) but doesn't block -- collisions are data.
89
89
  isTrigger: false,
90
90
  debug: false,
91
- // Physics material, read by physics/matterBridge.js. `bounciness` =
91
+ // Physics material, read by engine/physics/matterBridge.js. `bounciness` =
92
92
  // restitution (0 dead .. ~1 very bouncy); `friction` = surface friction;
93
93
  // `density` sets mass (= density x total shape area); matter's default 0.001.
94
94
  bounciness: 0,
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
- import { Panel } from '../../engine/ui';
3
- import { AutoFields } from '../../engine/autoInspector';
4
- import { centerOf, chaseVelocity, inActorWorldSpace } from '../controls';
2
+ import { Panel } from '../engine/ui';
3
+ import { AutoFields } from '../engine/autoInspector';
4
+ import { centerOf, chaseVelocity, inActorWorldSpace } from '../engine/physics/controls';
5
5
 
6
6
  // Draggable: grab the actor with the pointer and fling it around. Chases the
7
7
  // pointer with a springy velocity (via the physics system) so it still collides
@@ -1,8 +1,8 @@
1
1
  import React, { useState } from 'react';
2
- import { Panel, SelectField, NumberField, CheckboxField, Button } from '../../engine/ui';
3
- import { centerOf, inActorWorldSpace } from '../controls';
4
- import { JOINT_TYPES } from '../joints';
5
- import { drawJointArt } from '../jointArt';
2
+ import { Panel, SelectField, NumberField, CheckboxField, Button } from '../engine/ui';
3
+ import { centerOf, inActorWorldSpace } from '../engine/physics/controls';
4
+ import { JOINT_TYPES } from '../engine/physics/joints';
5
+ import { drawJointArt } from '../engine/physics/jointArt';
6
6
 
7
7
  const RENDER_MODES = ['line', 'hidden', 'sprite'];
8
8
  // Bound anchor offsets (px, local frame). A large offset lengthens the pivot's
@@ -72,7 +72,7 @@ export class Joints {
72
72
 
73
73
  static Inspector({ component, setComponent, override, beginPick, pickActive, files }) {
74
74
  const list = Array.isArray(component.list) ? component.list : [];
75
- const spriteFiles = Object.keys(files ?? {}).filter((f) => f.endsWith('.pxart'));
75
+ const spriteFiles = Object.keys(files ?? {}).filter((f) => f.endsWith('.sprite') || f.endsWith('.pxart'));
76
76
  // Which entry's target-pick is live. Combined with the editor's global
77
77
  // `pickActive` so a cancel (Esc/empty click) clears every entry's state.
78
78
  const [pickingIndex, setPickingIndex] = useState(null);
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
- import { Panel, SelectField } from '../../engine/ui';
3
- import { AutoFields, overrideProps } from '../../engine/autoInspector';
2
+ import { Panel, SelectField } from '../engine/ui';
3
+ import { AutoFields, overrideProps } from '../engine/autoInspector';
4
4
 
5
5
  // RigidBody makes an actor a physics object. Pair it with a Collider (which
6
6
  // gives the shape + material). The simulation reads these props each frame; the
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
- import { Panel } from '../../engine/ui';
3
- import { AutoFields } from '../../engine/autoInspector';
4
- import { inActorWorldSpace, pressOnDraggable, slingLaunch, slingPull } from '../controls';
2
+ import { Panel } from '../engine/ui';
3
+ import { AutoFields } from '../engine/autoInspector';
4
+ import { inActorWorldSpace, pressOnDraggable, slingLaunch, slingPull } from '../engine/physics/controls';
5
5
 
6
6
  // Slingshot: pull back and release to fling the actor the opposite way
7
7
  // (angry-birds style) -- the physics feel of castle-client's Sling. While
@@ -0,0 +1,152 @@
1
+ import { resolveDeckFile } from 'castle-web-sdk';
2
+ import React from 'react';
3
+ import { mediaElement, playMedia, playOneShot, setMediaPan, stopMedia } from '../engine/media';
4
+ import { mediaFilesOfKind } from '../engine/files';
5
+ import { Panel, SelectField } from '../engine/ui';
6
+ import { AutoFields, overrideProps } from '../engine/autoInspector';
7
+
8
+ // Plays a sound file (.mp3 / .wav / .m4a) from an actor.
9
+ //
10
+ // Two ways to use it. Set `playOnStart` and it plays when the scene starts --
11
+ // music, ambience, a jingle on a game-over screen. Or leave it off and trigger
12
+ // it from a behavior, through the handle this puts on the actor:
13
+ //
14
+ // scene.getActor('sfx').runtime.sound.play(); // from the top
15
+ // await actor.runtime.sound.play(); // ...and wait for it to end
16
+ // actor.runtime.sound.stop();
17
+ //
18
+ // `play()` on a `polyphonic` sound overlaps copies instead of cutting the last
19
+ // one off, which is what a sound effect fired twice in a row should do. Without
20
+ // it, a second play restarts the one element, which is what music wants.
21
+ //
22
+ // Sound only plays during play, never in the editor: `update` is what wires the
23
+ // element up, and the editor doesn't run it. Pressing Stop silences everything,
24
+ // as does `scene.sound.stopAll()` (see systems/media.js).
25
+ //
26
+ // Browsers refuse to play audio until the player has touched the page. A sound
27
+ // that starts before that isn't lost -- it's queued and starts on the first tap
28
+ // or key (engine/media.js), which for a game is usually the first input anyway.
29
+ export class Sound {
30
+ static behaviorName = 'Sound';
31
+
32
+ static defaultProps = {
33
+ file: '',
34
+ volume: 1,
35
+ playbackRate: 1,
36
+ pan: 0,
37
+ loop: false,
38
+ polyphonic: false,
39
+ playOnStart: true,
40
+ };
41
+
42
+ constructor(props) {
43
+ this.props = props;
44
+ }
45
+
46
+ update(actor) {
47
+ if (!actor.runtime || actor.runtime.collected) return;
48
+ const file = resolveDeckFile(this.props.file);
49
+ const element = mediaElement(actor.id, 'audio', file);
50
+ if (!element) {
51
+ actor.runtime.sound = null;
52
+ return;
53
+ }
54
+ // Props are live: turning the volume down, the pitch up, or the loop on in
55
+ // the inspector takes effect on the sound already playing.
56
+ const settings = readSettings(this.props);
57
+ element.volume = settings.volume;
58
+ element.playbackRate = settings.playbackRate;
59
+ element.loop = settings.loop;
60
+ if (settings.pan !== 0) setMediaPan(element, settings.pan);
61
+
62
+ const handle = actor.runtime.sound;
63
+ if (!handle || handle.file !== file) {
64
+ actor.runtime.sound = makeHandle(file, element, () => readSettings(this.props));
65
+ if (this.props.playOnStart) void actor.runtime.sound.play();
66
+ } else {
67
+ handle.settings = settings;
68
+ }
69
+ }
70
+
71
+ static Inspector({ component, setComponent, override }) {
72
+ return (
73
+ <Panel title="Sound" overridden={override?.anyOverridden()}>
74
+ <SelectField
75
+ label="File"
76
+ value={component.file}
77
+ onChange={(file) => setComponent({ file })}
78
+ options={mediaFilesOfKind('audio')}
79
+ {...overrideProps(override, 'file')}
80
+ />
81
+ <AutoFields
82
+ defaultProps={Sound.defaultProps}
83
+ component={component}
84
+ setComponent={setComponent}
85
+ only={['volume', 'playbackRate', 'pan', 'loop', 'polyphonic', 'playOnStart']}
86
+ override={override}
87
+ />
88
+ </Panel>
89
+ );
90
+ }
91
+ }
92
+
93
+ function clamp(value, min, max, fallback) {
94
+ return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
95
+ }
96
+
97
+ function readSettings(props) {
98
+ return {
99
+ volume: clamp(props.volume, 0, 1, 1),
100
+ // The same ceiling the classic editor used: past 10x a sound is a click.
101
+ playbackRate: clamp(props.playbackRate, 0.01, 10, 1),
102
+ pan: clamp(props.pan, -1, 1, 0),
103
+ loop: Boolean(props.loop),
104
+ polyphonic: Boolean(props.polyphonic),
105
+ };
106
+ }
107
+
108
+ // What a behavior gets at `actor.runtime.sound`. Plain methods over the element,
109
+ // so game code never has to know an <audio> is involved.
110
+ //
111
+ // `play()` returns a promise that resolves when the sound ends, so a behavior
112
+ // can `await` one before doing the next thing. A looping sound resolves right
113
+ // away rather than never -- awaiting something that by definition doesn't end
114
+ // would hang the caller forever, and that is never what was meant.
115
+ function makeHandle(file, element, currentSettings) {
116
+ const handle = {
117
+ file,
118
+ element,
119
+ settings: currentSettings(),
120
+ play() {
121
+ const s = handle.settings;
122
+ if (s.polyphonic) {
123
+ return playOneShot(file, { volume: s.volume, playbackRate: s.playbackRate, pan: s.pan });
124
+ }
125
+ try {
126
+ element.currentTime = 0;
127
+ } catch {
128
+ // Not seekable yet (still loading) -- it plays from the start anyway.
129
+ }
130
+ playMedia(element);
131
+ if (s.loop) return Promise.resolve();
132
+ return new Promise((resolve) => {
133
+ const done = () => resolve();
134
+ element.addEventListener('ended', done, { once: true });
135
+ element.addEventListener('error', done, { once: true });
136
+ });
137
+ },
138
+ resume() {
139
+ playMedia(element);
140
+ },
141
+ pause() {
142
+ element.pause();
143
+ },
144
+ stop() {
145
+ stopMedia(element);
146
+ },
147
+ get playing() {
148
+ return !element.paused;
149
+ },
150
+ };
151
+ return handle;
152
+ }