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,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
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { basename, formatJson, parseJsonFile } from '../engine/files';
|
|
3
|
+
import {
|
|
4
|
+
cx,
|
|
5
|
+
EditorBody,
|
|
6
|
+
EditorHeader,
|
|
7
|
+
IconButton,
|
|
8
|
+
NumberField,
|
|
9
|
+
Panel,
|
|
10
|
+
SheetGrabHandle,
|
|
11
|
+
styles,
|
|
12
|
+
theme,
|
|
13
|
+
useMobileSheet,
|
|
14
|
+
} from '../engine/ui';
|
|
15
|
+
import { useEditHistory } from './editorHistory';
|
|
16
|
+
const palette = [
|
|
17
|
+
'#00000000',
|
|
18
|
+
'#000000FF',
|
|
19
|
+
'#050505FF',
|
|
20
|
+
'#111111FF',
|
|
21
|
+
'#242234FF',
|
|
22
|
+
'#444444FF',
|
|
23
|
+
'#777777FF',
|
|
24
|
+
'#ffffffFF',
|
|
25
|
+
'#e3e6ffFF',
|
|
26
|
+
'#8db7ffFF',
|
|
27
|
+
'#ff5d5dFF',
|
|
28
|
+
'#ffe17aFF',
|
|
29
|
+
];
|
|
30
|
+
export function DrawingEditor({ path, text, onChange, onToggleFiles, filesOpen }) {
|
|
31
|
+
const canvasRef = useRef(null);
|
|
32
|
+
const strokeRef = useRef({ active: false, recorded: false });
|
|
33
|
+
const [color, setColor] = useState('#ffffffFF');
|
|
34
|
+
const [inspectorOpen, setInspectorOpen] = useState(true);
|
|
35
|
+
const history = useEditHistory(text, onChange);
|
|
36
|
+
const inspectorSheet = useInspectorSheet(inspectorOpen);
|
|
37
|
+
const { value: drawing, error } = parseJsonFile(path, text);
|
|
38
|
+
const isWide = drawing ? drawing.width >= drawing.height : true;
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!drawing || !canvasRef.current) return;
|
|
41
|
+
const canvas = canvasRef.current;
|
|
42
|
+
const ctx = canvas.getContext('2d');
|
|
43
|
+
if (!ctx) return;
|
|
44
|
+
canvas.width = drawing.width;
|
|
45
|
+
canvas.height = drawing.height;
|
|
46
|
+
ctx.clearRect(0, 0, drawing.width, drawing.height);
|
|
47
|
+
for (let y = 0; y < drawing.height; y++) {
|
|
48
|
+
for (let x = 0; x < drawing.width; x++) {
|
|
49
|
+
const pixel = drawing.pixels[y * drawing.width + x];
|
|
50
|
+
if (!pixel || pixel.endsWith('00')) continue;
|
|
51
|
+
ctx.fillStyle = pixel;
|
|
52
|
+
ctx.fillRect(x, y, 1, 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}, [drawing, text]);
|
|
56
|
+
function commitDrawing(nextDrawing) {
|
|
57
|
+
history.commit(formatJson(nextDrawing));
|
|
58
|
+
}
|
|
59
|
+
function resizeDrawing(nextWidth, nextHeight) {
|
|
60
|
+
if (!drawing) return;
|
|
61
|
+
const width = clampInteger(nextWidth, 1, 128);
|
|
62
|
+
const height = clampInteger(nextHeight, 1, 128);
|
|
63
|
+
if (width === drawing.width && height === drawing.height) return;
|
|
64
|
+
const pixels = Array(width * height).fill('#00000000');
|
|
65
|
+
const copiedWidth = Math.min(width, drawing.width);
|
|
66
|
+
const copiedHeight = Math.min(height, drawing.height);
|
|
67
|
+
for (let y = 0; y < copiedHeight; y++) {
|
|
68
|
+
for (let x = 0; x < copiedWidth; x++) {
|
|
69
|
+
pixels[y * width + x] = drawing.pixels[y * drawing.width + x] ?? '#00000000';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
commitDrawing({ ...drawing, width, height, pixels });
|
|
73
|
+
}
|
|
74
|
+
function paintAt(event) {
|
|
75
|
+
if (!drawing) return;
|
|
76
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
77
|
+
const x = Math.floor(((event.clientX - rect.left) / rect.width) * drawing.width);
|
|
78
|
+
const y = Math.floor(((event.clientY - rect.top) / rect.height) * drawing.height);
|
|
79
|
+
if (x < 0 || y < 0 || x >= drawing.width || y >= drawing.height) return;
|
|
80
|
+
if (drawing.pixels[y * drawing.width + x] === color) return;
|
|
81
|
+
const next = structuredClone(drawing);
|
|
82
|
+
next.pixels[y * next.width + x] = color;
|
|
83
|
+
// Record one undo snapshot per pointer stroke (not per painted pixel).
|
|
84
|
+
if (!strokeRef.current.recorded) {
|
|
85
|
+
history.recordSnapshot();
|
|
86
|
+
strokeRef.current.recorded = true;
|
|
87
|
+
}
|
|
88
|
+
onChange(formatJson(next));
|
|
89
|
+
}
|
|
90
|
+
function startPaint(event) {
|
|
91
|
+
strokeRef.current = { active: true, recorded: false };
|
|
92
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
93
|
+
paintAt(event);
|
|
94
|
+
}
|
|
95
|
+
function finishPaint() {
|
|
96
|
+
strokeRef.current = { active: false, recorded: false };
|
|
97
|
+
}
|
|
98
|
+
const canvasStyle = drawing
|
|
99
|
+
? {
|
|
100
|
+
width: isWide ? 'min(70vh, 520px)' : 'auto',
|
|
101
|
+
height: isWide ? 'auto' : 'min(70vh, 520px)',
|
|
102
|
+
}
|
|
103
|
+
: undefined;
|
|
104
|
+
const sharedActionButtons = (
|
|
105
|
+
<>
|
|
106
|
+
<IconButton icon="undo" label="Undo" onClick={history.undo} disabled={!history.canUndo} />
|
|
107
|
+
<IconButton icon="redo" label="Redo" onClick={history.redo} disabled={!history.canRedo} />
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<EditorHeader
|
|
113
|
+
title={basename(path)}
|
|
114
|
+
subtitle={drawing ? `${drawing.width} x ${drawing.height} pixels` : error}
|
|
115
|
+
right={
|
|
116
|
+
<span className={styles.mobileOnly}>
|
|
117
|
+
{sharedActionButtons}
|
|
118
|
+
<IconButton
|
|
119
|
+
icon="palette"
|
|
120
|
+
label="Tools"
|
|
121
|
+
active={inspectorOpen}
|
|
122
|
+
onClick={() => setInspectorOpen((previous) => !previous)}
|
|
123
|
+
/>
|
|
124
|
+
</span>
|
|
125
|
+
}
|
|
126
|
+
onToggleFiles={onToggleFiles}
|
|
127
|
+
filesOpen={filesOpen}
|
|
128
|
+
/>
|
|
129
|
+
<EditorBody>
|
|
130
|
+
<div className={styles.drawingEditor}>
|
|
131
|
+
<div className={styles.drawingTools}>{sharedActionButtons}</div>
|
|
132
|
+
<div className={styles.drawingCanvasWrap}>
|
|
133
|
+
<canvas
|
|
134
|
+
ref={canvasRef}
|
|
135
|
+
className={styles.drawingCanvas}
|
|
136
|
+
style={canvasStyle}
|
|
137
|
+
onPointerDown={startPaint}
|
|
138
|
+
onPointerMove={(event) => {
|
|
139
|
+
if (strokeRef.current.active && event.buttons === 1) paintAt(event);
|
|
140
|
+
}}
|
|
141
|
+
onPointerUp={finishPaint}
|
|
142
|
+
onPointerCancel={finishPaint}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
<DrawingInspector
|
|
146
|
+
sheet={inspectorSheet}
|
|
147
|
+
drawing={drawing}
|
|
148
|
+
color={color}
|
|
149
|
+
onSelectColor={setColor}
|
|
150
|
+
onResize={resizeDrawing}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</EditorBody>
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
function useInspectorSheet(inspectorOpen) {
|
|
158
|
+
const [snap, setSnap] = useState('high');
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (inspectorOpen) setSnap('high');
|
|
161
|
+
}, [inspectorOpen]);
|
|
162
|
+
const effectiveSnap = inspectorOpen ? snap : 'hidden';
|
|
163
|
+
return useMobileSheet({
|
|
164
|
+
snap: effectiveSnap,
|
|
165
|
+
baseClassName: styles.inspector,
|
|
166
|
+
onTransition: (direction) => {
|
|
167
|
+
if (!inspectorOpen) return;
|
|
168
|
+
if (direction === 'tap') setSnap((previous) => (previous === 'high' ? 'low' : 'high'));
|
|
169
|
+
else if (direction === 'down') setSnap('low');
|
|
170
|
+
else if (direction === 'up') setSnap('high');
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function DrawingInspector({ sheet, drawing, color, onSelectColor, onResize }) {
|
|
175
|
+
return (
|
|
176
|
+
<aside {...sheet.rootProps} className={cx(sheet.rootProps.className, styles.inspectorAuto)}>
|
|
177
|
+
<div {...sheet.grabProps}>
|
|
178
|
+
<SheetGrabHandle
|
|
179
|
+
label="Inspector"
|
|
180
|
+
hint={drawing ? `${drawing.width}x${drawing.height} · color` : 'drawing tools'}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
<div className={cx(styles.sheetBody, styles.inspectorBody)}>
|
|
184
|
+
<Panel title="Size">
|
|
185
|
+
<NumberField
|
|
186
|
+
label="Width"
|
|
187
|
+
value={drawing?.width ?? 1}
|
|
188
|
+
min={1}
|
|
189
|
+
max={128}
|
|
190
|
+
onChange={(width) => onResize(width, drawing?.height ?? 1)}
|
|
191
|
+
/>
|
|
192
|
+
<NumberField
|
|
193
|
+
label="Height"
|
|
194
|
+
value={drawing?.height ?? 1}
|
|
195
|
+
min={1}
|
|
196
|
+
max={128}
|
|
197
|
+
onChange={(height) => onResize(drawing?.width ?? 1, height)}
|
|
198
|
+
/>
|
|
199
|
+
</Panel>
|
|
200
|
+
<Panel title="Palette">
|
|
201
|
+
<div className={styles.palette}>
|
|
202
|
+
{palette.map((swatch) => (
|
|
203
|
+
<button
|
|
204
|
+
key={swatch}
|
|
205
|
+
className={cx(styles.swatch, color === swatch && styles.swatchSelected)}
|
|
206
|
+
title={swatch}
|
|
207
|
+
style={{
|
|
208
|
+
background: swatch.endsWith('00') ? theme.transparentChecker : swatch,
|
|
209
|
+
}}
|
|
210
|
+
onClick={() => onSelectColor(swatch)}
|
|
211
|
+
/>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
</Panel>
|
|
215
|
+
</div>
|
|
216
|
+
</aside>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
function clampInteger(value, min, max) {
|
|
220
|
+
if (!Number.isFinite(value)) return min;
|
|
221
|
+
return Math.max(min, Math.min(max, Math.round(value)));
|
|
222
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { basename } from '../engine/files';
|
|
3
|
+
import { cx, Icon, SheetGrabHandle, styles, useMobileSheet } from '../engine/ui';
|
|
4
|
+
export function FileBrowser({
|
|
5
|
+
files,
|
|
6
|
+
selectedPath,
|
|
7
|
+
onSelect,
|
|
8
|
+
sheetOpen = false,
|
|
9
|
+
onSheetOpenChange,
|
|
10
|
+
}) {
|
|
11
|
+
const tree = buildFileTree(Object.keys(files));
|
|
12
|
+
const [expanded, setExpanded] = useState(() => new Set(collectDirectoryPaths(tree)));
|
|
13
|
+
const sheet = useMobileSheet({
|
|
14
|
+
snap: sheetOpen ? 'high' : 'hidden',
|
|
15
|
+
baseClassName: styles.fileBrowser,
|
|
16
|
+
onTransition: (direction) => {
|
|
17
|
+
if (direction !== 'up') onSheetOpenChange?.(false);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
function toggle(path) {
|
|
21
|
+
setExpanded((current) => {
|
|
22
|
+
const next = new Set(current);
|
|
23
|
+
if (next.has(path)) next.delete(path);
|
|
24
|
+
else next.add(path);
|
|
25
|
+
return next;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
<aside {...sheet.rootProps}>
|
|
30
|
+
<div {...sheet.grabProps}>
|
|
31
|
+
<SheetGrabHandle label="Files" hint={basename(selectedPath)} />
|
|
32
|
+
</div>
|
|
33
|
+
<div className={styles.fileBrowserHeader}>
|
|
34
|
+
<div>
|
|
35
|
+
<div className={styles.fileBrowserTitle}>Deck Files</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div className={styles.fileTree}>
|
|
39
|
+
{tree.children.map((node) => (
|
|
40
|
+
<FileNode
|
|
41
|
+
key={node.path}
|
|
42
|
+
node={node}
|
|
43
|
+
depth={0}
|
|
44
|
+
expanded={expanded}
|
|
45
|
+
selectedPath={selectedPath}
|
|
46
|
+
onSelect={onSelect}
|
|
47
|
+
onToggle={toggle}
|
|
48
|
+
/>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</aside>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
function FileNode({ node, depth, expanded, selectedPath, onSelect, onToggle }) {
|
|
55
|
+
const depthStyle = { '--file-depth': depth };
|
|
56
|
+
if (node.type === 'directory') {
|
|
57
|
+
const isExpanded = expanded.has(node.path);
|
|
58
|
+
return (
|
|
59
|
+
<div className={styles.fileBranch}>
|
|
60
|
+
<button
|
|
61
|
+
className={styles.fileDirRow}
|
|
62
|
+
style={depthStyle}
|
|
63
|
+
onClick={() => onToggle(node.path)}>
|
|
64
|
+
<span className={styles.fileDisclosure}>
|
|
65
|
+
<Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} />
|
|
66
|
+
</span>
|
|
67
|
+
<span>{node.name}</span>
|
|
68
|
+
</button>
|
|
69
|
+
{isExpanded ? (
|
|
70
|
+
<div>
|
|
71
|
+
{node.children.map((child) => (
|
|
72
|
+
<FileNode
|
|
73
|
+
key={child.path}
|
|
74
|
+
node={child}
|
|
75
|
+
depth={depth + 1}
|
|
76
|
+
expanded={expanded}
|
|
77
|
+
selectedPath={selectedPath}
|
|
78
|
+
onSelect={onSelect}
|
|
79
|
+
onToggle={onToggle}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
// File rows nest under their directory header: extra left padding so
|
|
88
|
+
// file text sits to the right of the parent directory header.
|
|
89
|
+
const fileRowStyle = {
|
|
90
|
+
...depthStyle,
|
|
91
|
+
paddingLeft: `calc(9px + ${depth} * 16px + 7px)`,
|
|
92
|
+
};
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
className={cx(styles.fileRow, selectedPath === node.path && styles.fileRowSelected)}
|
|
96
|
+
style={fileRowStyle}
|
|
97
|
+
onClick={() => onSelect(node.path)}>
|
|
98
|
+
<span>{basename(node.path)}</span>
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
function buildFileTree(paths) {
|
|
103
|
+
const root = {
|
|
104
|
+
type: 'directory',
|
|
105
|
+
name: '',
|
|
106
|
+
path: '',
|
|
107
|
+
children: [],
|
|
108
|
+
childMap: new Map(),
|
|
109
|
+
};
|
|
110
|
+
for (const path of paths) {
|
|
111
|
+
const parts = path.split('/');
|
|
112
|
+
let parent = root;
|
|
113
|
+
for (let index = 0; index < parts.length; index++) {
|
|
114
|
+
const name = parts[index];
|
|
115
|
+
const nodePath = parts.slice(0, index + 1).join('/');
|
|
116
|
+
const isFile = index === parts.length - 1;
|
|
117
|
+
if (!parent.childMap?.has(name)) {
|
|
118
|
+
const node = isFile
|
|
119
|
+
? { type: 'file', name, path: nodePath }
|
|
120
|
+
: { type: 'directory', name, path: nodePath, children: [], childMap: new Map() };
|
|
121
|
+
parent.childMap?.set(name, node);
|
|
122
|
+
parent.children.push(node);
|
|
123
|
+
}
|
|
124
|
+
const child = parent.childMap?.get(name);
|
|
125
|
+
if (!child || child.type !== 'directory') break;
|
|
126
|
+
parent = child;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
pruneChildMaps(root);
|
|
130
|
+
return root;
|
|
131
|
+
}
|
|
132
|
+
function pruneChildMaps(node) {
|
|
133
|
+
if (node.type !== 'directory') return;
|
|
134
|
+
for (const child of node.children) pruneChildMaps(child);
|
|
135
|
+
delete node.childMap;
|
|
136
|
+
}
|
|
137
|
+
function collectDirectoryPaths(node) {
|
|
138
|
+
if (node.type !== 'directory') return [];
|
|
139
|
+
return [
|
|
140
|
+
...(node.path ? [node.path] : []),
|
|
141
|
+
...node.children.flatMap((child) => collectDirectoryPaths(child)),
|
|
142
|
+
];
|
|
143
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { initialFiles, parseJsonFile } from '../engine/files';
|
|
3
|
+
import { ScenePlayer } from '../engine/ScenePlayer';
|
|
4
|
+
import { behaviorClasses } from './behaviorRegistry';
|
|
5
|
+
// Play-mode entry point. Intentionally thin: it locates the start scene and
|
|
6
|
+
// hands it to the engine's `ScenePlayer`, which owns the runtime and input.
|
|
7
|
+
// Deck/game logic belongs in `scenes/` and `behaviors/`, not here.
|
|
8
|
+
export function PlayOnly() {
|
|
9
|
+
const sceneText = initialFiles['scenes/main.scene'] ?? '';
|
|
10
|
+
const { value: sceneData } = parseJsonFile('scenes/main.scene', sceneText);
|
|
11
|
+
if (!sceneData) return null;
|
|
12
|
+
const drawings = {};
|
|
13
|
+
for (const [path, text] of Object.entries(initialFiles)) {
|
|
14
|
+
if (!path.endsWith('.drawing')) continue;
|
|
15
|
+
const parsed = parseJsonFile(path, text);
|
|
16
|
+
if (parsed.value) drawings[path] = parsed.value;
|
|
17
|
+
}
|
|
18
|
+
return (
|
|
19
|
+
<ScenePlayer sceneData={sceneData} drawings={drawings} behaviorClasses={behaviorClasses} />
|
|
20
|
+
);
|
|
21
|
+
}
|