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.
Files changed (58) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +703 -222
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +818 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +169 -13
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/playConsole.js +66 -0
  41. package/kits/basic-2d/engine/pxart.js +985 -0
  42. package/kits/basic-2d/engine/scene.js +222 -41
  43. package/kits/basic-2d/engine/ui.jsx +155 -26
  44. package/kits/basic-2d/engine/ui.module.css +1385 -332
  45. package/kits/basic-2d/eslint.config.js +21 -0
  46. package/kits/basic-2d/index.html +13 -0
  47. package/kits/basic-2d/package.json +1 -0
  48. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  49. package/kits/basic-2d/scenes/main.scene +19 -26
  50. package/kits/basic-2d/scripts/draw.mjs +121 -0
  51. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  52. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  53. package/package.json +1 -1
  54. package/dist/shell/assets/index-vmdKwUE1.js +0 -106
  55. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  56. package/kits/basic-2d/drawings/block.drawing +0 -70
  57. package/kits/basic-2d/drawings/default.drawing +0 -70
  58. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
@@ -1,52 +1,13 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
2
  import { javascript } from '@codemirror/lang-javascript';
3
- import { HighlightStyle, indentUnit, syntaxHighlighting } from '@codemirror/language';
3
+ import { indentUnit, syntaxHighlighting } from '@codemirror/language';
4
4
  import { EditorState } from '@codemirror/state';
5
5
  import { EditorView } from '@codemirror/view';
6
- import { tags } from '@lezer/highlight';
7
6
  import { basicSetup } from 'codemirror';
8
7
  import { basename } from '../engine/files';
9
8
  import { EditorBody, EditorHeader, styles } from '../engine/ui';
10
- const castleHighlightStyle = HighlightStyle.define([
11
- { tag: tags.strong, color: '#285CC4' },
12
- { tag: tags.namespace, color: '#BB7547' },
13
- { tag: tags.keyword, color: '#BC4A9B' },
14
- { tag: [tags.literal, tags.inserted], color: '#5DAF8D' },
15
- { tag: [tags.string, tags.deleted], color: '#E86A73' },
16
- { tag: tags.comment, color: '#8B93AF', fontStyle: 'italic' },
17
- ]);
18
- const castleCodeTheme = EditorView.theme({
19
- '&': {
20
- width: '100%',
21
- height: '100%',
22
- fontSize: '9pt',
23
- backgroundColor: '#fff',
24
- },
25
- '&.cm-editor.cm-focused': {
26
- outline: 'none',
27
- },
28
- '.cm-scroller': {
29
- overflow: 'auto',
30
- fontFamily: 'Menlo, Monaco, Lucida Console, monospace',
31
- },
32
- '.cm-content': {
33
- minHeight: '100%',
34
- color: '#322b28',
35
- paddingBottom: '400px',
36
- paddingRight: '16px',
37
- },
38
- '.cm-gutters': {
39
- display: 'none',
40
- },
41
- '.cm-activeLine': {
42
- backgroundColor: '#eeeeeea0',
43
- },
44
- '.cm-activeLineGutter': {
45
- color: '#000',
46
- backgroundColor: '#ddd',
47
- },
48
- });
49
- export function CodeEditor({ path, text, onChange, onToggleFiles, filesOpen, bare = false }) {
9
+ import { castleCodeTheme, castleHighlightStyle } from './codeTheme';
10
+ export function CodeEditor({ path, text, onChange, onToggleFiles, filesOpen, headerHost }) {
50
11
  const editorRef = useRef(null);
51
12
  const viewRef = useRef(null);
52
13
  const onChangeRef = useRef(onChange);
@@ -102,9 +63,12 @@ export function CodeEditor({ path, text, onChange, onToggleFiles, filesOpen, bar
102
63
  }, [text]);
103
64
  return (
104
65
  <>
105
- {bare ? null : (
106
- <EditorHeader title={basename(path)} onToggleFiles={onToggleFiles} filesOpen={filesOpen} />
107
- )}
66
+ <EditorHeader
67
+ title={basename(path)}
68
+ onToggleFiles={onToggleFiles}
69
+ filesOpen={filesOpen}
70
+ headerHost={headerHost}
71
+ />
108
72
  <EditorBody>
109
73
  <div className={styles.codeEditor}>
110
74
  <div ref={editorRef} className={styles.codeMirrorHost} />
@@ -1,11 +1,40 @@
1
- import React, { useState } from 'react';
1
+ import React, { useEffect, useRef, useState } from 'react';
2
2
  import { basename } from '../engine/files';
3
- import { cx, Icon, SheetGrabHandle, styles, useMobileSheet } from '../engine/ui';
4
- // Just the file tree (no aside / mobile-sheet / header chrome) so it can be
5
- // reused bare in the shell's file panel. All directories expanded by default.
6
- export function FileTree({ files, selectedPath, onSelect }) {
3
+ import { ContextMenu, cx, Icon, SheetGrabHandle, styles, useMobileSheet } from '../engine/ui';
4
+ // The file tree plus its right-click menus and inline rename. By default it
5
+ // renders inside an `aside` with mobile-sheet chrome (the combined editor's
6
+ // sidebar). Pass `bare` to drop that chrome and render just the tree + menus so
7
+ // it can be mounted on its own in the dockview "Files" panel.
8
+ //
9
+ // The file-op props (onAddFile / onDuplicate / onRename / onDelete) are
10
+ // OPTIONAL. When none are provided the browser is read-only: no context menus,
11
+ // no inline rename, no add affordances -- just a browsable, clickable tree.
12
+ export function FileBrowser({
13
+ files,
14
+ selectedPath,
15
+ onSelect,
16
+ onAddFile,
17
+ onDuplicate,
18
+ onRename,
19
+ onDelete,
20
+ sheetOpen = false,
21
+ onSheetOpenChange,
22
+ bare = false,
23
+ }) {
24
+ const canMutate = Boolean(onAddFile || onDuplicate || onRename || onDelete);
7
25
  const tree = buildFileTree(Object.keys(files));
8
26
  const [expanded, setExpanded] = useState(() => new Set(collectDirectoryPaths(tree)));
27
+ // `menu` is { x, y, node } for a row menu, or { x, y, node: null } for the
28
+ // panel-background menu. `renaming` holds the path whose row is in edit mode.
29
+ const [menu, setMenu] = useState(null);
30
+ const [renaming, setRenaming] = useState(null);
31
+ const sheet = useMobileSheet({
32
+ snap: sheetOpen ? 'high' : 'hidden',
33
+ baseClassName: styles.fileBrowser,
34
+ onTransition: (direction) => {
35
+ if (direction !== 'up') onSheetOpenChange?.(false);
36
+ },
37
+ });
9
38
  function toggle(path) {
10
39
  setExpanded((current) => {
11
40
  const next = new Set(current);
@@ -14,7 +43,49 @@ export function FileTree({ files, selectedPath, onSelect }) {
14
43
  return next;
15
44
  });
16
45
  }
17
- return (
46
+ function expandDir(path) {
47
+ if (!path) return;
48
+ setExpanded((current) => new Set(current).add(path));
49
+ }
50
+ function openMenu(event, node) {
51
+ if (!canMutate) return;
52
+ event.preventDefault();
53
+ event.stopPropagation();
54
+ setMenu({ x: event.clientX, y: event.clientY, node });
55
+ }
56
+ function handleAddFile(dirPath) {
57
+ onAddFile?.(dirPath);
58
+ expandDir(dirPath);
59
+ }
60
+ function handleDuplicate(node) {
61
+ const newPath = onDuplicate?.(node);
62
+ if (node.type === 'directory' && newPath) expandDir(newPath);
63
+ }
64
+ function submitRename(node, rawName) {
65
+ setRenaming(null);
66
+ const newName = rawName.trim();
67
+ if (!newName || newName === node.name) return;
68
+ const newPath = onRename?.(node, newName);
69
+ if (node.type === 'directory' && newPath && newPath !== node.path) {
70
+ setExpanded((current) => repathExpanded(current, node.path, newPath));
71
+ }
72
+ }
73
+ function menuItems(node) {
74
+ const fileOnly = [
75
+ { key: 'duplicate', label: 'Duplicate', onClick: () => handleDuplicate(node) },
76
+ { key: 'rename', label: 'Rename', onClick: () => setRenaming(node.path) },
77
+ { key: 'delete', label: 'Delete', onClick: () => onDelete?.(node) },
78
+ ];
79
+ if (!node) return [{ key: 'add', label: 'Add file', onClick: () => handleAddFile('') }];
80
+ if (node.type === 'directory') {
81
+ return [
82
+ { key: 'add', label: 'Add file', onClick: () => handleAddFile(node.path) },
83
+ ...fileOnly,
84
+ ];
85
+ }
86
+ return fileOnly;
87
+ }
88
+ const treeNodes = (
18
89
  <div className={styles.fileTree}>
19
90
  {tree.children.map((node) => (
20
91
  <FileNode
@@ -23,56 +94,89 @@ export function FileTree({ files, selectedPath, onSelect }) {
23
94
  depth={0}
24
95
  expanded={expanded}
25
96
  selectedPath={selectedPath}
97
+ renaming={renaming}
26
98
  onSelect={onSelect}
27
99
  onToggle={toggle}
100
+ onContextMenu={openMenu}
101
+ onRenameSubmit={submitRename}
102
+ onRenameCancel={() => setRenaming(null)}
28
103
  />
29
104
  ))}
30
105
  </div>
31
106
  );
32
- }
33
- export function FileBrowser({
34
- files,
35
- selectedPath,
36
- onSelect,
37
- sheetOpen = false,
38
- onSheetOpenChange,
39
- }) {
40
- const sheet = useMobileSheet({
41
- snap: sheetOpen ? 'high' : 'hidden',
42
- baseClassName: styles.fileBrowser,
43
- onTransition: (direction) => {
44
- if (direction !== 'up') onSheetOpenChange?.(false);
45
- },
46
- });
107
+ const menuEl = menu ? (
108
+ <ContextMenu x={menu.x} y={menu.y} items={menuItems(menu.node)} onClose={() => setMenu(null)} />
109
+ ) : null;
110
+ // Panel mount: no sheet/grab-handle chrome, just a full-height tree whose
111
+ // empty area below the rows opens the blank-area ("Add file") menu.
112
+ if (bare) {
113
+ return (
114
+ <div className={styles.fileBrowserBare} onContextMenu={(event) => openMenu(event, null)}>
115
+ {treeNodes}
116
+ {menuEl}
117
+ </div>
118
+ );
119
+ }
47
120
  return (
48
- <aside {...sheet.rootProps}>
121
+ <aside {...sheet.rootProps} onContextMenu={(event) => openMenu(event, null)}>
49
122
  <div {...sheet.grabProps}>
50
123
  <SheetGrabHandle label="Files" hint={basename(selectedPath)} />
51
124
  </div>
52
- <div className={styles.fileBrowserHeader}>
53
- <div>
54
- <div className={styles.fileBrowserTitle}>Deck Files</div>
55
- </div>
56
- </div>
57
- <FileTree files={files} selectedPath={selectedPath} onSelect={onSelect} />
125
+ {treeNodes}
126
+ {menuEl}
58
127
  </aside>
59
128
  );
60
129
  }
61
- function FileNode({ node, depth, expanded, selectedPath, onSelect, onToggle }) {
130
+ function FileNode({
131
+ node,
132
+ depth,
133
+ expanded,
134
+ selectedPath,
135
+ renaming,
136
+ onSelect,
137
+ onToggle,
138
+ onContextMenu,
139
+ onRenameSubmit,
140
+ onRenameCancel,
141
+ }) {
62
142
  const depthStyle = { '--file-depth': depth };
143
+ const isRenaming = renaming === node.path;
63
144
  if (node.type === 'directory') {
64
145
  const isExpanded = expanded.has(node.path);
146
+ const label = isRenaming ? (
147
+ <span className={styles.fileLabel}>
148
+ <span className={cx(styles.fileIcon, styles.fileDisclosure)}>
149
+ <Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} />
150
+ </span>
151
+ <RenameInput
152
+ initialName={node.name}
153
+ onSubmit={(value) => onRenameSubmit(node, value)}
154
+ onCancel={onRenameCancel}
155
+ />
156
+ </span>
157
+ ) : (
158
+ <span className={styles.fileLabel}>
159
+ <span className={cx(styles.fileIcon, styles.fileDisclosure)}>
160
+ <Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} />
161
+ </span>
162
+ <span>{node.name}</span>
163
+ </span>
164
+ );
65
165
  return (
66
166
  <div className={styles.fileBranch}>
67
- <button
68
- className={styles.fileDirRow}
69
- style={depthStyle}
70
- onClick={() => onToggle(node.path)}>
71
- <span className={styles.fileDisclosure}>
72
- <Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} />
73
- </span>
74
- <span>{node.name}</span>
75
- </button>
167
+ {isRenaming ? (
168
+ <div className={styles.fileDirRow} style={depthStyle}>
169
+ {label}
170
+ </div>
171
+ ) : (
172
+ <button
173
+ className={styles.fileDirRow}
174
+ style={depthStyle}
175
+ onClick={() => onToggle(node.path)}
176
+ onContextMenu={(event) => onContextMenu(event, node)}>
177
+ {label}
178
+ </button>
179
+ )}
76
180
  {isExpanded ? (
77
181
  <div>
78
182
  {node.children.map((child) => (
@@ -82,8 +186,12 @@ function FileNode({ node, depth, expanded, selectedPath, onSelect, onToggle }) {
82
186
  depth={depth + 1}
83
187
  expanded={expanded}
84
188
  selectedPath={selectedPath}
189
+ renaming={renaming}
85
190
  onSelect={onSelect}
86
191
  onToggle={onToggle}
192
+ onContextMenu={onContextMenu}
193
+ onRenameSubmit={onRenameSubmit}
194
+ onRenameCancel={onRenameCancel}
87
195
  />
88
196
  ))}
89
197
  </div>
@@ -91,21 +199,100 @@ function FileNode({ node, depth, expanded, selectedPath, onSelect, onToggle }) {
91
199
  </div>
92
200
  );
93
201
  }
94
- // File rows nest under their directory header: extra left padding so
95
- // file text sits to the right of the parent directory header.
96
- const fileRowStyle = {
97
- ...depthStyle,
98
- paddingLeft: `calc(9px + ${depth} * 16px + 7px)`,
99
- };
202
+ if (isRenaming) {
203
+ return (
204
+ <div className={styles.fileRow} style={depthStyle}>
205
+ <span className={styles.fileLabel}>
206
+ <span className={styles.fileIcon}>
207
+ <Icon name={fileIconName(node.path)} />
208
+ </span>
209
+ <RenameInput
210
+ initialName={basename(node.path)}
211
+ onSubmit={(value) => onRenameSubmit(node, value)}
212
+ onCancel={onRenameCancel}
213
+ />
214
+ </span>
215
+ </div>
216
+ );
217
+ }
100
218
  return (
101
219
  <button
102
220
  className={cx(styles.fileRow, selectedPath === node.path && styles.fileRowSelected)}
103
- style={fileRowStyle}
104
- onClick={() => onSelect(node.path)}>
105
- <span>{basename(node.path)}</span>
221
+ style={depthStyle}
222
+ onClick={() => onSelect(node.path)}
223
+ onContextMenu={(event) => onContextMenu(event, node)}>
224
+ <span className={styles.fileLabel}>
225
+ <span className={styles.fileIcon}>
226
+ <Icon name={fileIconName(node.path)} />
227
+ </span>
228
+ <span>{basename(node.path)}</span>
229
+ </span>
106
230
  </button>
107
231
  );
108
232
  }
233
+ // Inline rename field: focuses + selects on mount, commits on Enter or blur,
234
+ // abandons on Escape. A done-guard keeps the blur that follows an
235
+ // Enter/Escape from firing a second submit/cancel.
236
+ function RenameInput({ initialName, onSubmit, onCancel }) {
237
+ const [value, setValue] = useState(initialName);
238
+ const inputRef = useRef(null);
239
+ const doneRef = useRef(false);
240
+ useEffect(() => {
241
+ const el = inputRef.current;
242
+ if (!el) return;
243
+ el.focus();
244
+ el.select();
245
+ }, []);
246
+ function commit() {
247
+ if (doneRef.current) return;
248
+ doneRef.current = true;
249
+ onSubmit(value);
250
+ }
251
+ function abandon() {
252
+ if (doneRef.current) return;
253
+ doneRef.current = true;
254
+ onCancel();
255
+ }
256
+ return (
257
+ <input
258
+ ref={inputRef}
259
+ className={styles.fileRenameInput}
260
+ value={value}
261
+ onClick={(event) => event.stopPropagation()}
262
+ onChange={(event) => setValue(event.target.value)}
263
+ onBlur={commit}
264
+ onKeyDown={(event) => {
265
+ if (event.key === 'Enter') {
266
+ event.preventDefault();
267
+ commit();
268
+ } else if (event.key === 'Escape') {
269
+ event.preventDefault();
270
+ abandon();
271
+ }
272
+ }}
273
+ />
274
+ );
275
+ }
276
+ // Re-path a set of expanded directory paths after a folder rename so the
277
+ // renamed folder (and any expanded descendants) stay open in place.
278
+ function repathExpanded(set, oldDir, newDir) {
279
+ const next = new Set();
280
+ for (const path of set) {
281
+ if (path === oldDir) next.add(newDir);
282
+ else if (path.startsWith(`${oldDir}/`)) next.add(`${newDir}${path.slice(oldDir.length)}`);
283
+ else next.add(path);
284
+ }
285
+ return next;
286
+ }
287
+ // Per-file-type icon, keyed by extension. Sprites (.pxart) -> layer-group,
288
+ // scenes -> globe, behaviors (.jsx) -> code, anything else -> a generic file
289
+ // icon.
290
+ function fileIconName(path) {
291
+ if (path.endsWith('.pxart')) return 'layer-group';
292
+ if (path.endsWith('.scene')) return 'globe';
293
+ if (path.endsWith('.jsx')) return 'code';
294
+ return 'file';
295
+ }
109
296
  function buildFileTree(paths) {
110
297
  const root = {
111
298
  type: 'directory',
@@ -1,5 +1,7 @@
1
1
  import React from 'react';
2
+ import { Lifecycle } from 'castle-web-sdk';
2
3
  import { initialFiles, parseJsonFile } from '../engine/files';
4
+ import { collectAssets } from '../engine/assets';
3
5
  import { ScenePlayer } from '../engine/ScenePlayer';
4
6
  import { behaviorClasses } from './behaviorRegistry';
5
7
  // Play-mode entry point. Intentionally thin: it locates the start scene and
@@ -9,13 +11,13 @@ export function PlayOnly() {
9
11
  const sceneText = initialFiles['scenes/main.scene'] ?? '';
10
12
  const { value: sceneData } = parseJsonFile('scenes/main.scene', sceneText);
11
13
  if (!sceneData) return null;
12
- const drawings = {};
13
- for (const [path, text] of Object.entries(initialFiles)) {
14
- if (!path.endsWith('.drawing')) continue;
15
- const parsed = parseJsonFile(path, text);
16
- if (parsed.value) drawings[path] = parsed.value;
17
- }
14
+ const { sprites } = collectAssets(initialFiles);
18
15
  return (
19
- <ScenePlayer sceneData={sceneData} drawings={drawings} behaviorClasses={behaviorClasses} />
16
+ <ScenePlayer
17
+ sceneData={sceneData}
18
+ sprites={sprites}
19
+ behaviorClasses={behaviorClasses}
20
+ onFirstFrame={Lifecycle.ready}
21
+ />
20
22
  );
21
23
  }