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.
- 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 +84 -57
- 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 +4 -1
- package/dist/preview.js +63 -41
- 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 +293 -22
- 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 -24
- 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 -110
- package/src/init.ts +0 -71
- package/src/login.ts +0 -24
- package/src/preview.ts +0 -93
- package/src/push.ts +0 -118
- package/src/serve.ts +0 -128
- 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,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
|
+
}
|