castle-web-cli 0.4.113 → 0.4.115

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 (57) hide show
  1. package/dist/castleJson.d.ts +1 -0
  2. package/dist/editorConfig.d.ts +34 -0
  3. package/dist/editorConfig.js +101 -0
  4. package/dist/ide.js +245 -120
  5. package/dist/imports.d.ts +6 -0
  6. package/dist/imports.js +90 -7
  7. package/dist/init.js +15 -2
  8. package/dist/serve.js +16 -0
  9. package/dist/shell/assets/index-CUamb8rK.js +144 -0
  10. package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
  11. package/dist/shell/index.html +2 -2
  12. package/dist/unsupportedMedia.d.ts +1 -0
  13. package/dist/unsupportedMedia.js +54 -0
  14. package/dist/vitePlugins.js +12 -19
  15. package/kits/basic-2d/castle.json +1 -1
  16. package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
  17. package/kits/physics-2d/CLAUDE.md +42 -23
  18. package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
  19. package/kits/physics-2d/behaviors/Collider.jsx +1 -1
  20. package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
  21. package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +4 -4
  22. package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
  23. package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
  24. package/kits/physics-2d/behaviors/Sound.jsx +152 -0
  25. package/kits/physics-2d/behaviors/Sprite.jsx +98 -33
  26. package/kits/physics-2d/behaviors/Tone.jsx +83 -0
  27. package/kits/physics-2d/behaviors/Video.jsx +111 -0
  28. package/kits/physics-2d/behaviors/tint.js +24 -9
  29. package/kits/physics-2d/castle.json +64 -2
  30. package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
  31. package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
  32. package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
  33. package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
  34. package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
  35. package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
  36. package/kits/physics-2d/editors/mediaFile.js +20 -0
  37. package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
  38. package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
  39. package/kits/physics-2d/engine/art.js +105 -0
  40. package/kits/physics-2d/engine/assets.js +11 -3
  41. package/kits/physics-2d/engine/audioContext.js +34 -0
  42. package/kits/physics-2d/engine/collider.js +28 -17
  43. package/kits/physics-2d/engine/files.js +74 -0
  44. package/kits/physics-2d/engine/media.js +212 -0
  45. package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
  46. package/kits/physics-2d/{physics → engine/physics}/jointArt.js +1 -2
  47. package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
  48. package/kits/physics-2d/engine/scene.js +12 -0
  49. package/kits/physics-2d/engine/tone.js +112 -0
  50. package/kits/physics-2d/systems/media.js +34 -0
  51. package/kits/physics-2d/systems/physics.js +12 -3
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-BjOuaDJM.js +0 -144
  54. package/dist/shell/assets/index-CdXEpv_P.css +0 -1
  55. package/kits/physics-2d/physics/index.js +0 -26
  56. /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
  57. /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
@@ -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,10 +1,18 @@
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
17
  if (path.endsWith('.pxart')) {
10
18
  const sprite = parseFull(text);
@@ -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
+ }
@@ -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.
@@ -33,9 +33,83 @@ export const initialFiles = Object.fromEntries(
33
33
  .map(([globPath, text]) => [globPath.replace(/^\//, ''), text])
34
34
  .sort(([a], [b]) => a.localeCompare(b))
35
35
  );
36
+
37
+ // Media files -- images, sounds, videos -- as path -> URL. These are BYTES, not
38
+ // text: the bundler hands back a URL (a real one while serving, a data: URI in a
39
+ // saved deck, which is why loading one through this rather than fetching a path
40
+ // keeps working once the deck is a single file).
41
+ //
42
+ // Unlike the text globs above these aren't scoped to a folder: media arrives by
43
+ // upload and lands wherever the person uploading put it, so the pattern is the
44
+ // whole deck minus what is never deck content. (Both patterns and options must
45
+ // be literals -- import.meta.glob is a compile-time transform.)
46
+ const mediaModules = import.meta.glob(
47
+ [
48
+ '/**/*.{png,jpg,jpeg,gif,webp,svg,mp3,wav,m4a,mp4}',
49
+ '!/node_modules/**',
50
+ '!/dist/**',
51
+ '!/.castle/**',
52
+ ],
53
+ { eager: true, import: 'default' }
54
+ );
55
+ export const initialMedia = Object.fromEntries(
56
+ Object.entries(mediaModules)
57
+ .map(([globPath, url]) => [globPath.replace(/^\//, ''), url])
58
+ .sort(([a], [b]) => a.localeCompare(b))
59
+ );
60
+
61
+ // What a media path is, by extension.
62
+ //
63
+ // This list is deliberately short. A deck is published as a single file, where
64
+ // every asset becomes a `data:` URI -- and a data URI's declared type is taken
65
+ // at face value, unlike a served file whose bytes the browser will sniff. So a
66
+ // format the browser merely tolerates when served can fail once published, and
67
+ // a format one browser plays can be silently dead on another. Both failures land
68
+ // on the player, after the creator has stopped looking.
69
+ //
70
+ // So: h264/aac mp4 for video, mp3/wav/m4a for audio, and the image formats every
71
+ // browser has agreed on for a decade. Anything else is something to convert, and
72
+ // castle.json says so by name (see the `unsupported` file types there).
73
+ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
74
+ const AUDIO_EXTS = ['.mp3', '.wav', '.m4a'];
75
+ const VIDEO_EXTS = ['.mp4'];
76
+
77
+ const hasExt = (path, exts) => exts.some((ext) => path.toLowerCase().endsWith(ext));
78
+
79
+ export function isImageFile(path) {
80
+ return hasExt(path, IMAGE_EXTS);
81
+ }
82
+ export function isAudioFile(path) {
83
+ return hasExt(path, AUDIO_EXTS);
84
+ }
85
+ export function isVideoFile(path) {
86
+ return hasExt(path, VIDEO_EXTS);
87
+ }
88
+
89
+ // Deck-relative paths of every media file of one kind, sorted. What the
90
+ // inspectors' file pickers list.
91
+ export function mediaFilesOfKind(kind) {
92
+ const matches = { image: isImageFile, audio: isAudioFile, video: isVideoFile }[kind];
93
+ return Object.keys(initialMedia).filter((path) => matches(path));
94
+ }
95
+ // Which editor a file opens in. The image / audio kinds are VIEWERS -- an
96
+ // uploaded asset is content this kit shows but doesn't edit (art you can edit is
97
+ // `.pxart`). Each extension gets its own line: the serve parses this function to
98
+ // learn which extensions the kit owns an editor for, and only matches one
99
+ // `endsWith` per `return`.
36
100
  export function getFileKind(path) {
37
101
  if (path.endsWith('.scene')) return 'scene';
38
102
  if (path.endsWith('.pxart')) return 'pxart';
103
+ if (path.endsWith('.png')) return 'image';
104
+ if (path.endsWith('.jpg')) return 'image';
105
+ if (path.endsWith('.jpeg')) return 'image';
106
+ if (path.endsWith('.gif')) return 'image';
107
+ if (path.endsWith('.webp')) return 'image';
108
+ if (path.endsWith('.svg')) return 'image';
109
+ if (path.endsWith('.mp3')) return 'audio';
110
+ if (path.endsWith('.wav')) return 'audio';
111
+ if (path.endsWith('.m4a')) return 'audio';
112
+ if (path.endsWith('.mp4')) return 'video';
39
113
  if (path.endsWith('.js') || path.endsWith('.jsx')) return 'code';
40
114
  return 'text';
41
115
  }
@@ -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
+ }
@@ -8,7 +8,7 @@
8
8
  // previews (which never call update) pay nothing.
9
9
 
10
10
  import Matter from 'matter-js';
11
- import { getColliderRect } from '../engine/collider';
11
+ import { getColliderRect } from '../collider';
12
12
  import { buildJointConstraints, jointSignature, patchJoint } from './joints';
13
13
  import {
14
14
  DEFAULT_GRAVITY,
@@ -6,9 +6,8 @@
6
6
  // Used by Joints.draw() for `render: 'sprite'`. Reuses the kit's pxart renderer;
7
7
  // a small WeakMap caches the native-resolution frame canvas per sprite object.
8
8
 
9
- /* global document */
10
9
  import { resolveDeckFile } from 'castle-web-sdk';
11
- import { renderSpriteFrame } from '../engine/pxart';
10
+ import { renderSpriteFrame } from '../pxart';
12
11
 
13
12
  export const DEFAULT_JOINT_SPRITE = 'drawings/joint-rope.pxart';
14
13
 
@@ -16,7 +16,7 @@
16
16
  // emulated here (applyWorldGravity, and kinematic == static-moved-from-Layout).
17
17
 
18
18
  import Matter from 'matter-js';
19
- import { getColliderShapes } from '../engine/collider';
19
+ import { getColliderShapes, pointsBounds } from '../collider';
20
20
 
21
21
  export const DEG_TO_RAD = Math.PI / 180;
22
22
  export const RAD_TO_DEG = 180 / Math.PI;
@@ -64,18 +64,11 @@ function shapeToPart(s) {
64
64
  if ((s.type === 'triangle' || s.type === 'polygon') && s.points && s.points.length >= 3) {
65
65
  let cx = 0;
66
66
  let cy = 0;
67
- let x0 = Infinity;
68
- let y0 = Infinity;
69
- let x1 = -Infinity;
70
- let y1 = -Infinity;
71
67
  for (const p of s.points) {
72
68
  cx += p.x;
73
69
  cy += p.y;
74
- if (p.x < x0) x0 = p.x;
75
- if (p.y < y0) y0 = p.y;
76
- if (p.x > x1) x1 = p.x;
77
- if (p.y > y1) y1 = p.y;
78
70
  }
71
+ const [x0, y0, x1, y1] = pointsBounds(s.points);
79
72
  const part = Matter.Bodies.fromVertices(cx / s.points.length, cy / s.points.length, [s.points]);
80
73
  if (part) return part;
81
74
  return Matter.Bodies.rectangle((x0 + x1) / 2, (y0 + y1) / 2, Math.max(1, x1 - x0), Math.max(1, y1 - y0));
@@ -61,6 +61,15 @@ export class SceneRuntime {
61
61
  return system;
62
62
  }
63
63
 
64
+ // Tear this runtime down for good: the player is unmounting, or the editor
65
+ // left play mode. `reset` is for "start this scene over" and runs on every
66
+ // load; this is "nothing of this runtime should still be happening", which is
67
+ // the difference that matters to a system holding something the browser keeps
68
+ // doing on its own -- a playing sound outlives its scene otherwise.
69
+ dispose() {
70
+ for (const system of this.systems) system.dispose?.(this);
71
+ }
72
+
64
73
  load(sceneData) {
65
74
  // Reset registered systems (e.g. physics) so a reload/restart/scene
66
75
  // transition starts from the authored layout -- otherwise a system holding
@@ -611,6 +620,9 @@ function drawDotGrid(ctx, gridSize = 25, viewport = DEFAULT_VIEWPORT, camera = {
611
620
  // represented separately by the DOM SelectionOverlay's solid white box, so a
612
621
  // Sprite-backed actor never shows this box, selected or not.
613
622
  function shouldDrawEditPlaceholder(actor, sprites) {
623
+ // A Video draws its own picture into the box, so the actor is not sprite-less
624
+ // in the sense this box is for.
625
+ if (actor?.components?.Video?.file) return false;
614
626
  const sprite = actor?.components?.Sprite;
615
627
  if (!sprite || !sprite.file) return true;
616
628
  const resolved = sprites?.[sprite.file];