castle-web-cli 0.4.114 → 0.4.116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/agent-prompts.js +2 -2
  2. package/dist/agent.d.ts +9 -9
  3. package/dist/agent.js +590 -589
  4. package/dist/castleJson.d.ts +7 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.d.ts +34 -0
  7. package/dist/editorConfig.js +125 -0
  8. package/dist/ide.js +245 -120
  9. package/dist/imports.d.ts +10 -0
  10. package/dist/imports.js +149 -17
  11. package/dist/init.d.ts +3 -0
  12. package/dist/init.js +113 -88
  13. package/dist/install.d.ts +1 -1
  14. package/dist/install.js +26 -24
  15. package/dist/serve.js +16 -0
  16. package/dist/shell/assets/index-DiPlPGyg.js +144 -0
  17. package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
  18. package/dist/shell/index.html +2 -2
  19. package/dist/unsupportedMedia.d.ts +1 -0
  20. package/dist/unsupportedMedia.js +54 -0
  21. package/dist/vitePlugins.js +13 -20
  22. package/kits/basic-2d/castle.json +1 -1
  23. package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
  24. package/kits/physics-2d/CLAUDE.md +65 -34
  25. package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
  26. package/kits/physics-2d/behaviors/Collider.jsx +1 -1
  27. package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
  28. package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +5 -5
  29. package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
  30. package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
  31. package/kits/physics-2d/behaviors/Sound.jsx +152 -0
  32. package/kits/physics-2d/behaviors/Sprite.jsx +112 -42
  33. package/kits/physics-2d/behaviors/Tone.jsx +83 -0
  34. package/kits/physics-2d/behaviors/Video.jsx +111 -0
  35. package/kits/physics-2d/behaviors/tint.js +24 -9
  36. package/kits/physics-2d/blueprints/ball.scene +1 -1
  37. package/kits/physics-2d/blueprints/block.scene +1 -1
  38. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  39. package/kits/physics-2d/blueprints/crate.scene +1 -1
  40. package/kits/physics-2d/castle.json +74 -4
  41. package/kits/physics-2d/docs/pxart-format.md +537 -51
  42. package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
  43. package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
  44. package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
  45. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  46. package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
  47. package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
  48. package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
  49. package/kits/physics-2d/editors/brushFit.js +535 -0
  50. package/kits/physics-2d/editors/brushShapes.js +140 -0
  51. package/kits/physics-2d/editors/mediaFile.js +31 -0
  52. package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
  53. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  54. package/kits/physics-2d/editors/pathTools.js +1906 -0
  55. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  56. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  57. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  58. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  59. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  60. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  61. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  62. package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
  63. package/kits/physics-2d/engine/art.js +105 -0
  64. package/kits/physics-2d/engine/assets.js +12 -4
  65. package/kits/physics-2d/engine/audioContext.js +34 -0
  66. package/kits/physics-2d/engine/blueprint.js +3 -3
  67. package/kits/physics-2d/engine/collider.js +28 -17
  68. package/kits/physics-2d/engine/files.js +77 -1
  69. package/kits/physics-2d/engine/liveReload.js +1 -1
  70. package/kits/physics-2d/engine/media.js +212 -0
  71. package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
  72. package/kits/physics-2d/{physics → engine/physics}/jointArt.js +3 -4
  73. package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
  74. package/kits/physics-2d/engine/pxart.js +153 -35
  75. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  76. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  77. package/kits/physics-2d/engine/scene.js +12 -0
  78. package/kits/physics-2d/engine/tone.js +112 -0
  79. package/kits/physics-2d/engine/ui.jsx +22 -1
  80. package/kits/physics-2d/engine/ui.module.css +36 -12
  81. package/kits/physics-2d/package-lock.json +1 -1
  82. package/kits/physics-2d/scripts/draw.mjs +7 -7
  83. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  84. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  85. package/kits/physics-2d/systems/media.js +34 -0
  86. package/kits/physics-2d/systems/physics.js +12 -3
  87. package/package.json +1 -1
  88. package/dist/shell/assets/index-CqpY1xZR.js +0 -144
  89. package/dist/shell/assets/index-DCwVFL5u.css +0 -1
  90. package/kits/physics-2d/physics/index.js +0 -26
  91. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  92. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  93. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
  94. /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
  95. /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
- import { renderSpriteFrame } from '../engine/pxart';
2
+ import { onArtLoaded, renderArtFrame } from '../engine/art';
3
3
  import { screenToCard } from '../engine/scene';
4
4
  import { countBlueprintInstances, listBlueprints } from '../engine/blueprint';
5
5
  import { ConfirmDialog, ContextMenu, cx, Icon, styles } from '../engine/ui';
@@ -222,11 +222,15 @@ export function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
222
222
  const canvasRef = useRef(null);
223
223
  const spritePath = blueprint.components?.Sprite?.file;
224
224
  const sprite = spritePath ? sprites?.[spritePath] : null;
225
+ // Thumbnails draw on change, not on a frame loop, so an image that finishes
226
+ // decoding after this rendered has to say so or the slot stays blank.
227
+ const [loadTick, setLoadTick] = useState(0);
228
+ useEffect(() => onArtLoaded(() => setLoadTick((tick) => tick + 1)), []);
225
229
  useEffect(() => {
226
230
  const canvas = canvasRef.current;
227
231
  if (!canvas || !sprite) return;
228
- renderSpriteFrame(sprite, 0, canvas);
229
- }, [sprite]);
232
+ renderArtFrame(sprite, 0, canvas);
233
+ }, [sprite, loadTick]);
230
234
  if (!sprite) {
231
235
  return (
232
236
  <span className={styles.bpSlotFallback}>
@@ -0,0 +1,206 @@
1
+ // Read-only image viewer. Opened for the image extensions the deck declares as
2
+ // kit-edited (castle.json `fileTypes`), so an uploaded .png/.jpg/.svg opens as a
3
+ // picture instead of a wall of binary in the code editor.
4
+ //
5
+ // It VIEWS: fit / zoom / pan, at 1:1 when you want to count pixels. Nothing here
6
+ // writes -- editing an image is the pixel editor's job (.pxart), and a photo the
7
+ // user uploaded is not something this kit knows how to modify.
8
+
9
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
10
+ import { EditorBody, IconButton, styles as uiStyles } from '../engine/ui';
11
+ import { useTwoFingerZoomPan } from '../engine/gestureZoomPan';
12
+ import { basename } from '../engine/files';
13
+ import { useMediaUrl } from './mediaFile';
14
+ import styles from './mediaViewer.module.css';
15
+
16
+ const MIN_SCALE = 0.05;
17
+ const MAX_SCALE = 64;
18
+ // Past this the image is being inspected, not previewed: drop smoothing so
19
+ // pixels read as pixels.
20
+ const PIXELATE_ABOVE = 2.5;
21
+ const WHEEL_ZOOM_PER_PX = 0.0016;
22
+
23
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
24
+
25
+ // Zoom `view` by `factor` about a point in STAGE coordinates measured from the
26
+ // stage centre (the transform's origin), holding whatever is under that point
27
+ // still. `x`/`y` are the image centre's offset from the stage centre.
28
+ function zoomAbout(view, factor, px, py) {
29
+ const scale = clamp(view.scale * factor, MIN_SCALE, MAX_SCALE);
30
+ const k = scale / view.scale;
31
+ return { scale, x: px - (px - view.x) * k, y: py - (py - view.y) * k };
32
+ }
33
+
34
+ // Scale that fits the whole image in the stage, with a little breathing room.
35
+ // Small images are scaled UP to fill: at 16x16 a "true size" preview is a speck.
36
+ function fitScale(stage, natural) {
37
+ if (!stage.width || !stage.height || !natural.width || !natural.height) return 1;
38
+ const margin = 24;
39
+ const sx = (stage.width - margin) / natural.width;
40
+ const sy = (stage.height - margin) / natural.height;
41
+ return clamp(Math.min(sx, sy), MIN_SCALE, MAX_SCALE);
42
+ }
43
+
44
+ // The pan/zoom state for the stage, plus the input handlers that drive it:
45
+ // wheel to zoom about the cursor, drag to pan, two-finger pinch/pan on touch.
46
+ // Refits whenever the stage resizes or a different image loads, until the user
47
+ // takes over (`touched`) -- then their framing is theirs to keep.
48
+ function useImageView(stageRef, natural) {
49
+ const [view, setView] = useState({ scale: 1, x: 0, y: 0 });
50
+ const [dragging, setDragging] = useState(false);
51
+ const touched = useRef(false);
52
+ const stageSize = useRef({ width: 0, height: 0 });
53
+
54
+ const fit = useCallback(() => {
55
+ touched.current = false;
56
+ setView({ scale: fitScale(stageSize.current, natural), x: 0, y: 0 });
57
+ }, [natural]);
58
+
59
+ useLayoutEffect(() => {
60
+ const stage = stageRef.current;
61
+ if (!stage || typeof ResizeObserver === 'undefined') return undefined;
62
+ const measure = () => {
63
+ stageSize.current = { width: stage.clientWidth, height: stage.clientHeight };
64
+ if (!touched.current) fit();
65
+ };
66
+ measure();
67
+ const observer = new ResizeObserver(measure);
68
+ observer.observe(stage);
69
+ return () => observer.disconnect();
70
+ }, [stageRef, fit]);
71
+
72
+ // Zoom about a client-space point (cursor, pinch centroid).
73
+ const zoomAt = useCallback((factor, clientX, clientY) => {
74
+ const rect = stageRef.current?.getBoundingClientRect();
75
+ if (!rect) return;
76
+ touched.current = true;
77
+ const px = clientX - rect.left - rect.width / 2;
78
+ const py = clientY - rect.top - rect.height / 2;
79
+ setView((current) => zoomAbout(current, factor, px, py));
80
+ }, [stageRef]);
81
+
82
+ // Zoom about the stage centre, for the toolbar buttons.
83
+ const zoomBy = useCallback((factor) => {
84
+ touched.current = true;
85
+ setView((current) => zoomAbout(current, factor, 0, 0));
86
+ }, []);
87
+
88
+ const setScale = useCallback((scale) => {
89
+ touched.current = true;
90
+ setView((current) => zoomAbout(current, scale / current.scale, 0, 0));
91
+ }, []);
92
+
93
+ const panBy = useCallback((dx, dy) => {
94
+ touched.current = true;
95
+ setView((current) => ({ ...current, x: current.x + dx, y: current.y + dy }));
96
+ }, []);
97
+
98
+ useEffect(() => {
99
+ const stage = stageRef.current;
100
+ if (!stage) return undefined;
101
+ const onWheel = (event) => {
102
+ event.preventDefault();
103
+ zoomAt(Math.exp(-event.deltaY * WHEEL_ZOOM_PER_PX), event.clientX, event.clientY);
104
+ };
105
+ stage.addEventListener('wheel', onWheel, { passive: false });
106
+ return () => stage.removeEventListener('wheel', onWheel);
107
+ }, [stageRef, zoomAt]);
108
+
109
+ // Mouse/pen drag to pan. Touch drags belong to the pinch gesture below, which
110
+ // stops their propagation before they reach this.
111
+ const onPointerDown = (event) => {
112
+ if (event.pointerType === 'touch' || event.button !== 0) return;
113
+ event.currentTarget.setPointerCapture(event.pointerId);
114
+ setDragging(true);
115
+ };
116
+ const onPointerMove = (event) => {
117
+ if (!dragging) return;
118
+ panBy(event.movementX, event.movementY);
119
+ };
120
+ const endDrag = () => setDragging(false);
121
+
122
+ useTwoFingerZoomPan({
123
+ targetRef: stageRef,
124
+ onGestureStart: endDrag,
125
+ onGestureMove: ({ factor, cx, cy, dx, dy }) => {
126
+ zoomAt(factor, cx, cy);
127
+ panBy(dx, dy);
128
+ },
129
+ });
130
+
131
+ return {
132
+ view,
133
+ dragging,
134
+ fit,
135
+ zoomBy,
136
+ setScale,
137
+ stageProps: {
138
+ onPointerDown,
139
+ onPointerMove,
140
+ onPointerUp: endDrag,
141
+ onPointerCancel: endDrag,
142
+ onDoubleClick: fit,
143
+ },
144
+ };
145
+ }
146
+
147
+ export function ImageViewer({ path }) {
148
+ const url = useMediaUrl(path);
149
+ const stageRef = useRef(null);
150
+ const [natural, setNatural] = useState({ width: 0, height: 0 });
151
+ const [status, setStatus] = useState('loading');
152
+ const { view, dragging, fit, zoomBy, setScale, stageProps } = useImageView(stageRef, natural);
153
+
154
+ // A new URL (different file, or the same one re-uploaded) starts over.
155
+ useEffect(() => setStatus('loading'), [url]);
156
+
157
+ const onLoad = (event) => {
158
+ const image = event.currentTarget;
159
+ // SVGs without intrinsic dimensions report 0; fall back so fit has numbers.
160
+ setNatural({ width: image.naturalWidth || 300, height: image.naturalHeight || 300 });
161
+ setStatus('ready');
162
+ };
163
+
164
+ const percent = Math.round(view.scale * 100);
165
+ return (
166
+ <EditorBody>
167
+ <div className={styles.viewer}>
168
+ <div className={styles.toolbar}>
169
+ <IconButton icon="zoom-out" label="Zoom out" onClick={() => zoomBy(1 / 1.4)} />
170
+ <IconButton icon="zoom-in" label="Zoom in" onClick={() => zoomBy(1.4)} />
171
+ <IconButton icon="fit" label="Fit to panel" onClick={fit} />
172
+ <button type="button" className={uiStyles.button} onClick={() => setScale(1)}>
173
+ 1:1
174
+ </button>
175
+ <span className={styles.readout}>
176
+ {status === 'ready' ? `${natural.width}×${natural.height} · ${percent}%` : ''}
177
+ </span>
178
+ </div>
179
+ <div
180
+ ref={stageRef}
181
+ className={`${styles.stage}${dragging ? ` ${styles.dragging}` : ''}`}
182
+ {...stageProps}>
183
+ <img
184
+ src={url}
185
+ alt={basename(path)}
186
+ className={`${styles.image}${view.scale >= PIXELATE_ABOVE ? ` ${styles.pixelated}` : ''}`}
187
+ style={{
188
+ width: natural.width || undefined,
189
+ height: natural.height || undefined,
190
+ transform: `translate(-50%, -50%) translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
191
+ visibility: status === 'ready' ? 'visible' : 'hidden',
192
+ }}
193
+ onLoad={onLoad}
194
+ onError={() => setStatus('error')}
195
+ />
196
+ {status === 'error' ? (
197
+ <div className={`${styles.message} ${styles.messageError}`}>
198
+ Could not load {basename(path)}.
199
+ </div>
200
+ ) : null}
201
+ {status === 'loading' ? <div className={styles.message}>Loading…</div> : null}
202
+ </div>
203
+ </div>
204
+ </EditorBody>
205
+ );
206
+ }
@@ -0,0 +1,57 @@
1
+ // Read-only audio / video player. Opened for the sound and video extensions the
2
+ // deck declares as kit-edited (castle.json `fileTypes`), so an uploaded .mp3 or
3
+ // .mp4 can be played in place instead of being a file you can only guess at from
4
+ // its name.
5
+ //
6
+ // The transport is the BROWSER'S: `<audio controls>` / `<video controls>`. Play,
7
+ // pause, scrub, volume, fullscreen, and picture-in-picture all come with it,
8
+ // they match what the platform's users already know, and they stay accessible
9
+ // and touch-sized without us reimplementing any of it. This file is the frame
10
+ // around that element, not a player.
11
+ //
12
+ // Nothing here writes: trimming or converting a media file is not something this
13
+ // kit does, and a viewer that can't damage the file needs no save state.
14
+
15
+ import { useEffect, useState } from 'react';
16
+ import { EditorBody } from '../engine/ui';
17
+ import { basename } from '../engine/files';
18
+ import { useMediaUrl } from './mediaFile';
19
+ import styles from './mediaViewer.module.css';
20
+
21
+ export function MediaPlayer({ path, kind }) {
22
+ const url = useMediaUrl(path);
23
+ const [failed, setFailed] = useState(false);
24
+ // A new file (or a re-upload of this one) starts over.
25
+ useEffect(() => setFailed(false), [url]);
26
+
27
+ const isVideo = kind === 'video';
28
+ const Media = isVideo ? 'video' : 'audio';
29
+ return (
30
+ <EditorBody>
31
+ <div className={styles.viewer}>
32
+ <div className={styles.mediaStage}>
33
+ <div className={isVideo ? styles.videoPanel : styles.audioPanel}>
34
+ <div className={styles.fileName}>{basename(path)}</div>
35
+ <Media
36
+ key={url}
37
+ className={isVideo ? styles.video : styles.audio}
38
+ src={url}
39
+ controls
40
+ // Metadata only: opening a file shouldn't pull the whole thing
41
+ // down until someone presses play. Video shows its first frame as
42
+ // the poster so the panel isn't a black box.
43
+ preload={isVideo ? 'auto' : 'metadata'}
44
+ playsInline
45
+ onError={() => setFailed(true)}
46
+ />
47
+ {failed ? (
48
+ <div className={styles.inlineError}>
49
+ Could not play {basename(path)} — this browser may not support its format.
50
+ </div>
51
+ ) : null}
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </EditorBody>
56
+ );
57
+ }