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
@@ -171,8 +171,19 @@
171
171
  each owned by exactly one element (no two borders meet), so there are no
172
172
  doubled/thicker seams. The grid's outer top is the toolbar's existing bottom
173
173
  divider; outer right/bottom come from the last cells. */
174
- .corner {
174
+ /* Shared sticky left-column width: corner and layer heads must match or
175
+ frame columns drift out of alignment. Content may not force growth
176
+ (min-width:auto would otherwise expand past the flex basis). */
177
+ .corner,
178
+ .layerHead {
175
179
  flex: 0 0 168px;
180
+ width: 168px;
181
+ min-width: 168px;
182
+ max-width: 168px;
183
+ box-sizing: border-box;
184
+ }
185
+
186
+ .corner {
176
187
  border-right: 1px solid var(--castle-inspector-divider);
177
188
  border-bottom: 1px solid var(--castle-inspector-divider);
178
189
  background: var(--castle-workspace-bg);
@@ -225,11 +236,10 @@
225
236
  }
226
237
 
227
238
  .layerHead {
228
- flex: 0 0 168px;
229
239
  display: flex;
230
240
  flex-direction: column;
231
241
  justify-content: center;
232
- gap: 4px;
242
+ gap: 0;
233
243
  padding: 4px 8px;
234
244
  border-right: 1px solid var(--castle-inspector-divider);
235
245
  border-bottom: 1px solid var(--castle-inspector-divider);
@@ -250,6 +260,12 @@
250
260
  gap: 6px;
251
261
  }
252
262
 
263
+ .layerBottomRow {
264
+ display: flex;
265
+ align-items: center;
266
+ gap: 6px;
267
+ }
268
+
253
269
  .visBtn {
254
270
  flex: 0 0 auto;
255
271
  width: 22px;
@@ -305,7 +321,9 @@
305
321
  /* Slim opacity slider: a thin custom track + small thumb so the layer header
306
322
  cell stays no taller than the art (thumbnail) cells in its row. */
307
323
  .opacity {
308
- width: 100%;
324
+ flex: 1 1 auto;
325
+ min-width: 0;
326
+ width: auto;
309
327
  height: 12px;
310
328
  margin: 0;
311
329
  cursor: pointer;
@@ -390,6 +408,13 @@
390
408
  display: block;
391
409
  }
392
410
 
411
+ .thumbCanvasSmooth {
412
+ width: 100%;
413
+ height: 100%;
414
+ image-rendering: auto;
415
+ display: block;
416
+ }
417
+
393
418
  /* Empty/absent cell: plain and empty. The cell's own grid separators (see the
394
419
  .corner/.cell border rules) still bound it; there's no dashed interior. */
395
420
  .empty {
@@ -427,7 +452,8 @@
427
452
  /* Fade only the linked cell's thumbnail IMAGE (the canvas) to signal a shared
428
453
  image. Scoped to the canvas inside a linked thumb, so the blue ring
429
454
  (.linkRing box-shadow) and the link badge (.linkBadge) stay full opacity. */
430
- .linkRing .thumbCanvas {
455
+ .linkRing .thumbCanvas,
456
+ .linkRing .thumbCanvasSmooth {
431
457
  opacity: 0.5;
432
458
  }
433
459
 
@@ -74,6 +74,7 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
74
74
  const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onLoopError);
75
75
  return () => {
76
76
  stopLoop();
77
+ runtime.dispose();
77
78
  window.removeEventListener('keydown', onKeyDown);
78
79
  window.removeEventListener('keyup', onKeyUp);
79
80
  canvas.removeEventListener('pointerdown', onPointerDown);
@@ -0,0 +1,105 @@
1
+ // "Art" is anything a Sprite can draw: a parsed `.pxart` sprite, or a raster
2
+ // image file (png / jpg / gif / webp / svg) the deck uploaded. Both shapes are
3
+ // handed around in the same `scene.sprites` map, so everything downstream --
4
+ // the Sprite behavior, collider auto-fit, blueprint thumbnails, the editor's
5
+ // empty-actor placeholder -- is written once against this module instead of
6
+ // twice against two formats.
7
+ //
8
+ // An image art carries the same fields a pxart sprite exposes to those readers
9
+ // (`resolution`, `frames`, `tags`, `cornerRadius`), so a reader that only needs
10
+ // geometry or a frame count needs no branch at all. The two functions that
11
+ // really do differ -- rendering a frame, and how many frames there are -- are
12
+ // `renderArtFrame` / `artFrameCount` here.
13
+
14
+ import { frameCount, renderSpriteFrame } from './pxart';
15
+
16
+ // An image is one frame, held for as long as anyone asks (a still).
17
+ const STILL_FRAME = Object.freeze({ durationMs: 0 });
18
+ // Before the browser has decoded the file there is no size to lay out against.
19
+ // Zero, rather than a guess, so a sprite drawn early is skipped rather than
20
+ // drawn at the wrong aspect ratio and snapping into place a frame later.
21
+ const PENDING_SIZE = Object.freeze({ width: 0, height: 0 });
22
+
23
+ // Image arts by URL. Identity has to be stable: the asset map is rebuilt on
24
+ // every editor render, and a fresh object each time would restart the download
25
+ // and miss every cache keyed on the art (frame canvases, opaque bounds).
26
+ const imageArts = new Map();
27
+
28
+ const listeners = new Set();
29
+
30
+ // Called when an image finishes decoding. The play loop and the editor's canvas
31
+ // redraw every frame and pick it up on their own; this is for the surfaces that
32
+ // only draw when something changes (blueprint thumbnails).
33
+ export function onArtLoaded(listener) {
34
+ listeners.add(listener);
35
+ return () => listeners.delete(listener);
36
+ }
37
+
38
+ export function imageArt(path, url) {
39
+ const existing = imageArts.get(url);
40
+ if (existing) return existing;
41
+ const image = new Image();
42
+ const art = {
43
+ kind: 'image',
44
+ path,
45
+ url,
46
+ image,
47
+ ready: false,
48
+ failed: false,
49
+ resolution: PENDING_SIZE,
50
+ // pxart-shaped fields, so readers that only want geometry or frame counts
51
+ // don't have to know which kind of art they were handed.
52
+ frames: [STILL_FRAME],
53
+ layers: [],
54
+ palette: [],
55
+ tags: [],
56
+ defaultTag: '',
57
+ defaultDurationMs: 0,
58
+ cornerRadius: 0,
59
+ };
60
+ image.onload = () => {
61
+ art.resolution = { width: image.naturalWidth, height: image.naturalHeight };
62
+ art.ready = art.resolution.width > 0 && art.resolution.height > 0;
63
+ for (const listener of listeners) listener();
64
+ };
65
+ image.onerror = () => {
66
+ art.failed = true;
67
+ for (const listener of listeners) listener();
68
+ };
69
+ image.src = url;
70
+ imageArts.set(url, art);
71
+ return art;
72
+ }
73
+
74
+ export function isImageArt(art) {
75
+ return Boolean(art) && art.kind === 'image';
76
+ }
77
+
78
+ // False while an image is still downloading (or after it failed). Callers use
79
+ // it to skip work that would otherwise be cached against an empty render --
80
+ // there is nothing wrong with the art, it just isn't here yet.
81
+ export function artReady(art) {
82
+ return isImageArt(art) ? art.ready : Boolean(art);
83
+ }
84
+
85
+ export function artFrameCount(art) {
86
+ return isImageArt(art) ? 1 : frameCount(art);
87
+ }
88
+
89
+ // Draw one frame into `canvas` at the art's native resolution, resizing it to
90
+ // match. Returns false when there was nothing to draw yet (an image still
91
+ // loading), so callers don't cache the blank result.
92
+ export function renderArtFrame(art, frameIndex, canvas) {
93
+ if (!isImageArt(art)) {
94
+ renderSpriteFrame(art, frameIndex, canvas);
95
+ return true;
96
+ }
97
+ if (!art.ready) return false;
98
+ canvas.width = art.resolution.width;
99
+ canvas.height = art.resolution.height;
100
+ const ctx = canvas.getContext('2d');
101
+ if (!ctx) return false;
102
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
103
+ ctx.drawImage(art.image, 0, 0);
104
+ return true;
105
+ }
@@ -1,12 +1,20 @@
1
1
  import { parseFull } from './pxart';
2
+ import { imageArt } from './art';
3
+ import { initialMedia, isImageFile } from './files';
2
4
 
3
- // Build the runtime asset map from a path->text file map: parsed `.pxart`
4
- // Sprites keyed by file path (nulls skipped). Shared by the editor App and the
5
- // play-only entry point so both hand the SceneRuntime the same map.
5
+ // Build the runtime asset map from a path->text file map: everything a Sprite
6
+ // can draw, keyed by file path. Two sources feed one map -- `.pxart` sprites
7
+ // parsed from the text files, and image files from the media manifest (which is
8
+ // URLs, not text, so it comes straight from the bundler rather than through the
9
+ // live file map). Shared by the editor App and the play-only entry point so both
10
+ // hand the SceneRuntime the same map.
6
11
  export function collectAssets(files) {
7
12
  const sprites = {};
13
+ for (const [path, url] of Object.entries(initialMedia)) {
14
+ if (isImageFile(path)) sprites[path] = imageArt(path, url);
15
+ }
8
16
  for (const [path, text] of Object.entries(files)) {
9
- if (path.endsWith('.pxart')) {
17
+ if (path.endsWith('.sprite') || path.endsWith('.pxart')) {
10
18
  const sprite = parseFull(text);
11
19
  if (sprite) sprites[path] = sprite;
12
20
  }
@@ -0,0 +1,34 @@
1
+ // One WebAudio context for the whole deck, made on first use.
2
+ //
3
+ // Most decks never need one: a `<audio>` element plays on its own, and that is
4
+ // the whole Sound behavior until someone asks for something the element can't do
5
+ // -- panning a sound in space, or synthesizing a note that was never a file.
6
+ // Those go through here.
7
+ //
8
+ // A context starts SUSPENDED until the page has been interacted with, exactly
9
+ // like autoplay, so `resumeAudioContext` is called from the same unlock path
10
+ // that retries a refused play (engine/media.js).
11
+
12
+ let context = null;
13
+
14
+ export function getAudioContext() {
15
+ if (context) return context;
16
+ const Ctor = window.AudioContext || window.webkitAudioContext;
17
+ if (!Ctor) return null;
18
+ context = new Ctor();
19
+ return context;
20
+ }
21
+
22
+ // The context, only if one already exists -- for callers that shouldn't create
23
+ // one just by asking (the unlock handler, teardown).
24
+ export function existingAudioContext() {
25
+ return context;
26
+ }
27
+
28
+ export function resumeAudioContext() {
29
+ if (context && context.state === 'suspended') {
30
+ context.resume().catch(() => {
31
+ /* still blocked; the next gesture tries again */
32
+ });
33
+ }
34
+ }
@@ -372,16 +372,16 @@ export function blankPxArtText() {
372
372
  return serializeCompact({ palette: {}, grid });
373
373
  }
374
374
 
375
- // `drawings/drawing-N.pxart` -- next free numbered slug, so repeated adds don't
375
+ // `drawings/drawing-N.sprite` -- next free numbered slug, so repeated adds don't
376
376
  // collide and a deleted drawing's number isn't reused.
377
377
  function mintDrawingPath(files) {
378
- const re = new RegExp(`^${DRAWINGS_DIR}/drawing-(\\d+)\\.pxart$`);
378
+ const re = new RegExp(`^${DRAWINGS_DIR}/drawing-(\\d+)\\.(?:pxart|sprite)$`);
379
379
  let max = 0;
380
380
  for (const path of Object.keys(files ?? {})) {
381
381
  const m = path.match(re);
382
382
  if (m) max = Math.max(max, Number(m[1]));
383
383
  }
384
- return `${DRAWINGS_DIR}/drawing-${max + 1}.pxart`;
384
+ return `${DRAWINGS_DIR}/drawing-${max + 1}.sprite`;
385
385
  }
386
386
 
387
387
  // The z that draws on top of every current actor: max effective z + 1 (0 for
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // Shapes are in the UNROTATED box frame; the draw pipeline (scene.js) and the
18
18
  // matter body (matterBridge) apply Layout.rotation, so nothing here rotates.
19
- import { frameCount, renderSpriteFrame } from './pxart';
19
+ import { artFrameCount, artReady, renderArtFrame } from './art';
20
20
  import { spriteDestRect } from './spriteGeometry';
21
21
 
22
22
  // ---------------------------------------------------------------------------
@@ -41,13 +41,17 @@ function accumulateOpaquePixels(acc, ctx, canvas) {
41
41
 
42
42
  function opaqueBoundsFraction(sprite) {
43
43
  if (opaqueBoundsCache.has(sprite)) return opaqueBoundsCache.get(sprite);
44
+ // An image that hasn't decoded has no pixels to scan yet. Report "unknown"
45
+ // without caching, so the answer is computed once it does rather than a
46
+ // never-revisited null.
47
+ if (!artReady(sprite)) return undefined;
44
48
  const { width, height } = sprite.resolution;
45
49
  const canvas = document.createElement('canvas');
46
50
  const ctx = canvas.getContext('2d');
47
51
  const acc = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
48
- const total = frameCount(sprite);
52
+ const total = artFrameCount(sprite);
49
53
  for (let frame = 0; frame < total; frame++) {
50
- renderSpriteFrame(sprite, frame, canvas);
54
+ renderArtFrame(sprite, frame, canvas);
51
55
  accumulateOpaquePixels(acc, ctx, canvas);
52
56
  }
53
57
  const bounds =
@@ -63,7 +67,9 @@ function opaqueBoundsFraction(sprite) {
63
67
  return bounds;
64
68
  }
65
69
 
66
- // True when a resolved sprite has no opaque pixels across any frame.
70
+ // True when a resolved sprite has no opaque pixels across any frame. An image
71
+ // still decoding is NOT empty -- it just isn't here yet, and flashing the
72
+ // editor's placeholder box under it while it loads would be a lie.
67
73
  export function spriteIsEmpty(sprite) {
68
74
  return !sprite || opaqueBoundsFraction(sprite) === null;
69
75
  }
@@ -160,21 +166,26 @@ export function getColliderShape(actor) {
160
166
  return shapes && shapes.length ? shapes[0] : null;
161
167
  }
162
168
 
169
+ // Bounding box of a point list, as [x0, y0, x1, y1]. Shared with the physics
170
+ // bridge, which needs the same box to fall back to when matter can't build a
171
+ // body from the vertices.
172
+ export function pointsBounds(points) {
173
+ let x0 = Infinity;
174
+ let y0 = Infinity;
175
+ let x1 = -Infinity;
176
+ let y1 = -Infinity;
177
+ for (const p of points) {
178
+ if (p.x < x0) x0 = p.x;
179
+ if (p.y < y0) y0 = p.y;
180
+ if (p.x > x1) x1 = p.x;
181
+ if (p.y > y1) y1 = p.y;
182
+ }
183
+ return [x0, y0, x1, y1];
184
+ }
185
+
163
186
  function shapeAabb(s) {
164
187
  if (s.type === 'circle') return [s.cx - s.radius, s.cy - s.radius, s.cx + s.radius, s.cy + s.radius];
165
- if (s.type === 'triangle' || s.type === 'polygon') {
166
- let x0 = Infinity;
167
- let y0 = Infinity;
168
- let x1 = -Infinity;
169
- let y1 = -Infinity;
170
- for (const p of s.points) {
171
- if (p.x < x0) x0 = p.x;
172
- if (p.y < y0) y0 = p.y;
173
- if (p.x > x1) x1 = p.x;
174
- if (p.y > y1) y1 = p.y;
175
- }
176
- return [x0, y0, x1, y1];
177
- }
188
+ if (s.type === 'triangle' || s.type === 'polygon') return pointsBounds(s.points);
178
189
  const ang = ((s.angle ?? 0) * Math.PI) / 180;
179
190
  if (!ang) return [s.x, s.y, s.x + s.width, s.y + s.height];
180
191
  // AABB of a box rotated about its center: project the half-extents onto the axes.
@@ -19,12 +19,13 @@ const rawModules = {
19
19
  '/imports/*/scenes/**/*.scene',
20
20
  '/imports/*/blueprints/**/*.scene',
21
21
  '/imports/*/drawings/**/*.pxart',
22
+ '/imports/*/drawings/**/*.sprite',
22
23
  '/imports/*/behaviors/**/*.jsx',
23
24
  ],
24
25
  { query: '?raw', import: 'default', eager: true }
25
26
  ),
26
27
  ...import.meta.glob(
27
- ['/scenes/**/*.scene', '/blueprints/**/*.scene', '/drawings/**/*.pxart', '/behaviors/**/*.jsx'],
28
+ ['/scenes/**/*.scene', '/blueprints/**/*.scene', '/drawings/**/*.pxart', '/drawings/**/*.sprite', '/behaviors/**/*.jsx'],
28
29
  { query: '?raw', import: 'default', eager: true }
29
30
  ),
30
31
  };
@@ -33,9 +34,84 @@ export const initialFiles = Object.fromEntries(
33
34
  .map(([globPath, text]) => [globPath.replace(/^\//, ''), text])
34
35
  .sort(([a], [b]) => a.localeCompare(b))
35
36
  );
37
+
38
+ // Media files -- images, sounds, videos -- as path -> URL. These are BYTES, not
39
+ // text: the bundler hands back a URL (a real one while serving, a data: URI in a
40
+ // saved deck, which is why loading one through this rather than fetching a path
41
+ // keeps working once the deck is a single file).
42
+ //
43
+ // Unlike the text globs above these aren't scoped to a folder: media arrives by
44
+ // upload and lands wherever the person uploading put it, so the pattern is the
45
+ // whole deck minus what is never deck content. (Both patterns and options must
46
+ // be literals -- import.meta.glob is a compile-time transform.)
47
+ const mediaModules = import.meta.glob(
48
+ [
49
+ '/**/*.{png,jpg,jpeg,gif,webp,svg,mp3,wav,m4a,mp4}',
50
+ '!/node_modules/**',
51
+ '!/dist/**',
52
+ '!/.castle/**',
53
+ ],
54
+ { eager: true, import: 'default' }
55
+ );
56
+ export const initialMedia = Object.fromEntries(
57
+ Object.entries(mediaModules)
58
+ .map(([globPath, url]) => [globPath.replace(/^\//, ''), url])
59
+ .sort(([a], [b]) => a.localeCompare(b))
60
+ );
61
+
62
+ // What a media path is, by extension.
63
+ //
64
+ // This list is deliberately short. A deck is published as a single file, where
65
+ // every asset becomes a `data:` URI -- and a data URI's declared type is taken
66
+ // at face value, unlike a served file whose bytes the browser will sniff. So a
67
+ // format the browser merely tolerates when served can fail once published, and
68
+ // a format one browser plays can be silently dead on another. Both failures land
69
+ // on the player, after the creator has stopped looking.
70
+ //
71
+ // So: h264/aac mp4 for video, mp3/wav/m4a for audio, and the image formats every
72
+ // browser has agreed on for a decade. Anything else is something to convert, and
73
+ // castle.json says so by name (see the `unsupported` file types there).
74
+ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
75
+ const AUDIO_EXTS = ['.mp3', '.wav', '.m4a'];
76
+ const VIDEO_EXTS = ['.mp4'];
77
+
78
+ const hasExt = (path, exts) => exts.some((ext) => path.toLowerCase().endsWith(ext));
79
+
80
+ export function isImageFile(path) {
81
+ return hasExt(path, IMAGE_EXTS);
82
+ }
83
+ export function isAudioFile(path) {
84
+ return hasExt(path, AUDIO_EXTS);
85
+ }
86
+ export function isVideoFile(path) {
87
+ return hasExt(path, VIDEO_EXTS);
88
+ }
89
+
90
+ // Deck-relative paths of every media file of one kind, sorted. What the
91
+ // inspectors' file pickers list.
92
+ export function mediaFilesOfKind(kind) {
93
+ const matches = { image: isImageFile, audio: isAudioFile, video: isVideoFile }[kind];
94
+ return Object.keys(initialMedia).filter((path) => matches(path));
95
+ }
96
+ // Which editor a file opens in. The image / audio kinds are VIEWERS -- an
97
+ // uploaded asset is content this kit shows but doesn't edit (art you can edit is
98
+ // `.pxart`). Each extension gets its own line: the serve parses this function to
99
+ // learn which extensions the kit owns an editor for, and only matches one
100
+ // `endsWith` per `return`.
36
101
  export function getFileKind(path) {
37
102
  if (path.endsWith('.scene')) return 'scene';
103
+ if (path.endsWith('.sprite')) return 'pxart';
38
104
  if (path.endsWith('.pxart')) return 'pxart';
105
+ if (path.endsWith('.png')) return 'image';
106
+ if (path.endsWith('.jpg')) return 'image';
107
+ if (path.endsWith('.jpeg')) return 'image';
108
+ if (path.endsWith('.gif')) return 'image';
109
+ if (path.endsWith('.webp')) return 'image';
110
+ if (path.endsWith('.svg')) return 'image';
111
+ if (path.endsWith('.mp3')) return 'audio';
112
+ if (path.endsWith('.wav')) return 'audio';
113
+ if (path.endsWith('.m4a')) return 'audio';
114
+ if (path.endsWith('.mp4')) return 'video';
39
115
  if (path.endsWith('.js') || path.endsWith('.jsx')) return 'code';
40
116
  return 'text';
41
117
  }
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react';
9
9
  import { onFilesChanged, requestReload } from 'castle-web-sdk';
10
10
  import { initialFiles } from './files';
11
11
 
12
- const DATA_EXTS = ['.scene', '.pxart', '.drawing'];
12
+ const DATA_EXTS = ['.scene', '.pxart', '.sprite', '.drawing'];
13
13
  const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
14
14
 
15
15
  export function isDataFile(path) {
@@ -0,0 +1,212 @@
1
+ // The pool of playing media elements: one `<audio>` / `<video>` per actor per
2
+ // file. Behaviors (Sound, Video) ask for theirs each frame and set the state
3
+ // they want on it; this module owns creating, reusing, and reaping them.
4
+ //
5
+ // The pool is MODULE-level, not per-SceneRuntime, because the editor builds a
6
+ // throwaway runtime every frame to draw the edit preview -- a per-runtime pool
7
+ // would mean a new <video> element sixty times a second. Keyed by actor + file
8
+ // instead, entries survive that churn and are reaped by disuse: anything not
9
+ // asked for in the last few frames belongs to an actor that was despawned, a
10
+ // scene that was left, or a file prop that changed, and gets stopped and
11
+ // dropped. `stopAll` is the hard version, for leaving play mode.
12
+ //
13
+ // Nothing here plays anything on its own. A behavior's `update` only runs in
14
+ // play mode, so a deck's sound stays silent in the editor by construction; the
15
+ // editor's video preview draws a paused element's first frame.
16
+
17
+ import { initialMedia } from './files';
18
+ import { getAudioContext, resumeAudioContext } from './audioContext';
19
+
20
+ // Reaped after this long without being asked for. Long enough to survive a
21
+ // scene's worth of frames where an actor is briefly not drawn (offscreen,
22
+ // behind a `playing: false`), short enough that a despawned actor's sound stops
23
+ // promptly.
24
+ const IDLE_REAP_MS = 1500;
25
+
26
+ const entries = new Map();
27
+ // One-shot plays: copies of an element that overlap the original instead of
28
+ // cutting it off (a sound effect fired again before the last one finished).
29
+ // They are not keyed by anything -- they exist until they end.
30
+ const oneShots = new Set();
31
+ // Elements routed through WebAudio for panning, and their panner nodes. An
32
+ // element can only be connected to the graph ONCE, so this is also the record
33
+ // of which ones already are.
34
+ const panners = new WeakMap();
35
+ // Elements whose play() was refused for want of a user gesture. The browser
36
+ // blocks audio until someone has interacted with the page, and a deck's first
37
+ // sound is often on load, so they're retried on the first real input.
38
+ const blocked = new Set();
39
+ let unlockBound = false;
40
+
41
+ function now() {
42
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
43
+ }
44
+
45
+ // Resolve a deck-relative media path to the URL the bundler gave it. Returns
46
+ // null for a path the deck doesn't have, which is how a cleared or misspelled
47
+ // file prop stays silent instead of throwing every frame.
48
+ export function mediaUrl(path) {
49
+ return (path && initialMedia[path]) || null;
50
+ }
51
+
52
+ function bindUnlock() {
53
+ if (unlockBound || typeof window === 'undefined') return;
54
+ unlockBound = true;
55
+ const retry = () => {
56
+ resumeAudioContext();
57
+ for (const element of blocked) {
58
+ element.play().then(
59
+ () => blocked.delete(element),
60
+ () => {
61
+ /* still refused -- keep it queued for the next gesture */
62
+ }
63
+ );
64
+ }
65
+ };
66
+ window.addEventListener('pointerdown', retry, { passive: true });
67
+ window.addEventListener('keydown', retry, { passive: true });
68
+ }
69
+
70
+ // Play, tolerating the autoplay policy: a refused play is queued for the next
71
+ // user gesture rather than thrown away or logged every frame.
72
+ export function playMedia(element) {
73
+ if (!element.paused) return;
74
+ bindUnlock();
75
+ const started = element.play();
76
+ if (started && typeof started.catch === 'function') {
77
+ started.then(
78
+ () => blocked.delete(element),
79
+ () => blocked.add(element)
80
+ );
81
+ }
82
+ }
83
+
84
+ // Pan a sound left/right (-1..1). The element has no panning of its own, so the
85
+ // first non-zero pan routes it through a WebAudio graph -- permanently, since a
86
+ // media element can only be connected once. Which is why pan 0 (the default)
87
+ // touches none of this: a deck that doesn't pan never builds an audio graph.
88
+ export function setMediaPan(element, pan) {
89
+ const wanted = Number.isFinite(pan) ? Math.min(1, Math.max(-1, pan)) : 0;
90
+ let node = panners.get(element);
91
+ if (!node) {
92
+ if (wanted === 0) return;
93
+ const ctx = getAudioContext();
94
+ if (!ctx || !ctx.createStereoPanner) return;
95
+ try {
96
+ const source = ctx.createMediaElementSource(element);
97
+ node = ctx.createStereoPanner();
98
+ source.connect(node);
99
+ node.connect(ctx.destination);
100
+ panners.set(element, node);
101
+ } catch {
102
+ // Already connected elsewhere, or the browser refused -- play unpanned.
103
+ return;
104
+ }
105
+ }
106
+ node.pan.value = wanted;
107
+ resumeAudioContext();
108
+ }
109
+
110
+ // Play a copy of this sound, overlapping whatever is already playing. Returns a
111
+ // promise that resolves when the copy finishes, so a caller can wait for it.
112
+ // Copies are never reused: a one-shot is done when it's done.
113
+ export function playOneShot(path, { volume = 1, playbackRate = 1, pan = 0 } = {}) {
114
+ const url = mediaUrl(path);
115
+ if (!url) return Promise.resolve();
116
+ const element = document.createElement('audio');
117
+ element.src = url;
118
+ element.volume = volume;
119
+ element.playbackRate = playbackRate;
120
+ oneShots.add(element);
121
+ const finished = new Promise((resolve) => {
122
+ const done = () => {
123
+ oneShots.delete(element);
124
+ element.removeAttribute('src');
125
+ resolve();
126
+ };
127
+ element.addEventListener('ended', done, { once: true });
128
+ element.addEventListener('error', done, { once: true });
129
+ });
130
+ if (pan !== 0) setMediaPan(element, pan);
131
+ playMedia(element);
132
+ return finished;
133
+ }
134
+
135
+ export function stopMedia(element) {
136
+ blocked.delete(element);
137
+ element.pause();
138
+ try {
139
+ element.currentTime = 0;
140
+ } catch {
141
+ // A element that never loaded has no seekable timeline; nothing to reset.
142
+ }
143
+ }
144
+
145
+ // The element for one actor's media prop, created on first ask. `kind` is
146
+ // 'audio' or 'video'. Returns null when the path names no file in the deck.
147
+ // Asking is also what keeps it alive -- see the reap above.
148
+ export function mediaElement(actorId, kind, path) {
149
+ const url = mediaUrl(path);
150
+ if (!url) return null;
151
+ const key = `${actorId}|${kind}|${path}`;
152
+ const existing = entries.get(key);
153
+ if (existing) {
154
+ existing.usedAt = now();
155
+ return existing.element;
156
+ }
157
+ const element = document.createElement(kind);
158
+ element.src = url;
159
+ element.preload = 'auto';
160
+ element.crossOrigin = 'anonymous';
161
+ if (kind === 'video') {
162
+ // Decode a first frame without playing, so the editor preview and a
163
+ // `playing: false` video show the picture rather than nothing. `playsInline`
164
+ // keeps iOS from taking a played video fullscreen over the deck.
165
+ element.playsInline = true;
166
+ element.muted = true;
167
+ element.addEventListener('loadedmetadata', () => {
168
+ try {
169
+ element.currentTime = 0;
170
+ } catch {
171
+ // Seeking before the stream is ready; the first frame arrives anyway.
172
+ }
173
+ });
174
+ }
175
+ entries.set(key, { element, actorId, usedAt: now() });
176
+ return element;
177
+ }
178
+
179
+ // Stop and drop what no longer belongs to the running scene. Called once a
180
+ // frame by the media system, with the live actor ids.
181
+ //
182
+ // A despawned actor is reaped on the spot -- its sound has to stop the moment
183
+ // the thing making it is gone, not a beat later. Everything else goes by
184
+ // disuse, which is what catches a `file` prop that changed under an actor that
185
+ // is still there.
186
+ export function reapUnusedMedia(liveActorIds) {
187
+ const cutoff = now() - IDLE_REAP_MS;
188
+ for (const [key, entry] of entries) {
189
+ const gone = liveActorIds ? !liveActorIds.has(entry.actorId) : false;
190
+ if (!gone && entry.usedAt >= cutoff) continue;
191
+ stopMedia(entry.element);
192
+ entry.element.removeAttribute('src');
193
+ entries.delete(key);
194
+ }
195
+ }
196
+
197
+ // Stop and drop everything, now: leaving play mode, loading another scene,
198
+ // tearing the player down. Silence has to be immediate here -- a sound that
199
+ // outlives the scene that started it is the worst version of this feature.
200
+ export function stopAllMedia() {
201
+ for (const [, entry] of entries) {
202
+ stopMedia(entry.element);
203
+ entry.element.removeAttribute('src');
204
+ }
205
+ for (const element of oneShots) {
206
+ stopMedia(element);
207
+ element.removeAttribute('src');
208
+ }
209
+ entries.clear();
210
+ oneShots.clear();
211
+ blocked.clear();
212
+ }