castle-web-cli 0.4.11 → 0.4.12

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 (49) hide show
  1. package/dist/agent-prompts.d.ts +31 -0
  2. package/dist/agent-prompts.js +100 -0
  3. package/dist/agent.d.ts +17 -0
  4. package/dist/agent.js +894 -0
  5. package/dist/chat-client.d.ts +1 -0
  6. package/dist/chat-client.js +398 -0
  7. package/dist/commonInstructions.d.ts +1 -0
  8. package/dist/commonInstructions.js +8 -0
  9. package/dist/ide-client.js +46 -14
  10. package/dist/ide.d.ts +2 -0
  11. package/dist/ide.js +321 -36
  12. package/dist/init.js +12 -1
  13. package/dist/serve.js +18 -3
  14. package/kits/basic-2d/CLAUDE.md +3 -1
  15. package/kits/basic-3d/.prettierrc +8 -0
  16. package/kits/basic-3d/CLAUDE.md +162 -0
  17. package/kits/basic-3d/behaviors/Camera.jsx +56 -0
  18. package/kits/basic-3d/behaviors/Collider.jsx +78 -0
  19. package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
  20. package/kits/basic-3d/behaviors/Model.jsx +61 -0
  21. package/kits/basic-3d/behaviors/Transform.jsx +35 -0
  22. package/kits/basic-3d/editors/App.jsx +147 -0
  23. package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
  24. package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
  25. package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
  26. package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
  27. package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
  28. package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
  29. package/kits/basic-3d/editors/editorHistory.js +52 -0
  30. package/kits/basic-3d/editors/viewportRig.js +90 -0
  31. package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
  32. package/kits/basic-3d/engine/SceneUI.jsx +67 -0
  33. package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
  34. package/kits/basic-3d/engine/TouchControls.jsx +136 -0
  35. package/kits/basic-3d/engine/autoInspector.jsx +51 -0
  36. package/kits/basic-3d/engine/files.js +73 -0
  37. package/kits/basic-3d/engine/scene.js +502 -0
  38. package/kits/basic-3d/engine/threeUtil.js +260 -0
  39. package/kits/basic-3d/engine/ui.jsx +352 -0
  40. package/kits/basic-3d/engine/ui.module.css +944 -0
  41. package/kits/basic-3d/eslint.config.js +51 -0
  42. package/kits/basic-3d/index.html +11 -0
  43. package/kits/basic-3d/main.jsx +10 -0
  44. package/kits/basic-3d/models/block.model +14 -0
  45. package/kits/basic-3d/package-lock.json +2713 -0
  46. package/kits/basic-3d/package.json +41 -0
  47. package/kits/basic-3d/scenes/main.scene +76 -0
  48. package/kits/basic-3d/vite.config.js +1 -0
  49. package/package.json +6 -1
@@ -0,0 +1,24 @@
1
+ // Discover every behavior class from `behaviors/*.tsx`. Vite HMR is off, so a
2
+ // newly-added behavior file is picked up on the next reload/restart.
3
+ const modules = import.meta.glob('../behaviors/*.jsx', { eager: true });
4
+ function isBehaviorClass(value) {
5
+ return typeof value === 'function' && typeof value.behaviorName === 'string';
6
+ }
7
+ function collectBehaviors() {
8
+ const found = new Map();
9
+ for (const mod of Object.values(modules)) {
10
+ for (const exported of Object.values(mod)) {
11
+ if (isBehaviorClass(exported)) found.set(exported.behaviorName, exported);
12
+ }
13
+ }
14
+ // Layout first (every actor needs it), then alphabetical.
15
+ return [...found.values()].sort((a, b) => {
16
+ if (a.behaviorName === 'Layout') return -1;
17
+ if (b.behaviorName === 'Layout') return 1;
18
+ return a.behaviorName.localeCompare(b.behaviorName);
19
+ });
20
+ }
21
+ export const behaviorClasses = collectBehaviors();
22
+ export function findBehaviorClass(behaviorName) {
23
+ return behaviorClasses.find((candidate) => candidate.behaviorName === behaviorName);
24
+ }
@@ -0,0 +1,52 @@
1
+ import { useState } from 'react';
2
+ const HISTORY_LIMIT = 50;
3
+ // Text-undo/redo for file-backed editors. `text` is the canonical current
4
+ // value; `onChange` writes the new value back. The hook owns the undo/redo
5
+ // stacks; it never mutates `text` directly.
6
+ export function useEditHistory(text, onChange) {
7
+ const [history, setHistory] = useState({ undo: [], redo: [] });
8
+ function commit(nextText) {
9
+ if (nextText === text) return;
10
+ setHistory((current) => ({
11
+ undo: [...current.undo, text].slice(-HISTORY_LIMIT),
12
+ redo: [],
13
+ }));
14
+ onChange(nextText);
15
+ }
16
+ function recordSnapshot() {
17
+ setHistory((current) => ({
18
+ undo: [...current.undo, text].slice(-HISTORY_LIMIT),
19
+ redo: [],
20
+ }));
21
+ }
22
+ function undo() {
23
+ setHistory((current) => {
24
+ const previous = current.undo.at(-1);
25
+ if (!previous) return current;
26
+ onChange(previous);
27
+ return {
28
+ undo: current.undo.slice(0, -1),
29
+ redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
30
+ };
31
+ });
32
+ }
33
+ function redo() {
34
+ setHistory((current) => {
35
+ const next = current.redo[0];
36
+ if (!next) return current;
37
+ onChange(next);
38
+ return {
39
+ undo: [...current.undo, text].slice(-HISTORY_LIMIT),
40
+ redo: current.redo.slice(1),
41
+ };
42
+ });
43
+ }
44
+ return {
45
+ commit,
46
+ undo,
47
+ redo,
48
+ canUndo: history.undo.length > 0,
49
+ canRedo: history.redo.length > 0,
50
+ recordSnapshot,
51
+ };
52
+ }
@@ -0,0 +1,90 @@
1
+ import * as THREE from 'three';
2
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3
+ import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
4
+ import { applyCameraSpec, defaultLighting } from '../engine/threeUtil';
5
+ import { defaultCameraSpec } from '../engine/scene';
6
+
7
+ // Editor viewport rig: a perspective camera with orbit navigation plus a
8
+ // transform gizmo, shared by the scene editor and the model editor.
9
+ //
10
+ // Navigation: right-drag orbits, middle-drag pans, wheel dollies. When
11
+ // `navMode` is on (the toolbar camera toggle, for touch / trackpads),
12
+ // left-drag orbits and two-finger gestures dolly+pan; otherwise left input
13
+ // belongs to the caller (selection, painting, marquee).
14
+ export function createViewportRig({ canvas, scene, onGizmoChange, onGizmoDraggingChanged }) {
15
+ const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
16
+ applyCameraSpec(camera, defaultCameraSpec);
17
+
18
+ const orbit = new OrbitControls(camera, canvas);
19
+ orbit.target.set(defaultCameraSpec.targetX, defaultCameraSpec.targetY, defaultCameraSpec.targetZ);
20
+ orbit.enableDamping = false;
21
+ orbit.update();
22
+
23
+ const gizmo = new TransformControls(camera, canvas);
24
+ gizmo.addEventListener('objectChange', () => onGizmoChange?.(gizmo));
25
+ gizmo.addEventListener('dragging-changed', (event) => {
26
+ orbit.enabled = !event.value;
27
+ onGizmoDraggingChanged?.(event.value);
28
+ });
29
+ // r169+ exposes the renderable part via getHelper(); older versions are the
30
+ // Object3D themselves.
31
+ const gizmoHelper = gizmo.getHelper ? gizmo.getHelper() : gizmo;
32
+ scene.add(gizmoHelper);
33
+
34
+ const rig = {
35
+ camera,
36
+ orbit,
37
+ gizmo,
38
+ // True while the gizmo is being hovered or dragged -- callers skip their
39
+ // own left-pointer gestures so the two don't fight.
40
+ gizmoBusy: () => !!gizmo.dragging || gizmo.axis != null,
41
+ setNavMode(navMode) {
42
+ orbit.mouseButtons = {
43
+ LEFT: navMode ? THREE.MOUSE.ROTATE : null,
44
+ MIDDLE: THREE.MOUSE.PAN,
45
+ RIGHT: THREE.MOUSE.ROTATE,
46
+ };
47
+ orbit.touches = {
48
+ ONE: navMode ? THREE.TOUCH.ROTATE : null,
49
+ TWO: THREE.TOUCH.DOLLY_PAN,
50
+ };
51
+ },
52
+ dispose() {
53
+ gizmo.detach();
54
+ gizmoHelper.parent?.remove(gizmoHelper);
55
+ gizmo.dispose();
56
+ orbit.dispose();
57
+ },
58
+ };
59
+ rig.setNavMode(false);
60
+ return rig;
61
+ }
62
+
63
+ // Configure gizmo snapping from scene editor settings.
64
+ export function applyGizmoSnap(gizmo, snapSettings) {
65
+ const enabled = !!snapSettings?.enabled;
66
+ gizmo.setTranslationSnap(enabled ? (snapSettings.gridSize ?? 1) : null);
67
+ gizmo.setRotationSnap(enabled ? THREE.MathUtils.degToRad(15) : null);
68
+ gizmo.setScaleSnap(enabled ? 0.25 : null);
69
+ }
70
+
71
+ export const GIZMO_MODES = [
72
+ { key: 'translate', icon: 'move', label: 'Move' },
73
+ { key: 'rotate', icon: 'rotate', label: 'Rotate' },
74
+ { key: 'scale', icon: 'scale', label: 'Scale' },
75
+ ];
76
+
77
+ // A standalone lit preview scene for editors that aren't backed by a
78
+ // SceneRuntime (the model editor): lights + grid on a dark background.
79
+ export function createPreviewScene() {
80
+ const scene = new THREE.Scene();
81
+ scene.background = new THREE.Color('#191d28');
82
+ const hemi = new THREE.HemisphereLight(defaultLighting.sky, defaultLighting.ground, 1);
83
+ const sun = new THREE.DirectionalLight(defaultLighting.sun, 2);
84
+ sun.position.set(20, 35, 25);
85
+ sun.castShadow = true;
86
+ scene.add(hemi, sun);
87
+ const grid = new THREE.GridHelper(20, 20, 0x666666, 0x333333);
88
+ scene.add(grid);
89
+ return scene;
90
+ }
@@ -0,0 +1,55 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import * as THREE from 'three';
3
+ import { makeScene, defaultCameraSpec } from './scene';
4
+ import { applyCameraSpec, fitRendererToCanvas } from './threeUtil';
5
+ import { attachSceneKeys, makePlayPointerHandlers, ThreeCanvas } from './SceneViewport';
6
+ import { SceneUI } from './SceneUI';
7
+ import { TouchControls } from './TouchControls';
8
+ // Engine-level scene player: mount a `SceneRuntime` against a WebGL canvas,
9
+ // wire keyboard / pointer input, run the update+sync+render loop, and render
10
+ // the behavior-driven UI overlay. No game logic lives here -- behaviors and
11
+ // scenes are the place for that.
12
+ export function ScenePlayer({ sceneData, models, behaviorClasses }) {
13
+ const runtimeRef = useRef(null);
14
+ const cameraRef = useRef(null);
15
+ const getKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
16
+ const getRuntime = useCallback(() => runtimeRef.current, []);
17
+ const pointerHandlers = useMemo(() => makePlayPointerHandlers(getRuntime), [getRuntime]);
18
+ useEffect(() => {
19
+ const runtime = makeScene(sceneData, behaviorClasses, models);
20
+ runtimeRef.current = runtime;
21
+ const detachKeys = attachSceneKeys(getRuntime);
22
+ return () => {
23
+ detachKeys();
24
+ runtime.dispose();
25
+ runtimeRef.current = null;
26
+ };
27
+ // eslint-disable-next-line react-hooks/exhaustive-deps
28
+ }, []);
29
+ const onSetup = () => {
30
+ cameraRef.current = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
31
+ };
32
+ const onFrame = ({ canvas, renderer, dt }) => {
33
+ const runtime = runtimeRef.current;
34
+ const camera = cameraRef.current;
35
+ if (!runtime || !camera) return;
36
+ runtime.update(dt);
37
+ runtime.syncFrame(dt, {});
38
+ if (!fitRendererToCanvas(renderer, camera, canvas)) return;
39
+ applyCameraSpec(camera, runtime.camera ?? defaultCameraSpec);
40
+ runtime.activeCamera = camera;
41
+ renderer.render(runtime.three, camera);
42
+ };
43
+ return (
44
+ <div style={{ position: 'fixed', inset: 0, background: '#000' }}>
45
+ <ThreeCanvas
46
+ onSetup={onSetup}
47
+ onFrame={onFrame}
48
+ style={{ width: '100%', height: '100%', display: 'block', touchAction: 'none' }}
49
+ {...pointerHandlers}
50
+ />
51
+ <SceneUI getRuntime={getRuntime} />
52
+ <TouchControls getKeys={getKeys} />
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,67 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import styles from './ui.module.css';
3
+ import { cardSize } from './scene';
4
+ // Game-time UI overlay.
5
+ //
6
+ // Behaviors expose a `ui(actor, scene)` handler -- the React counterpart of
7
+ // `draw`. `SceneUI` collects every behavior's UI output each frame and renders
8
+ // it into a deck-sized layer that sits exactly over the canvas. The layer is
9
+ // `cardSize` units wide/tall and scaled onto the canvas box, so behaviors lay
10
+ // out in the same card units as `Layout`. The root clips with `overflow:
11
+ // hidden`, so deck UI can never render outside the card.
12
+ export function SceneUI({ getRuntime }) {
13
+ const rootRef = useRef(null);
14
+ const [box, setBox] = useState({ width: 0, height: 0 });
15
+ const [, forceRender] = useState(0);
16
+ // Track the canvas-sized overlay box so the card-unit layer scales onto it.
17
+ useEffect(() => {
18
+ const root = rootRef.current;
19
+ if (!root) return undefined;
20
+ const measure = () => setBox({ width: root.clientWidth, height: root.clientHeight });
21
+ const observer = new ResizeObserver(measure);
22
+ observer.observe(root);
23
+ measure();
24
+ return () => observer.disconnect();
25
+ }, []);
26
+ // Re-render every frame so behavior UI reflects live runtime state.
27
+ useEffect(() => {
28
+ let raf = 0;
29
+ const frame = () => {
30
+ forceRender((tick) => tick + 1);
31
+ raf = requestAnimationFrame(frame);
32
+ };
33
+ raf = requestAnimationFrame(frame);
34
+ return () => cancelAnimationFrame(raf);
35
+ }, []);
36
+ return (
37
+ <div ref={rootRef} className={styles.sceneUiRoot}>
38
+ <div
39
+ className={styles.sceneUiLayer}
40
+ style={{
41
+ width: cardSize.width,
42
+ height: cardSize.height,
43
+ transform: `scale(${box.width / cardSize.width}, ${box.height / cardSize.height})`,
44
+ }}>
45
+ {collectUiNodes(getRuntime())}
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+ // Build one keyed React node per behavior that defines a `ui` handler.
51
+ function collectUiNodes(runtime) {
52
+ if (!runtime) return [];
53
+ const nodes = [];
54
+ for (const actor of runtime.getActors()) {
55
+ for (const [behaviorName, props] of Object.entries(actor.components)) {
56
+ if (!props) continue;
57
+ const Behavior = runtime.behaviors.get(behaviorName);
58
+ if (!Behavior) continue;
59
+ const instance = new Behavior(props);
60
+ if (!instance.ui) continue;
61
+ const node = instance.ui(actor, runtime);
62
+ if (node == null) continue;
63
+ nodes.push(<React.Fragment key={`${actor.id}:${behaviorName}`}>{node}</React.Fragment>);
64
+ }
65
+ }
66
+ return nodes;
67
+ }
@@ -0,0 +1,102 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { createRenderer } from './threeUtil';
3
+
4
+ // Shared WebGL viewport plumbing: a canvas + renderer + frame loop, and the
5
+ // input helpers every play surface (standalone player, editor play mode)
6
+ // shares so behaviors see identical `scene.keys` / `scene.pointer` everywhere.
7
+
8
+ // Mounts a WebGL canvas and drives a per-frame callback. `onSetup` runs once
9
+ // with `{ canvas, renderer }` (may return a cleanup); `onFrame` runs every
10
+ // animation frame with `{ canvas, renderer, dt }`. Both are read through a
11
+ // ref, so the latest render's closures are always the ones called.
12
+ export function ThreeCanvas({ onSetup, onFrame, ...canvasProps }) {
13
+ const canvasRef = useRef(null);
14
+ const callbacksRef = useRef({ onSetup, onFrame });
15
+ callbacksRef.current = { onSetup, onFrame };
16
+ useEffect(() => {
17
+ const canvas = canvasRef.current;
18
+ if (!canvas) return undefined;
19
+ const renderer = createRenderer(canvas);
20
+ const cleanup = callbacksRef.current.onSetup?.({ canvas, renderer });
21
+ let raf = 0;
22
+ let previousTime = performance.now();
23
+ const tick = (now) => {
24
+ const dt = Math.min(0.033, (now - previousTime) / 1000);
25
+ previousTime = now;
26
+ callbacksRef.current.onFrame?.({ canvas, renderer, dt });
27
+ raf = requestAnimationFrame(tick);
28
+ };
29
+ raf = requestAnimationFrame(tick);
30
+ return () => {
31
+ cancelAnimationFrame(raf);
32
+ cleanup?.();
33
+ renderer.dispose();
34
+ };
35
+ }, []);
36
+ return <canvas ref={canvasRef} {...canvasProps} />;
37
+ }
38
+
39
+ // Window-level key listeners feeding `scene.keys`. Both `event.key` ('a',
40
+ // 'ArrowLeft', ' ') and `event.code` ('KeyA', 'Space') are added so behaviors
41
+ // can read either. Optional `onSpace` intercepts the space key outside text
42
+ // inputs (the editor's play/stop toggle). Returns a cleanup function.
43
+ export function attachSceneKeys(getRuntime, { onSpace } = {}) {
44
+ const down = (event) => {
45
+ if (onSpace && event.key === ' ' && !isEditableTarget(event.target)) {
46
+ event.preventDefault();
47
+ onSpace();
48
+ return;
49
+ }
50
+ const runtime = getRuntime();
51
+ if (!runtime) return;
52
+ runtime.keys.add(event.key);
53
+ runtime.keys.add(event.code);
54
+ };
55
+ const up = (event) => {
56
+ const runtime = getRuntime();
57
+ if (!runtime) return;
58
+ runtime.keys.delete(event.key);
59
+ runtime.keys.delete(event.code);
60
+ };
61
+ window.addEventListener('keydown', down);
62
+ window.addEventListener('keyup', up);
63
+ return () => {
64
+ window.removeEventListener('keydown', down);
65
+ window.removeEventListener('keyup', up);
66
+ };
67
+ }
68
+
69
+ export function isEditableTarget(target) {
70
+ return (
71
+ target instanceof Element &&
72
+ !!target.closest('input, textarea, select, [contenteditable="true"], .cm-editor')
73
+ );
74
+ }
75
+
76
+ // React pointer handlers feeding `scene.pointer` during play.
77
+ export function makePlayPointerHandlers(getRuntime) {
78
+ const set = (event, down) => {
79
+ const runtime = getRuntime();
80
+ if (!runtime) return;
81
+ runtime.setPointerFromScreen(event.currentTarget, event.clientX, event.clientY, down);
82
+ };
83
+ return {
84
+ onPointerDown: (event) => {
85
+ try {
86
+ event.currentTarget.setPointerCapture(event.pointerId);
87
+ } catch {
88
+ // Synthetic / already-released pointers can't be captured.
89
+ }
90
+ set(event, true);
91
+ },
92
+ onPointerMove: (event) => set(event),
93
+ onPointerUp: (event) => {
94
+ set(event, false);
95
+ try {
96
+ event.currentTarget.releasePointerCapture(event.pointerId);
97
+ } catch {
98
+ // Pointer capture may already be gone after cancel/blur.
99
+ }
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,136 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ // On-screen movement controls: a 4-way d-pad feeds arrow keys onto
3
+ // `scene.keys` -- the exact same keyboard keys behaviors consume, no separate
4
+ // input path.
5
+ const KEYS = {
6
+ up: 'ArrowUp',
7
+ down: 'ArrowDown',
8
+ left: 'ArrowLeft',
9
+ right: 'ArrowRight',
10
+ };
11
+ export function TouchControls({ getKeys, visible = true }) {
12
+ const [isTouch, setIsTouch] = useState(false);
13
+ const heldRef = useRef(new Set());
14
+ useEffect(() => {
15
+ const params = new URLSearchParams(window.location.search);
16
+ const override = params.get('touch');
17
+ if (override === '1' || override === 'true') {
18
+ setIsTouch(true);
19
+ return undefined;
20
+ }
21
+ if (override === '0' || override === 'false') {
22
+ setIsTouch(false);
23
+ return undefined;
24
+ }
25
+ const mq = window.matchMedia('(pointer: coarse)');
26
+ const update = () => {
27
+ setIsTouch(mq.matches || 'ontouchstart' in window || navigator.maxTouchPoints > 0);
28
+ };
29
+ update();
30
+ mq.addEventListener?.('change', update);
31
+ return () => mq.removeEventListener?.('change', update);
32
+ }, []);
33
+ const release = useCallback(
34
+ (id) => {
35
+ if (!heldRef.current.has(id)) return;
36
+ heldRef.current.delete(id);
37
+ getKeys()?.delete(KEYS[id]);
38
+ },
39
+ [getKeys]
40
+ );
41
+ useEffect(() => {
42
+ if (!visible || !isTouch) {
43
+ // Drop any keys we were holding when controls hide.
44
+ const keys = getKeys();
45
+ if (keys) for (const id of heldRef.current) keys.delete(KEYS[id]);
46
+ heldRef.current.clear();
47
+ }
48
+ }, [visible, isTouch, getKeys]);
49
+ if (!visible || !isTouch) return null;
50
+ const press = (id) => {
51
+ heldRef.current.add(id);
52
+ getKeys()?.add(KEYS[id]);
53
+ };
54
+ const button = (id, label, extra) => (
55
+ <button
56
+ key={id}
57
+ type="button"
58
+ tabIndex={-1}
59
+ aria-label={id}
60
+ onTouchStart={(event) => {
61
+ event.preventDefault();
62
+ press(id);
63
+ }}
64
+ onTouchEnd={(event) => {
65
+ event.preventDefault();
66
+ release(id);
67
+ }}
68
+ onTouchCancel={(event) => {
69
+ event.preventDefault();
70
+ release(id);
71
+ }}
72
+ onMouseDown={(event) => {
73
+ event.preventDefault();
74
+ press(id);
75
+ }}
76
+ onMouseUp={() => release(id)}
77
+ onMouseLeave={() => release(id)}
78
+ onContextMenu={(event) => event.preventDefault()}
79
+ style={{ ...buttonStyle, ...extra }}>
80
+ {label}
81
+ </button>
82
+ );
83
+ return (
84
+ <div style={containerStyle}>
85
+ <div style={dpadStyle}>
86
+ {button('up', '▲', dpadUpStyle)}
87
+ {button('left', '◀', dpadLeftStyle)}
88
+ {button('right', '▶', dpadRightStyle)}
89
+ {button('down', '▼', dpadDownStyle)}
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+ const containerStyle = {
95
+ position: 'fixed',
96
+ left: 0,
97
+ right: 0,
98
+ bottom: 0,
99
+ display: 'flex',
100
+ justifyContent: 'flex-start',
101
+ alignItems: 'flex-end',
102
+ padding: '12px 16px calc(env(safe-area-inset-bottom, 0px) + 12px)',
103
+ pointerEvents: 'none',
104
+ zIndex: 50,
105
+ };
106
+ // 3x3 grid -- the d-pad arms occupy the cross cells, corners stay empty.
107
+ const dpadStyle = {
108
+ display: 'grid',
109
+ gridTemplateColumns: 'repeat(3, 64px)',
110
+ gridTemplateRows: 'repeat(3, 64px)',
111
+ gap: 6,
112
+ pointerEvents: 'auto',
113
+ };
114
+ const dpadUpStyle = { gridColumn: 2, gridRow: 1 };
115
+ const dpadLeftStyle = { gridColumn: 1, gridRow: 2 };
116
+ const dpadRightStyle = { gridColumn: 3, gridRow: 2 };
117
+ const dpadDownStyle = { gridColumn: 2, gridRow: 3 };
118
+ const buttonStyle = {
119
+ width: 64,
120
+ height: 64,
121
+ borderRadius: 12,
122
+ border: '1px solid rgba(255, 255, 255, 0.35)',
123
+ background: 'rgba(0, 0, 0, 0.35)',
124
+ color: 'rgba(255, 255, 255, 0.92)',
125
+ fontSize: 24,
126
+ fontWeight: 600,
127
+ display: 'flex',
128
+ alignItems: 'center',
129
+ justifyContent: 'center',
130
+ userSelect: 'none',
131
+ WebkitUserSelect: 'none',
132
+ WebkitTouchCallout: 'none',
133
+ WebkitTapHighlightColor: 'transparent',
134
+ touchAction: 'none',
135
+ cursor: 'pointer',
136
+ };
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { CheckboxField, ColorField, NumberField, Panel, TextField, isHexColor } from './ui';
3
+ export function AutoFields({ defaultProps, component, setComponent, only, exclude }) {
4
+ const keys = Object.keys(defaultProps).filter((key) => {
5
+ if (only) return only.includes(key);
6
+ if (exclude) return !exclude.includes(key);
7
+ return true;
8
+ });
9
+ return (
10
+ <>
11
+ {keys.map((key) => {
12
+ const fallback = defaultProps[key];
13
+ const current = key in component ? component[key] : fallback;
14
+ const set = (value) => setComponent({ [key]: value });
15
+ const label = humanizeKey(key);
16
+ const sample = fallback ?? current;
17
+ if (typeof sample === 'number') {
18
+ return <NumberField key={key} label={label} value={current} onChange={set} />;
19
+ }
20
+ if (typeof sample === 'boolean') {
21
+ return <CheckboxField key={key} label={label} checked={current} onChange={set} />;
22
+ }
23
+ if (isHexColor(sample)) {
24
+ return <ColorField key={key} label={label} value={current} onChange={set} />;
25
+ }
26
+ return (
27
+ <TextField
28
+ key={key}
29
+ label={label}
30
+ value={current == null ? '' : String(current)}
31
+ onChange={set}
32
+ />
33
+ );
34
+ })}
35
+ </>
36
+ );
37
+ }
38
+ export function AutoInspector({ behaviorName, defaultProps, component, setComponent }) {
39
+ return (
40
+ <Panel title={humanizeKey(behaviorName)}>
41
+ <AutoFields defaultProps={defaultProps} component={component} setComponent={setComponent} />
42
+ </Panel>
43
+ );
44
+ }
45
+ // `deadZoneY` -> `Dead Zone Y`, `PlatformerCharacter` -> `Platformer Character`.
46
+ export function humanizeKey(key) {
47
+ return key
48
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
49
+ .replace(/^./, (c) => c.toUpperCase())
50
+ .trim();
51
+ }
@@ -0,0 +1,73 @@
1
+ // Seed the file map by scanning the deck dir. Vite module-cache is invalidated
2
+ // on restart, so a newly-created file just shows up on the next reload.
3
+ const rawModules = import.meta.glob(
4
+ ['../scenes/*.scene', '../models/*.model', '../behaviors/*.jsx'],
5
+ { query: '?raw', import: 'default', eager: true }
6
+ );
7
+ export const initialFiles = Object.fromEntries(
8
+ Object.entries(rawModules)
9
+ .map(([globPath, text]) => [globPath.replace(/^\.\.\//, ''), text])
10
+ .sort(([a], [b]) => a.localeCompare(b))
11
+ );
12
+ export function getFileKind(path) {
13
+ if (path.endsWith('.scene')) return 'scene';
14
+ if (path.endsWith('.model')) return 'model';
15
+ if (path.endsWith('.js') || path.endsWith('.jsx')) return 'code';
16
+ return 'text';
17
+ }
18
+ export function parseJsonFile(path, text) {
19
+ try {
20
+ return { value: JSON.parse(text), error: null };
21
+ } catch (error) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ return { value: null, error: `${path}: ${message}` };
24
+ }
25
+ }
26
+ export function formatJson(value) {
27
+ return `${JSON.stringify(value, null, 2)}\n`;
28
+ }
29
+ export function basename(path) {
30
+ return path.split('/').pop() ?? path;
31
+ }
32
+ // Flat list of file paths in the order FileBrowser renders them: a
33
+ // depth-first walk of the directory tree, mirroring buildFileTree there.
34
+ export function flatFileOrder(paths) {
35
+ const root = { isFile: false, path: '', children: [], childMap: new Map() };
36
+ for (const path of paths) {
37
+ const parts = path.split('/');
38
+ let parent = root;
39
+ for (let index = 0; index < parts.length; index++) {
40
+ const name = parts[index];
41
+ const nodePath = parts.slice(0, index + 1).join('/');
42
+ const isFile = index === parts.length - 1;
43
+ if (!parent.childMap.has(name)) {
44
+ const node = { isFile, path: nodePath, children: [], childMap: new Map() };
45
+ parent.childMap.set(name, node);
46
+ parent.children.push(node);
47
+ }
48
+ const child = parent.childMap.get(name);
49
+ if (!child || child.isFile) break;
50
+ parent = child;
51
+ }
52
+ }
53
+ const order = [];
54
+ const walk = (node) => {
55
+ for (const child of node.children) {
56
+ if (child.isFile) order.push(child.path);
57
+ else walk(child);
58
+ }
59
+ };
60
+ walk(root);
61
+ return order;
62
+ }
63
+
64
+ // Parse every `.model` file into a map for `scene.models`.
65
+ export function parseModels(files) {
66
+ const models = {};
67
+ for (const [path, text] of Object.entries(files)) {
68
+ if (!path.endsWith('.model')) continue;
69
+ const parsed = parseJsonFile(path, text);
70
+ if (parsed.value) models[path] = parsed.value;
71
+ }
72
+ return models;
73
+ }