castle-web-cli 0.4.10 → 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 (50) 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 -2
  13. package/dist/serve.js +62 -3
  14. package/kits/basic-2d/CLAUDE.md +3 -1
  15. package/kits/basic-2d/package.json +0 -1
  16. package/kits/basic-3d/.prettierrc +8 -0
  17. package/kits/basic-3d/CLAUDE.md +162 -0
  18. package/kits/basic-3d/behaviors/Camera.jsx +56 -0
  19. package/kits/basic-3d/behaviors/Collider.jsx +78 -0
  20. package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
  21. package/kits/basic-3d/behaviors/Model.jsx +61 -0
  22. package/kits/basic-3d/behaviors/Transform.jsx +35 -0
  23. package/kits/basic-3d/editors/App.jsx +147 -0
  24. package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
  25. package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
  26. package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
  27. package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
  28. package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
  29. package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
  30. package/kits/basic-3d/editors/editorHistory.js +52 -0
  31. package/kits/basic-3d/editors/viewportRig.js +90 -0
  32. package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
  33. package/kits/basic-3d/engine/SceneUI.jsx +67 -0
  34. package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
  35. package/kits/basic-3d/engine/TouchControls.jsx +136 -0
  36. package/kits/basic-3d/engine/autoInspector.jsx +51 -0
  37. package/kits/basic-3d/engine/files.js +73 -0
  38. package/kits/basic-3d/engine/scene.js +502 -0
  39. package/kits/basic-3d/engine/threeUtil.js +260 -0
  40. package/kits/basic-3d/engine/ui.jsx +352 -0
  41. package/kits/basic-3d/engine/ui.module.css +944 -0
  42. package/kits/basic-3d/eslint.config.js +51 -0
  43. package/kits/basic-3d/index.html +11 -0
  44. package/kits/basic-3d/main.jsx +10 -0
  45. package/kits/basic-3d/models/block.model +14 -0
  46. package/kits/basic-3d/package-lock.json +2713 -0
  47. package/kits/basic-3d/package.json +41 -0
  48. package/kits/basic-3d/scenes/main.scene +76 -0
  49. package/kits/basic-3d/vite.config.js +1 -0
  50. package/package.json +6 -1
@@ -0,0 +1,162 @@
1
+ # basic-3d kit
2
+
3
+ Actor / behavior / scene framework on a three.js WebGL viewport. To build a game you (a) write behaviors in `behaviors/*.jsx`, (b) place actors that use them in `scenes/*.scene` (plain JSON). Behaviors auto-register via file glob — drop a new file and it's picked up on next reload.
4
+
5
+ This kit is plain JavaScript (no TypeScript). Use `.jsx` for files that contain JSX, `.js` otherwise. Don't add types or `.ts`/`.tsx` files.
6
+
7
+ DO NOT READ `engine/`, `editors/`, OR the built-in `behaviors/` files (`Transform.jsx`, `Mesh.jsx`, `Model.jsx`, `Collider.jsx`, `Camera.jsx`) to build a game. Those are the framework itself; their complete public API (props, helpers, calling conventions) is documented inline below. Read framework source only if you are explicitly modifying the framework.
8
+
9
+ ## Scope
10
+
11
+ Write the smallest game that satisfies what the user asked for. No sound, particles, menus, multi-level progression, or visual polish unless they specifically asked for it. A typical behavior is 30–80 lines — if yours is hitting 200, you're over-engineering: cut feel-good extras, fewer fields on props, fewer edge cases, fewer comments. Ship the core loop first; the user can ask for more.
12
+
13
+ ## Workflow
14
+
15
+ The deck is already serving when you start (`castle-web init` set that up; see `.castle/serve.json` for the URL). The user is watching that page right now. Your job is to make it interesting incrementally:
16
+
17
+ 1. **Build incrementally.** Start with the smallest playable thing (one mechanic, one scene change), `npm run restart`, then add the next piece. Do NOT write the whole game in one shot.
18
+ 2. **After every edit:** `npm run restart` (no hot reload). The served page refreshes and the user sees the change.
19
+
20
+ World coordinates: **+Y is up, the ground plane is XZ**, units are arbitrary world units (a character is ~1–2 units tall). Rotations are degrees. `x`/`y`/`z` on Transform is the actor's CENTER.
21
+
22
+ ## Behavior shape
23
+
24
+ A behavior is a class. Minimal contract:
25
+
26
+ ```jsx
27
+ // behaviors/MyThing.jsx
28
+ export class MyThing {
29
+ static behaviorName = 'MyThing'; // must match the key used in .scene
30
+ static defaultProps = { speed: 6 };
31
+
32
+ constructor(props) {
33
+ this.props = props;
34
+ }
35
+
36
+ // Called every frame in play mode. dt is seconds.
37
+ update(actor, scene, dt) {
38
+ const transform = actor.components.Transform;
39
+ transform.x += this.props.speed * dt; // mutate component in place
40
+ }
41
+
42
+ // Optional. Custom three.js objects (you usually don't need this; use a
43
+ // Mesh or Model component instead). Runs every frame in BOTH edit and play
44
+ // mode. Attach objects under `scene.actorGroup(actor)` and cache them on
45
+ // `actor.runtime` so you don't rebuild every frame.
46
+ sync(actor, scene, dt) {}
47
+
48
+ // Optional. Return React nodes for game-time HUD. Coordinates are a fixed
49
+ // 500x700 design space scaled over the viewport. Read state your `update`
50
+ // set; do not start your own loops.
51
+ ui(actor, scene) {
52
+ return null;
53
+ }
54
+ }
55
+ ```
56
+
57
+ A fresh `Behavior` instance is constructed per actor per frame from the actor's component props — DO NOT store per-actor state on `this`. Persist transient state on `actor.runtime` (a free-form object the framework will not serialize) or on the component props themselves.
58
+
59
+ `sync` is the retained-mode analog of a draw call: the framework keeps a `THREE.Group` per actor (`scene.actorGroup(actor)`, already positioned by Transform); your sync adds/updates children. Pattern: build once, cache on `actor.runtime._myThing`, rebuild only when a props key changes (`replaceGroupChild` from `engine/threeUtil` disposes the old object). Setting `actor.runtime.hidden = true` makes the built-in Mesh/Model behaviors skip rendering.
60
+
61
+ ## Scene file (`scenes/main.scene`, plain JSON)
62
+
63
+ ```json
64
+ {
65
+ "background": "#10131c",
66
+ "lighting": { "sunAzimuth": 35, "sunElevation": 55, "sunIntensity": 2.4 },
67
+ "actors": [
68
+ {
69
+ "id": "player",
70
+ "components": {
71
+ "Transform": { "x": 0, "y": 0.5, "z": 0 },
72
+ "Mesh": { "geometry": "capsule", "height": 1.6, "color": "#8db7ffff" },
73
+ "Collider": { "width": 1, "height": 1.6, "depth": 1 },
74
+ "Mover": { "speed": 6 }
75
+ }
76
+ }
77
+ ]
78
+ }
79
+ ```
80
+
81
+ Rules: every actor needs a unique `id` (any string) and almost always a `Transform`. `components` keys are behavior names (the `static behaviorName`). Unspecified props fall back to that behavior's `defaultProps` — omit optional fields (rotations, scales, `tint: '#ffffffff'`, scene `name`) to keep scenes compact, especially when generating many actors. Add a behavior to an actor by adding its key to `components`; remove it by deleting the key.
82
+
83
+ Scene-level fields: `background` (hex color), `lighting` (hemisphere + shadowed sun: `sky`, `ground`, `hemiIntensity`, `sun`, `sunIntensity`, `sunAzimuth`, `sunElevation`, `shadows`), `editor` (`snapToGrid`, `gridSize`). All optional with sensible defaults.
84
+
85
+ ## Built-in behaviors
86
+
87
+ - **Transform** — `{ x, y, z, rotationX?, rotationY?, rotationZ?, scaleX?, scaleY?, scaleZ? }`. Every actor needs one. Position is the actor's center; rotations are degrees.
88
+ - **Mesh** — `{ geometry: 'box'|'sphere'|'cylinder'|'cone'|'capsule'|'torus'|'plane', width, height, depth, color, roughness?, metalness?, emissive?, opacity?, castShadow?, receiveShadow? }`. One sized primitive with a PBR material. `width`/`height`/`depth` are the bounding dimensions for every geometry kind. Colors are `#rrggbbaa` (alpha multiplies opacity).
89
+ - **Model** — `{ file: "models/foo.model", tint?: "#rrggbbaa" }`. Renders a multi-part `.model` file (see below). `tint` multiplies every part color; use white or omit for the original colors.
90
+ - **Collider** — `{ kind: 'solid'|'pickup', width, height, depth, offsetX?, offsetY?, offsetZ?, debug? }`. An axis-aligned box centered on the Transform plus offset; the framework does NOT auto-resolve collisions for you. Use it as data:
91
+
92
+ ```jsx
93
+ for (const other of scene.getActors()) {
94
+ if (other.id === actor.id) continue;
95
+ if (scene.overlaps(actor, other)) {
96
+ /* react */
97
+ }
98
+ }
99
+ ```
100
+
101
+ - **Camera** — `{ target: actorId, mode: 'follow'|'orbit', distance, azimuth, elevation, fov, lookHeight?, lerp?, orbitSpeed? }`. Place on a dedicated actor; sets `scene.camera` each frame. `follow` keeps fixed angles tracking the target (`lerp` > 0 smooths, e.g. 6); `orbit` spins the azimuth at `orbitSpeed` deg/s. Low `fov` (15–25) + high `elevation` (~40–55) + large `distance` reads near-isometric. Omit the behavior entirely for a fixed default view.
102
+
103
+ ## Model file (`models/*.model`, plain JSON)
104
+
105
+ A model is a list of primitive parts in actor-local units (the 3d analog of a pixel `.drawing`):
106
+
107
+ ```json
108
+ {
109
+ "parts": [
110
+ { "geometry": "cylinder", "y": 0.4, "width": 0.5, "height": 0.8, "depth": 0.5, "color": "#7a5230ff" },
111
+ { "geometry": "sphere", "y": 1.2, "width": 1.4, "height": 1.2, "depth": 1.4, "color": "#3e7c3aff" }
112
+ ]
113
+ }
114
+ ```
115
+
116
+ Part fields: `geometry`, `x/y/z` (offset), `rotationX/Y/Z` (degrees), `width/height/depth`, `color`, `roughness?`, `metalness?`. Edit visually in the model editor (click a part, gizmo moves/rotates it) or by hand. The actor's Transform scale resizes the whole model.
117
+
118
+ ## SceneRuntime API (what `scene` exposes to behaviors)
119
+
120
+ - `scene.time` — seconds since start.
121
+ - `scene.keys` — `Set` of currently-held keys, both `event.key` and `event.code` forms (`'ArrowLeft'`, `'a'`, `'KeyA'`, `' '`, `'Space'`). Read in `update`.
122
+ - `scene.pointer` — `{ x, y, down, ground }`. `x`/`y` are normalized device coords (-1..1); `ground` is `{ x, z }` where the pointer ray hits the y=0 plane (or null).
123
+ - `scene.getActor(id)` / `scene.getActors()` / `scene.getComponent(actor, name)`.
124
+ - `scene.actorWith('GameController')` / `scene.actorsWith('Tree')` — find one / all actors carrying a given behavior. Prefer these to `getActors().find(a => a.components.X)`.
125
+ - `scene.colliderBox(actorOrId)` — center-based box `{ x, y, z, width, height, depth }` from Transform + Collider, or null.
126
+ - `scene.overlaps(a, b)` — true when two actors/ids with Collider overlap.
127
+ - `scene.data` — the live scene data. Mutate `actor.components.X = {...}` to change props.
128
+ - `scene.spawnActor({ components: { Transform: {...}, MyBehavior: {...} } })` — add a new actor at runtime. Returns the actor (with auto-minted `id` and `runtime = {}`). Use this; don't push to `scene.data.actors` by hand.
129
+ - `scene.despawnActor(id)` — remove an actor at runtime (disposes its three objects). Use this; don't `splice` + `delete` by hand.
130
+ - `scene.status` — string you can set/read for game-state ('playing', 'gameover', ...).
131
+ - `scene.actorGroup(actor)` — the actor's `THREE.Group` (for custom `sync`).
132
+ - `scene.three` — the `THREE.Scene` itself (escape hatch for scene-wide effects).
133
+ - `actor.runtime` — per-instance scratchpad for transient state across frames (e.g. velocity). Not serialized.
134
+
135
+ ## Input shortcuts
136
+
137
+ ```jsx
138
+ if (scene.keys.has('ArrowLeft') || scene.keys.has('KeyA')) transform.x -= speed * dt;
139
+ if (scene.keys.has('ArrowRight') || scene.keys.has('KeyD')) transform.x += speed * dt;
140
+ if (scene.keys.has('KeyJ')) /* jump */ ;
141
+ ```
142
+
143
+ **Space is reserved** — the editor binds it to the play/stop toggle, so don't bind Space to a gameplay action (jump / shoot / launch / ...). Use arrows, WASD, letter keys, or on-screen buttons instead.
144
+
145
+ For HUD text use a behavior's `ui` hook (returns React, 500x700 design space); for in-world visuals, use Mesh/Model components or a custom `sync`. The deck has TouchControls overlay support out of the box for mobile play (arrow keys d-pad); no setup needed.
146
+
147
+ ## Common exploration-shaped recipe (sketch)
148
+
149
+ - `Mover` behavior on the player: read keys, move on XZ, set `transform.rotationY` to the facing direction.
150
+ - `Camera` actor: `{ target: 'player', mode: 'follow', distance: 30, elevation: 45, fov: 20, lerp: 6 }` for a near-isometric follow view.
151
+ - Ground: a large flat `Mesh` (`geometry: 'box'`, thin height, `castShadow: false`).
152
+ - Pickups: `Collider { kind: 'pickup' }` + a marker behavior; on `scene.overlaps(player, pickup)` set `pickup.runtime.hidden = true` or `scene.despawnActor(...)`.
153
+ - `GameController` (Transform optional): tracks score / status; expose HUD via `ui()`.
154
+
155
+ ## Don't
156
+
157
+ - Don't `console.log` in tight loops — flood the serve log.
158
+ - Don't keep per-actor state on the behavior class instance; it's recreated each frame. Use `actor.runtime` or component props.
159
+ - Don't try to import from `editors/`; behaviors run in the play runtime too.
160
+ - Don't create three.js objects every frame in `sync` — build once, cache on `actor.runtime`, rebuild on a props-key change.
161
+ - Don't add types or `.ts`/`.tsx` files. This kit is JavaScript.
162
+ - Don't add a build step or change `vite.config.js` for a game — it's configured for you.
@@ -0,0 +1,56 @@
1
+ // Play-mode camera. Place on a dedicated actor (or the target itself). Two
2
+ // modes:
3
+ // - 'follow': fixed azimuth/elevation/distance offset tracking the target's
4
+ // position (optionally smoothed with `lerp`, 0 = instant).
5
+ // - 'orbit': same, but the azimuth spins at `orbitSpeed` degrees/second.
6
+ // With no `target` the camera looks at the world origin. Omit the behavior
7
+ // entirely for a fixed default view. A low `fov` + high `elevation` reads
8
+ // near-isometric.
9
+ export class Camera {
10
+ static behaviorName = 'Camera';
11
+
12
+ static defaultProps = {
13
+ target: '',
14
+ mode: 'follow',
15
+ distance: 24,
16
+ azimuth: 45,
17
+ elevation: 35,
18
+ fov: 50,
19
+ lookHeight: 0,
20
+ lerp: 0,
21
+ orbitSpeed: 20,
22
+ };
23
+
24
+ constructor(props) {
25
+ this.props = props;
26
+ }
27
+
28
+ update(actor, scene, dt) {
29
+ const target = this.props.target ? scene.getActor(this.props.target) : null;
30
+ const transform = target?.components.Transform;
31
+ const goal = {
32
+ x: transform?.x ?? 0,
33
+ y: (transform?.y ?? 0) + this.props.lookHeight,
34
+ z: transform?.z ?? 0,
35
+ };
36
+ const state = (actor.runtime._camera ??= { ...goal, azimuth: this.props.azimuth });
37
+ if (this.props.mode === 'orbit') {
38
+ state.azimuth += this.props.orbitSpeed * dt;
39
+ } else {
40
+ state.azimuth = this.props.azimuth;
41
+ }
42
+ const t = this.props.lerp > 0 ? Math.min(1, dt * this.props.lerp) : 1;
43
+ state.x += (goal.x - state.x) * t;
44
+ state.y += (goal.y - state.y) * t;
45
+ state.z += (goal.z - state.z) * t;
46
+ scene.camera = {
47
+ targetX: state.x,
48
+ targetY: state.y,
49
+ targetZ: state.z,
50
+ azimuth: state.azimuth,
51
+ elevation: this.props.elevation,
52
+ distance: this.props.distance,
53
+ fov: this.props.fov,
54
+ };
55
+ }
56
+ }
@@ -0,0 +1,78 @@
1
+ import React from 'react';
2
+ import * as THREE from 'three';
3
+ import { Panel, SelectField } from '../engine/ui';
4
+ import { AutoFields } from '../engine/autoInspector';
5
+ import { replaceGroupChild } from '../engine/threeUtil';
6
+
7
+ // An axis-aligned box, centered on the actor's Transform plus an offset. Pure
8
+ // data -- the framework does NOT auto-resolve collisions; behaviors read
9
+ // overlaps via `scene.overlaps(a, b)` / `scene.colliderBox(actor)`.
10
+ export class Collider {
11
+ static behaviorName = 'Collider';
12
+
13
+ static defaultProps = {
14
+ kind: 'solid',
15
+ width: 1,
16
+ height: 1,
17
+ depth: 1,
18
+ offsetX: 0,
19
+ offsetY: 0,
20
+ offsetZ: 0,
21
+ debug: false,
22
+ };
23
+
24
+ constructor(props) {
25
+ this.props = props;
26
+ }
27
+
28
+ sync(actor, scene, _dt, options) {
29
+ const group = scene.actorGroup(actor);
30
+ if (!group) return;
31
+ const cache = actor.runtime;
32
+ const show = !!(options?.showDebugColliders || this.props.debug);
33
+ if (!show) {
34
+ if (cache._colliderDebug) {
35
+ cache._colliderDebug = replaceGroupChild(group, cache._colliderDebug, null);
36
+ cache._colliderDebugKey = null;
37
+ }
38
+ return;
39
+ }
40
+ const key = JSON.stringify(this.props);
41
+ if (!cache._colliderDebug || cache._colliderDebugKey !== key) {
42
+ cache._colliderDebug = replaceGroupChild(group, cache._colliderDebug, makeDebugBox(this.props));
43
+ cache._colliderDebugKey = key;
44
+ }
45
+ }
46
+
47
+ static Inspector({ component, setComponent }) {
48
+ return (
49
+ <Panel title="Collider">
50
+ <SelectField
51
+ label="Kind"
52
+ value={component.kind}
53
+ onChange={(kind) => setComponent({ kind })}
54
+ options={['solid', 'pickup']}
55
+ />
56
+ <AutoFields
57
+ defaultProps={Collider.defaultProps}
58
+ component={component}
59
+ setComponent={setComponent}
60
+ exclude={['kind']}
61
+ />
62
+ </Panel>
63
+ );
64
+ }
65
+ }
66
+
67
+ function makeDebugBox(props) {
68
+ const geometry = new THREE.BoxGeometry(props.width, props.height, props.depth);
69
+ const edges = new THREE.EdgesGeometry(geometry);
70
+ geometry.dispose();
71
+ const material = new THREE.LineBasicMaterial({
72
+ color: props.kind === 'pickup' ? 0xffe17a : 0x8db7ff,
73
+ });
74
+ const lines = new THREE.LineSegments(edges, material);
75
+ lines.position.set(props.offsetX, props.offsetY, props.offsetZ);
76
+ lines.userData.editorPlaceholder = true;
77
+ return lines;
78
+ }
@@ -0,0 +1,82 @@
1
+ import React from 'react';
2
+ import * as THREE from 'three';
3
+ import { Panel, SelectField } from '../engine/ui';
4
+ import { AutoFields } from '../engine/autoInspector';
5
+ import {
6
+ applyStandardMaterial,
7
+ buildGeometry,
8
+ buildStandardMaterial,
9
+ geometryKinds,
10
+ renderTarget,
11
+ replaceGroupChild,
12
+ } from '../engine/threeUtil';
13
+
14
+ // A single primitive mesh: sized geometry + a standard PBR material. For
15
+ // multi-part shapes use a Model behavior pointing at a `.model` file instead.
16
+ export class Mesh {
17
+ static behaviorName = 'Mesh';
18
+
19
+ static defaultProps = {
20
+ geometry: 'box',
21
+ width: 1,
22
+ height: 1,
23
+ depth: 1,
24
+ color: '#8db7ffff',
25
+ roughness: 0.8,
26
+ metalness: 0,
27
+ emissive: '#00000000',
28
+ opacity: 1,
29
+ castShadow: true,
30
+ receiveShadow: true,
31
+ };
32
+
33
+ constructor(props) {
34
+ this.props = props;
35
+ }
36
+
37
+ sync(actor, scene) {
38
+ const target = renderTarget(actor, scene);
39
+ if (!target) return;
40
+ const { group, cache } = target;
41
+ const geometryKey = JSON.stringify([
42
+ this.props.geometry,
43
+ this.props.width,
44
+ this.props.height,
45
+ this.props.depth,
46
+ ]);
47
+ if (!cache._mesh || cache._meshGeometryKey !== geometryKey) {
48
+ const geometry = buildGeometry(
49
+ this.props.geometry,
50
+ this.props.width,
51
+ this.props.height,
52
+ this.props.depth
53
+ );
54
+ const mesh = new THREE.Mesh(geometry, buildStandardMaterial(this.props));
55
+ cache._mesh = replaceGroupChild(group, cache._mesh, mesh);
56
+ cache._meshGeometryKey = geometryKey;
57
+ }
58
+ // Material props are cheap to apply in place every frame.
59
+ applyStandardMaterial(cache._mesh.material, this.props);
60
+ cache._mesh.castShadow = !!this.props.castShadow;
61
+ cache._mesh.receiveShadow = !!this.props.receiveShadow;
62
+ }
63
+
64
+ static Inspector({ component, setComponent }) {
65
+ return (
66
+ <Panel title="Mesh">
67
+ <SelectField
68
+ label="Geometry"
69
+ value={component.geometry}
70
+ onChange={(geometry) => setComponent({ geometry })}
71
+ options={geometryKinds}
72
+ />
73
+ <AutoFields
74
+ defaultProps={Mesh.defaultProps}
75
+ component={component}
76
+ setComponent={setComponent}
77
+ exclude={['geometry']}
78
+ />
79
+ </Panel>
80
+ );
81
+ }
82
+ }
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+ import { Panel, SelectField } from '../engine/ui';
3
+ import { AutoFields } from '../engine/autoInspector';
4
+ import { buildModelGroup, renderTarget, replaceGroupChild } from '../engine/threeUtil';
5
+
6
+ // Renders a `.model` file (a list of primitive parts -- the 3d analog of the
7
+ // 2d kit's `.drawing`) scaled by the actor's Transform. `tint` multiplies
8
+ // every part color; white or omitted keeps the original colors.
9
+ export class Model {
10
+ static behaviorName = 'Model';
11
+
12
+ static defaultProps = {
13
+ file: 'models/block.model',
14
+ tint: '#ffffffff',
15
+ };
16
+
17
+ constructor(props) {
18
+ this.props = props;
19
+ }
20
+
21
+ sync(actor, scene) {
22
+ const target = renderTarget(actor, scene);
23
+ if (!target) return;
24
+ const { group, cache } = target;
25
+ const modelData = scene.models[this.props.file];
26
+ if (!modelData) {
27
+ if (cache._model) {
28
+ cache._model = replaceGroupChild(group, cache._model, null);
29
+ cache._modelKey = null;
30
+ }
31
+ return;
32
+ }
33
+ // Model files are parsed once per text change (a fresh object identity),
34
+ // so identity + tint is a sufficient rebuild key.
35
+ if (cache._modelData !== modelData || cache._modelTint !== this.props.tint) {
36
+ cache._model = replaceGroupChild(group, cache._model, buildModelGroup(modelData, this.props.tint));
37
+ cache._modelData = modelData;
38
+ cache._modelTint = this.props.tint;
39
+ }
40
+ }
41
+
42
+ static Inspector({ component, setComponent, files }) {
43
+ const modelFiles = Object.keys(files).filter((path) => path.endsWith('.model'));
44
+ return (
45
+ <Panel title="Model">
46
+ <SelectField
47
+ label="File"
48
+ value={component.file}
49
+ onChange={(file) => setComponent({ file })}
50
+ options={modelFiles}
51
+ />
52
+ <AutoFields
53
+ defaultProps={Model.defaultProps}
54
+ component={component}
55
+ setComponent={setComponent}
56
+ only={['tint']}
57
+ />
58
+ </Panel>
59
+ );
60
+ }
61
+ }
@@ -0,0 +1,35 @@
1
+ import { degreesToRadians } from '../engine/threeUtil';
2
+
3
+ // Position / rotation / scale of the actor's group in world units. `x`/`y`/`z`
4
+ // is the actor's CENTER; rotations are degrees; the ground plane is XZ, +Y up.
5
+ export class Transform {
6
+ static behaviorName = 'Transform';
7
+
8
+ static defaultProps = {
9
+ x: 0,
10
+ y: 0,
11
+ z: 0,
12
+ rotationX: 0,
13
+ rotationY: 0,
14
+ rotationZ: 0,
15
+ scaleX: 1,
16
+ scaleY: 1,
17
+ scaleZ: 1,
18
+ };
19
+
20
+ constructor(props) {
21
+ this.props = props;
22
+ }
23
+
24
+ sync(actor, scene) {
25
+ const group = scene.actorGroup(actor);
26
+ if (!group) return;
27
+ group.position.set(this.props.x, this.props.y, this.props.z);
28
+ group.rotation.set(
29
+ degreesToRadians(this.props.rotationX),
30
+ degreesToRadians(this.props.rotationY),
31
+ degreesToRadians(this.props.rotationZ)
32
+ );
33
+ group.scale.set(this.props.scaleX || 1, this.props.scaleY || 1, this.props.scaleZ || 1);
34
+ }
35
+ }
@@ -0,0 +1,147 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { writeFile } from 'castle-web-sdk';
3
+ import {
4
+ flatFileOrder,
5
+ formatJson,
6
+ getFileKind,
7
+ initialFiles,
8
+ parseModels,
9
+ } from '../engine/files';
10
+ import { AppShell, cx, MainEditor, styles } from '../engine/ui';
11
+ import { CodeEditor } from './CodeEditor';
12
+ import { ModelEditor } from './ModelEditor';
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 models = useMemo(() => parseModels(files), [files]);
24
+ function updateSelectedFile(nextText) {
25
+ const path = selectedPath;
26
+ setFiles((current) => ({
27
+ ...current,
28
+ [path]: nextText,
29
+ }));
30
+ scheduleFileWrite(path, nextText);
31
+ }
32
+ function scheduleFileWrite(path, nextText) {
33
+ const version = (saveVersionsRef.current[path] ?? 0) + 1;
34
+ saveVersionsRef.current[path] = version;
35
+ const existingTimer = saveTimersRef.current[path];
36
+ if (existingTimer) window.clearTimeout(existingTimer);
37
+ saveTimersRef.current[path] = window.setTimeout(() => {
38
+ delete saveTimersRef.current[path];
39
+ writeFile(path, nextText).catch((error) => {
40
+ if (saveVersionsRef.current[path] !== version) return;
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ console.error(`Failed to save ${path}: ${message}`);
43
+ });
44
+ }, 1500);
45
+ }
46
+ const kind = getFileKind(selectedPath);
47
+ const text = files[selectedPath] ?? '';
48
+ function selectFile(path) {
49
+ setSelectedPath(path);
50
+ setFilesSheetOpen(false);
51
+ setSelectedActorIds([]);
52
+ setMultiSelectMode(false);
53
+ }
54
+ const onToggleFiles = () => {
55
+ setFilesSheetOpen((previous) => !previous);
56
+ setSelectedActorIds([]);
57
+ setMultiSelectMode(false);
58
+ };
59
+ // Alt+Up / Alt+Down steps through files in file-browser sidebar order.
60
+ useEffect(() => {
61
+ function onKeyDown(event) {
62
+ if (!event.altKey || event.metaKey || event.ctrlKey) return;
63
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
64
+ const active = document.activeElement;
65
+ if (
66
+ active &&
67
+ (active.tagName === 'INPUT' ||
68
+ active.tagName === 'TEXTAREA' ||
69
+ active.tagName === 'SELECT' ||
70
+ active.isContentEditable ||
71
+ active.closest('.cm-editor'))
72
+ ) {
73
+ return;
74
+ }
75
+ const order = flatFileOrder(Object.keys(files));
76
+ const index = order.indexOf(selectedPath);
77
+ if (index === -1) return;
78
+ const nextIndex = event.key === 'ArrowUp' ? index - 1 : index + 1;
79
+ if (nextIndex < 0 || nextIndex >= order.length) return;
80
+ event.preventDefault();
81
+ selectFile(order[nextIndex]);
82
+ }
83
+ window.addEventListener('keydown', onKeyDown);
84
+ return () => window.removeEventListener('keydown', onKeyDown);
85
+ }, [files, selectedPath]);
86
+ return (
87
+ <AppShell>
88
+ <FileBrowser
89
+ files={files}
90
+ selectedPath={selectedPath}
91
+ onSelect={selectFile}
92
+ sheetOpen={filesSheetOpen}
93
+ onSheetOpenChange={setFilesSheetOpen}
94
+ />
95
+ {filesSheetOpen ? (
96
+ <div
97
+ className={cx(styles.sheetBackdrop, styles.mobileOnly)}
98
+ onClick={() => setFilesSheetOpen(false)}
99
+ />
100
+ ) : null}
101
+ <MainEditor>
102
+ {kind === 'scene' ? (
103
+ <SceneEditor
104
+ path={selectedPath}
105
+ text={text}
106
+ files={files}
107
+ models={models}
108
+ onChange={updateSelectedFile}
109
+ onToggleFiles={onToggleFiles}
110
+ filesOpen={filesSheetOpen}
111
+ selectedActorIds={selectedActorIds}
112
+ onSelectActorIds={setSelectedActorIds}
113
+ multiSelectMode={multiSelectMode}
114
+ onSetMultiSelectMode={setMultiSelectMode}
115
+ />
116
+ ) : null}
117
+ {kind === 'model' ? (
118
+ <ModelEditor
119
+ path={selectedPath}
120
+ text={text}
121
+ onChange={updateSelectedFile}
122
+ onToggleFiles={onToggleFiles}
123
+ filesOpen={filesSheetOpen}
124
+ />
125
+ ) : null}
126
+ {kind === 'code' ? (
127
+ <CodeEditor
128
+ path={selectedPath}
129
+ text={text}
130
+ onChange={updateSelectedFile}
131
+ onToggleFiles={onToggleFiles}
132
+ filesOpen={filesSheetOpen}
133
+ />
134
+ ) : null}
135
+ {kind === 'text' ? (
136
+ <CodeEditor
137
+ path={selectedPath}
138
+ text={formatJson(text)}
139
+ onChange={updateSelectedFile}
140
+ onToggleFiles={onToggleFiles}
141
+ filesOpen={filesSheetOpen}
142
+ />
143
+ ) : null}
144
+ </MainEditor>
145
+ </AppShell>
146
+ );
147
+ }