castle-web-cli 0.4.1 → 0.4.2
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/api.d.ts +53 -5
- package/dist/api.js +42 -15
- package/dist/config.d.ts +2 -0
- package/dist/config.js +25 -11
- package/dist/get-deck.d.ts +3 -0
- package/dist/get-deck.js +64 -0
- package/dist/ide-client.d.ts +1 -0
- package/dist/ide-client.js +537 -0
- package/dist/ide.d.ts +16 -0
- package/dist/ide.js +546 -0
- package/dist/index.js +36 -41
- package/dist/init.d.ts +3 -1
- package/dist/init.js +170 -24
- package/dist/localPaths.d.ts +6 -0
- package/dist/localPaths.js +33 -0
- package/dist/login.js +1 -1
- package/dist/preview.d.ts +3 -0
- package/dist/preview.js +53 -34
- package/dist/save-deck.d.ts +2 -0
- package/dist/{push.js → save-deck.js} +66 -5
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +290 -27
- package/kits/basic-2d/.prettierrc +8 -0
- package/kits/basic-2d/CLAUDE.md +131 -0
- package/kits/basic-2d/behaviors/Camera.jsx +43 -0
- package/kits/basic-2d/behaviors/Collider.jsx +71 -0
- package/kits/basic-2d/behaviors/Drawing.jsx +139 -0
- package/kits/basic-2d/behaviors/Layout.jsx +16 -0
- package/kits/basic-2d/drawings/floor.drawing +70 -0
- package/kits/basic-2d/editors/App.jsx +152 -0
- package/kits/basic-2d/editors/CodeEditor.jsx +112 -0
- package/kits/basic-2d/editors/DrawingEditor.jsx +222 -0
- package/kits/basic-2d/editors/FileBrowser.jsx +143 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +21 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +1012 -0
- package/kits/basic-2d/editors/behaviorRegistry.js +24 -0
- package/kits/basic-2d/editors/editorHistory.js +52 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +83 -0
- package/kits/basic-2d/engine/SceneUI.jsx +67 -0
- package/kits/basic-2d/engine/TouchControls.jsx +136 -0
- package/kits/basic-2d/engine/autoInspector.jsx +51 -0
- package/kits/basic-2d/engine/files.js +62 -0
- package/kits/basic-2d/engine/scene.js +420 -0
- package/kits/basic-2d/engine/ui.jsx +344 -0
- package/kits/basic-2d/engine/ui.module.css +928 -0
- package/kits/basic-2d/eslint.config.js +50 -0
- package/kits/basic-2d/index.html +11 -0
- package/kits/basic-2d/main.jsx +10 -0
- package/kits/basic-2d/package-lock.json +2706 -0
- package/kits/basic-2d/package.json +41 -0
- package/kits/basic-2d/scenes/main.scene +108 -0
- package/kits/basic-2d/vite.config.js +1 -0
- package/kits/basic-2d-frozen/.prettierrc +8 -0
- package/kits/basic-2d-frozen/CLAUDE.md +131 -0
- package/kits/basic-2d-frozen/behaviors/Camera.jsx +43 -0
- package/kits/basic-2d-frozen/behaviors/Collider.jsx +71 -0
- package/kits/basic-2d-frozen/behaviors/Drawing.jsx +139 -0
- package/kits/basic-2d-frozen/behaviors/Layout.jsx +16 -0
- package/kits/basic-2d-frozen/drawings/floor.drawing +70 -0
- package/kits/basic-2d-frozen/editors/App.jsx +152 -0
- package/kits/basic-2d-frozen/editors/CodeEditor.jsx +112 -0
- package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +222 -0
- package/kits/basic-2d-frozen/editors/FileBrowser.jsx +143 -0
- package/kits/basic-2d-frozen/editors/PlayOnly.jsx +21 -0
- package/kits/basic-2d-frozen/editors/SceneEditor.jsx +1012 -0
- package/kits/basic-2d-frozen/editors/behaviorRegistry.js +24 -0
- package/kits/basic-2d-frozen/editors/editorHistory.js +52 -0
- package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +83 -0
- package/kits/basic-2d-frozen/engine/SceneUI.jsx +67 -0
- package/kits/basic-2d-frozen/engine/TouchControls.jsx +136 -0
- package/kits/basic-2d-frozen/engine/autoInspector.jsx +51 -0
- package/kits/basic-2d-frozen/engine/files.js +62 -0
- package/kits/basic-2d-frozen/engine/scene.js +420 -0
- package/kits/basic-2d-frozen/engine/ui.jsx +344 -0
- package/kits/basic-2d-frozen/engine/ui.module.css +928 -0
- package/kits/basic-2d-frozen/eslint.config.js +50 -0
- package/kits/basic-2d-frozen/index.html +11 -0
- package/kits/basic-2d-frozen/main.jsx +10 -0
- package/kits/basic-2d-frozen/package-lock.json +2706 -0
- package/kits/basic-2d-frozen/package.json +41 -0
- package/kits/basic-2d-frozen/scenes/main.scene +108 -0
- package/kits/basic-2d-frozen/vite.config.js +1 -0
- package/kits/rpg-2d/.prettierrc +8 -0
- package/kits/rpg-2d/behaviors/Camera.tsx +52 -0
- package/kits/rpg-2d/behaviors/Collider.tsx +98 -0
- package/kits/rpg-2d/behaviors/Dialog.tsx +184 -0
- package/kits/rpg-2d/behaviors/Drawing.tsx +161 -0
- package/kits/rpg-2d/behaviors/Friend.tsx +45 -0
- package/kits/rpg-2d/behaviors/Layout.tsx +29 -0
- package/kits/rpg-2d/behaviors/PlayerController.tsx +255 -0
- package/kits/rpg-2d/behaviors/Portal.tsx +60 -0
- package/kits/rpg-2d/behaviors/QuestLog.tsx +90 -0
- package/kits/rpg-2d/behaviors/SaveMenu.tsx +123 -0
- package/kits/rpg-2d/behaviors/Tilemap.tsx +90 -0
- package/kits/rpg-2d/drawings/bld-home.drawing +8136 -0
- package/kits/rpg-2d/drawings/env-crate.drawing +509 -0
- package/kits/rpg-2d/drawings/env-fence.drawing +536 -0
- package/kits/rpg-2d/drawings/env-flower-bed.drawing +607 -0
- package/kits/rpg-2d/drawings/env-fountain.drawing +2622 -0
- package/kits/rpg-2d/drawings/env-hedge.drawing +601 -0
- package/kits/rpg-2d/drawings/env-house-blue.drawing +1 -0
- package/kits/rpg-2d/drawings/env-house-green.drawing +1 -0
- package/kits/rpg-2d/drawings/env-tree-oak.drawing +1540 -0
- package/kits/rpg-2d/drawings/env-tree-pine.drawing +1315 -0
- package/kits/rpg-2d/drawings/floor.drawing +70 -0
- package/kits/rpg-2d/drawings/fx-sparkle.drawing +926 -0
- package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +1099 -0
- package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +4177 -0
- package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +1099 -0
- package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +4177 -0
- package/kits/rpg-2d/drawings/player-idle-down.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-left.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-right.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-up.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-walk-down.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-left.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-right.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-up.drawing +4148 -0
- package/kits/rpg-2d/editors/App.tsx +163 -0
- package/kits/rpg-2d/editors/CodeEditor.tsx +120 -0
- package/kits/rpg-2d/editors/DrawingEditor.tsx +278 -0
- package/kits/rpg-2d/editors/FileBrowser.tsx +191 -0
- package/kits/rpg-2d/editors/PlayOnly.tsx +26 -0
- package/kits/rpg-2d/editors/SceneEditor.tsx +1093 -0
- package/kits/rpg-2d/editors/behaviorRegistry.ts +33 -0
- package/kits/rpg-2d/editors/editorHistory.ts +75 -0
- package/kits/rpg-2d/editors/editorProps.ts +10 -0
- package/kits/rpg-2d/engine/ScenePlayer.tsx +130 -0
- package/kits/rpg-2d/engine/SceneUI.tsx +74 -0
- package/kits/rpg-2d/engine/TouchControls.tsx +157 -0
- package/kits/rpg-2d/engine/autoInspector.tsx +111 -0
- package/kits/rpg-2d/engine/drawing.ts +81 -0
- package/kits/rpg-2d/engine/files.ts +215 -0
- package/kits/rpg-2d/engine/scene.ts +484 -0
- package/kits/rpg-2d/engine/ui.module.css +928 -0
- package/kits/rpg-2d/engine/ui.tsx +483 -0
- package/kits/rpg-2d/eslint.config.js +46 -0
- package/kits/rpg-2d/index.html +11 -0
- package/kits/rpg-2d/main.tsx +14 -0
- package/kits/rpg-2d/package-lock.json +3149 -0
- package/kits/rpg-2d/package.json +46 -0
- package/kits/rpg-2d/scenes/main.scene +203 -0
- package/kits/rpg-2d/tsconfig.json +17 -0
- package/kits/rpg-2d/vite-env.d.ts +7 -0
- package/kits/rpg-2d/vite.config.js +1 -0
- package/package.json +27 -5
- package/AGENTS.md +0 -25
- package/dist/push.d.ts +0 -1
- package/src/api.ts +0 -160
- package/src/bundle.ts +0 -28
- package/src/config.ts +0 -36
- package/src/index.ts +0 -143
- package/src/init.ts +0 -71
- package/src/login.ts +0 -24
- package/src/preview.ts +0 -94
- package/src/push.ts +0 -118
- package/src/serve.ts +0 -134
- package/tsconfig.json +0 -13
package/dist/serve.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as net from 'net';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
4
5
|
import { createServer } from 'vite';
|
|
5
6
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
7
|
+
import { createIdeServer, DECK_FOCUS_SCRIPT } from './ide.js';
|
|
8
|
+
import * as config from './config.js';
|
|
6
9
|
function isPortFree(port) {
|
|
7
10
|
return new Promise((resolve) => {
|
|
8
11
|
const srv = net.createServer();
|
|
@@ -18,21 +21,59 @@ async function findFreePorts(startPort) {
|
|
|
18
21
|
}
|
|
19
22
|
throw new Error(`No free port pair found starting from ${startPort}`);
|
|
20
23
|
}
|
|
21
|
-
function castlePlugin(wsPort) {
|
|
24
|
+
function castlePlugin(wsPort, ideServer) {
|
|
22
25
|
return {
|
|
23
26
|
name: 'castle-dev',
|
|
27
|
+
transformIndexHtml: {
|
|
28
|
+
order: 'pre',
|
|
29
|
+
handler(html) {
|
|
30
|
+
return html.replace(/<head(\s[^>]*)?>/i, (match) => `${match}\n <script>window.CastleEmbed={edit:true};</script>\n ${DECK_FOCUS_SCRIPT}`);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
24
33
|
configureServer(server) {
|
|
25
34
|
server.middlewares.use((req, res, next) => {
|
|
26
35
|
if (req.url === '/__castle/ws-port') {
|
|
27
36
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
28
|
-
res.end(JSON.stringify({ port: wsPort }));
|
|
37
|
+
res.end(JSON.stringify({ port: wsPort, path: '/__castle/ws' }));
|
|
29
38
|
return;
|
|
30
39
|
}
|
|
40
|
+
if (req.url === '/__castle/auth') {
|
|
41
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
42
|
+
res.end(JSON.stringify({ token: config.getToken(), userId: config.getUserId() }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (req.url === '/__castle/context') {
|
|
46
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
47
|
+
res.end(JSON.stringify(readDeckContext(server.config.root)));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// The split-view shell (`/`) + its static assets are served outside
|
|
51
|
+
// Vite's module pipeline; the deck itself loads in the iframe via
|
|
52
|
+
// `/index.html`, which Vite still serves normally.
|
|
53
|
+
if (req.url) {
|
|
54
|
+
const reqPath = req.url.split('?')[0];
|
|
55
|
+
if (ideServer.handleHttpRequest(req, res, reqPath))
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
31
58
|
next();
|
|
32
59
|
});
|
|
33
60
|
},
|
|
34
61
|
};
|
|
35
62
|
}
|
|
63
|
+
function readDeckContext(projectDir) {
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs.readFileSync(path.join(projectDir, 'castle.json'), 'utf8');
|
|
66
|
+
const data = JSON.parse(raw);
|
|
67
|
+
const deckId = typeof data.deckId === 'string' ? data.deckId : undefined;
|
|
68
|
+
const cardId = typeof data.cardId === 'string' ? data.cardId : undefined;
|
|
69
|
+
return { deckId, cardId };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const DEFAULT_PORT = 5757;
|
|
76
|
+
const SCREENSHOT_REQUEST_TTL_MS = 15_000;
|
|
36
77
|
export async function serve(dir, options = {}) {
|
|
37
78
|
const projectDir = path.resolve(dir);
|
|
38
79
|
if (!fs.existsSync(projectDir)) {
|
|
@@ -43,7 +84,21 @@ export async function serve(dir, options = {}) {
|
|
|
43
84
|
console.error(`No index.html found in ${projectDir}`);
|
|
44
85
|
process.exit(1);
|
|
45
86
|
}
|
|
46
|
-
|
|
87
|
+
if (options.detach) {
|
|
88
|
+
await serveDetached(projectDir, options);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Close stdin when not attached to a TTY so the process can be cleanly
|
|
92
|
+
// backgrounded with `&` (otherwise stdin reads can block or hold the parent).
|
|
93
|
+
if (!process.stdin.isTTY) {
|
|
94
|
+
try {
|
|
95
|
+
process.stdin.destroy();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// already destroyed / inaccessible
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const startPort = parseInt(options.port ?? String(DEFAULT_PORT), 10);
|
|
47
102
|
const { port, wsPort } = options.port
|
|
48
103
|
? { port: startPort, wsPort: startPort + 1 }
|
|
49
104
|
: await findFreePorts(startPort);
|
|
@@ -54,38 +109,173 @@ export async function serve(dir, options = {}) {
|
|
|
54
109
|
const logFile = path.join(castleDir, 'logs.txt');
|
|
55
110
|
const screenshotsDir = path.join(castleDir, 'screenshots');
|
|
56
111
|
const serveJsonPath = path.join(castleDir, 'serve.json');
|
|
57
|
-
fs.writeFileSync(serveJsonPath, JSON.stringify({ port, wsPort }) + '\n');
|
|
58
|
-
process.on('exit', () => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
112
|
+
fs.writeFileSync(serveJsonPath, JSON.stringify({ port, wsPort, pid: process.pid }) + '\n');
|
|
113
|
+
process.on('exit', () => {
|
|
114
|
+
try {
|
|
115
|
+
fs.unlinkSync(serveJsonPath);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// already removed / never written
|
|
119
|
+
}
|
|
120
|
+
});
|
|
62
121
|
process.on('SIGINT', () => process.exit());
|
|
63
122
|
process.on('SIGTERM', () => process.exit());
|
|
64
|
-
|
|
123
|
+
// The WS server forwards `restart` to the browser, but it also needs the
|
|
124
|
+
// Vite instance so it can drop transform caches first (see invalidateModuleCaches).
|
|
125
|
+
const viteHolder = { vite: null };
|
|
126
|
+
startWSServer(wsPort, projectDir, logFile, screenshotsDir, viteHolder);
|
|
127
|
+
// The deck root `/` serves a split-view shell: the deck in an iframe plus a
|
|
128
|
+
// toggle button for an xterm.js terminal. The terminal's PTY is lazy -- it
|
|
129
|
+
// spawns only when a browser first opens the PTY WebSocket.
|
|
130
|
+
const ideServer = createIdeServer({
|
|
131
|
+
deckDir: projectDir,
|
|
132
|
+
deckLabel: path.basename(projectDir),
|
|
133
|
+
});
|
|
134
|
+
process.on('exit', () => ideServer.shutdown());
|
|
65
135
|
const vite = await createServer({
|
|
66
136
|
root: projectDir,
|
|
67
|
-
plugins: [castlePlugin(wsPort)],
|
|
137
|
+
plugins: [castlePlugin(wsPort, ideServer)],
|
|
68
138
|
server: {
|
|
69
139
|
port,
|
|
70
140
|
strictPort: true,
|
|
141
|
+
host: options.host,
|
|
71
142
|
open: options.open ? true : undefined,
|
|
72
143
|
hmr: false,
|
|
144
|
+
proxy: {
|
|
145
|
+
'/__castle/ws': {
|
|
146
|
+
target: `ws://localhost:${wsPort}`,
|
|
147
|
+
ws: true,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
73
150
|
fs: { strict: false },
|
|
74
151
|
},
|
|
75
152
|
logLevel: 'info',
|
|
76
153
|
});
|
|
154
|
+
viteHolder.vite = vite;
|
|
77
155
|
await vite.listen();
|
|
156
|
+
// The PTY WebSocket upgrade is handled directly on Vite's HTTP server rather
|
|
157
|
+
// than through Vite's proxy (Vite's ws proxy didn't forward this path).
|
|
158
|
+
if (vite.httpServer) {
|
|
159
|
+
vite.httpServer.on('upgrade', (req, socket, head) => {
|
|
160
|
+
ideServer.handleUpgrade(req, socket, head);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
78
163
|
vite.printUrls();
|
|
79
164
|
}
|
|
80
|
-
|
|
165
|
+
// `restart` does a full browser reload, but Vite keeps transformed modules
|
|
166
|
+
// cached in its module graph. Newly-added files picked up by `import.meta.glob`
|
|
167
|
+
// (e.g. a new behavior in `behaviors/`) would otherwise be missed because the
|
|
168
|
+
// cached glob importer is re-served as-is. Invalidating the graph forces every
|
|
169
|
+
// module to re-transform on the next request, so the glob is rescanned.
|
|
170
|
+
function invalidateModuleCaches(vite) {
|
|
171
|
+
if (!vite)
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
const clientGraph = vite.environments?.client?.moduleGraph;
|
|
175
|
+
if (clientGraph)
|
|
176
|
+
clientGraph.invalidateAll();
|
|
177
|
+
else
|
|
178
|
+
vite.moduleGraph.invalidateAll();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Vite changed its graph shape between minor versions — best-effort
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function serveDetached(projectDir, options) {
|
|
185
|
+
const castleDir = path.join(projectDir, '.castle');
|
|
186
|
+
if (!fs.existsSync(castleDir))
|
|
187
|
+
fs.mkdirSync(castleDir, { recursive: true });
|
|
188
|
+
const serveJsonPath = path.join(castleDir, 'serve.json');
|
|
189
|
+
// Clear stale serve.json before spawning so we wait for the new one.
|
|
190
|
+
try {
|
|
191
|
+
fs.unlinkSync(serveJsonPath);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// no stale serve.json — fine
|
|
195
|
+
}
|
|
196
|
+
const logPath = path.join(castleDir, 'serve.log');
|
|
197
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
198
|
+
// Re-exec this CLI with the same args minus --detach. process.argv[0] is
|
|
199
|
+
// node, process.argv[1] is the cli entry.
|
|
200
|
+
const entry = process.argv[1];
|
|
201
|
+
if (!entry)
|
|
202
|
+
throw new Error('cannot find cli entrypoint for detached serve');
|
|
203
|
+
const args = ['serve', projectDir];
|
|
204
|
+
if (options.port)
|
|
205
|
+
args.push('--port', options.port);
|
|
206
|
+
if (options.host)
|
|
207
|
+
args.push('--host', options.host);
|
|
208
|
+
if (options.open)
|
|
209
|
+
args.push('--open');
|
|
210
|
+
const child = spawn(process.execPath, [entry, ...args], {
|
|
211
|
+
cwd: projectDir,
|
|
212
|
+
detached: true,
|
|
213
|
+
stdio: ['ignore', logFd, logFd],
|
|
214
|
+
env: process.env,
|
|
215
|
+
});
|
|
216
|
+
child.unref();
|
|
217
|
+
fs.closeSync(logFd);
|
|
218
|
+
// Wait up to 15s for serve.json to appear so we can print the URL.
|
|
219
|
+
const deadline = Date.now() + 15_000;
|
|
220
|
+
while (Date.now() < deadline) {
|
|
221
|
+
if (fs.existsSync(serveJsonPath)) {
|
|
222
|
+
try {
|
|
223
|
+
const info = JSON.parse(fs.readFileSync(serveJsonPath, 'utf8'));
|
|
224
|
+
console.log(`Started serve in background: http://localhost:${info.port}`);
|
|
225
|
+
console.log(`PID: ${child.pid}`);
|
|
226
|
+
console.log(`Logs: ${logPath}`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// serve.json written but not yet fully flushed — try again
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
234
|
+
}
|
|
235
|
+
throw new Error(`Detached serve did not write ${serveJsonPath} within 15s; check ${logPath} for startup errors.`);
|
|
236
|
+
}
|
|
237
|
+
function startWSServer(port, projectDir, logFile, screenshotsDir, viteHolder) {
|
|
81
238
|
const wss = new WebSocketServer({ port });
|
|
82
239
|
const clients = new Set();
|
|
240
|
+
// For round-trip requests (e.g. screenshot), remember which socket issued a
|
|
241
|
+
// given requestId so the matching response is routed back to just that
|
|
242
|
+
// client instead of broadcast to every tab/CLI. Mirrors how `write_file`
|
|
243
|
+
// replies only to its sender.
|
|
244
|
+
const requestOrigins = new Map();
|
|
245
|
+
const forgetRequest = (requestId) => {
|
|
246
|
+
const pending = requestOrigins.get(requestId);
|
|
247
|
+
if (pending)
|
|
248
|
+
clearTimeout(pending.timeout);
|
|
249
|
+
requestOrigins.delete(requestId);
|
|
250
|
+
};
|
|
83
251
|
wss.on('connection', (ws) => {
|
|
84
252
|
clients.add(ws);
|
|
85
253
|
ws.on('message', (raw) => {
|
|
86
254
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
255
|
+
const text = Array.isArray(raw)
|
|
256
|
+
? Buffer.concat(raw).toString('utf8')
|
|
257
|
+
: Buffer.isBuffer(raw)
|
|
258
|
+
? raw.toString('utf8')
|
|
259
|
+
: Buffer.from(raw).toString('utf8');
|
|
260
|
+
const msg = JSON.parse(text);
|
|
261
|
+
if (msg.type === 'restart') {
|
|
262
|
+
// Fire-and-forget: reload every open tab.
|
|
263
|
+
invalidateModuleCaches(viteHolder.vite);
|
|
264
|
+
const fwd = JSON.stringify(msg);
|
|
265
|
+
for (const c of clients) {
|
|
266
|
+
if (c !== ws && c.readyState === WebSocket.OPEN)
|
|
267
|
+
c.send(fwd);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (msg.type === 'screenshot_request') {
|
|
271
|
+
// Record the requester, then forward to the other clients (the tab).
|
|
272
|
+
if (typeof msg.requestId === 'string') {
|
|
273
|
+
forgetRequest(msg.requestId);
|
|
274
|
+
const timeout = setTimeout(() => {
|
|
275
|
+
requestOrigins.delete(msg.requestId);
|
|
276
|
+
}, SCREENSHOT_REQUEST_TTL_MS);
|
|
277
|
+
requestOrigins.set(msg.requestId, { origin: ws, timeout });
|
|
278
|
+
}
|
|
89
279
|
const fwd = JSON.stringify(msg);
|
|
90
280
|
for (const c of clients) {
|
|
91
281
|
if (c !== ws && c.readyState === WebSocket.OPEN)
|
|
@@ -100,24 +290,97 @@ function startWSServer(port, logFile, screenshotsDir) {
|
|
|
100
290
|
else
|
|
101
291
|
process.stdout.write(line);
|
|
102
292
|
}
|
|
103
|
-
if (msg.type === 'screenshot_response'
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
293
|
+
if (msg.type === 'screenshot_response' &&
|
|
294
|
+
(typeof msg.data === 'string' || msg.ok === false)) {
|
|
295
|
+
const hasId = typeof msg.requestId === 'string';
|
|
296
|
+
const pending = hasId ? requestOrigins.get(msg.requestId) : undefined;
|
|
297
|
+
const origin = pending?.origin;
|
|
298
|
+
// A response whose requestId is no longer tracked is a duplicate from
|
|
299
|
+
// another tab for an already-served request -> drop it (otherwise we'd
|
|
300
|
+
// write the file and reply twice).
|
|
301
|
+
if (!hasId || origin) {
|
|
302
|
+
if (hasId)
|
|
303
|
+
forgetRequest(msg.requestId);
|
|
304
|
+
if (typeof msg.data === 'string') {
|
|
305
|
+
if (!fs.existsSync(screenshotsDir))
|
|
306
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
307
|
+
const filename = `screenshot-${Date.now()}.png`;
|
|
308
|
+
const filepath = path.join(screenshotsDir, filename);
|
|
309
|
+
const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
|
|
310
|
+
fs.writeFileSync(filepath, Buffer.from(base64, 'base64'));
|
|
311
|
+
console.log(`Screenshot saved: ${filepath}`);
|
|
312
|
+
}
|
|
313
|
+
const fwd = JSON.stringify(msg);
|
|
314
|
+
if (origin) {
|
|
315
|
+
// Route back to just the requester.
|
|
316
|
+
if (origin !== ws && origin.readyState === WebSocket.OPEN)
|
|
317
|
+
origin.send(fwd);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// Legacy path (response without a requestId): broadcast as before.
|
|
321
|
+
for (const c of clients) {
|
|
322
|
+
if (c !== ws && c.readyState === WebSocket.OPEN)
|
|
323
|
+
c.send(fwd);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
115
326
|
}
|
|
116
327
|
}
|
|
328
|
+
if (msg.type === 'write_file') {
|
|
329
|
+
const response = writeProjectFile(projectDir, msg.path, msg.contents);
|
|
330
|
+
ws.send(JSON.stringify({
|
|
331
|
+
type: 'write_file_response',
|
|
332
|
+
requestId: msg.requestId,
|
|
333
|
+
...response,
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// ignore malformed frames
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
ws.on('close', () => {
|
|
342
|
+
clients.delete(ws);
|
|
343
|
+
// Drop any in-flight requests this socket issued so the map can't leak.
|
|
344
|
+
for (const [id, pending] of requestOrigins) {
|
|
345
|
+
if (pending.origin === ws)
|
|
346
|
+
forgetRequest(id);
|
|
117
347
|
}
|
|
118
|
-
catch { }
|
|
119
348
|
});
|
|
120
|
-
ws.on('close', () => { clients.delete(ws); });
|
|
121
349
|
});
|
|
122
350
|
return wss;
|
|
123
351
|
}
|
|
352
|
+
function writeProjectFile(projectDir, requestedPath, contents) {
|
|
353
|
+
if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
|
|
354
|
+
return { ok: false, error: 'Missing file path.' };
|
|
355
|
+
}
|
|
356
|
+
if (typeof contents !== 'string') {
|
|
357
|
+
return { ok: false, error: 'File contents must be a string.' };
|
|
358
|
+
}
|
|
359
|
+
const normalized = path.normalize(requestedPath.replace(/\\/g, '/'));
|
|
360
|
+
if (normalized === '.') {
|
|
361
|
+
return { ok: false, error: 'Missing file path.' };
|
|
362
|
+
}
|
|
363
|
+
if (path.isAbsolute(normalized) ||
|
|
364
|
+
normalized === '..' ||
|
|
365
|
+
normalized.startsWith(`..${path.sep}`)) {
|
|
366
|
+
return { ok: false, error: `Refusing to write outside the deck: ${requestedPath}` };
|
|
367
|
+
}
|
|
368
|
+
const parts = normalized.split(path.sep);
|
|
369
|
+
if (parts.includes('.git') || parts.includes('.castle') || parts.includes('node_modules')) {
|
|
370
|
+
return { ok: false, error: `Refusing to write protected deck path: ${requestedPath}` };
|
|
371
|
+
}
|
|
372
|
+
const filepath = path.resolve(projectDir, normalized);
|
|
373
|
+
const relative = path.relative(projectDir, filepath);
|
|
374
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
375
|
+
return { ok: false, error: `Refusing to write outside the deck: ${requestedPath}` };
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
fs.mkdirSync(path.dirname(filepath), { recursive: true });
|
|
379
|
+
fs.writeFileSync(filepath, contents, 'utf-8');
|
|
380
|
+
return { ok: true, path: normalized };
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
384
|
+
return { ok: false, error: `Could not write ${requestedPath}: ${message}` };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# basic-2d kit
|
|
2
|
+
|
|
3
|
+
Actor / behavior / scene framework on a 500×700 canvas ("card"). To build a game you (a) write behaviors in `behaviors/*.jsx`, (b) place actors that use them in `scenes/*.scene` (plain JSON). Behaviors auto-register via file glob — drop a new file and it's picked up on next reload.
|
|
4
|
+
|
|
5
|
+
This kit is plain JavaScript (no TypeScript). Use `.jsx` for files that contain JSX, `.js` otherwise. Don't add types or `.ts`/`.tsx` files.
|
|
6
|
+
|
|
7
|
+
DO NOT READ `engine/`, `editors/`, OR the built-in `behaviors/` files (`Layout.jsx`, `Drawing.jsx`, `Collider.jsx`, `Camera.jsx`) to build a game. Those are the framework itself; their complete public API (props, helpers, calling conventions) is documented inline below. Read framework source only if you are explicitly modifying the framework.
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
Write the smallest game that satisfies what the user asked for. No sound, particles, menus, multi-level progression, or visual polish unless they specifically asked for it. A typical behavior is 30–80 lines — if yours is hitting 200, you're over-engineering: cut feel-good extras, fewer fields on props, fewer edge cases, fewer comments. Ship the core loop first; the user can ask for more.
|
|
12
|
+
|
|
13
|
+
## Workflow
|
|
14
|
+
|
|
15
|
+
1. Edit `behaviors/*.jsx` and `scenes/main.scene`.
|
|
16
|
+
2. `castle-web serve . --detach` (returns the URL, default port 5757). Curl once to confirm.
|
|
17
|
+
3. After every edit: `npm run restart` (no hot reload). Then re-check.
|
|
18
|
+
|
|
19
|
+
Card size is **500 wide × 700 tall** (origin top-left, +y is down).
|
|
20
|
+
|
|
21
|
+
## Behavior shape
|
|
22
|
+
|
|
23
|
+
A behavior is a class. Minimal contract:
|
|
24
|
+
|
|
25
|
+
```jsx
|
|
26
|
+
// behaviors/MyThing.jsx
|
|
27
|
+
export class MyThing {
|
|
28
|
+
static behaviorName = 'MyThing'; // must match the key used in .scene
|
|
29
|
+
static defaultProps = { speed: 200 };
|
|
30
|
+
|
|
31
|
+
constructor(props) {
|
|
32
|
+
this.props = props;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Called every frame in play mode. dt is seconds.
|
|
36
|
+
update(actor, scene, dt) {
|
|
37
|
+
const layout = actor.components.Layout;
|
|
38
|
+
layout.x += this.props.speed * dt; // mutate component in place
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Optional. Custom drawing (you usually don't need this; use a Drawing
|
|
42
|
+
// component instead). ctx is in card units already.
|
|
43
|
+
draw(actor, scene, ctx) {}
|
|
44
|
+
|
|
45
|
+
// Optional. Return React nodes for game-time HUD. Coordinates are card
|
|
46
|
+
// units. Read state your `update` set; do not start your own loops.
|
|
47
|
+
ui(actor, scene) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A fresh `Behavior` instance is constructed per actor per frame from the actor's component props — DO NOT store per-actor state on `this`. Persist transient state on `actor.runtime` (a free-form object the framework will not serialize) or on the component props themselves.
|
|
54
|
+
|
|
55
|
+
## Scene file (`scenes/main.scene`, plain JSON)
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"background": "#1b2030",
|
|
60
|
+
"actors": [
|
|
61
|
+
{
|
|
62
|
+
"id": "paddle",
|
|
63
|
+
"components": {
|
|
64
|
+
"Layout": { "x": 210, "y": 640, "width": 80, "height": 12 },
|
|
65
|
+
"Drawing": { "file": "drawings/floor.drawing" },
|
|
66
|
+
"Collider": { "kind": "solid", "width": 80, "height": 12 },
|
|
67
|
+
"Paddle": { "speed": 360 }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Rules: every actor needs a unique `id` (any string) and almost always a `Layout`. `components` keys are behavior names (the `static behaviorName`). Unspecified props fall back to that behavior's `defaultProps` — omit optional fields (`z`, `rotation`, actor `name`, `tint: '#ffffffff'`, scene `name`) to keep scenes compact, especially when generating many actors. Add a behavior to an actor by adding its key to `components`; remove it by deleting the key.
|
|
75
|
+
|
|
76
|
+
## Built-in behaviors
|
|
77
|
+
|
|
78
|
+
- **Layout** — `{ x, y, width, height, z?, rotation? }`. Every actor needs one. `z` orders draw (low first). `rotation` is degrees about the center.
|
|
79
|
+
- **Drawing** — `{ file: "drawings/foo.drawing", tint?: "#rrggbbaa" }`. Renders the pixel-art file scaled into the Layout box. `tint` multiplies; use white (`#ffffffff`) or omit for the original colors. For a solid-color rectangle, point `file` at `drawings/floor.drawing` (an 8×8 white pixel sprite) and set `tint` to your color.
|
|
80
|
+
- **Collider** — `{ kind: 'solid'|'pickup', width, height, offsetX?, offsetY?, debug? }`. Just a rect; the framework does NOT auto-resolve collisions for you. Use it as data:
|
|
81
|
+
|
|
82
|
+
```jsx
|
|
83
|
+
for (const other of scene.getActors()) {
|
|
84
|
+
if (other.id === actor.id) continue;
|
|
85
|
+
if (scene.overlaps(actor, other)) {
|
|
86
|
+
/* react */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- **Camera** — `{ target: actorId, followX, followY, roomWidth, roomHeight }`. Place on a dedicated actor; sets `scene.camera` clamped to the room. Omit entirely for a fixed view (no camera = no translation).
|
|
92
|
+
|
|
93
|
+
## SceneRuntime API (what `scene` exposes to behaviors)
|
|
94
|
+
|
|
95
|
+
- `scene.time` — seconds since start.
|
|
96
|
+
- `scene.keys` — `Set` of currently-held KeyboardEvent codes (e.g. `'ArrowLeft'`, `'KeyA'`, `'Space'`). Read in `update`.
|
|
97
|
+
- `scene.pointer` — `{ x, y, down }` in world (card) coordinates, camera-adjusted.
|
|
98
|
+
- `scene.getActor(id)` / `scene.getActors()` (sorted by Layout.z) / `scene.getComponent(actor, name)`.
|
|
99
|
+
- `scene.actorWith('GameController')` / `scene.actorsWith('Brick')` — find one / all actors carrying a given behavior. Prefer these to `getActors().find(a => a.components.X)`.
|
|
100
|
+
- `scene.colliderRect(actorOrId)` — rect from Layout + Collider, or null.
|
|
101
|
+
- `scene.overlaps(a, b)` — true when two actors/ids with Collider overlap.
|
|
102
|
+
- `scene.data` — the live scene data. Mutate `actor.components.X = {...}` to change props.
|
|
103
|
+
- `scene.spawnActor({ components: { Layout: {...}, MyBehavior: {...} } })` — add a new actor at runtime. Returns the actor (with auto-minted `id` and `runtime = {}`). Use this; don't push to `scene.data.actors` by hand.
|
|
104
|
+
- `scene.despawnActor(id)` — remove an actor at runtime. Use this; don't `splice` + `delete` by hand.
|
|
105
|
+
- `scene.status` — string you can set/read for game-state ('playing', 'gameover', ...).
|
|
106
|
+
- `actor.runtime` — per-instance scratchpad for transient state across frames (e.g. velocity, trail history). Not serialized.
|
|
107
|
+
|
|
108
|
+
## Input shortcuts
|
|
109
|
+
|
|
110
|
+
```jsx
|
|
111
|
+
if (scene.keys.has('ArrowLeft')) layout.x -= speed * dt;
|
|
112
|
+
if (scene.keys.has('ArrowRight')) layout.x += speed * dt;
|
|
113
|
+
if (scene.keys.has('Space')) /* launch ball */ ;
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For HUD text use a behavior's `ui` hook (returns React); for in-world text or shapes, draw with `ctx` from `draw`. The deck has TouchControls overlay support out of the box for mobile play (arrow keys + a button); no setup needed.
|
|
117
|
+
|
|
118
|
+
## Common breakout-shaped recipe (sketch)
|
|
119
|
+
|
|
120
|
+
- `Paddle` behavior: read keys, clamp x to `[0, 500 - layout.width]`.
|
|
121
|
+
- `Ball` behavior: store `vx, vy` on `actor.runtime`; integrate; bounce off wall edges (`x<0`, `x+w>500`, `y<0`); on `scene.overlaps(actor, paddle)`, flip `vy`; for each `brick` in `scene.actorsWith('Brick')` check `scene.overlaps(actor, brick)` → flip `vy` and `scene.despawnActor(brick.id)`; if `y > 700` lose a life.
|
|
122
|
+
- `Brick` behavior: typically just a marker — `kind: 'solid'` collider is enough. State (hit count) goes on `actor.runtime` or the brick's own props.
|
|
123
|
+
- `GameController` (no Layout needed if you don't draw it): tracks score / lives / status; expose HUD via `ui()`.
|
|
124
|
+
|
|
125
|
+
## Don't
|
|
126
|
+
|
|
127
|
+
- Don't `console.log` in tight loops — flood the serve log.
|
|
128
|
+
- Don't keep per-actor state on the behavior class instance; it's recreated each frame. Use `actor.runtime` or component props.
|
|
129
|
+
- Don't try to import from `editors/`; behaviors run in the play runtime too.
|
|
130
|
+
- Don't add types or `.ts`/`.tsx` files. This kit is JavaScript.
|
|
131
|
+
- Don't add a build step or change `vite.config.js` for a game — it's configured for you.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { cardSize } from '../engine/scene';
|
|
2
|
+
|
|
3
|
+
// Follow camera: keeps the target actor centered in the viewport, clamped to
|
|
4
|
+
// the room bounds so the empty area past the room edges never scrolls in.
|
|
5
|
+
export class Camera {
|
|
6
|
+
static behaviorName = 'Camera';
|
|
7
|
+
|
|
8
|
+
static defaultProps = {
|
|
9
|
+
target: '',
|
|
10
|
+
followX: true,
|
|
11
|
+
followY: true,
|
|
12
|
+
roomWidth: 900,
|
|
13
|
+
roomHeight: 1100,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
constructor(props) {
|
|
17
|
+
this.props = props;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
update(_actor, scene) {
|
|
21
|
+
const target = this.props.target ? scene.getActor(this.props.target) : undefined;
|
|
22
|
+
const targetLayout = target?.components.Layout;
|
|
23
|
+
if (!targetLayout) return;
|
|
24
|
+
|
|
25
|
+
const centerX = targetLayout.x + targetLayout.width / 2;
|
|
26
|
+
const centerY = targetLayout.y + targetLayout.height / 2;
|
|
27
|
+
const desiredX = centerX - cardSize.width / 2;
|
|
28
|
+
const desiredY = centerY - cardSize.height / 2;
|
|
29
|
+
|
|
30
|
+
scene.camera = {
|
|
31
|
+
x: this.props.followX
|
|
32
|
+
? clamp(desiredX, 0, this.props.roomWidth - cardSize.width)
|
|
33
|
+
: 0,
|
|
34
|
+
y: this.props.followY
|
|
35
|
+
? clamp(desiredY, 0, this.props.roomHeight - cardSize.height)
|
|
36
|
+
: 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clamp(value, min, max) {
|
|
42
|
+
return Math.max(min, Math.min(Math.max(min, max), value));
|
|
43
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Panel, SelectField } from '../engine/ui';
|
|
3
|
+
import { AutoFields } from '../engine/autoInspector';
|
|
4
|
+
|
|
5
|
+
export class Collider {
|
|
6
|
+
static behaviorName = 'Collider';
|
|
7
|
+
|
|
8
|
+
static defaultProps = {
|
|
9
|
+
kind: 'solid',
|
|
10
|
+
width: 32,
|
|
11
|
+
height: 32,
|
|
12
|
+
offsetX: 0,
|
|
13
|
+
offsetY: 0,
|
|
14
|
+
debug: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
constructor(props) {
|
|
18
|
+
this.props = props;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
draw(actor, _scene, ctx, options) {
|
|
22
|
+
if (!options.showDebugColliders && !this.props.debug) return;
|
|
23
|
+
const rect = getColliderRect(actor);
|
|
24
|
+
if (!rect) return;
|
|
25
|
+
ctx.save();
|
|
26
|
+
ctx.strokeStyle = this.props.kind === 'pickup' ? '#ffe17a' : '#8db7ff';
|
|
27
|
+
ctx.lineWidth = 2;
|
|
28
|
+
ctx.strokeRect(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2);
|
|
29
|
+
ctx.restore();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static Inspector({ component, setComponent }) {
|
|
33
|
+
return (
|
|
34
|
+
<Panel title="Collider">
|
|
35
|
+
<SelectField
|
|
36
|
+
label="Kind"
|
|
37
|
+
value={component.kind}
|
|
38
|
+
onChange={(kind) => setComponent({ kind: kind })}
|
|
39
|
+
options={['solid', 'pickup']}
|
|
40
|
+
/>
|
|
41
|
+
<AutoFields
|
|
42
|
+
defaultProps={Collider.defaultProps}
|
|
43
|
+
component={component}
|
|
44
|
+
setComponent={setComponent}
|
|
45
|
+
exclude={['kind']}
|
|
46
|
+
/>
|
|
47
|
+
</Panel>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getColliderRect(actor) {
|
|
53
|
+
const layout = actor.components.Layout;
|
|
54
|
+
const collider = actor.components.Collider;
|
|
55
|
+
if (!layout || !collider) return null;
|
|
56
|
+
return {
|
|
57
|
+
x: layout.x + (collider.offsetX ?? 0),
|
|
58
|
+
y: layout.y + (collider.offsetY ?? 0),
|
|
59
|
+
width: collider.width ?? layout.width,
|
|
60
|
+
height: collider.height ?? layout.height,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function intersects(a, b) {
|
|
65
|
+
return (
|
|
66
|
+
a.x < b.x + b.width &&
|
|
67
|
+
a.x + a.width > b.x &&
|
|
68
|
+
a.y < b.y + b.height &&
|
|
69
|
+
a.y + a.height > b.y
|
|
70
|
+
);
|
|
71
|
+
}
|