castle-web-cli 0.4.72 → 0.4.74
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 +22 -3
- package/dist/agent.js +731 -313
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
- package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +3 -2
- package/kits/basic-2d/CLAUDE.md +29 -8
- package/kits/basic-2d/behaviors/Collider.jsx +6 -4
- package/kits/basic-2d/behaviors/Layout.jsx +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
- package/kits/basic-2d/behaviors/tint.js +47 -0
- package/kits/basic-2d/docs/pxart-format.md +298 -0
- package/kits/basic-2d/drawings/pig.pxart +59 -0
- package/kits/basic-2d/editors/App.jsx +125 -76
- package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
- package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
- package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
- package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +703 -222
- package/kits/basic-2d/editors/SelectionOverlay.jsx +818 -0
- package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
- package/kits/basic-2d/editors/codeTheme.js +135 -0
- package/kits/basic-2d/editors/editorHistory.js +44 -17
- package/kits/basic-2d/editors/inspectorSheet.js +23 -0
- package/kits/basic-2d/editors/pixelCanvas.js +11 -0
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
- package/kits/basic-2d/editors/pixelGeometry.js +45 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
- package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
- package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/basic-2d/editors/pxArtTools.js +124 -0
- package/kits/basic-2d/editors/useArtboardFit.js +102 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +169 -13
- package/kits/basic-2d/engine/SceneUI.jsx +3 -11
- package/kits/basic-2d/engine/assets.js +15 -0
- package/kits/basic-2d/engine/files.js +57 -2
- package/kits/basic-2d/engine/playConsole.js +66 -0
- package/kits/basic-2d/engine/pxart.js +985 -0
- package/kits/basic-2d/engine/scene.js +222 -41
- package/kits/basic-2d/engine/ui.jsx +155 -26
- package/kits/basic-2d/engine/ui.module.css +1385 -332
- package/kits/basic-2d/eslint.config.js +21 -0
- package/kits/basic-2d/index.html +13 -0
- package/kits/basic-2d/package.json +1 -0
- package/kits/basic-2d/pnpm-lock.yaml +5 -5
- package/kits/basic-2d/scenes/main.scene +19 -26
- package/kits/basic-2d/scripts/draw.mjs +121 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
- package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-vmdKwUE1.js +0 -106
- package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
- package/kits/basic-2d/drawings/block.drawing +0 -70
- package/kits/basic-2d/drawings/default.drawing +0 -70
- package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const MAX_ART = 460;
|
|
4
|
+
const STACK_GAP = 10;
|
|
5
|
+
|
|
6
|
+
// Matches @container editor (max-width) in ui.module.css.
|
|
7
|
+
export const PXART_COMPACT_MAX_WIDTH = 620;
|
|
8
|
+
|
|
9
|
+
// True when .editorBody is at or below the compact-layout container threshold.
|
|
10
|
+
export function useEditorCompactLayout(bodyRef, maxWidth = PXART_COMPACT_MAX_WIDTH) {
|
|
11
|
+
const [compact, setCompact] = useState(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const body = bodyRef.current;
|
|
15
|
+
if (!body || typeof ResizeObserver === 'undefined') return undefined;
|
|
16
|
+
|
|
17
|
+
const update = () => setCompact(body.clientWidth <= maxWidth);
|
|
18
|
+
update();
|
|
19
|
+
const observer = new ResizeObserver(update);
|
|
20
|
+
observer.observe(body);
|
|
21
|
+
return () => observer.disconnect();
|
|
22
|
+
}, [bodyRef, maxWidth]);
|
|
23
|
+
|
|
24
|
+
return compact;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fit the artboard canvas inside its wrap region, accounting for padding, the
|
|
28
|
+
// canvas-size bar above the art, and the sprite's aspect ratio. Re-runs when the
|
|
29
|
+
// wrap resizes (timeline drag, window resize, compact layout flip).
|
|
30
|
+
export function useArtboardFit({ wrapRef, sizeBarRef, resolution }) {
|
|
31
|
+
const { width, height } = resolution;
|
|
32
|
+
const isWide = width >= height;
|
|
33
|
+
const [displaySize, setDisplaySize] = useState(null);
|
|
34
|
+
|
|
35
|
+
const fit = useCallback(() => {
|
|
36
|
+
const wrap = wrapRef.current;
|
|
37
|
+
if (!wrap) return;
|
|
38
|
+
const wrapStyle = getComputedStyle(wrap);
|
|
39
|
+
const padX = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight);
|
|
40
|
+
const padY = parseFloat(wrapStyle.paddingTop) + parseFloat(wrapStyle.paddingBottom);
|
|
41
|
+
const barHeight = sizeBarRef.current?.offsetHeight ?? 0;
|
|
42
|
+
const maxW = wrap.clientWidth - padX;
|
|
43
|
+
const maxH = wrap.clientHeight - padY - barHeight - (barHeight ? STACK_GAP : 0);
|
|
44
|
+
if (maxW <= 0 || maxH <= 0) {
|
|
45
|
+
setDisplaySize(null);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let displayW;
|
|
50
|
+
let displayH;
|
|
51
|
+
if (width === height) {
|
|
52
|
+
const size = Math.floor(Math.min(maxW, maxH, MAX_ART));
|
|
53
|
+
displayW = size;
|
|
54
|
+
displayH = size;
|
|
55
|
+
} else if (isWide) {
|
|
56
|
+
displayW = Math.floor(Math.min(maxW, MAX_ART));
|
|
57
|
+
displayH = Math.floor((displayW * height) / width);
|
|
58
|
+
if (displayH > maxH) {
|
|
59
|
+
displayH = Math.floor(maxH);
|
|
60
|
+
displayW = Math.floor((displayH * width) / height);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
displayH = Math.floor(Math.min(maxH, MAX_ART));
|
|
64
|
+
displayW = Math.floor((displayH * width) / height);
|
|
65
|
+
if (displayW > maxW) {
|
|
66
|
+
displayW = Math.floor(maxW);
|
|
67
|
+
displayH = Math.floor((displayW * height) / width);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!Number.isFinite(displayW) || !Number.isFinite(displayH) || displayW <= 0 || displayH <= 0) {
|
|
72
|
+
setDisplaySize(null);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setDisplaySize({ width: displayW, height: displayH });
|
|
76
|
+
}, [width, height, isWide, wrapRef, sizeBarRef]);
|
|
77
|
+
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
fit();
|
|
80
|
+
}, [fit]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const wrap = wrapRef.current;
|
|
84
|
+
if (!wrap) return undefined;
|
|
85
|
+
window.addEventListener('resize', fit);
|
|
86
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
87
|
+
return () => window.removeEventListener('resize', fit);
|
|
88
|
+
}
|
|
89
|
+
const observer = new ResizeObserver(fit);
|
|
90
|
+
observer.observe(wrap);
|
|
91
|
+
return () => {
|
|
92
|
+
observer.disconnect();
|
|
93
|
+
window.removeEventListener('resize', fit);
|
|
94
|
+
};
|
|
95
|
+
}, [fit, wrapRef]);
|
|
96
|
+
|
|
97
|
+
const canvasStyle = displaySize
|
|
98
|
+
? { width: `${displaySize.width}px`, height: `${displaySize.height}px` }
|
|
99
|
+
: undefined;
|
|
100
|
+
|
|
101
|
+
return { canvasStyle, refit: fit };
|
|
102
|
+
}
|
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
2
3
|
import { configureSceneCanvas, makeScene } from './scene';
|
|
3
4
|
import { SceneUI } from './SceneUI';
|
|
4
5
|
import { TouchControls } from './TouchControls';
|
|
6
|
+
import { cx, Icon, styles } from './ui';
|
|
7
|
+
import { usePlayLogs } from './playConsole';
|
|
5
8
|
// Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
|
|
6
9
|
// keyboard / pointer input, run the update+draw loop, and render the
|
|
7
10
|
// behavior-driven UI overlay. No game logic lives here -- behaviors and
|
|
8
11
|
// scenes are the place for that.
|
|
9
|
-
export function ScenePlayer({ sceneData,
|
|
12
|
+
export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame }) {
|
|
10
13
|
const canvasRef = useRef(null);
|
|
11
14
|
const runtimeRef = useRef(null);
|
|
15
|
+
// Bumping this re-runs the mount effect below, which tears down the running
|
|
16
|
+
// loop/runtime and rebuilds a fresh scene from the same data -- a clean restart.
|
|
17
|
+
const [runToken, setRunToken] = useState(0);
|
|
12
18
|
const getKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
|
|
13
19
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
20
|
+
const restart = useCallback(() => setRunToken((token) => token + 1), []);
|
|
14
21
|
useEffect(() => {
|
|
15
22
|
const canvas = canvasRef.current;
|
|
16
23
|
if (!canvas) return undefined;
|
|
17
24
|
const ctx = canvas.getContext('2d');
|
|
18
25
|
if (!ctx) return undefined;
|
|
19
26
|
configureSceneCanvas(canvas, ctx);
|
|
20
|
-
const runtime = makeScene(sceneData, behaviorClasses,
|
|
27
|
+
const runtime = makeScene(sceneData, behaviorClasses, sprites).clone();
|
|
21
28
|
runtimeRef.current = runtime;
|
|
22
29
|
// Store BOTH the physical code ('KeyX', 'ArrowLeft', 'Space') and the
|
|
23
30
|
// logical key ('x', 'ArrowLeft', ' ') so behaviors can match either. Codes
|
|
@@ -56,7 +63,9 @@ export function ScenePlayer({ sceneData, drawings, behaviorClasses }) {
|
|
|
56
63
|
canvas.addEventListener('pointerup', onPointerUp);
|
|
57
64
|
canvas.addEventListener('pointercancel', onPointerUp);
|
|
58
65
|
canvas.focus();
|
|
59
|
-
|
|
66
|
+
// Only signal first-frame readiness on the initial run; a restart shouldn't
|
|
67
|
+
// re-fire the host launcher reveal.
|
|
68
|
+
const stopLoop = startPlayerLoop(canvas, ctx, runtime, runToken === 0 ? onFirstFrame : undefined);
|
|
60
69
|
return () => {
|
|
61
70
|
stopLoop();
|
|
62
71
|
window.removeEventListener('keydown', onKeyDown);
|
|
@@ -68,28 +77,175 @@ export function ScenePlayer({ sceneData, drawings, behaviorClasses }) {
|
|
|
68
77
|
runtimeRef.current = null;
|
|
69
78
|
};
|
|
70
79
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [runToken]);
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<div style={{ position: 'fixed', inset: 0, background: '#000' }}>
|
|
84
|
+
<canvas
|
|
85
|
+
ref={canvasRef}
|
|
86
|
+
tabIndex={0}
|
|
87
|
+
style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
|
|
88
|
+
/>
|
|
89
|
+
<SceneUI getRuntime={getRuntime} />
|
|
90
|
+
<TouchControls getKeys={getKeys} />
|
|
91
|
+
</div>
|
|
92
|
+
{/* Portal to <body> so the console drawer escapes the SDK's card-sized,
|
|
93
|
+
transformed `#root > *` wrapper and pins to the panel, outside the
|
|
94
|
+
play preview. */}
|
|
95
|
+
{createPortal(<PlayConsole onRestart={restart} />, document.body)}
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
function PlayConsole({ onRestart }) {
|
|
100
|
+
const { logs, clearLogs } = usePlayLogs();
|
|
101
|
+
const [open, setOpen] = useState(false);
|
|
102
|
+
const rootRef = useRef(null);
|
|
103
|
+
const bodyRef = useRef(null);
|
|
104
|
+
// Reserve the drawer's footprint by shrinking + lifting the SDK deck card so it
|
|
105
|
+
// sits fully above the drawer (visible + interactive) instead of behind it.
|
|
106
|
+
// Driven by the live drawer height; recomputed on toggle (height change via the
|
|
107
|
+
// ResizeObserver) and on window/card resize.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const el = rootRef.current;
|
|
110
|
+
if (!el) return undefined;
|
|
111
|
+
const docEl = document.documentElement;
|
|
112
|
+
docEl.dataset.castleLogs = '';
|
|
113
|
+
const TOP_MARGIN = 16;
|
|
114
|
+
const MIN_CARD_SCALE = 0.2;
|
|
115
|
+
const apply = () => {
|
|
116
|
+
const drawerHeight = el.offsetHeight;
|
|
117
|
+
const cs = getComputedStyle(docEl);
|
|
118
|
+
const fullW = parseFloat(cs.getPropertyValue('--castle-card-w'));
|
|
119
|
+
const fullH = parseFloat(cs.getPropertyValue('--castle-card-h'));
|
|
120
|
+
docEl.style.setProperty('--castle-logs-shift', `${drawerHeight / 2}px`);
|
|
121
|
+
// Only resize the card when we know its natural size; otherwise leave it to
|
|
122
|
+
// the SDK fallback (the rule's var() defaults handle this).
|
|
123
|
+
if (fullW > 0 && fullH > 0) {
|
|
124
|
+
const available = window.innerHeight - drawerHeight - TOP_MARGIN;
|
|
125
|
+
// Floor the scale so a very short panel (drawer taller than the area)
|
|
126
|
+
// can't drive the card to 0/negative and make the deck disappear.
|
|
127
|
+
const scale = Math.min(1, Math.max(MIN_CARD_SCALE, available / fullH));
|
|
128
|
+
docEl.style.setProperty('--castle-logs-card-w', `${fullW * scale}px`);
|
|
129
|
+
docEl.style.setProperty('--castle-logs-card-h', `${fullH * scale}px`);
|
|
130
|
+
} else {
|
|
131
|
+
docEl.style.removeProperty('--castle-logs-card-w');
|
|
132
|
+
docEl.style.removeProperty('--castle-logs-card-h');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
apply();
|
|
136
|
+
const observer = new ResizeObserver(apply);
|
|
137
|
+
observer.observe(el);
|
|
138
|
+
window.addEventListener('resize', apply);
|
|
139
|
+
return () => {
|
|
140
|
+
observer.disconnect();
|
|
141
|
+
window.removeEventListener('resize', apply);
|
|
142
|
+
delete docEl.dataset.castleLogs;
|
|
143
|
+
docEl.style.removeProperty('--castle-logs-card-w');
|
|
144
|
+
docEl.style.removeProperty('--castle-logs-card-h');
|
|
145
|
+
docEl.style.removeProperty('--castle-logs-shift');
|
|
146
|
+
};
|
|
71
147
|
}, []);
|
|
148
|
+
// Keep the newest line in view while expanded.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (open && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
|
151
|
+
}, [logs, open]);
|
|
152
|
+
const copy = useCallback(() => {
|
|
153
|
+
const text = logs.map((line) => line.text).join('\n');
|
|
154
|
+
navigator.clipboard?.writeText(text).catch(() => {});
|
|
155
|
+
}, [logs]);
|
|
156
|
+
const hasLogs = logs.length > 0;
|
|
72
157
|
return (
|
|
73
|
-
<div
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
158
|
+
<div ref={rootRef} className={styles.playConsole}>
|
|
159
|
+
{/* The whole header row toggles; the action buttons stopPropagation so
|
|
160
|
+
they don't also collapse/expand. */}
|
|
161
|
+
<div
|
|
162
|
+
className={styles.playConsoleHeader}
|
|
163
|
+
role="button"
|
|
164
|
+
tabIndex={-1}
|
|
165
|
+
aria-expanded={open}
|
|
166
|
+
aria-label={open ? 'Collapse logs' : 'Expand logs'}
|
|
167
|
+
onClick={() => setOpen((value) => !value)}>
|
|
168
|
+
<span className={styles.playConsoleToggle}>
|
|
169
|
+
<span className={styles.playConsoleCaret}>
|
|
170
|
+
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
|
|
171
|
+
</span>
|
|
172
|
+
<span className={styles.playConsoleTitle}>Logs</span>
|
|
173
|
+
</span>
|
|
174
|
+
<div
|
|
175
|
+
className={styles.playConsoleActions}
|
|
176
|
+
onClick={(event) => event.stopPropagation()}>
|
|
177
|
+
{open ? (
|
|
178
|
+
<>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
tabIndex={-1}
|
|
182
|
+
className={styles.playConsoleBtn}
|
|
183
|
+
aria-label="Copy logs"
|
|
184
|
+
title="Copy logs"
|
|
185
|
+
disabled={!hasLogs}
|
|
186
|
+
onClick={copy}>
|
|
187
|
+
<Icon name="clone" />
|
|
188
|
+
</button>
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
tabIndex={-1}
|
|
192
|
+
className={styles.playConsoleBtn}
|
|
193
|
+
aria-label="Clear logs"
|
|
194
|
+
title="Clear logs"
|
|
195
|
+
disabled={!hasLogs}
|
|
196
|
+
onClick={clearLogs}>
|
|
197
|
+
<Icon name="trash" />
|
|
198
|
+
</button>
|
|
199
|
+
</>
|
|
200
|
+
) : null}
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
tabIndex={-1}
|
|
204
|
+
className={styles.playConsoleBtn}
|
|
205
|
+
aria-label="Restart"
|
|
206
|
+
title="Restart"
|
|
207
|
+
onClick={onRestart}>
|
|
208
|
+
<Icon name="rotate" />
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
{open ? (
|
|
213
|
+
<div ref={bodyRef} className={styles.playConsoleBody}>
|
|
214
|
+
{hasLogs ? (
|
|
215
|
+
logs.map((line) => (
|
|
216
|
+
<div
|
|
217
|
+
key={line.id}
|
|
218
|
+
className={cx(
|
|
219
|
+
styles.playConsoleLine,
|
|
220
|
+
line.level === 'warn' && styles.playConsoleLineWarn,
|
|
221
|
+
line.level === 'error' && styles.playConsoleLineError
|
|
222
|
+
)}>
|
|
223
|
+
{line.text}
|
|
224
|
+
</div>
|
|
225
|
+
))
|
|
226
|
+
) : (
|
|
227
|
+
<div className={styles.playConsoleEmpty}>no output</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
) : null}
|
|
81
231
|
</div>
|
|
82
232
|
);
|
|
83
233
|
}
|
|
84
|
-
function startPlayerLoop(canvas, ctx, runtime) {
|
|
234
|
+
function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
|
|
85
235
|
let raf = 0;
|
|
86
236
|
let previousTime = performance.now();
|
|
237
|
+
let firstFrameSignaled = false;
|
|
87
238
|
const tick = (now) => {
|
|
88
239
|
const dt = Math.min(0.033, (now - previousTime) / 1000);
|
|
89
240
|
previousTime = now;
|
|
90
241
|
runtime.update(dt);
|
|
91
242
|
configureSceneCanvas(canvas, ctx);
|
|
92
243
|
runtime.draw(ctx, { useCamera: true });
|
|
244
|
+
if (!firstFrameSignaled) {
|
|
245
|
+
firstFrameSignaled = true;
|
|
246
|
+
// Wait one frame so the draw composites before reveal, avoiding a blank flash.
|
|
247
|
+
requestAnimationFrame(() => onFirstFrame?.());
|
|
248
|
+
}
|
|
93
249
|
raf = requestAnimationFrame(tick);
|
|
94
250
|
};
|
|
95
251
|
raf = requestAnimationFrame(tick);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import styles from './ui.module.css';
|
|
3
|
+
import { useElementSize } from './ui';
|
|
3
4
|
import { cardSize } from './scene';
|
|
4
5
|
// Game-time UI overlay.
|
|
5
6
|
//
|
|
@@ -11,18 +12,9 @@ import { cardSize } from './scene';
|
|
|
11
12
|
// hidden`, so deck UI can never render outside the card.
|
|
12
13
|
export function SceneUI({ getRuntime }) {
|
|
13
14
|
const rootRef = useRef(null);
|
|
14
|
-
const [box, setBox] = useState({ width: 0, height: 0 });
|
|
15
|
-
const [, forceRender] = useState(0);
|
|
16
15
|
// Track the canvas-sized overlay box so the card-unit layer scales onto it.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!root) return undefined;
|
|
20
|
-
const measure = () => setBox({ width: root.clientWidth, height: root.clientHeight });
|
|
21
|
-
const observer = new ResizeObserver(measure);
|
|
22
|
-
observer.observe(root);
|
|
23
|
-
measure();
|
|
24
|
-
return () => observer.disconnect();
|
|
25
|
-
}, []);
|
|
16
|
+
const box = useElementSize(rootRef);
|
|
17
|
+
const [, forceRender] = useState(0);
|
|
26
18
|
// Re-render every frame so behavior UI reflects live runtime state.
|
|
27
19
|
useEffect(() => {
|
|
28
20
|
let raf = 0;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parseFull } from './pxart';
|
|
2
|
+
|
|
3
|
+
// Build the runtime asset map from a path->text file map: parsed `.pxart`
|
|
4
|
+
// Sprites keyed by file path (nulls skipped). Shared by the editor App and the
|
|
5
|
+
// play-only entry point so both hand the SceneRuntime the same map.
|
|
6
|
+
export function collectAssets(files) {
|
|
7
|
+
const sprites = {};
|
|
8
|
+
for (const [path, text] of Object.entries(files)) {
|
|
9
|
+
if (path.endsWith('.pxart')) {
|
|
10
|
+
const sprite = parseFull(text);
|
|
11
|
+
if (sprite) sprites[path] = sprite;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return { sprites };
|
|
15
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Seed the file map by scanning the deck dir. Vite module-cache is invalidated
|
|
2
2
|
// on restart, so a newly-created file just shows up on the next reload.
|
|
3
3
|
const rawModules = import.meta.glob(
|
|
4
|
-
['../scenes/*.scene', '../drawings/*.
|
|
4
|
+
['../scenes/*.scene', '../drawings/*.pxart', '../behaviors/*.jsx'],
|
|
5
5
|
{ query: '?raw', import: 'default', eager: true }
|
|
6
6
|
);
|
|
7
7
|
export const initialFiles = Object.fromEntries(
|
|
@@ -11,7 +11,7 @@ export const initialFiles = Object.fromEntries(
|
|
|
11
11
|
);
|
|
12
12
|
export function getFileKind(path) {
|
|
13
13
|
if (path.endsWith('.scene')) return 'scene';
|
|
14
|
-
if (path.endsWith('.
|
|
14
|
+
if (path.endsWith('.pxart')) return 'pxart';
|
|
15
15
|
if (path.endsWith('.js') || path.endsWith('.jsx')) return 'code';
|
|
16
16
|
return 'text';
|
|
17
17
|
}
|
|
@@ -29,6 +29,61 @@ export function formatJson(value) {
|
|
|
29
29
|
export function basename(path) {
|
|
30
30
|
return path.split('/').pop() ?? path;
|
|
31
31
|
}
|
|
32
|
+
// Parent directory of a path ('' for a root-level path).
|
|
33
|
+
export function dirname(path) {
|
|
34
|
+
const index = path.lastIndexOf('/');
|
|
35
|
+
return index === -1 ? '' : path.slice(0, index);
|
|
36
|
+
}
|
|
37
|
+
// Join a directory and a name, tolerating a '' (root) directory.
|
|
38
|
+
export function joinPath(dir, name) {
|
|
39
|
+
return dir ? `${dir}/${name}` : name;
|
|
40
|
+
}
|
|
41
|
+
// Split a filename into stem + extension (the extension includes the leading
|
|
42
|
+
// dot). Leading-dot names (e.g. '.gitignore') count as all-stem.
|
|
43
|
+
export function splitFileName(name) {
|
|
44
|
+
const dot = name.lastIndexOf('.');
|
|
45
|
+
if (dot <= 0) return { stem: name, ext: '' };
|
|
46
|
+
return { stem: name.slice(0, dot), ext: name.slice(dot) };
|
|
47
|
+
}
|
|
48
|
+
// A filename in `dir` that doesn't collide with any existing path. If
|
|
49
|
+
// `desiredName` is free it's returned as-is; otherwise '-2', '-3', ... is
|
|
50
|
+
// inserted before the extension until a free name is found.
|
|
51
|
+
export function uniqueFileName(existingPaths, dir, desiredName) {
|
|
52
|
+
const taken = new Set(existingPaths);
|
|
53
|
+
if (!taken.has(joinPath(dir, desiredName))) return desiredName;
|
|
54
|
+
const { stem, ext } = splitFileName(desiredName);
|
|
55
|
+
for (let n = 2; ; n++) {
|
|
56
|
+
const candidate = `${stem}-${n}${ext}`;
|
|
57
|
+
if (!taken.has(joinPath(dir, candidate))) return candidate;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Top-level child segment names (files and virtual folders) directly inside
|
|
61
|
+
// `parentDir`. Used to keep new sibling folder names collision-free.
|
|
62
|
+
export function childNamesIn(existingPaths, parentDir) {
|
|
63
|
+
const prefix = parentDir ? `${parentDir}/` : '';
|
|
64
|
+
const names = new Set();
|
|
65
|
+
for (const path of existingPaths) {
|
|
66
|
+
if (prefix && !path.startsWith(prefix)) continue;
|
|
67
|
+
const segment = path.slice(prefix.length).split('/')[0];
|
|
68
|
+
if (segment) names.add(segment);
|
|
69
|
+
}
|
|
70
|
+
return names;
|
|
71
|
+
}
|
|
72
|
+
// A folder name inside `parentDir` that doesn't collide with an existing
|
|
73
|
+
// sibling file or folder. Appends '-2', '-3', ... until free.
|
|
74
|
+
export function uniqueFolderName(existingPaths, parentDir, desiredName) {
|
|
75
|
+
const taken = childNamesIn(existingPaths, parentDir);
|
|
76
|
+
if (!taken.has(desiredName)) return desiredName;
|
|
77
|
+
for (let n = 2; ; n++) {
|
|
78
|
+
const candidate = `${desiredName}-${n}`;
|
|
79
|
+
if (!taken.has(candidate)) return candidate;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// All file paths under a virtual folder (paths prefixed with `dirPath/`).
|
|
83
|
+
export function filesUnder(existingPaths, dirPath) {
|
|
84
|
+
const prefix = `${dirPath}/`;
|
|
85
|
+
return existingPaths.filter((path) => path.startsWith(prefix));
|
|
86
|
+
}
|
|
32
87
|
// Flat list of file paths in the order FileBrowser renders them: a
|
|
33
88
|
// depth-first walk of the directory tree, mirroring buildFileTree there.
|
|
34
89
|
export function flatFileOrder(paths) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
// Captures console output from the running deck into an in-memory ring buffer and
|
|
3
|
+
// notifies subscribers, so the on-panel play console can display it.
|
|
4
|
+
//
|
|
5
|
+
// The SDK already wraps console (to forward logs to the dev server over its
|
|
6
|
+
// websocket); this wraps ON TOP and always calls through, so that forwarding and
|
|
7
|
+
// the real devtools output are preserved. Install is idempotent.
|
|
8
|
+
const MAX_LINES = 500;
|
|
9
|
+
let buffer = [];
|
|
10
|
+
let nextId = 1;
|
|
11
|
+
let installed = false;
|
|
12
|
+
const subscribers = new Set();
|
|
13
|
+
function formatArgs(args) {
|
|
14
|
+
return args
|
|
15
|
+
.map((arg) => {
|
|
16
|
+
if (typeof arg === 'string') return arg;
|
|
17
|
+
if (arg instanceof Error) return arg.stack || arg.message;
|
|
18
|
+
try {
|
|
19
|
+
const json = JSON.stringify(arg);
|
|
20
|
+
return json ?? String(arg);
|
|
21
|
+
} catch {
|
|
22
|
+
return String(arg);
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
.join(' ');
|
|
26
|
+
}
|
|
27
|
+
function emit(level, args) {
|
|
28
|
+
const entry = { id: nextId++, level, text: formatArgs(args) };
|
|
29
|
+
const next = buffer.concat(entry);
|
|
30
|
+
buffer = next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
|
|
31
|
+
for (const cb of subscribers) cb(buffer);
|
|
32
|
+
}
|
|
33
|
+
export function installConsoleCapture() {
|
|
34
|
+
if (installed || typeof console === 'undefined') return;
|
|
35
|
+
installed = true;
|
|
36
|
+
for (const level of ['log', 'warn', 'error']) {
|
|
37
|
+
const original = typeof console[level] === 'function' ? console[level].bind(console) : null;
|
|
38
|
+
console[level] = (...args) => {
|
|
39
|
+
if (original) original(...args);
|
|
40
|
+
// Never let capture throw into the caller's logging path.
|
|
41
|
+
try {
|
|
42
|
+
emit(level, args);
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function subscribeLogs(callback) {
|
|
50
|
+
subscribers.add(callback);
|
|
51
|
+
callback(buffer);
|
|
52
|
+
return () => subscribers.delete(callback);
|
|
53
|
+
}
|
|
54
|
+
export function clearLogs() {
|
|
55
|
+
buffer = [];
|
|
56
|
+
for (const cb of subscribers) cb(buffer);
|
|
57
|
+
}
|
|
58
|
+
// React hook: install capture (idempotent) and track the live log buffer.
|
|
59
|
+
export function usePlayLogs() {
|
|
60
|
+
const [logs, setLogs] = useState(buffer);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
installConsoleCapture();
|
|
63
|
+
return subscribeLogs(setLogs);
|
|
64
|
+
}, []);
|
|
65
|
+
return { logs, clearLogs };
|
|
66
|
+
}
|