castle-web-cli 0.4.0 → 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 +84 -57
  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 +4 -1
  18. package/dist/preview.js +63 -41
  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 +293 -22
  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 -24
  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 -110
  153. package/src/init.ts +0 -71
  154. package/src/login.ts +0 -24
  155. package/src/preview.ts +0 -93
  156. package/src/push.ts +0 -118
  157. package/src/serve.ts +0 -128
  158. package/tsconfig.json +0 -13
@@ -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
+ }
@@ -0,0 +1,139 @@
1
+ import React from 'react';
2
+ import { Panel, SelectField } from '../engine/ui';
3
+ import { AutoFields } from '../engine/autoInspector';
4
+
5
+ export class Drawing {
6
+ static behaviorName = 'Drawing';
7
+
8
+ static defaultProps = {
9
+ file: 'drawings/floor.drawing',
10
+ tint: '#ffffffff',
11
+ };
12
+
13
+ constructor(props) {
14
+ this.props = props;
15
+ }
16
+
17
+ draw(actor, scene, ctx) {
18
+ if (actor.runtime?.collected) return;
19
+ const layout = actor.components.Layout;
20
+ if (!layout) return;
21
+ const drawing = scene.drawings[this.props.file];
22
+ if (!drawing) return;
23
+ drawPixelDrawing(
24
+ ctx,
25
+ drawing,
26
+ layout.x,
27
+ layout.y,
28
+ layout.width,
29
+ layout.height,
30
+ this.props.tint
31
+ );
32
+ }
33
+
34
+ static Inspector({ component, setComponent, files }) {
35
+ const drawingFiles = getDrawingFiles(files);
36
+ return (
37
+ <Panel title="Drawing">
38
+ <SelectField
39
+ label="File"
40
+ value={component.file}
41
+ onChange={(file) => setComponent({ file })}
42
+ options={drawingFiles}
43
+ />
44
+ <AutoFields
45
+ defaultProps={Drawing.defaultProps}
46
+ component={component}
47
+ setComponent={setComponent}
48
+ only={['tint']}
49
+ />
50
+ </Panel>
51
+ );
52
+ }
53
+ }
54
+
55
+ export function drawPixelDrawing(ctx, drawing, x, y, width, height, tint) {
56
+ if (drawing.width <= 0 || drawing.height <= 0) return;
57
+ // Blit the cached native-resolution canvas as a single image. Painting each
58
+ // pixel as its own `fillRect` left hairline anti-aliased seams under a
59
+ // rotation transform; one `drawImage` has no inter-pixel edges.
60
+ const canvas = getDrawingCanvas(drawing, tint);
61
+ const prevSmoothing = ctx.imageSmoothingEnabled;
62
+ ctx.imageSmoothingEnabled = false;
63
+ ctx.drawImage(canvas, x, y, width, height);
64
+ ctx.imageSmoothingEnabled = prevSmoothing;
65
+ }
66
+
67
+ // Cache of native-resolution offscreen canvases keyed by drawing data and
68
+ // tint. A drawing edit produces a fresh `DrawingData` object (see
69
+ // DrawingEditor's `structuredClone`), so a new object naturally misses the
70
+ // WeakMap and rebuilds.
71
+ const drawingCanvasCache = new WeakMap();
72
+
73
+ // Render the pixel art once, unrotated, at 1px per pixel into an offscreen
74
+ // canvas, applying the tint. Cached so the per-frame draw is a single blit.
75
+ function getDrawingCanvas(drawing, tint) {
76
+ const tintKey = tint ?? '';
77
+ let byTint = drawingCanvasCache.get(drawing);
78
+ if (!byTint) {
79
+ byTint = new Map();
80
+ drawingCanvasCache.set(drawing, byTint);
81
+ }
82
+ const cached = byTint.get(tintKey);
83
+ if (cached) return cached;
84
+
85
+ const canvas = document.createElement('canvas');
86
+ canvas.width = drawing.width;
87
+ canvas.height = drawing.height;
88
+ const octx = canvas.getContext('2d');
89
+ if (octx) {
90
+ const tintRgba = parseTint(tint);
91
+ for (let py = 0; py < drawing.height; py++) {
92
+ for (let px = 0; px < drawing.width; px++) {
93
+ const color = drawing.pixels[py * drawing.width + px];
94
+ if (!color || color === 'transparent' || color.endsWith('00')) continue;
95
+ octx.fillStyle = tintRgba ? applyTint(color, tintRgba) : color;
96
+ octx.fillRect(px, py, 1, 1);
97
+ }
98
+ }
99
+ }
100
+ byTint.set(tintKey, canvas);
101
+ return canvas;
102
+ }
103
+
104
+ // Parse a hex color into [r, g, b, a] channels (0-255). Returns null when the
105
+ // string is not a hex color so callers can fall back to the raw color.
106
+ function parseHexColor(color) {
107
+ const hex = color.trim().replace(/^#/, '');
108
+ if (hex.length !== 6 && hex.length !== 8) return null;
109
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
110
+ const r = parseInt(hex.slice(0, 2), 16);
111
+ const g = parseInt(hex.slice(2, 4), 16);
112
+ const b = parseInt(hex.slice(4, 6), 16);
113
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255;
114
+ return [r, g, b, a];
115
+ }
116
+
117
+ // Returns the tint as [r, g, b, a] channels, or null when there is no tint or
118
+ // the tint is white (#ffffffff) -- both meaning "no change".
119
+ function parseTint(tint) {
120
+ if (!tint) return null;
121
+ const rgba = parseHexColor(tint);
122
+ if (!rgba) return null;
123
+ if (rgba[0] === 255 && rgba[1] === 255 && rgba[2] === 255 && rgba[3] === 255) {
124
+ return null;
125
+ }
126
+ return rgba;
127
+ }
128
+
129
+ // Multiply each channel of a pixel color by the tint color (both 0-255).
130
+ function applyTint(color, tint) {
131
+ const rgba = parseHexColor(color);
132
+ if (!rgba) return color;
133
+ const out = rgba.map((channel, i) => Math.round((channel * tint[i]) / 255));
134
+ return '#' + out.map((channel) => channel.toString(16).padStart(2, '0')).join('');
135
+ }
136
+
137
+ function getDrawingFiles(files) {
138
+ return Object.keys(files).filter((path) => path.endsWith('.drawing'));
139
+ }
@@ -0,0 +1,16 @@
1
+ export class Layout {
2
+ static behaviorName = 'Layout';
3
+
4
+ static defaultProps = {
5
+ x: 0,
6
+ y: 0,
7
+ width: 32,
8
+ height: 32,
9
+ z: 0,
10
+ rotation: 0,
11
+ };
12
+
13
+ constructor(props) {
14
+ this.props = props;
15
+ }
16
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "width": 8,
3
+ "height": 8,
4
+ "pixels": [
5
+ "#ffffffff",
6
+ "#ffffffff",
7
+ "#ffffffff",
8
+ "#ffffffff",
9
+ "#ffffffff",
10
+ "#ffffffff",
11
+ "#ffffffff",
12
+ "#ffffffff",
13
+ "#ffffffff",
14
+ "#d8d8d8ff",
15
+ "#d8d8d8ff",
16
+ "#d8d8d8ff",
17
+ "#d8d8d8ff",
18
+ "#d8d8d8ff",
19
+ "#d8d8d8ff",
20
+ "#ffffffff",
21
+ "#ffffffff",
22
+ "#d8d8d8ff",
23
+ "#ffffffff",
24
+ "#ffffffff",
25
+ "#ffffffff",
26
+ "#ffffffff",
27
+ "#d8d8d8ff",
28
+ "#ffffffff",
29
+ "#ffffffff",
30
+ "#d8d8d8ff",
31
+ "#ffffffff",
32
+ "#ffffffff",
33
+ "#ffffffff",
34
+ "#ffffffff",
35
+ "#d8d8d8ff",
36
+ "#ffffffff",
37
+ "#ffffffff",
38
+ "#d8d8d8ff",
39
+ "#ffffffff",
40
+ "#ffffffff",
41
+ "#ffffffff",
42
+ "#ffffffff",
43
+ "#d8d8d8ff",
44
+ "#ffffffff",
45
+ "#ffffffff",
46
+ "#d8d8d8ff",
47
+ "#ffffffff",
48
+ "#ffffffff",
49
+ "#ffffffff",
50
+ "#ffffffff",
51
+ "#d8d8d8ff",
52
+ "#ffffffff",
53
+ "#ffffffff",
54
+ "#d8d8d8ff",
55
+ "#d8d8d8ff",
56
+ "#d8d8d8ff",
57
+ "#d8d8d8ff",
58
+ "#d8d8d8ff",
59
+ "#d8d8d8ff",
60
+ "#ffffffff",
61
+ "#ffffffff",
62
+ "#ffffffff",
63
+ "#ffffffff",
64
+ "#ffffffff",
65
+ "#ffffffff",
66
+ "#ffffffff",
67
+ "#ffffffff",
68
+ "#ffffffff"
69
+ ]
70
+ }
@@ -0,0 +1,152 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { writeFile } from 'castle-web-sdk';
3
+ import {
4
+ flatFileOrder,
5
+ formatJson,
6
+ getFileKind,
7
+ initialFiles,
8
+ parseJsonFile,
9
+ } from '../engine/files';
10
+ import { AppShell, cx, MainEditor, styles } from '../engine/ui';
11
+ import { CodeEditor } from './CodeEditor';
12
+ import { DrawingEditor } from './DrawingEditor';
13
+ import { FileBrowser } from './FileBrowser';
14
+ import { SceneEditor } from './SceneEditor';
15
+ export function App() {
16
+ const [files, setFiles] = useState(initialFiles);
17
+ const [selectedPath, setSelectedPath] = useState('scenes/main.scene');
18
+ const [filesSheetOpen, setFilesSheetOpen] = useState(false);
19
+ const [selectedActorIds, setSelectedActorIds] = useState([]);
20
+ const [multiSelectMode, setMultiSelectMode] = useState(false);
21
+ const saveTimersRef = useRef({});
22
+ const saveVersionsRef = useRef({});
23
+ const drawings = {};
24
+ for (const [path, text] of Object.entries(files)) {
25
+ if (!path.endsWith('.drawing')) continue;
26
+ const parsed = parseJsonFile(path, text);
27
+ if (parsed.value) drawings[path] = parsed.value;
28
+ }
29
+ function updateSelectedFile(nextText) {
30
+ const path = selectedPath;
31
+ setFiles((current) => ({
32
+ ...current,
33
+ [path]: nextText,
34
+ }));
35
+ scheduleFileWrite(path, nextText);
36
+ }
37
+ function scheduleFileWrite(path, nextText) {
38
+ const version = (saveVersionsRef.current[path] ?? 0) + 1;
39
+ saveVersionsRef.current[path] = version;
40
+ const existingTimer = saveTimersRef.current[path];
41
+ if (existingTimer) window.clearTimeout(existingTimer);
42
+ saveTimersRef.current[path] = window.setTimeout(() => {
43
+ delete saveTimersRef.current[path];
44
+ writeFile(path, nextText).catch((error) => {
45
+ if (saveVersionsRef.current[path] !== version) return;
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ console.error(`Failed to save ${path}: ${message}`);
48
+ });
49
+ }, 1500);
50
+ }
51
+ const kind = getFileKind(selectedPath);
52
+ const text = files[selectedPath] ?? '';
53
+ function selectFile(path) {
54
+ setSelectedPath(path);
55
+ setFilesSheetOpen(false);
56
+ setSelectedActorIds([]);
57
+ setMultiSelectMode(false);
58
+ }
59
+ const onToggleFiles = () => {
60
+ setFilesSheetOpen((previous) => !previous);
61
+ setSelectedActorIds([]);
62
+ setMultiSelectMode(false);
63
+ };
64
+ // Alt+Up / Alt+Down steps through files in file-browser sidebar order.
65
+ useEffect(() => {
66
+ function onKeyDown(event) {
67
+ if (!event.altKey || event.metaKey || event.ctrlKey) return;
68
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
69
+ const active = document.activeElement;
70
+ if (
71
+ active &&
72
+ (active.tagName === 'INPUT' ||
73
+ active.tagName === 'TEXTAREA' ||
74
+ active.tagName === 'SELECT' ||
75
+ active.isContentEditable ||
76
+ active.closest('.cm-editor'))
77
+ ) {
78
+ return;
79
+ }
80
+ const order = flatFileOrder(Object.keys(files));
81
+ const index = order.indexOf(selectedPath);
82
+ if (index === -1) return;
83
+ const nextIndex = event.key === 'ArrowUp' ? index - 1 : index + 1;
84
+ if (nextIndex < 0 || nextIndex >= order.length) return;
85
+ event.preventDefault();
86
+ selectFile(order[nextIndex]);
87
+ }
88
+ window.addEventListener('keydown', onKeyDown);
89
+ return () => window.removeEventListener('keydown', onKeyDown);
90
+ }, [files, selectedPath]);
91
+ return (
92
+ <AppShell>
93
+ <FileBrowser
94
+ files={files}
95
+ selectedPath={selectedPath}
96
+ onSelect={selectFile}
97
+ sheetOpen={filesSheetOpen}
98
+ onSheetOpenChange={setFilesSheetOpen}
99
+ />
100
+ {filesSheetOpen ? (
101
+ <div
102
+ className={cx(styles.sheetBackdrop, styles.mobileOnly)}
103
+ onClick={() => setFilesSheetOpen(false)}
104
+ />
105
+ ) : null}
106
+ <MainEditor>
107
+ {kind === 'scene' ? (
108
+ <SceneEditor
109
+ path={selectedPath}
110
+ text={text}
111
+ files={files}
112
+ drawings={drawings}
113
+ onChange={updateSelectedFile}
114
+ onToggleFiles={onToggleFiles}
115
+ filesOpen={filesSheetOpen}
116
+ selectedActorIds={selectedActorIds}
117
+ onSelectActorIds={setSelectedActorIds}
118
+ multiSelectMode={multiSelectMode}
119
+ onSetMultiSelectMode={setMultiSelectMode}
120
+ />
121
+ ) : null}
122
+ {kind === 'drawing' ? (
123
+ <DrawingEditor
124
+ path={selectedPath}
125
+ text={text}
126
+ onChange={updateSelectedFile}
127
+ onToggleFiles={onToggleFiles}
128
+ filesOpen={filesSheetOpen}
129
+ />
130
+ ) : null}
131
+ {kind === 'code' ? (
132
+ <CodeEditor
133
+ path={selectedPath}
134
+ text={text}
135
+ onChange={updateSelectedFile}
136
+ onToggleFiles={onToggleFiles}
137
+ filesOpen={filesSheetOpen}
138
+ />
139
+ ) : null}
140
+ {kind === 'text' ? (
141
+ <CodeEditor
142
+ path={selectedPath}
143
+ text={formatJson(text)}
144
+ onChange={updateSelectedFile}
145
+ onToggleFiles={onToggleFiles}
146
+ filesOpen={filesSheetOpen}
147
+ />
148
+ ) : null}
149
+ </MainEditor>
150
+ </AppShell>
151
+ );
152
+ }
@@ -0,0 +1,112 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { javascript } from '@codemirror/lang-javascript';
3
+ import { HighlightStyle, indentUnit, syntaxHighlighting } from '@codemirror/language';
4
+ import { EditorState } from '@codemirror/state';
5
+ import { EditorView } from '@codemirror/view';
6
+ import { tags } from '@lezer/highlight';
7
+ import { basicSetup } from 'codemirror';
8
+ import { basename } from '../engine/files';
9
+ 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
+ height: '100%',
21
+ fontSize: '9pt',
22
+ backgroundColor: '#fff',
23
+ },
24
+ '&.cm-editor.cm-focused': {
25
+ outline: 'none',
26
+ },
27
+ '.cm-scroller': {
28
+ overflow: 'auto',
29
+ fontFamily: 'Menlo, Monaco, Lucida Console, monospace',
30
+ },
31
+ '.cm-content': {
32
+ minHeight: '100%',
33
+ color: '#322b28',
34
+ paddingBottom: '400px',
35
+ paddingRight: '80px',
36
+ },
37
+ '.cm-gutters': {
38
+ display: 'none',
39
+ },
40
+ '.cm-activeLine': {
41
+ backgroundColor: '#eeeeeea0',
42
+ },
43
+ '.cm-activeLineGutter': {
44
+ color: '#000',
45
+ backgroundColor: '#ddd',
46
+ },
47
+ });
48
+ export function CodeEditor({ path, text, onChange, onToggleFiles, filesOpen }) {
49
+ const editorRef = useRef(null);
50
+ const viewRef = useRef(null);
51
+ const onChangeRef = useRef(onChange);
52
+ const initialTextRef = useRef(text);
53
+ const applyingExternalTextRef = useRef(false);
54
+ useEffect(() => {
55
+ onChangeRef.current = onChange;
56
+ }, [onChange]);
57
+ // The editor is created once per `path`; the initial doc comes from a ref
58
+ // so `text` is not a dep of this effect (subsequent `text` updates flow
59
+ // through the second effect below). On `path` change we read whatever the
60
+ // latest `text` is at the moment of mount.
61
+ initialTextRef.current = text;
62
+ useEffect(() => {
63
+ if (!editorRef.current) return undefined;
64
+ const view = new EditorView({
65
+ parent: editorRef.current,
66
+ state: EditorState.create({
67
+ doc: initialTextRef.current,
68
+ extensions: [
69
+ basicSetup,
70
+ javascript({ jsx: true, typescript: true }),
71
+ indentUnit.of(' '),
72
+ castleCodeTheme,
73
+ syntaxHighlighting(castleHighlightStyle),
74
+ EditorView.updateListener.of((update) => {
75
+ if (!update.docChanged || applyingExternalTextRef.current) return;
76
+ onChangeRef.current(update.state.doc.toString());
77
+ }),
78
+ ],
79
+ }),
80
+ });
81
+ viewRef.current = view;
82
+ return () => {
83
+ view.destroy();
84
+ viewRef.current = null;
85
+ };
86
+ }, [path]);
87
+ useEffect(() => {
88
+ const view = viewRef.current;
89
+ if (!view) return;
90
+ const currentText = view.state.doc.toString();
91
+ if (currentText === text) return;
92
+ applyingExternalTextRef.current = true;
93
+ view.dispatch({
94
+ changes: {
95
+ from: 0,
96
+ to: currentText.length,
97
+ insert: text,
98
+ },
99
+ });
100
+ applyingExternalTextRef.current = false;
101
+ }, [text]);
102
+ return (
103
+ <>
104
+ <EditorHeader title={basename(path)} onToggleFiles={onToggleFiles} filesOpen={filesOpen} />
105
+ <EditorBody>
106
+ <div className={styles.codeEditor}>
107
+ <div ref={editorRef} className={styles.codeMirrorHost} />
108
+ </div>
109
+ </EditorBody>
110
+ </>
111
+ );
112
+ }