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.
- package/dist/castleJson.d.ts +1 -0
- package/dist/editorConfig.d.ts +34 -0
- package/dist/editorConfig.js +101 -0
- package/dist/ide.js +245 -120
- package/dist/imports.d.ts +6 -0
- package/dist/imports.js +90 -7
- package/dist/init.js +15 -2
- package/dist/serve.js +16 -0
- package/dist/shell/assets/index-CUamb8rK.js +144 -0
- package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
- package/dist/shell/index.html +2 -2
- package/dist/unsupportedMedia.d.ts +1 -0
- package/dist/unsupportedMedia.js +54 -0
- package/dist/vitePlugins.js +12 -19
- package/kits/basic-2d/castle.json +1 -1
- package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
- package/kits/physics-2d/CLAUDE.md +42 -23
- package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
- package/kits/physics-2d/behaviors/Collider.jsx +1 -1
- package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
- package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +4 -4
- package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
- package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
- package/kits/physics-2d/behaviors/Sound.jsx +152 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +98 -33
- package/kits/physics-2d/behaviors/Tone.jsx +83 -0
- package/kits/physics-2d/behaviors/Video.jsx +111 -0
- package/kits/physics-2d/behaviors/tint.js +24 -9
- package/kits/physics-2d/castle.json +64 -2
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
- package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
- package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
- package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
- package/kits/physics-2d/editors/mediaFile.js +20 -0
- package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
- package/kits/physics-2d/engine/art.js +105 -0
- package/kits/physics-2d/engine/assets.js +11 -3
- package/kits/physics-2d/engine/audioContext.js +34 -0
- package/kits/physics-2d/engine/collider.js +28 -17
- package/kits/physics-2d/engine/files.js +74 -0
- package/kits/physics-2d/engine/media.js +212 -0
- package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
- package/kits/physics-2d/{physics → engine/physics}/jointArt.js +1 -2
- package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
- package/kits/physics-2d/engine/scene.js +12 -0
- package/kits/physics-2d/engine/tone.js +112 -0
- package/kits/physics-2d/systems/media.js +34 -0
- package/kits/physics-2d/systems/physics.js +12 -3
- package/package.json +1 -1
- package/dist/shell/assets/index-BjOuaDJM.js +0 -144
- package/dist/shell/assets/index-CdXEpv_P.css +0 -1
- package/kits/physics-2d/physics/index.js +0 -26
- /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
- /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:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
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 =
|
|
52
|
+
const total = artFrameCount(sprite);
|
|
49
53
|
for (let frame = 0; frame < total; frame++) {
|
|
50
|
-
|
|
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 '../
|
|
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 '../
|
|
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 '../
|
|
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];
|