castle-web-cli 0.4.84 → 0.4.85
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/ide.js +35 -13
- package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
- package/dist/shell/index.html +1 -1
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +199 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +196 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +686 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/vite.config.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { EDG64, KEY_ALPHABET, normalizeHex, TRANSPARENT } from '../engine/pxart';
|
|
2
|
+
import {
|
|
3
|
+
forEachDab,
|
|
4
|
+
forEachEllipseCells,
|
|
5
|
+
forEachLinePoint,
|
|
6
|
+
forEachRectCells,
|
|
7
|
+
forEachTriangleCells,
|
|
8
|
+
} from './pixelGeometry';
|
|
9
|
+
|
|
10
|
+
// The PxArt editor paints over the FIXED full Endesga-64 palette (no free color
|
|
11
|
+
// picking). Each palette color gets a stable single-char key (KEY_ALPHABET, 64
|
|
12
|
+
// distinct keys); the transparent cell is the reserved "." (TRANSPARENT).
|
|
13
|
+
export const EDITOR_KEYS = EDG64.map((_, index) => KEY_ALPHABET[index]);
|
|
14
|
+
|
|
15
|
+
// key -> "#rrggbb" record, in palette order. Passed to the SDK's fromCells so
|
|
16
|
+
// serialized files carry only the palette entries actually used.
|
|
17
|
+
export const EDITOR_PALETTE = Object.fromEntries(
|
|
18
|
+
EDG64.map((hex, index) => [EDITOR_KEYS[index], hex])
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// hex -> palette key, for mapping a loaded sprite's colors onto the swatch.
|
|
22
|
+
const KEY_BY_HEX = new Map(EDG64.map((hex, index) => [hex, EDITOR_KEYS[index]]));
|
|
23
|
+
|
|
24
|
+
// Build a `height x width` matrix of "." (transparent) cells.
|
|
25
|
+
export function blankCells(width, height) {
|
|
26
|
+
return Array.from({ length: height }, () => Array.from({ length: width }, () => TRANSPARENT));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Snap an arbitrary hex color to the nearest palette key by RGB distance, so a
|
|
30
|
+
// loaded sprite whose palette isn't exactly the Endesga-64 set still maps onto
|
|
31
|
+
// the fixed swatch. Returns "." for invalid/transparent input.
|
|
32
|
+
export function editorKeyForHex(hex) {
|
|
33
|
+
const norm = typeof hex === 'string' ? normalizeHex(hex) : null;
|
|
34
|
+
if (!norm) return TRANSPARENT;
|
|
35
|
+
const exact = KEY_BY_HEX.get('#' + norm.slice(1, 7));
|
|
36
|
+
if (exact) return exact;
|
|
37
|
+
const r = parseInt(norm.slice(1, 3), 16);
|
|
38
|
+
const g = parseInt(norm.slice(3, 5), 16);
|
|
39
|
+
const b = parseInt(norm.slice(5, 7), 16);
|
|
40
|
+
let bestKey = EDITOR_KEYS[0];
|
|
41
|
+
let bestDist = Infinity;
|
|
42
|
+
EDG64.forEach((swatch, index) => {
|
|
43
|
+
const dist =
|
|
44
|
+
(r - parseInt(swatch.slice(1, 3), 16)) ** 2 +
|
|
45
|
+
(g - parseInt(swatch.slice(3, 5), 16)) ** 2 +
|
|
46
|
+
(b - parseInt(swatch.slice(5, 7), 16)) ** 2;
|
|
47
|
+
if (dist < bestDist) {
|
|
48
|
+
bestDist = dist;
|
|
49
|
+
bestKey = EDITOR_KEYS[index];
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return bestKey;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The swatch the editor opens on: Endesga-64's lead bright red (#ff0040).
|
|
56
|
+
export const DEFAULT_KEY = editorKeyForHex('#ff0040');
|
|
57
|
+
|
|
58
|
+
function isInside(cells, x, y) {
|
|
59
|
+
return y >= 0 && y < cells.length && x >= 0 && x < (cells[0]?.length ?? 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function copyCells(cells) {
|
|
63
|
+
return cells.map((row) => row.slice());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Paint a single cell in a working matrix; returns true when it changed.
|
|
67
|
+
function writeCell(cells, x, y, key) {
|
|
68
|
+
if (!isInside(cells, x, y) || cells[y][x] === key) return false;
|
|
69
|
+
cells[y][x] = key;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Paint `key` along the segment from `from` to `to` with a brush of `size`
|
|
74
|
+
// cells (1 = single cell). Returns a new matrix when anything changed, else the
|
|
75
|
+
// original (so callers can skip no-op commits).
|
|
76
|
+
export function paintLine(cells, from, to, key, size = 1) {
|
|
77
|
+
const next = copyCells(cells);
|
|
78
|
+
let changed = false;
|
|
79
|
+
forEachLinePoint(from, to, (cx, cy) => {
|
|
80
|
+
forEachDab(size, (dx, dy) => {
|
|
81
|
+
changed = writeCell(next, cx + dx, cy + dy, key) || changed;
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return changed ? next : cells;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Draw a shape with `key` into a copy of `cells`, defined by the drag from
|
|
88
|
+
// `from` to `to`. `mode` selects line / square / circle / triangle; `filled`
|
|
89
|
+
// applies to the closed shapes (the line ignores it). Returns a new matrix when
|
|
90
|
+
// anything changed, else the original (so callers can skip no-op commits).
|
|
91
|
+
export function paintShape(cells, from, to, key, mode, filled = false) {
|
|
92
|
+
const next = copyCells(cells);
|
|
93
|
+
let changed = false;
|
|
94
|
+
const write = (x, y) => {
|
|
95
|
+
changed = writeCell(next, x, y, key) || changed;
|
|
96
|
+
};
|
|
97
|
+
if (mode === 'line') {
|
|
98
|
+
forEachLinePoint(from, to, write);
|
|
99
|
+
} else if (mode === 'square') {
|
|
100
|
+
forEachRectCells(from, to, filled, write);
|
|
101
|
+
} else if (mode === 'circle') {
|
|
102
|
+
forEachEllipseCells(from, to, filled, write);
|
|
103
|
+
} else if (mode === 'triangle') {
|
|
104
|
+
forEachTriangleCells(from, to, filled, write);
|
|
105
|
+
}
|
|
106
|
+
return changed ? next : cells;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Both region tools share the same guard: bail when the start is out of bounds
|
|
110
|
+
// or already the target key, else return a working copy plus the key to replace.
|
|
111
|
+
function beginKeyReplace(cells, start, key) {
|
|
112
|
+
if (!isInside(cells, start.x, start.y)) return null;
|
|
113
|
+
const target = cells[start.y][start.x];
|
|
114
|
+
if (target === key) return null;
|
|
115
|
+
return { next: copyCells(cells), target };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Flood-fill the contiguous region matching the start cell's key with `key`.
|
|
119
|
+
// Passing TRANSPARENT erases the contiguous region (the "fill mode" eraser).
|
|
120
|
+
export function fillRegion(cells, start, key) {
|
|
121
|
+
const begin = beginKeyReplace(cells, start, key);
|
|
122
|
+
if (!begin) return cells;
|
|
123
|
+
const { next, target } = begin;
|
|
124
|
+
const queue = [start];
|
|
125
|
+
while (queue.length) {
|
|
126
|
+
const { x, y } = queue.pop();
|
|
127
|
+
if (!isInside(next, x, y) || next[y][x] !== target) continue;
|
|
128
|
+
next[y][x] = key;
|
|
129
|
+
queue.push({ x: x + 1, y }, { x: x - 1, y }, { x, y: y + 1 }, { x, y: y - 1 });
|
|
130
|
+
}
|
|
131
|
+
return next;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Recolor EVERY cell matching the start cell's key to `key` (global swap): a
|
|
135
|
+
// fixed-palette palette-swap fill mode.
|
|
136
|
+
export function swapKey(cells, start, key) {
|
|
137
|
+
const begin = beginKeyReplace(cells, start, key);
|
|
138
|
+
if (!begin) return cells;
|
|
139
|
+
const { next, target } = begin;
|
|
140
|
+
let changed = false;
|
|
141
|
+
for (let y = 0; y < next.length; y++) {
|
|
142
|
+
const row = next[y];
|
|
143
|
+
for (let x = 0; x < row.length; x++) {
|
|
144
|
+
if (row[x] !== target) continue;
|
|
145
|
+
row[x] = key;
|
|
146
|
+
changed = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return changed ? next : cells;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The palette key under a cell (for the eyedropper), or "." when transparent.
|
|
153
|
+
export function sampleKey(cells, point) {
|
|
154
|
+
return isInside(cells, point.x, point.y) ? cells[point.y][point.x] : TRANSPARENT;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Mirror the canvas-window matrix left-to-right (dimensions unchanged).
|
|
158
|
+
export function flipCellsH(cells) {
|
|
159
|
+
return cells.map((row) => row.slice().reverse());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Mirror the canvas-window matrix top-to-bottom (dimensions unchanged).
|
|
163
|
+
export function flipCellsV(cells) {
|
|
164
|
+
return cells.slice().reverse().map((row) => row.slice());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Clear a rectangular region of the canvas-window matrix to transparent (the
|
|
168
|
+
// marquee delete). `rect` is { x, y, w, h } in canvas cells. Returns a new
|
|
169
|
+
// matrix when anything changed, else the original.
|
|
170
|
+
export function clearRegion(cells, rect) {
|
|
171
|
+
const next = copyCells(cells);
|
|
172
|
+
let changed = false;
|
|
173
|
+
for (let y = rect.y; y < rect.y + rect.h; y++) {
|
|
174
|
+
for (let x = rect.x; x < rect.x + rect.w; x++) {
|
|
175
|
+
changed = writeCell(next, x, y, TRANSPARENT) || changed;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return changed ? next : cells;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Lift the pixels in `rect`, clear that source region to transparent, then stamp
|
|
182
|
+
// the lifted OPAQUE pixels back at (dx, dy) offset — the marquee move's lift &
|
|
183
|
+
// float, applied as one combined result. Transparent cells in the lifted region
|
|
184
|
+
// don't erase the destination (they let underlying art show through). Pixels
|
|
185
|
+
// pushed outside the canvas window are clipped. Returns the original matrix on a
|
|
186
|
+
// zero delta (so a click-without-drag records no history); otherwise a new copy.
|
|
187
|
+
export function moveRegion(cells, rect, dx, dy) {
|
|
188
|
+
if (dx === 0 && dy === 0) return cells;
|
|
189
|
+
const buffer = [];
|
|
190
|
+
for (let y = 0; y < rect.h; y++) {
|
|
191
|
+
const row = [];
|
|
192
|
+
for (let x = 0; x < rect.w; x++) {
|
|
193
|
+
const sx = rect.x + x;
|
|
194
|
+
const sy = rect.y + y;
|
|
195
|
+
row.push(isInside(cells, sx, sy) ? cells[sy][sx] : TRANSPARENT);
|
|
196
|
+
}
|
|
197
|
+
buffer.push(row);
|
|
198
|
+
}
|
|
199
|
+
const next = copyCells(cells);
|
|
200
|
+
for (let y = 0; y < rect.h; y++) {
|
|
201
|
+
for (let x = 0; x < rect.w; x++) writeCell(next, rect.x + x, rect.y + y, TRANSPARENT);
|
|
202
|
+
}
|
|
203
|
+
for (let y = 0; y < rect.h; y++) {
|
|
204
|
+
for (let x = 0; x < rect.w; x++) {
|
|
205
|
+
const key = buffer[y][x];
|
|
206
|
+
if (key !== TRANSPARENT) writeCell(next, rect.x + x + dx, rect.y + y + dy, key);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return next;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Rotate the canvas-window matrix 90° clockwise about the canvas center, keeping
|
|
213
|
+
// the same width × height window (so it merges back through withCellsAt). For a
|
|
214
|
+
// square canvas this is the exact lossless rotation; on a non-square canvas the
|
|
215
|
+
// rotated corners that fall outside the window are clipped (filled transparent).
|
|
216
|
+
export function rotateCellsCW(cells) {
|
|
217
|
+
const height = cells.length;
|
|
218
|
+
const width = height ? cells[0].length : 0;
|
|
219
|
+
const cx = (width - 1) / 2;
|
|
220
|
+
const cy = (height - 1) / 2;
|
|
221
|
+
const out = [];
|
|
222
|
+
for (let oy = 0; oy < height; oy++) {
|
|
223
|
+
const row = [];
|
|
224
|
+
for (let ox = 0; ox < width; ox++) {
|
|
225
|
+
const sx = Math.round(cx + (oy - cy));
|
|
226
|
+
const sy = Math.round(cy - (ox - cx));
|
|
227
|
+
row.push(sy >= 0 && sy < height && sx >= 0 && sx < width ? cells[sy][sx] : TRANSPARENT);
|
|
228
|
+
}
|
|
229
|
+
out.push(row);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { configureSceneCanvas, makeScene } from './scene';
|
|
3
|
+
import { SceneUI } from './SceneUI';
|
|
4
|
+
// Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
|
|
5
|
+
// keyboard / pointer input, run the update+draw loop, and render the
|
|
6
|
+
// behavior-driven UI overlay. No game logic lives here -- behaviors and
|
|
7
|
+
// scenes are the place for that. The dev logs drawer is a builtin shell panel
|
|
8
|
+
// now (cli/src/shell), not rendered here.
|
|
9
|
+
export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirstFrame }) {
|
|
10
|
+
const canvasRef = useRef(null);
|
|
11
|
+
const runtimeRef = useRef(null);
|
|
12
|
+
// An error thrown from a behavior's update/draw kills the raf loop (it can't
|
|
13
|
+
// schedule the next frame), which otherwise just black-screens the preview
|
|
14
|
+
// with the throw buried in the console. Capture it here and show a redbox
|
|
15
|
+
// overlay instead. `runId` bumps to remount the loop on Restart.
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const [runId, setRunId] = useState(0);
|
|
18
|
+
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const canvas = canvasRef.current;
|
|
21
|
+
if (!canvas) return undefined;
|
|
22
|
+
const ctx = canvas.getContext('2d');
|
|
23
|
+
if (!ctx) return undefined;
|
|
24
|
+
configureSceneCanvas(canvas, ctx);
|
|
25
|
+
const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
|
|
26
|
+
runtimeRef.current = runtime;
|
|
27
|
+
// Store BOTH the physical code ('KeyX', 'ArrowLeft', 'Space') and the
|
|
28
|
+
// logical key ('x', 'ArrowLeft', ' ') so behaviors can match either. Codes
|
|
29
|
+
// are what the kit docs/examples use; keeping key too is forgiving.
|
|
30
|
+
const onKeyDown = (event) => {
|
|
31
|
+
runtime.keys.add(event.code);
|
|
32
|
+
runtime.keys.add(event.key);
|
|
33
|
+
};
|
|
34
|
+
const onKeyUp = (event) => {
|
|
35
|
+
runtime.keys.delete(event.code);
|
|
36
|
+
runtime.keys.delete(event.key);
|
|
37
|
+
};
|
|
38
|
+
const onPointerDown = (event) => {
|
|
39
|
+
// The play surface listens on window keydown, so it only gets keys when
|
|
40
|
+
// its document/iframe holds focus. Grab focus on any click into the game
|
|
41
|
+
// (and on mount below) so the keyboard works inside the launcher embed.
|
|
42
|
+
canvas.focus();
|
|
43
|
+
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, true);
|
|
44
|
+
canvas.setPointerCapture(event.pointerId);
|
|
45
|
+
};
|
|
46
|
+
const onPointerMove = (event) => {
|
|
47
|
+
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY);
|
|
48
|
+
};
|
|
49
|
+
const onPointerUp = (event) => {
|
|
50
|
+
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, false);
|
|
51
|
+
try {
|
|
52
|
+
canvas.releasePointerCapture(event.pointerId);
|
|
53
|
+
} catch {
|
|
54
|
+
// Pointer capture may already be gone after cancel/blur.
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
window.addEventListener('keydown', onKeyDown);
|
|
58
|
+
window.addEventListener('keyup', onKeyUp);
|
|
59
|
+
canvas.addEventListener('pointerdown', onPointerDown);
|
|
60
|
+
canvas.addEventListener('pointermove', onPointerMove);
|
|
61
|
+
canvas.addEventListener('pointerup', onPointerUp);
|
|
62
|
+
canvas.addEventListener('pointercancel', onPointerUp);
|
|
63
|
+
// Don't steal focus from the embedding shell (e.g. the chat composer) on
|
|
64
|
+
// reload/remount. A real standalone/play, or an iframe that's
|
|
65
|
+
// already focused, still auto-focuses; a click into the game always
|
|
66
|
+
// focuses via onPointerDown above.
|
|
67
|
+
if (document.hasFocus() || window.parent === window) canvas.focus();
|
|
68
|
+
const onLoopError = (err) => {
|
|
69
|
+
// Forward to the console (the serve log picks it up) and surface it.
|
|
70
|
+
// eslint-disable-next-line no-console
|
|
71
|
+
console.error(err);
|
|
72
|
+
setError(err);
|
|
73
|
+
};
|
|
74
|
+
const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onLoopError);
|
|
75
|
+
return () => {
|
|
76
|
+
stopLoop();
|
|
77
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
78
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
79
|
+
canvas.removeEventListener('pointerdown', onPointerDown);
|
|
80
|
+
canvas.removeEventListener('pointermove', onPointerMove);
|
|
81
|
+
canvas.removeEventListener('pointerup', onPointerUp);
|
|
82
|
+
canvas.removeEventListener('pointercancel', onPointerUp);
|
|
83
|
+
runtimeRef.current = null;
|
|
84
|
+
};
|
|
85
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
+
}, [runId]);
|
|
87
|
+
const onRestart = useCallback(() => {
|
|
88
|
+
setError(null);
|
|
89
|
+
setRunId((n) => n + 1);
|
|
90
|
+
}, []);
|
|
91
|
+
return (
|
|
92
|
+
<div style={{ position: 'fixed', inset: 0, background: '#000' }}>
|
|
93
|
+
<canvas
|
|
94
|
+
ref={canvasRef}
|
|
95
|
+
tabIndex={0}
|
|
96
|
+
style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
|
|
97
|
+
/>
|
|
98
|
+
<SceneUI getRuntime={getRuntime} />
|
|
99
|
+
{error ? <PlayErrorOverlay error={error} onRestart={onRestart} /> : null}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
function startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onError) {
|
|
104
|
+
let raf = 0;
|
|
105
|
+
let previousTime = performance.now();
|
|
106
|
+
let firstFrameSignaled = false;
|
|
107
|
+
const tick = (now) => {
|
|
108
|
+
const dt = Math.min(0.033, (now - previousTime) / 1000);
|
|
109
|
+
previousTime = now;
|
|
110
|
+
// A throw in a behavior's update/draw must not abort the loop silently:
|
|
111
|
+
// catch it, surface it via onError, and stop scheduling frames (so it
|
|
112
|
+
// doesn't rethrow 60x/second). Restart remounts a fresh loop.
|
|
113
|
+
try {
|
|
114
|
+
runtime.update(dt);
|
|
115
|
+
configureSceneCanvas(canvas, ctx);
|
|
116
|
+
runtime.draw(ctx, { useCamera: true });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
onError?.(err);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!firstFrameSignaled) {
|
|
122
|
+
firstFrameSignaled = true;
|
|
123
|
+
// Wait one frame so the draw composites before reveal, avoiding a blank flash.
|
|
124
|
+
requestAnimationFrame(() => onFirstFrame?.());
|
|
125
|
+
}
|
|
126
|
+
raf = requestAnimationFrame(tick);
|
|
127
|
+
};
|
|
128
|
+
raf = requestAnimationFrame(tick);
|
|
129
|
+
return () => cancelAnimationFrame(raf);
|
|
130
|
+
}
|
|
131
|
+
// Redbox overlay shown when the play loop throws. Covers the canvas with the
|
|
132
|
+
// error name/message and stack so a broken behavior is legible instead of a
|
|
133
|
+
// black screen. Restart remounts the loop; fixing the code and restarting the
|
|
134
|
+
// serve reloads the whole preview fresh.
|
|
135
|
+
function PlayErrorOverlay({ error, onRestart }) {
|
|
136
|
+
const name = error?.name ?? 'Error';
|
|
137
|
+
const message = error?.message ?? String(error);
|
|
138
|
+
const stack = typeof error?.stack === 'string' ? error.stack : '';
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
style={{
|
|
142
|
+
position: 'absolute',
|
|
143
|
+
inset: 0,
|
|
144
|
+
zIndex: 2147483000,
|
|
145
|
+
display: 'flex',
|
|
146
|
+
flexDirection: 'column',
|
|
147
|
+
gap: 12,
|
|
148
|
+
padding: '20px 22px',
|
|
149
|
+
background: 'rgba(20, 8, 8, 0.94)',
|
|
150
|
+
color: '#ffd9dc',
|
|
151
|
+
font: "13px/1.5 'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
|
|
152
|
+
overflow: 'auto',
|
|
153
|
+
boxSizing: 'border-box',
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
157
|
+
<span style={{ fontSize: 15, fontWeight: 700, color: '#ff6b74' }}>Play error</span>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={onRestart}
|
|
161
|
+
style={{
|
|
162
|
+
marginLeft: 'auto',
|
|
163
|
+
padding: '4px 12px',
|
|
164
|
+
border: '1px solid #a02828',
|
|
165
|
+
borderRadius: 4,
|
|
166
|
+
background: '#a02828',
|
|
167
|
+
color: '#fff',
|
|
168
|
+
font: 'inherit',
|
|
169
|
+
cursor: 'pointer',
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
Restart
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
<div style={{ color: '#fff', fontWeight: 600, wordBreak: 'break-word' }}>
|
|
176
|
+
{name}: {message}
|
|
177
|
+
</div>
|
|
178
|
+
{stack ? (
|
|
179
|
+
<pre
|
|
180
|
+
style={{
|
|
181
|
+
margin: 0,
|
|
182
|
+
whiteSpace: 'pre-wrap',
|
|
183
|
+
wordBreak: 'break-word',
|
|
184
|
+
color: '#e79aa0',
|
|
185
|
+
fontSize: 12,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{stack}
|
|
189
|
+
</pre>
|
|
190
|
+
) : null}
|
|
191
|
+
<div style={{ color: '#c98a8f', fontSize: 12 }}>
|
|
192
|
+
The preview stopped. Fix the code and restart the serve to reload, or press Restart to retry.
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import styles from './ui.module.css';
|
|
3
|
+
import { useElementSize } from './ui';
|
|
4
|
+
import { cardSize } from './scene';
|
|
5
|
+
// Game-time UI overlay.
|
|
6
|
+
//
|
|
7
|
+
// Behaviors expose a `ui(actor, scene)` handler -- the React counterpart of
|
|
8
|
+
// `draw`. `SceneUI` collects every behavior's UI output each frame and renders
|
|
9
|
+
// it into a deck-sized layer that sits exactly over the canvas. The layer is
|
|
10
|
+
// `cardSize` units wide/tall and scaled onto the canvas box, so behaviors lay
|
|
11
|
+
// out in the same card units as `Layout`. The root clips with `overflow:
|
|
12
|
+
// hidden`, so deck UI can never render outside the card.
|
|
13
|
+
export function SceneUI({ getRuntime }) {
|
|
14
|
+
const rootRef = useRef(null);
|
|
15
|
+
// Track the canvas-sized overlay box so the card-unit layer scales onto it.
|
|
16
|
+
const box = useElementSize(rootRef);
|
|
17
|
+
const [, forceRender] = useState(0);
|
|
18
|
+
// Re-render every frame so behavior UI reflects live runtime state.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let raf = 0;
|
|
21
|
+
const frame = () => {
|
|
22
|
+
forceRender((tick) => tick + 1);
|
|
23
|
+
raf = requestAnimationFrame(frame);
|
|
24
|
+
};
|
|
25
|
+
raf = requestAnimationFrame(frame);
|
|
26
|
+
return () => cancelAnimationFrame(raf);
|
|
27
|
+
}, []);
|
|
28
|
+
return (
|
|
29
|
+
<div ref={rootRef} className={styles.sceneUiRoot}>
|
|
30
|
+
<div
|
|
31
|
+
className={styles.sceneUiLayer}
|
|
32
|
+
style={{
|
|
33
|
+
width: cardSize.width,
|
|
34
|
+
height: cardSize.height,
|
|
35
|
+
transform: `scale(${box.width / cardSize.width}, ${box.height / cardSize.height})`,
|
|
36
|
+
}}>
|
|
37
|
+
{collectUiNodes(getRuntime())}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
// Build one keyed React node per behavior that defines a `ui` handler.
|
|
43
|
+
function collectUiNodes(runtime) {
|
|
44
|
+
if (!runtime) return [];
|
|
45
|
+
const nodes = [];
|
|
46
|
+
for (const actor of runtime.getActors()) {
|
|
47
|
+
for (const [behaviorName, props] of Object.entries(actor.components)) {
|
|
48
|
+
if (!props) continue;
|
|
49
|
+
const Behavior = runtime.behaviors.get(behaviorName);
|
|
50
|
+
if (!Behavior) continue;
|
|
51
|
+
const instance = new Behavior(props);
|
|
52
|
+
if (!instance.ui) continue;
|
|
53
|
+
const node = instance.ui(actor, runtime);
|
|
54
|
+
if (node == null) continue;
|
|
55
|
+
nodes.push(<React.Fragment key={`${actor.id}:${behaviorName}`}>{node}</React.Fragment>);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return nodes;
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { CheckboxField, ColorField, NumberField, Panel, TextField, isHexColor } from './ui';
|
|
3
|
+
// Build the override-indicator field props (purple tint + "Default: X [Reset]")
|
|
4
|
+
// for a single property from a behavior panel's override context. Returns an
|
|
5
|
+
// empty object when there's no context (blueprint template editing) or the
|
|
6
|
+
// property isn't overridden on this instance, so fields render normally.
|
|
7
|
+
export function overrideProps(override, prop) {
|
|
8
|
+
if (!override || !override.isOverridden(prop)) return {};
|
|
9
|
+
return {
|
|
10
|
+
overridden: true,
|
|
11
|
+
defaultValue: override.baseline(prop),
|
|
12
|
+
onReset: () => override.reset(prop),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function AutoFields({ defaultProps, component, setComponent, only, exclude, override }) {
|
|
16
|
+
const keys = Object.keys(defaultProps).filter((key) => {
|
|
17
|
+
if (only) return only.includes(key);
|
|
18
|
+
if (exclude) return !exclude.includes(key);
|
|
19
|
+
return true;
|
|
20
|
+
});
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{keys.map((key) => {
|
|
24
|
+
const fallback = defaultProps[key];
|
|
25
|
+
const current = key in component ? component[key] : fallback;
|
|
26
|
+
const set = (value) => setComponent({ [key]: value });
|
|
27
|
+
const label = humanizeKey(key);
|
|
28
|
+
const sample = fallback ?? current;
|
|
29
|
+
const ov = overrideProps(override, key);
|
|
30
|
+
if (typeof sample === 'number') {
|
|
31
|
+
return <NumberField key={key} label={label} value={current} onChange={set} {...ov} />;
|
|
32
|
+
}
|
|
33
|
+
if (typeof sample === 'boolean') {
|
|
34
|
+
return <CheckboxField key={key} label={label} checked={current} onChange={set} {...ov} />;
|
|
35
|
+
}
|
|
36
|
+
if (isHexColor(sample)) {
|
|
37
|
+
return <ColorField key={key} label={label} value={current} onChange={set} {...ov} />;
|
|
38
|
+
}
|
|
39
|
+
return (
|
|
40
|
+
<TextField
|
|
41
|
+
key={key}
|
|
42
|
+
label={label}
|
|
43
|
+
value={current == null ? '' : String(current)}
|
|
44
|
+
onChange={set}
|
|
45
|
+
{...ov}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
export function AutoInspector({ behaviorName, defaultProps, component, setComponent, override }) {
|
|
53
|
+
return (
|
|
54
|
+
<Panel title={humanizeKey(behaviorName)} overridden={override?.anyOverridden()}>
|
|
55
|
+
<AutoFields
|
|
56
|
+
defaultProps={defaultProps}
|
|
57
|
+
component={component}
|
|
58
|
+
setComponent={setComponent}
|
|
59
|
+
override={override}
|
|
60
|
+
/>
|
|
61
|
+
</Panel>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// `deadZoneY` -> `Dead Zone Y`, `PlatformerCharacter` -> `Platformer Character`.
|
|
65
|
+
export function humanizeKey(key) {
|
|
66
|
+
return key
|
|
67
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
68
|
+
.replace(/^./, (c) => c.toUpperCase())
|
|
69
|
+
.trim();
|
|
70
|
+
}
|