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.
- package/dist/agent-prompts.js +2 -2
- package/dist/agent.d.ts +9 -9
- package/dist/agent.js +590 -589
- package/dist/castleJson.d.ts +7 -0
- package/dist/castleJson.js +10 -0
- package/dist/editorConfig.d.ts +34 -0
- package/dist/editorConfig.js +125 -0
- package/dist/ide.js +245 -120
- package/dist/imports.d.ts +10 -0
- package/dist/imports.js +149 -17
- package/dist/init.d.ts +3 -0
- package/dist/init.js +113 -88
- package/dist/install.d.ts +1 -1
- package/dist/install.js +26 -24
- package/dist/serve.js +16 -0
- package/dist/shell/assets/index-DiPlPGyg.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 +13 -20
- package/kits/basic-2d/castle.json +1 -1
- package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
- package/kits/physics-2d/CLAUDE.md +65 -34
- 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 +5 -5
- 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 +112 -42
- 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/blueprints/ball.scene +1 -1
- package/kits/physics-2d/blueprints/block.scene +1 -1
- package/kits/physics-2d/blueprints/cauldron.scene +1 -1
- package/kits/physics-2d/blueprints/crate.scene +1 -1
- package/kits/physics-2d/castle.json +74 -4
- package/kits/physics-2d/docs/pxart-format.md +537 -51
- 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/PxArtEditor.jsx +1794 -65
- 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/brushFit.js +535 -0
- package/kits/physics-2d/editors/brushShapes.js +140 -0
- package/kits/physics-2d/editors/mediaFile.js +31 -0
- package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
- package/kits/physics-2d/editors/pathOverlay.js +340 -0
- package/kits/physics-2d/editors/pathTools.js +1906 -0
- package/kits/physics-2d/editors/pixelCanvas.js +13 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
- package/kits/physics-2d/editors/pixelGeometry.js +4 -2
- package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
- package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
- package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
- package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
- 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 +12 -4
- package/kits/physics-2d/engine/audioContext.js +34 -0
- package/kits/physics-2d/engine/blueprint.js +3 -3
- package/kits/physics-2d/engine/collider.js +28 -17
- package/kits/physics-2d/engine/files.js +77 -1
- package/kits/physics-2d/engine/liveReload.js +1 -1
- 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 +3 -4
- package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
- package/kits/physics-2d/engine/pxart.js +153 -35
- package/kits/physics-2d/engine/pxartPath.js +1356 -0
- package/kits/physics-2d/engine/pxartSmooth.js +276 -125
- package/kits/physics-2d/engine/scene.js +12 -0
- package/kits/physics-2d/engine/tone.js +112 -0
- package/kits/physics-2d/engine/ui.jsx +22 -1
- package/kits/physics-2d/engine/ui.module.css +36 -12
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/scripts/draw.mjs +7 -7
- package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +92 -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-CqpY1xZR.js +0 -144
- package/dist/shell/assets/index-DCwVFL5u.css +0 -1
- package/kits/physics-2d/physics/index.js +0 -26
- /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
- /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
- /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
- /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
- /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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
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.
|
|
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}.
|
|
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 {
|
|
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.
|
|
@@ -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
|
+
}
|