castle-web-cli 0.4.131 → 0.4.133
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.js +2 -2
- package/dist/shell/assets/{index-D6K-0YDB.css → index-Bx4sNpO5.css} +1 -1
- package/dist/shell/assets/{index-BpOLUyVO.js → index-DNDk8nRG.js} +52 -52
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PlayOnly.jsx +3 -2
- package/kits/physics-2d/engine/liveReload.js +120 -23
- package/kits/physics-2d/scripts/svg-emission-guide.md +3 -2
- package/kits/physics-3d/CLAUDE.md +5 -2
- package/kits/physics-3d/castle.json +1 -1
- package/kits/physics-3d/engine3d/modelFiles.js +8 -3
- package/package.json +1 -1
package/dist/shell/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
|
|
11
11
|
<link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
|
|
12
12
|
<link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
|
|
13
|
-
<script type="module" crossorigin src="/__castle/ide/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/__castle/ide/assets/index-DNDk8nRG.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-Bx4sNpO5.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Lifecycle } from 'castle-web-sdk';
|
|
3
|
-
import {
|
|
3
|
+
import { parseJsonFile } from '../engine/files';
|
|
4
|
+
import { useLiveDeckFiles } from '../engine/liveReload';
|
|
4
5
|
import { collectAssets } from '../engine/assets';
|
|
5
6
|
import { ScenePlayer } from '../engine/ScenePlayer';
|
|
6
7
|
import { behaviorClasses } from './behaviorRegistry';
|
|
@@ -22,7 +23,7 @@ import { behaviorClasses } from './behaviorRegistry';
|
|
|
22
23
|
const DEFAULT_SCENE = 'scenes/main.scene';
|
|
23
24
|
|
|
24
25
|
export function PlayOnly({ initialScene } = {}) {
|
|
25
|
-
const files =
|
|
26
|
+
const { files } = useLiveDeckFiles({ live: false });
|
|
26
27
|
const scenePath = initialScene && files[initialScene] !== undefined ? initialScene : DEFAULT_SCENE;
|
|
27
28
|
const { value: sceneData } = parseJsonFile(scenePath, files[scenePath] ?? '');
|
|
28
29
|
if (!sceneData) return null;
|
|
@@ -13,8 +13,15 @@ import { initialFiles } from './files';
|
|
|
13
13
|
const DATA_EXTS = ['.scene', '.pxart', '.sprite', '.style', '.drawing'];
|
|
14
14
|
const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
|
|
15
15
|
|
|
16
|
+
// Extensions the DECK declared that this kit has never heard of, learned from
|
|
17
|
+
// the serve at mount (hydrateDeclaredFiles). Module-level because the change
|
|
18
|
+
// classifier calls isDataFile per event, with no state to thread through.
|
|
19
|
+
const declaredDataExts = new Set();
|
|
20
|
+
|
|
16
21
|
export function isDataFile(path) {
|
|
17
|
-
|
|
22
|
+
if (DATA_EXTS.some((ext) => path.endsWith(ext))) return true;
|
|
23
|
+
for (const ext of declaredDataExts) if (path.endsWith(ext)) return true;
|
|
24
|
+
return false;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
// Which iframe is speaking -- every panel runs its own copy of this.
|
|
@@ -47,34 +54,124 @@ async function readDeckFile(path) {
|
|
|
47
54
|
return typeof data.contents === 'string' ? data.contents : '';
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
57
|
+
// The glob in `files.js` is a compile-time transform, so its patterns can only
|
|
58
|
+
// name extensions THIS KIT knows. A deck that declares its own file type is
|
|
59
|
+
// invisible to it: `files[path]` is undefined, so an editor for that type is
|
|
60
|
+
// handed '' forever -- it renders its empty state over a full file, and its
|
|
61
|
+
// saves never come back through `files`. That breaks the `text`/`onChange`
|
|
62
|
+
// contract the authoring doc teaches, for exactly the types the doc is for.
|
|
63
|
+
//
|
|
64
|
+
// So ask the SERVE what the deck actually declared and read those files in.
|
|
65
|
+
// Declaration-driven rather than another literal list, because a literal list
|
|
66
|
+
// is the bug. Best-effort: a published deck has no serve to ask, and there the
|
|
67
|
+
// glob snapshot is all there is (its play code imports data through the
|
|
68
|
+
// bundler anyway).
|
|
69
|
+
// Null rather than throwing, for every reason a read can come back unusable:
|
|
70
|
+
// no serve, a race with a delete, or a declared type that is binary (an image
|
|
71
|
+
// the deck claims) -- the serve flags those rather than mangling them, and an
|
|
72
|
+
// image decoded as utf-8 is nothing.
|
|
73
|
+
async function readDeclaredFile(file) {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`/__castle/files/read?path=${encodeURIComponent(file)}`);
|
|
76
|
+
if (!res.ok) return null;
|
|
77
|
+
const data = await res.json();
|
|
78
|
+
if (data.binary || typeof data.contents !== 'string') return null;
|
|
79
|
+
return data.contents;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function hydrateDeclaredFiles(ensurePath) {
|
|
86
|
+
let info;
|
|
87
|
+
let listing;
|
|
88
|
+
try {
|
|
89
|
+
[info, listing] = await Promise.all([
|
|
90
|
+
fetch('/__castle/files/info').then((r) => (r.ok ? r.json() : null)),
|
|
91
|
+
fetch('/__castle/files/list').then((r) => (r.ok ? r.json() : null)),
|
|
92
|
+
]);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (!info || !listing) return null;
|
|
97
|
+
const exts = (info.fileTypes ?? [])
|
|
98
|
+
.filter((type) => type.data || (typeof type.editor === 'string' && type.editor !== 'kit'))
|
|
99
|
+
.map((type) => type.ext)
|
|
100
|
+
.filter((ext) => typeof ext === 'string' && ext && !DATA_EXTS.includes(ext));
|
|
101
|
+
if (exts.length === 0) return null;
|
|
102
|
+
for (const ext of exts) declaredDataExts.add(ext);
|
|
103
|
+
const paths = (listing.files ?? []).filter(
|
|
104
|
+
(file) => initialFiles[file] === undefined && exts.some((ext) => file.endsWith(ext))
|
|
105
|
+
);
|
|
106
|
+
const entries = await Promise.all(
|
|
107
|
+
paths.map(async (file) => {
|
|
108
|
+
const text = await readDeclaredFile(file);
|
|
109
|
+
return text === null ? null : [file, text];
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
const extra = Object.fromEntries(entries.filter(Boolean));
|
|
113
|
+
// `list` is the deck's CURATED listing, so a declared file the deck left out
|
|
114
|
+
// of visiblePaths is not in it. The shell can still open that file by path,
|
|
115
|
+
// and an editor handed '' for it would be back to the original bug -- so make
|
|
116
|
+
// sure whatever was actually opened is present, curated or not.
|
|
117
|
+
if (ensurePath && initialFiles[ensurePath] === undefined && extra[ensurePath] === undefined) {
|
|
118
|
+
const one = await readDeclaredFile(ensurePath);
|
|
119
|
+
if (one !== null) extra[ensurePath] = one;
|
|
120
|
+
}
|
|
121
|
+
return Object.keys(extra).length ? extra : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Deck files as live state: starts from the build-time glob snapshot, fills in
|
|
125
|
+
// the deck's own declared types from the serve, and stays current as files
|
|
126
|
+
// change on disk from any source (editors, agents, terminal).
|
|
127
|
+
//
|
|
52
128
|
// `shouldSkipPath` lets an editor keep its own in-flight edits from being
|
|
53
|
-
// clobbered by the fs echo of a stale write.
|
|
54
|
-
|
|
129
|
+
// clobbered by the fs echo of a stale write. `ensurePath` names the file this
|
|
130
|
+
// consumer actually opened, so a declared type outside the curated listing is
|
|
131
|
+
// still read. `live: false` takes the hydration without the subscription --
|
|
132
|
+
// what Play wants, since it never folds a change into a run in progress.
|
|
133
|
+
export function useLiveDeckFiles({ shouldSkipPath, ensurePath, live = true } = {}) {
|
|
55
134
|
const [files, setFiles] = useState(initialFiles);
|
|
135
|
+
// False until the declared-type read above has settled. An editor mounted
|
|
136
|
+
// before then would be handed '' for a file that exists, and an edit made in
|
|
137
|
+
// that window writes its empty document over the real one -- the same data
|
|
138
|
+
// loss the glob gap caused outright.
|
|
139
|
+
const [hydrated, setHydrated] = useState(false);
|
|
56
140
|
const skipRef = useRef(shouldSkipPath);
|
|
57
141
|
skipRef.current = shouldSkipPath;
|
|
58
142
|
|
|
59
|
-
useEffect(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
let alive = true;
|
|
145
|
+
void hydrateDeclaredFiles(ensurePath).then((extra) => {
|
|
146
|
+
if (!alive) return;
|
|
147
|
+
// `current` wins: by the time this lands the editor may already have made
|
|
148
|
+
// an optimistic edit, and the disk copy behind it is stale.
|
|
149
|
+
if (extra) setFiles((current) => ({ ...extra, ...current }));
|
|
150
|
+
setHydrated(true);
|
|
151
|
+
});
|
|
152
|
+
return () => {
|
|
153
|
+
alive = false;
|
|
154
|
+
};
|
|
155
|
+
}, [ensurePath]);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!live) return undefined;
|
|
159
|
+
return onFilesChanged((event) => {
|
|
160
|
+
const stale = event.changes.find(isCodeChange);
|
|
161
|
+
if (stale) console.log(`[reload] ${panelTag()} code stale <- ${stale.path}`);
|
|
162
|
+
// Data still applies in the same event: a change that carries both is
|
|
163
|
+
// one edit, and holding the data back would leave the panel showing
|
|
164
|
+
// neither the old state nor the new one.
|
|
165
|
+
const dataChanges = event.changes.filter(
|
|
166
|
+
(change) => isDataFile(change.path) && !skipRef.current?.(change.path)
|
|
167
|
+
);
|
|
168
|
+
if (dataChanges.length === 0) return;
|
|
169
|
+
console.log(`[reload] ${panelTag()} data <- ${dataChanges.map((c) => c.path).join(', ')}`);
|
|
170
|
+
void applyDataChanges(dataChanges, setFiles);
|
|
171
|
+
});
|
|
172
|
+
}, [live]);
|
|
76
173
|
|
|
77
|
-
return { files, setFiles };
|
|
174
|
+
return { files, setFiles, hydrated };
|
|
78
175
|
}
|
|
79
176
|
|
|
80
177
|
async function applyDataChanges(changes, setFiles) {
|
|
@@ -90,5 +90,6 @@ A curvy silhouette with an overlay accent (later shape covers earlier stroke):
|
|
|
90
90
|
</svg>
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
A newly written sprite is not in the deck's file glob until the panel reloads,
|
|
94
|
+
so an actor pointing at one renders the placeholder until then. That is fine --
|
|
95
|
+
the person reloads when they are ready; do not force it.
|
|
@@ -74,8 +74,11 @@ bottom }`; any ref can pick a frame of a multi-frame pxart with
|
|
|
74
74
|
- Controls are tank-style: W/S (or up/down) walk forward/back along facing,
|
|
75
75
|
A/D (or left/right) turn; a pointer/touch drag is a virtual stick where the
|
|
76
76
|
drag angle blends walking and turning. Space jumps.
|
|
77
|
-
-
|
|
78
|
-
|
|
77
|
+
- Do not reload. Data edits (scenes, drawings, models) appear in the open
|
|
78
|
+
editors on their own. Code edits wait for the person to apply them --
|
|
79
|
+
reloading a panel someone is working in loses their place.
|
|
80
|
+
- After a coherent change: `npm run check` (lint + duplicate gate +
|
|
81
|
+
single-file bundle) must pass.
|
|
79
82
|
|
|
80
83
|
## What lives where
|
|
81
84
|
|
|
@@ -50,7 +50,7 @@ async function readDeckFile(path) {
|
|
|
50
50
|
return typeof data.contents === 'string' ? data.contents : '';
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async function applyDataChanges(changes, setFiles) {
|
|
53
|
+
async function applyDataChanges(changes, setFiles, setDataVersion) {
|
|
54
54
|
const updates = await Promise.all(
|
|
55
55
|
changes.map(async (change) => {
|
|
56
56
|
if (change.event === 'delete') return { path: change.path, text: null };
|
|
@@ -71,6 +71,7 @@ async function applyDataChanges(changes, setFiles) {
|
|
|
71
71
|
}
|
|
72
72
|
return next;
|
|
73
73
|
});
|
|
74
|
+
setDataVersion((version) => version + 1);
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
// Live deck files including models. Same contract as the imported
|
|
@@ -82,6 +83,10 @@ async function applyDataChanges(changes, setFiles) {
|
|
|
82
83
|
// loses their place, so the panel header offers it instead.
|
|
83
84
|
export function useLiveDeckFiles3D({ shouldSkipPath } = {}) {
|
|
84
85
|
const [files, setFiles] = useState(initialFiles3D);
|
|
86
|
+
// Bumped on every applied data change. The editors watch it to re-apply when
|
|
87
|
+
// a file they REFERENCE changed -- their own `text` is unchanged then, so it
|
|
88
|
+
// is the only signal that the drawing under an actor moved.
|
|
89
|
+
const [dataVersion, setDataVersion] = useState(0);
|
|
85
90
|
const skipRef = useRef(shouldSkipPath);
|
|
86
91
|
skipRef.current = shouldSkipPath;
|
|
87
92
|
|
|
@@ -92,12 +97,12 @@ export function useLiveDeckFiles3D({ shouldSkipPath } = {}) {
|
|
|
92
97
|
(change) => isDataFile3D(change.path) && !skipRef.current?.(change.path)
|
|
93
98
|
);
|
|
94
99
|
if (dataChanges.length === 0) return;
|
|
95
|
-
void applyDataChanges(dataChanges, setFiles);
|
|
100
|
+
void applyDataChanges(dataChanges, setFiles, setDataVersion);
|
|
96
101
|
}),
|
|
97
102
|
[]
|
|
98
103
|
);
|
|
99
104
|
|
|
100
|
-
return { files, setFiles };
|
|
105
|
+
return { files, setFiles, dataVersion };
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
// The sprites map with parsed pxarts CACHED BY TEXT: re-parsing every file on
|