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.
Files changed (73) hide show
  1. package/dist/ide.js +35 -13
  2. package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
  3. package/dist/shell/index.html +1 -1
  4. package/kits/physics-2d/.prettierrc +8 -0
  5. package/kits/physics-2d/CLAUDE.md +329 -0
  6. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  7. package/kits/physics-2d/behaviors/Collider.jsx +199 -0
  8. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  9. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  10. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  11. package/kits/physics-2d/behaviors/tint.js +47 -0
  12. package/kits/physics-2d/blueprints/ball.scene +14 -0
  13. package/kits/physics-2d/blueprints/block.scene +12 -0
  14. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  15. package/kits/physics-2d/blueprints/crate.scene +14 -0
  16. package/kits/physics-2d/blueprints/goal.scene +12 -0
  17. package/kits/physics-2d/castle.json +13 -0
  18. package/kits/physics-2d/docs/pxart-format.md +377 -0
  19. package/kits/physics-2d/drawings/block.pxart +25 -0
  20. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  21. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  22. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  23. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  24. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  25. package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
  26. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  27. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  28. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  29. package/kits/physics-2d/editors/editorHistory.js +157 -0
  30. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  31. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  32. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  33. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  34. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  35. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  36. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  37. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  38. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  39. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  40. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  41. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  42. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  43. package/kits/physics-2d/engine/assets.js +15 -0
  44. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  45. package/kits/physics-2d/engine/blueprint.js +521 -0
  46. package/kits/physics-2d/engine/collider.js +196 -0
  47. package/kits/physics-2d/engine/files.js +117 -0
  48. package/kits/physics-2d/engine/liveReload.js +88 -0
  49. package/kits/physics-2d/engine/pxart.js +1032 -0
  50. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  51. package/kits/physics-2d/engine/scene.js +686 -0
  52. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  53. package/kits/physics-2d/engine/ui.jsx +688 -0
  54. package/kits/physics-2d/engine/ui.module.css +2287 -0
  55. package/kits/physics-2d/eslint.config.js +71 -0
  56. package/kits/physics-2d/index.html +24 -0
  57. package/kits/physics-2d/main.jsx +24 -0
  58. package/kits/physics-2d/package-lock.json +2706 -0
  59. package/kits/physics-2d/package.json +42 -0
  60. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  61. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  62. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  63. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  64. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  65. package/kits/physics-2d/physics/controls.js +79 -0
  66. package/kits/physics-2d/physics/index.js +26 -0
  67. package/kits/physics-2d/physics/matterBridge.js +126 -0
  68. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  69. package/kits/physics-2d/scenes/main.scene +12 -0
  70. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  71. package/kits/physics-2d/scripts/draw.mjs +121 -0
  72. package/kits/physics-2d/vite.config.js +1 -0
  73. 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
+ }