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
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"drawings/**",
|
|
23
23
|
"scenes/**",
|
|
24
24
|
"blueprints/**",
|
|
25
|
-
"behaviors/**"
|
|
25
|
+
"behaviors/**",
|
|
26
|
+
"assets/**"
|
|
26
27
|
],
|
|
27
28
|
"fileTypes": [
|
|
28
29
|
{
|
|
@@ -46,12 +47,73 @@
|
|
|
46
47
|
"label": "Behavior",
|
|
47
48
|
"new": "New behavior",
|
|
48
49
|
"icon": "code"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"ext": ".png",
|
|
53
|
+
"label": "Image",
|
|
54
|
+
"icon": "image",
|
|
55
|
+
"editor": "kit"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"ext": ".jpg",
|
|
59
|
+
"label": "Image",
|
|
60
|
+
"icon": "image",
|
|
61
|
+
"editor": "kit"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"ext": ".jpeg",
|
|
65
|
+
"label": "Image",
|
|
66
|
+
"icon": "image",
|
|
67
|
+
"editor": "kit"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"ext": ".gif",
|
|
71
|
+
"label": "Image",
|
|
72
|
+
"icon": "image",
|
|
73
|
+
"editor": "kit"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"ext": ".webp",
|
|
77
|
+
"label": "Image",
|
|
78
|
+
"icon": "image",
|
|
79
|
+
"editor": "kit"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"ext": ".svg",
|
|
83
|
+
"label": "Image",
|
|
84
|
+
"icon": "image",
|
|
85
|
+
"editor": "kit"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"ext": ".mp3",
|
|
89
|
+
"label": "Audio",
|
|
90
|
+
"icon": "music",
|
|
91
|
+
"editor": "kit"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"ext": ".wav",
|
|
95
|
+
"label": "Audio",
|
|
96
|
+
"icon": "music",
|
|
97
|
+
"editor": "kit"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"ext": ".m4a",
|
|
101
|
+
"label": "Audio",
|
|
102
|
+
"icon": "music",
|
|
103
|
+
"editor": "kit"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"ext": ".mp4",
|
|
107
|
+
"label": "Video",
|
|
108
|
+
"icon": "film",
|
|
109
|
+
"editor": "kit"
|
|
49
110
|
}
|
|
50
111
|
],
|
|
51
112
|
"defaultPlayFile": "scenes/main.scene"
|
|
52
113
|
},
|
|
53
114
|
"deckId": "ckRZGFW4iPrx",
|
|
54
115
|
"cardId": "_oBFbAW6DxsO",
|
|
116
|
+
"autoUpdateWhenImported": true,
|
|
55
117
|
"title": "physics-2d",
|
|
56
|
-
"publishedVersion": "2026-08-
|
|
118
|
+
"publishedVersion": "2026-08-06T19:53:55.080Z"
|
|
57
119
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { onArtLoaded, renderArtFrame } from '../engine/art';
|
|
3
3
|
import { screenToCard } from '../engine/scene';
|
|
4
4
|
import { countBlueprintInstances, listBlueprints } from '../engine/blueprint';
|
|
5
5
|
import { ConfirmDialog, ContextMenu, cx, Icon, styles } from '../engine/ui';
|
|
@@ -222,11 +222,15 @@ export function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
|
|
|
222
222
|
const canvasRef = useRef(null);
|
|
223
223
|
const spritePath = blueprint.components?.Sprite?.file;
|
|
224
224
|
const sprite = spritePath ? sprites?.[spritePath] : null;
|
|
225
|
+
// Thumbnails draw on change, not on a frame loop, so an image that finishes
|
|
226
|
+
// decoding after this rendered has to say so or the slot stays blank.
|
|
227
|
+
const [loadTick, setLoadTick] = useState(0);
|
|
228
|
+
useEffect(() => onArtLoaded(() => setLoadTick((tick) => tick + 1)), []);
|
|
225
229
|
useEffect(() => {
|
|
226
230
|
const canvas = canvasRef.current;
|
|
227
231
|
if (!canvas || !sprite) return;
|
|
228
|
-
|
|
229
|
-
}, [sprite]);
|
|
232
|
+
renderArtFrame(sprite, 0, canvas);
|
|
233
|
+
}, [sprite, loadTick]);
|
|
230
234
|
if (!sprite) {
|
|
231
235
|
return (
|
|
232
236
|
<span className={styles.bpSlotFallback}>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Read-only image viewer. Opened for the image extensions the deck declares as
|
|
2
|
+
// kit-edited (castle.json `fileTypes`), so an uploaded .png/.jpg/.svg opens as a
|
|
3
|
+
// picture instead of a wall of binary in the code editor.
|
|
4
|
+
//
|
|
5
|
+
// It VIEWS: fit / zoom / pan, at 1:1 when you want to count pixels. Nothing here
|
|
6
|
+
// writes -- editing an image is the pixel editor's job (.pxart), and a photo the
|
|
7
|
+
// user uploaded is not something this kit knows how to modify.
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
10
|
+
import { EditorBody, IconButton, styles as uiStyles } from '../engine/ui';
|
|
11
|
+
import { useTwoFingerZoomPan } from '../engine/gestureZoomPan';
|
|
12
|
+
import { basename } from '../engine/files';
|
|
13
|
+
import { useMediaUrl } from './mediaFile';
|
|
14
|
+
import styles from './mediaViewer.module.css';
|
|
15
|
+
|
|
16
|
+
const MIN_SCALE = 0.05;
|
|
17
|
+
const MAX_SCALE = 64;
|
|
18
|
+
// Past this the image is being inspected, not previewed: drop smoothing so
|
|
19
|
+
// pixels read as pixels.
|
|
20
|
+
const PIXELATE_ABOVE = 2.5;
|
|
21
|
+
const WHEEL_ZOOM_PER_PX = 0.0016;
|
|
22
|
+
|
|
23
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
24
|
+
|
|
25
|
+
// Zoom `view` by `factor` about a point in STAGE coordinates measured from the
|
|
26
|
+
// stage centre (the transform's origin), holding whatever is under that point
|
|
27
|
+
// still. `x`/`y` are the image centre's offset from the stage centre.
|
|
28
|
+
function zoomAbout(view, factor, px, py) {
|
|
29
|
+
const scale = clamp(view.scale * factor, MIN_SCALE, MAX_SCALE);
|
|
30
|
+
const k = scale / view.scale;
|
|
31
|
+
return { scale, x: px - (px - view.x) * k, y: py - (py - view.y) * k };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Scale that fits the whole image in the stage, with a little breathing room.
|
|
35
|
+
// Small images are scaled UP to fill: at 16x16 a "true size" preview is a speck.
|
|
36
|
+
function fitScale(stage, natural) {
|
|
37
|
+
if (!stage.width || !stage.height || !natural.width || !natural.height) return 1;
|
|
38
|
+
const margin = 24;
|
|
39
|
+
const sx = (stage.width - margin) / natural.width;
|
|
40
|
+
const sy = (stage.height - margin) / natural.height;
|
|
41
|
+
return clamp(Math.min(sx, sy), MIN_SCALE, MAX_SCALE);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The pan/zoom state for the stage, plus the input handlers that drive it:
|
|
45
|
+
// wheel to zoom about the cursor, drag to pan, two-finger pinch/pan on touch.
|
|
46
|
+
// Refits whenever the stage resizes or a different image loads, until the user
|
|
47
|
+
// takes over (`touched`) -- then their framing is theirs to keep.
|
|
48
|
+
function useImageView(stageRef, natural) {
|
|
49
|
+
const [view, setView] = useState({ scale: 1, x: 0, y: 0 });
|
|
50
|
+
const [dragging, setDragging] = useState(false);
|
|
51
|
+
const touched = useRef(false);
|
|
52
|
+
const stageSize = useRef({ width: 0, height: 0 });
|
|
53
|
+
|
|
54
|
+
const fit = useCallback(() => {
|
|
55
|
+
touched.current = false;
|
|
56
|
+
setView({ scale: fitScale(stageSize.current, natural), x: 0, y: 0 });
|
|
57
|
+
}, [natural]);
|
|
58
|
+
|
|
59
|
+
useLayoutEffect(() => {
|
|
60
|
+
const stage = stageRef.current;
|
|
61
|
+
if (!stage || typeof ResizeObserver === 'undefined') return undefined;
|
|
62
|
+
const measure = () => {
|
|
63
|
+
stageSize.current = { width: stage.clientWidth, height: stage.clientHeight };
|
|
64
|
+
if (!touched.current) fit();
|
|
65
|
+
};
|
|
66
|
+
measure();
|
|
67
|
+
const observer = new ResizeObserver(measure);
|
|
68
|
+
observer.observe(stage);
|
|
69
|
+
return () => observer.disconnect();
|
|
70
|
+
}, [stageRef, fit]);
|
|
71
|
+
|
|
72
|
+
// Zoom about a client-space point (cursor, pinch centroid).
|
|
73
|
+
const zoomAt = useCallback((factor, clientX, clientY) => {
|
|
74
|
+
const rect = stageRef.current?.getBoundingClientRect();
|
|
75
|
+
if (!rect) return;
|
|
76
|
+
touched.current = true;
|
|
77
|
+
const px = clientX - rect.left - rect.width / 2;
|
|
78
|
+
const py = clientY - rect.top - rect.height / 2;
|
|
79
|
+
setView((current) => zoomAbout(current, factor, px, py));
|
|
80
|
+
}, [stageRef]);
|
|
81
|
+
|
|
82
|
+
// Zoom about the stage centre, for the toolbar buttons.
|
|
83
|
+
const zoomBy = useCallback((factor) => {
|
|
84
|
+
touched.current = true;
|
|
85
|
+
setView((current) => zoomAbout(current, factor, 0, 0));
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const setScale = useCallback((scale) => {
|
|
89
|
+
touched.current = true;
|
|
90
|
+
setView((current) => zoomAbout(current, scale / current.scale, 0, 0));
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const panBy = useCallback((dx, dy) => {
|
|
94
|
+
touched.current = true;
|
|
95
|
+
setView((current) => ({ ...current, x: current.x + dx, y: current.y + dy }));
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const stage = stageRef.current;
|
|
100
|
+
if (!stage) return undefined;
|
|
101
|
+
const onWheel = (event) => {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
zoomAt(Math.exp(-event.deltaY * WHEEL_ZOOM_PER_PX), event.clientX, event.clientY);
|
|
104
|
+
};
|
|
105
|
+
stage.addEventListener('wheel', onWheel, { passive: false });
|
|
106
|
+
return () => stage.removeEventListener('wheel', onWheel);
|
|
107
|
+
}, [stageRef, zoomAt]);
|
|
108
|
+
|
|
109
|
+
// Mouse/pen drag to pan. Touch drags belong to the pinch gesture below, which
|
|
110
|
+
// stops their propagation before they reach this.
|
|
111
|
+
const onPointerDown = (event) => {
|
|
112
|
+
if (event.pointerType === 'touch' || event.button !== 0) return;
|
|
113
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
114
|
+
setDragging(true);
|
|
115
|
+
};
|
|
116
|
+
const onPointerMove = (event) => {
|
|
117
|
+
if (!dragging) return;
|
|
118
|
+
panBy(event.movementX, event.movementY);
|
|
119
|
+
};
|
|
120
|
+
const endDrag = () => setDragging(false);
|
|
121
|
+
|
|
122
|
+
useTwoFingerZoomPan({
|
|
123
|
+
targetRef: stageRef,
|
|
124
|
+
onGestureStart: endDrag,
|
|
125
|
+
onGestureMove: ({ factor, cx, cy, dx, dy }) => {
|
|
126
|
+
zoomAt(factor, cx, cy);
|
|
127
|
+
panBy(dx, dy);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
view,
|
|
133
|
+
dragging,
|
|
134
|
+
fit,
|
|
135
|
+
zoomBy,
|
|
136
|
+
setScale,
|
|
137
|
+
stageProps: {
|
|
138
|
+
onPointerDown,
|
|
139
|
+
onPointerMove,
|
|
140
|
+
onPointerUp: endDrag,
|
|
141
|
+
onPointerCancel: endDrag,
|
|
142
|
+
onDoubleClick: fit,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function ImageViewer({ path }) {
|
|
148
|
+
const url = useMediaUrl(path);
|
|
149
|
+
const stageRef = useRef(null);
|
|
150
|
+
const [natural, setNatural] = useState({ width: 0, height: 0 });
|
|
151
|
+
const [status, setStatus] = useState('loading');
|
|
152
|
+
const { view, dragging, fit, zoomBy, setScale, stageProps } = useImageView(stageRef, natural);
|
|
153
|
+
|
|
154
|
+
// A new URL (different file, or the same one re-uploaded) starts over.
|
|
155
|
+
useEffect(() => setStatus('loading'), [url]);
|
|
156
|
+
|
|
157
|
+
const onLoad = (event) => {
|
|
158
|
+
const image = event.currentTarget;
|
|
159
|
+
// SVGs without intrinsic dimensions report 0; fall back so fit has numbers.
|
|
160
|
+
setNatural({ width: image.naturalWidth || 300, height: image.naturalHeight || 300 });
|
|
161
|
+
setStatus('ready');
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const percent = Math.round(view.scale * 100);
|
|
165
|
+
return (
|
|
166
|
+
<EditorBody>
|
|
167
|
+
<div className={styles.viewer}>
|
|
168
|
+
<div className={styles.toolbar}>
|
|
169
|
+
<IconButton icon="zoom-out" label="Zoom out" onClick={() => zoomBy(1 / 1.4)} />
|
|
170
|
+
<IconButton icon="zoom-in" label="Zoom in" onClick={() => zoomBy(1.4)} />
|
|
171
|
+
<IconButton icon="fit" label="Fit to panel" onClick={fit} />
|
|
172
|
+
<button type="button" className={uiStyles.button} onClick={() => setScale(1)}>
|
|
173
|
+
1:1
|
|
174
|
+
</button>
|
|
175
|
+
<span className={styles.readout}>
|
|
176
|
+
{status === 'ready' ? `${natural.width}×${natural.height} · ${percent}%` : ''}
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div
|
|
180
|
+
ref={stageRef}
|
|
181
|
+
className={`${styles.stage}${dragging ? ` ${styles.dragging}` : ''}`}
|
|
182
|
+
{...stageProps}>
|
|
183
|
+
<img
|
|
184
|
+
src={url}
|
|
185
|
+
alt={basename(path)}
|
|
186
|
+
className={`${styles.image}${view.scale >= PIXELATE_ABOVE ? ` ${styles.pixelated}` : ''}`}
|
|
187
|
+
style={{
|
|
188
|
+
width: natural.width || undefined,
|
|
189
|
+
height: natural.height || undefined,
|
|
190
|
+
transform: `translate(-50%, -50%) translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
|
|
191
|
+
visibility: status === 'ready' ? 'visible' : 'hidden',
|
|
192
|
+
}}
|
|
193
|
+
onLoad={onLoad}
|
|
194
|
+
onError={() => setStatus('error')}
|
|
195
|
+
/>
|
|
196
|
+
{status === 'error' ? (
|
|
197
|
+
<div className={`${styles.message} ${styles.messageError}`}>
|
|
198
|
+
Could not load {basename(path)}.
|
|
199
|
+
</div>
|
|
200
|
+
) : null}
|
|
201
|
+
{status === 'loading' ? <div className={styles.message}>Loading…</div> : null}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</EditorBody>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Read-only audio / video player. Opened for the sound and video extensions the
|
|
2
|
+
// deck declares as kit-edited (castle.json `fileTypes`), so an uploaded .mp3 or
|
|
3
|
+
// .mp4 can be played in place instead of being a file you can only guess at from
|
|
4
|
+
// its name.
|
|
5
|
+
//
|
|
6
|
+
// The transport is the BROWSER'S: `<audio controls>` / `<video controls>`. Play,
|
|
7
|
+
// pause, scrub, volume, fullscreen, and picture-in-picture all come with it,
|
|
8
|
+
// they match what the platform's users already know, and they stay accessible
|
|
9
|
+
// and touch-sized without us reimplementing any of it. This file is the frame
|
|
10
|
+
// around that element, not a player.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here writes: trimming or converting a media file is not something this
|
|
13
|
+
// kit does, and a viewer that can't damage the file needs no save state.
|
|
14
|
+
|
|
15
|
+
import { useEffect, useState } from 'react';
|
|
16
|
+
import { EditorBody } from '../engine/ui';
|
|
17
|
+
import { basename } from '../engine/files';
|
|
18
|
+
import { useMediaUrl } from './mediaFile';
|
|
19
|
+
import styles from './mediaViewer.module.css';
|
|
20
|
+
|
|
21
|
+
export function MediaPlayer({ path, kind }) {
|
|
22
|
+
const url = useMediaUrl(path);
|
|
23
|
+
const [failed, setFailed] = useState(false);
|
|
24
|
+
// A new file (or a re-upload of this one) starts over.
|
|
25
|
+
useEffect(() => setFailed(false), [url]);
|
|
26
|
+
|
|
27
|
+
const isVideo = kind === 'video';
|
|
28
|
+
const Media = isVideo ? 'video' : 'audio';
|
|
29
|
+
return (
|
|
30
|
+
<EditorBody>
|
|
31
|
+
<div className={styles.viewer}>
|
|
32
|
+
<div className={styles.mediaStage}>
|
|
33
|
+
<div className={isVideo ? styles.videoPanel : styles.audioPanel}>
|
|
34
|
+
<div className={styles.fileName}>{basename(path)}</div>
|
|
35
|
+
<Media
|
|
36
|
+
key={url}
|
|
37
|
+
className={isVideo ? styles.video : styles.audio}
|
|
38
|
+
src={url}
|
|
39
|
+
controls
|
|
40
|
+
// Metadata only: opening a file shouldn't pull the whole thing
|
|
41
|
+
// down until someone presses play. Video shows its first frame as
|
|
42
|
+
// the poster so the panel isn't a black box.
|
|
43
|
+
preload={isVideo ? 'auto' : 'metadata'}
|
|
44
|
+
playsInline
|
|
45
|
+
onError={() => setFailed(true)}
|
|
46
|
+
/>
|
|
47
|
+
{failed ? (
|
|
48
|
+
<div className={styles.inlineError}>
|
|
49
|
+
Could not play {basename(path)} — this browser may not support its format.
|
|
50
|
+
</div>
|
|
51
|
+
) : null}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</EditorBody>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -1464,10 +1464,14 @@ function useScenePlayLoop({
|
|
|
1464
1464
|
useEffect(() => {
|
|
1465
1465
|
if (!isPlaying) {
|
|
1466
1466
|
runtimeRef.current = null;
|
|
1467
|
-
return;
|
|
1467
|
+
return undefined;
|
|
1468
1468
|
}
|
|
1469
|
-
if (!sceneData) return;
|
|
1470
|
-
|
|
1469
|
+
if (!sceneData) return undefined;
|
|
1470
|
+
const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
|
|
1471
|
+
runtimeRef.current = runtime;
|
|
1472
|
+
// Pressing Stop has to stop what the runtime started -- a sound keeps
|
|
1473
|
+
// playing on its own otherwise, with no scene left to stop it.
|
|
1474
|
+
return () => runtime.dispose();
|
|
1471
1475
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1472
1476
|
}, [sprites, isPlaying, text, runtimeRef]);
|
|
1473
1477
|
// Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
|
|
@@ -10,6 +10,8 @@ import { getFileKind } from '../engine/files';
|
|
|
10
10
|
import { useLiveDeckFiles } from '../engine/liveReload';
|
|
11
11
|
import { collectAssets } from '../engine/assets';
|
|
12
12
|
import { MainEditor, styles } from '../engine/ui';
|
|
13
|
+
import { ImageViewer } from './ImageViewer';
|
|
14
|
+
import { MediaPlayer } from './MediaPlayer';
|
|
13
15
|
import { PxArtEditor } from './PxArtEditor';
|
|
14
16
|
import { SceneEditor } from './SceneEditor';
|
|
15
17
|
|
|
@@ -111,6 +113,12 @@ export function SingleEditor({ path, editor }) {
|
|
|
111
113
|
);
|
|
112
114
|
} else if (kind === 'pxart') {
|
|
113
115
|
body = <PxArtEditor path={path} text={text} onChange={onChange} />;
|
|
116
|
+
} else if (kind === 'image') {
|
|
117
|
+
// Viewers read their file's BYTES over the serve, not the text file map --
|
|
118
|
+
// `files` holds source, and an image decoded as utf-8 is nothing.
|
|
119
|
+
body = <ImageViewer path={path} />;
|
|
120
|
+
} else if (kind === 'audio' || kind === 'video') {
|
|
121
|
+
body = <MediaPlayer path={path} kind={kind} />;
|
|
114
122
|
}
|
|
115
123
|
// No `else`: code/text files are handled by the builtin shell code editor, so
|
|
116
124
|
// the shell never routes them here. `body` stays null for any other kind.
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
// Discover every behavior class from `behaviors
|
|
2
|
-
//
|
|
3
|
-
// picked up on the next reload/restart. The physics glob is what lets the
|
|
4
|
-
// self-contained physics module ship its behaviors without polluting the core
|
|
5
|
-
// `behaviors/` folder — a kit adopts physics by copying `physics/` in.
|
|
1
|
+
// Discover every behavior class from `behaviors/**/*.jsx`. Vite HMR is off, so a
|
|
2
|
+
// newly-added behavior file is picked up on the next reload/restart.
|
|
6
3
|
// Root-anchored so the kit finds the DECK's behaviors even when the kit is
|
|
7
4
|
// itself an import (see engine/files.js). Imports are swept first and the deck's
|
|
8
5
|
// own behaviors last: collectBehaviors keys by behaviorName, so a behavior the
|
|
9
6
|
// deck defines wins over one of the same name from a dependency.
|
|
7
|
+
// `behaviors/` is the ONE place behaviors live -- the rest of the system (the
|
|
8
|
+
// deck file map, the editor's visible paths, lint, the duplicate gate) keys off
|
|
9
|
+
// that directory, so a behavior kept anywhere else is invisible to all of it.
|
|
10
10
|
const modules = {
|
|
11
11
|
...import.meta.glob('/imports/*/behaviors/**/*.jsx', { eager: true }),
|
|
12
|
-
...import.meta.glob('/imports/*/physics/behaviors/**/*.jsx', { eager: true }),
|
|
13
12
|
...import.meta.glob('/behaviors/**/*.jsx', { eager: true }),
|
|
14
|
-
...import.meta.glob('/physics/behaviors/**/*.jsx', { eager: true }),
|
|
15
13
|
};
|
|
16
14
|
function isBehaviorClass(value) {
|
|
17
15
|
return typeof value === 'function' && typeof value.behaviorName === 'string';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { fileUrl, onFilesChanged } from 'castle-web-sdk';
|
|
3
|
+
|
|
4
|
+
// The URL a media viewer points at, re-minted whenever the file itself changes
|
|
5
|
+
// on disk (a re-upload over the same name, an agent regenerating an asset). The
|
|
6
|
+
// serve sends the bytes `no-store`, but an <img>/<audio> already showing the old
|
|
7
|
+
// ones won't re-request on its own -- a changed URL is what makes it.
|
|
8
|
+
export function useMediaUrl(path) {
|
|
9
|
+
const [version, setVersion] = useState(0);
|
|
10
|
+
useEffect(
|
|
11
|
+
() =>
|
|
12
|
+
onFilesChanged((event) => {
|
|
13
|
+
if (event.changes.some((change) => change.path === path)) {
|
|
14
|
+
setVersion((current) => current + 1);
|
|
15
|
+
}
|
|
16
|
+
}),
|
|
17
|
+
[path]
|
|
18
|
+
);
|
|
19
|
+
return version === 0 ? fileUrl(path) : `${fileUrl(path)}&v=${version}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* Shared chrome for the read-only media viewers (image, audio): a toolbar row
|
|
2
|
+
over a stage that fills the panel. Neither viewer edits anything, so there is
|
|
3
|
+
no inspector column and no save state -- just controls and the file. */
|
|
4
|
+
|
|
5
|
+
/* `.editorBody` is a flex row, so claim the whole panel rather than sizing to
|
|
6
|
+
the toolbar's content width. */
|
|
7
|
+
.viewer {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex: 1 1 auto;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
min-width: 0;
|
|
12
|
+
min-height: 0;
|
|
13
|
+
width: 100%;
|
|
14
|
+
height: 100%;
|
|
15
|
+
color: var(--castle-inspector-text, #e6e6ea);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.toolbar {
|
|
19
|
+
flex: 0 0 auto;
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 8px;
|
|
23
|
+
padding: 6px 10px;
|
|
24
|
+
border-bottom: 1px solid var(--castle-border, #2a2a33);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Pushed-right readout: dimensions / zoom / time. Tabular figures so a ticking
|
|
28
|
+
clock or a changing zoom doesn't jitter the row. */
|
|
29
|
+
.readout {
|
|
30
|
+
margin-left: auto;
|
|
31
|
+
font-size: 12px;
|
|
32
|
+
opacity: 0.7;
|
|
33
|
+
font-variant-numeric: tabular-nums;
|
|
34
|
+
white-space: nowrap;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.stage {
|
|
38
|
+
position: relative;
|
|
39
|
+
flex: 1 1 auto;
|
|
40
|
+
min-height: 0;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
background:
|
|
43
|
+
repeating-conic-gradient(#1b1b21 0% 25%, #23232b 0% 50%) 50% / 20px 20px;
|
|
44
|
+
/* The pan/pinch gestures own this surface. */
|
|
45
|
+
touch-action: none;
|
|
46
|
+
cursor: grab;
|
|
47
|
+
}
|
|
48
|
+
.stage.dragging {
|
|
49
|
+
cursor: grabbing;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.image {
|
|
53
|
+
position: absolute;
|
|
54
|
+
top: 50%;
|
|
55
|
+
left: 50%;
|
|
56
|
+
transform-origin: center;
|
|
57
|
+
/* No smoothing at high zoom: this is a pixel inspector as much as a preview. */
|
|
58
|
+
user-select: none;
|
|
59
|
+
-webkit-user-drag: none;
|
|
60
|
+
pointer-events: none;
|
|
61
|
+
}
|
|
62
|
+
.pixelated {
|
|
63
|
+
image-rendering: pixelated;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.message {
|
|
67
|
+
position: absolute;
|
|
68
|
+
inset: 0;
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
padding: 16px;
|
|
73
|
+
text-align: center;
|
|
74
|
+
font-size: 13px;
|
|
75
|
+
opacity: 0.7;
|
|
76
|
+
}
|
|
77
|
+
.messageError {
|
|
78
|
+
color: #e86a73;
|
|
79
|
+
opacity: 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Audio / video: the native element, centered, with the filename above it. The
|
|
83
|
+
browser draws the transport, so this only has to give it room. */
|
|
84
|
+
.mediaStage {
|
|
85
|
+
display: flex;
|
|
86
|
+
flex: 1 1 auto;
|
|
87
|
+
min-height: 0;
|
|
88
|
+
align-items: center;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
padding: 20px;
|
|
91
|
+
}
|
|
92
|
+
.audioPanel,
|
|
93
|
+
.videoPanel {
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-direction: column;
|
|
96
|
+
gap: 10px;
|
|
97
|
+
min-width: 0;
|
|
98
|
+
width: 100%;
|
|
99
|
+
}
|
|
100
|
+
.audioPanel {
|
|
101
|
+
max-width: 420px;
|
|
102
|
+
}
|
|
103
|
+
/* Video takes the panel it's given, capped so the controls stay a sane size. */
|
|
104
|
+
.videoPanel {
|
|
105
|
+
max-width: 100%;
|
|
106
|
+
max-height: 100%;
|
|
107
|
+
min-height: 0;
|
|
108
|
+
}
|
|
109
|
+
.audio {
|
|
110
|
+
width: 100%;
|
|
111
|
+
}
|
|
112
|
+
.video {
|
|
113
|
+
min-height: 0;
|
|
114
|
+
max-width: 100%;
|
|
115
|
+
max-height: 100%;
|
|
116
|
+
background: #000;
|
|
117
|
+
border-radius: 4px;
|
|
118
|
+
}
|
|
119
|
+
.inlineError {
|
|
120
|
+
font-size: 12px;
|
|
121
|
+
color: #e86a73;
|
|
122
|
+
}
|
|
123
|
+
.fileName {
|
|
124
|
+
font-size: 13px;
|
|
125
|
+
overflow: hidden;
|
|
126
|
+
text-overflow: ellipsis;
|
|
127
|
+
white-space: nowrap;
|
|
128
|
+
}
|
|
@@ -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);
|