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.
Files changed (158) hide show
  1. package/dist/api.d.ts +53 -5
  2. package/dist/api.js +42 -15
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.js +25 -11
  5. package/dist/get-deck.d.ts +3 -0
  6. package/dist/get-deck.js +64 -0
  7. package/dist/ide-client.d.ts +1 -0
  8. package/dist/ide-client.js +537 -0
  9. package/dist/ide.d.ts +16 -0
  10. package/dist/ide.js +546 -0
  11. package/dist/index.js +36 -41
  12. package/dist/init.d.ts +3 -1
  13. package/dist/init.js +170 -24
  14. package/dist/localPaths.d.ts +6 -0
  15. package/dist/localPaths.js +33 -0
  16. package/dist/login.js +1 -1
  17. package/dist/preview.d.ts +3 -0
  18. package/dist/preview.js +53 -34
  19. package/dist/save-deck.d.ts +2 -0
  20. package/dist/{push.js → save-deck.js} +66 -5
  21. package/dist/serve.d.ts +2 -0
  22. package/dist/serve.js +290 -27
  23. package/kits/basic-2d/.prettierrc +8 -0
  24. package/kits/basic-2d/CLAUDE.md +131 -0
  25. package/kits/basic-2d/behaviors/Camera.jsx +43 -0
  26. package/kits/basic-2d/behaviors/Collider.jsx +71 -0
  27. package/kits/basic-2d/behaviors/Drawing.jsx +139 -0
  28. package/kits/basic-2d/behaviors/Layout.jsx +16 -0
  29. package/kits/basic-2d/drawings/floor.drawing +70 -0
  30. package/kits/basic-2d/editors/App.jsx +152 -0
  31. package/kits/basic-2d/editors/CodeEditor.jsx +112 -0
  32. package/kits/basic-2d/editors/DrawingEditor.jsx +222 -0
  33. package/kits/basic-2d/editors/FileBrowser.jsx +143 -0
  34. package/kits/basic-2d/editors/PlayOnly.jsx +21 -0
  35. package/kits/basic-2d/editors/SceneEditor.jsx +1012 -0
  36. package/kits/basic-2d/editors/behaviorRegistry.js +24 -0
  37. package/kits/basic-2d/editors/editorHistory.js +52 -0
  38. package/kits/basic-2d/engine/ScenePlayer.jsx +83 -0
  39. package/kits/basic-2d/engine/SceneUI.jsx +67 -0
  40. package/kits/basic-2d/engine/TouchControls.jsx +136 -0
  41. package/kits/basic-2d/engine/autoInspector.jsx +51 -0
  42. package/kits/basic-2d/engine/files.js +62 -0
  43. package/kits/basic-2d/engine/scene.js +420 -0
  44. package/kits/basic-2d/engine/ui.jsx +344 -0
  45. package/kits/basic-2d/engine/ui.module.css +928 -0
  46. package/kits/basic-2d/eslint.config.js +50 -0
  47. package/kits/basic-2d/index.html +11 -0
  48. package/kits/basic-2d/main.jsx +10 -0
  49. package/kits/basic-2d/package-lock.json +2706 -0
  50. package/kits/basic-2d/package.json +41 -0
  51. package/kits/basic-2d/scenes/main.scene +108 -0
  52. package/kits/basic-2d/vite.config.js +1 -0
  53. package/kits/basic-2d-frozen/.prettierrc +8 -0
  54. package/kits/basic-2d-frozen/CLAUDE.md +131 -0
  55. package/kits/basic-2d-frozen/behaviors/Camera.jsx +43 -0
  56. package/kits/basic-2d-frozen/behaviors/Collider.jsx +71 -0
  57. package/kits/basic-2d-frozen/behaviors/Drawing.jsx +139 -0
  58. package/kits/basic-2d-frozen/behaviors/Layout.jsx +16 -0
  59. package/kits/basic-2d-frozen/drawings/floor.drawing +70 -0
  60. package/kits/basic-2d-frozen/editors/App.jsx +152 -0
  61. package/kits/basic-2d-frozen/editors/CodeEditor.jsx +112 -0
  62. package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +222 -0
  63. package/kits/basic-2d-frozen/editors/FileBrowser.jsx +143 -0
  64. package/kits/basic-2d-frozen/editors/PlayOnly.jsx +21 -0
  65. package/kits/basic-2d-frozen/editors/SceneEditor.jsx +1012 -0
  66. package/kits/basic-2d-frozen/editors/behaviorRegistry.js +24 -0
  67. package/kits/basic-2d-frozen/editors/editorHistory.js +52 -0
  68. package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +83 -0
  69. package/kits/basic-2d-frozen/engine/SceneUI.jsx +67 -0
  70. package/kits/basic-2d-frozen/engine/TouchControls.jsx +136 -0
  71. package/kits/basic-2d-frozen/engine/autoInspector.jsx +51 -0
  72. package/kits/basic-2d-frozen/engine/files.js +62 -0
  73. package/kits/basic-2d-frozen/engine/scene.js +420 -0
  74. package/kits/basic-2d-frozen/engine/ui.jsx +344 -0
  75. package/kits/basic-2d-frozen/engine/ui.module.css +928 -0
  76. package/kits/basic-2d-frozen/eslint.config.js +50 -0
  77. package/kits/basic-2d-frozen/index.html +11 -0
  78. package/kits/basic-2d-frozen/main.jsx +10 -0
  79. package/kits/basic-2d-frozen/package-lock.json +2706 -0
  80. package/kits/basic-2d-frozen/package.json +41 -0
  81. package/kits/basic-2d-frozen/scenes/main.scene +108 -0
  82. package/kits/basic-2d-frozen/vite.config.js +1 -0
  83. package/kits/rpg-2d/.prettierrc +8 -0
  84. package/kits/rpg-2d/behaviors/Camera.tsx +52 -0
  85. package/kits/rpg-2d/behaviors/Collider.tsx +98 -0
  86. package/kits/rpg-2d/behaviors/Dialog.tsx +184 -0
  87. package/kits/rpg-2d/behaviors/Drawing.tsx +161 -0
  88. package/kits/rpg-2d/behaviors/Friend.tsx +45 -0
  89. package/kits/rpg-2d/behaviors/Layout.tsx +29 -0
  90. package/kits/rpg-2d/behaviors/PlayerController.tsx +255 -0
  91. package/kits/rpg-2d/behaviors/Portal.tsx +60 -0
  92. package/kits/rpg-2d/behaviors/QuestLog.tsx +90 -0
  93. package/kits/rpg-2d/behaviors/SaveMenu.tsx +123 -0
  94. package/kits/rpg-2d/behaviors/Tilemap.tsx +90 -0
  95. package/kits/rpg-2d/drawings/bld-home.drawing +8136 -0
  96. package/kits/rpg-2d/drawings/env-crate.drawing +509 -0
  97. package/kits/rpg-2d/drawings/env-fence.drawing +536 -0
  98. package/kits/rpg-2d/drawings/env-flower-bed.drawing +607 -0
  99. package/kits/rpg-2d/drawings/env-fountain.drawing +2622 -0
  100. package/kits/rpg-2d/drawings/env-hedge.drawing +601 -0
  101. package/kits/rpg-2d/drawings/env-house-blue.drawing +1 -0
  102. package/kits/rpg-2d/drawings/env-house-green.drawing +1 -0
  103. package/kits/rpg-2d/drawings/env-tree-oak.drawing +1540 -0
  104. package/kits/rpg-2d/drawings/env-tree-pine.drawing +1315 -0
  105. package/kits/rpg-2d/drawings/floor.drawing +70 -0
  106. package/kits/rpg-2d/drawings/fx-sparkle.drawing +926 -0
  107. package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +1099 -0
  108. package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +4177 -0
  109. package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +1099 -0
  110. package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +4177 -0
  111. package/kits/rpg-2d/drawings/player-idle-down.drawing +1070 -0
  112. package/kits/rpg-2d/drawings/player-idle-left.drawing +1070 -0
  113. package/kits/rpg-2d/drawings/player-idle-right.drawing +1070 -0
  114. package/kits/rpg-2d/drawings/player-idle-up.drawing +1070 -0
  115. package/kits/rpg-2d/drawings/player-walk-down.drawing +4148 -0
  116. package/kits/rpg-2d/drawings/player-walk-left.drawing +4148 -0
  117. package/kits/rpg-2d/drawings/player-walk-right.drawing +4148 -0
  118. package/kits/rpg-2d/drawings/player-walk-up.drawing +4148 -0
  119. package/kits/rpg-2d/editors/App.tsx +163 -0
  120. package/kits/rpg-2d/editors/CodeEditor.tsx +120 -0
  121. package/kits/rpg-2d/editors/DrawingEditor.tsx +278 -0
  122. package/kits/rpg-2d/editors/FileBrowser.tsx +191 -0
  123. package/kits/rpg-2d/editors/PlayOnly.tsx +26 -0
  124. package/kits/rpg-2d/editors/SceneEditor.tsx +1093 -0
  125. package/kits/rpg-2d/editors/behaviorRegistry.ts +33 -0
  126. package/kits/rpg-2d/editors/editorHistory.ts +75 -0
  127. package/kits/rpg-2d/editors/editorProps.ts +10 -0
  128. package/kits/rpg-2d/engine/ScenePlayer.tsx +130 -0
  129. package/kits/rpg-2d/engine/SceneUI.tsx +74 -0
  130. package/kits/rpg-2d/engine/TouchControls.tsx +157 -0
  131. package/kits/rpg-2d/engine/autoInspector.tsx +111 -0
  132. package/kits/rpg-2d/engine/drawing.ts +81 -0
  133. package/kits/rpg-2d/engine/files.ts +215 -0
  134. package/kits/rpg-2d/engine/scene.ts +484 -0
  135. package/kits/rpg-2d/engine/ui.module.css +928 -0
  136. package/kits/rpg-2d/engine/ui.tsx +483 -0
  137. package/kits/rpg-2d/eslint.config.js +46 -0
  138. package/kits/rpg-2d/index.html +11 -0
  139. package/kits/rpg-2d/main.tsx +14 -0
  140. package/kits/rpg-2d/package-lock.json +3149 -0
  141. package/kits/rpg-2d/package.json +46 -0
  142. package/kits/rpg-2d/scenes/main.scene +203 -0
  143. package/kits/rpg-2d/tsconfig.json +17 -0
  144. package/kits/rpg-2d/vite-env.d.ts +7 -0
  145. package/kits/rpg-2d/vite.config.js +1 -0
  146. package/package.json +27 -5
  147. package/AGENTS.md +0 -25
  148. package/dist/push.d.ts +0 -1
  149. package/src/api.ts +0 -160
  150. package/src/bundle.ts +0 -28
  151. package/src/config.ts +0 -36
  152. package/src/index.ts +0 -143
  153. package/src/init.ts +0 -71
  154. package/src/login.ts +0 -24
  155. package/src/preview.ts +0 -94
  156. package/src/push.ts +0 -118
  157. package/src/serve.ts +0 -134
  158. 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
- const startPort = parseInt(options.port ?? '3737', 10);
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', () => { try {
59
- fs.unlinkSync(serveJsonPath);
60
- }
61
- catch { } });
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
- startWSServer(wsPort, logFile, screenshotsDir);
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
- function startWSServer(port, logFile, screenshotsDir) {
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 msg = JSON.parse(raw.toString());
88
- if (msg.type === 'restart' || msg.type === 'screenshot_request') {
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
- if (!fs.existsSync(screenshotsDir))
105
- fs.mkdirSync(screenshotsDir, { recursive: true });
106
- const filename = `screenshot-${Date.now()}.png`;
107
- const filepath = path.join(screenshotsDir, filename);
108
- const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
109
- fs.writeFileSync(filepath, Buffer.from(base64, 'base64'));
110
- console.log(`Screenshot saved: ${filepath}`);
111
- const fwd = JSON.stringify(msg);
112
- for (const c of clients) {
113
- if (c !== ws && c.readyState === WebSocket.OPEN)
114
- c.send(fwd);
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,8 @@
1
+ {
2
+ "printWidth": 100,
3
+ "tabWidth": 2,
4
+ "singleQuote": true,
5
+ "bracketSameLine": true,
6
+ "trailingComma": "es5",
7
+ "arrowParens": "always"
8
+ }
@@ -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
+ }