castle-web-cli 0.4.130 → 0.4.131

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.
@@ -131,5 +131,5 @@
131
131
  "main": "main.jsx",
132
132
  "autoUpdateWhenImported": true,
133
133
  "title": "physics-2d",
134
- "publishedVersion": "2026-08-21T22:11:52.086Z"
134
+ "publishedVersion": "2026-08-21T22:23:23.744Z"
135
135
  }
@@ -34,6 +34,7 @@ export function PlayOnly({ initialScene } = {}) {
34
34
  sprites={sprites}
35
35
  files={files}
36
36
  behaviorClasses={behaviorClasses}
37
+ scenePath={scenePath}
37
38
  onFirstFrame={Lifecycle.ready}
38
39
  />
39
40
  );
@@ -254,7 +254,7 @@ export function SceneEditor({
254
254
  // Sparse per-instance overrides merged with each actor's blueprint template
255
255
  // -- what hit-testing, drag-start snapshots, and the inspector should read.
256
256
  // Never the basis for a write (see engine/blueprint.js).
257
- const previewSceneData = sceneData ? makeScene(sceneData, behaviorClasses, sprites, files).data : null;
257
+ const previewSceneData = sceneData ? makeScene(sceneData, behaviorClasses, sprites, files, path).data : null;
258
258
  const serialize = useCallback(
259
259
  (next) => formatJson(isBlueprintFile ? stripTemplateId(next) : next),
260
260
  [isBlueprintFile]
@@ -928,7 +928,7 @@ function useSelectionGesture(args) {
928
928
  const cam = current.editCameraRef.current;
929
929
  const point = { x: raw.x + cam.x, y: raw.y + cam.y };
930
930
  current.canvasRef.current.setPointerCapture(event.pointerId);
931
- const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
931
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files, current.path);
932
932
  const stack = scene.actorsAt(point.x, point.y);
933
933
  const stackIds = stack.map((a) => a.id);
934
934
  const actor = stack[0] ?? null;
@@ -1408,7 +1408,7 @@ function finalizeMarquee(drag, current) {
1408
1408
  const m = current.marqueeRef.current;
1409
1409
  if (!m || !current.sceneData) return;
1410
1410
  if (m.width === 0 && m.height === 0) return;
1411
- const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
1411
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files, current.path);
1412
1412
  const hits = scene.actorIdsInRect(m);
1413
1413
  const merged = new Set(drag.selectionBeforeMarquee);
1414
1414
  for (const id of hits) merged.add(id);
@@ -1511,7 +1511,7 @@ function useScenePlayLoop({
1511
1511
  return undefined;
1512
1512
  }
1513
1513
  if (!sceneData) return undefined;
1514
- const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
1514
+ const runtime = makeScene(sceneData, behaviorClasses, sprites, files, path).clone();
1515
1515
  runtimeRef.current = runtime;
1516
1516
  // Pressing Stop has to stop what the runtime started -- a sound keeps
1517
1517
  // playing on its own otherwise, with no scene left to stop it.
@@ -1540,7 +1540,7 @@ function useScenePlayLoop({
1540
1540
  : editViewportForZoom(editCameraRef.current.zoom);
1541
1541
  const scene = isPlaying
1542
1542
  ? runtimeRef.current
1543
- : makeScene(sceneData, behaviorClasses, sprites, files);
1543
+ : makeScene(sceneData, behaviorClasses, sprites, files, path);
1544
1544
  if (scene) {
1545
1545
  const snap = getSnapSettings(sceneData);
1546
1546
  if (isPlaying) scene.update(dt);
@@ -6,7 +6,7 @@ import { SceneUI } from './SceneUI';
6
6
  // behavior-driven UI overlay. No game logic lives here -- behaviors and
7
7
  // scenes are the place for that. The dev logs drawer is a builtin shell panel
8
8
  // now (cli/src/shell), not rendered here.
9
- export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirstFrame }) {
9
+ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, scenePath, onFirstFrame }) {
10
10
  const canvasRef = useRef(null);
11
11
  const runtimeRef = useRef(null);
12
12
  // An error thrown from a behavior's update/draw kills the raf loop (it can't
@@ -22,7 +22,7 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
22
22
  const ctx = canvas.getContext('2d');
23
23
  if (!ctx) return undefined;
24
24
  configureSceneCanvas(canvas, ctx);
25
- const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
25
+ const runtime = makeScene(sceneData, behaviorClasses, sprites, files, scenePath).clone();
26
26
  runtimeRef.current = runtime;
27
27
  // Store BOTH the physical code ('KeyX', 'ArrowLeft', 'Space') and the
28
28
  // logical key ('x', 'ArrowLeft', ' ') so behaviors can match either. Codes
@@ -94,10 +94,16 @@ function resolveRefsIn(props, files, fromPath) {
94
94
  // call for live propagation. Returns null when the file is missing, deleted,
95
95
  // or malformed (a dangling `blueprint` ref degrades to "no template" rather
96
96
  // than throwing).
97
- export function getBlueprintTemplate(files, blueprintPath) {
97
+ export function getBlueprintTemplate(files, blueprintPath, fromPath) {
98
98
  // The ref may name this deck's blueprint or an import's; the template's own
99
99
  // refs then resolve against whichever deck the blueprint turned out to be in.
100
- const key = resolveDeckFile(blueprintPath);
100
+ //
101
+ // `fromPath` is the file the ref was WRITTEN in, and it matters as soon as a
102
+ // kit is opened as an import: a scene at `imports/<alias>/scenes/main.scene`
103
+ // says `blueprints/wall.scene` meaning its own deck's, and resolving that
104
+ // against the deck root finds nothing -- every actor silently loses its
105
+ // template and renders as an untextured default.
106
+ const key = resolveDeckFile(blueprintPath, fromPath);
101
107
  const template = parseBlueprintText(files?.[key]);
102
108
  return resolveTemplateRefs(template, files, key);
103
109
  }
@@ -35,7 +35,12 @@ export class SceneRuntime {
35
35
  // actor's `blueprint` ref against the CURRENT blueprint file content -- not
36
36
  // a load-time snapshot -- so a blueprint edit propagates to every scene that
37
37
  // places it, in both the editor and play mode / `PlayOnly`.
38
- constructor(sceneData, behaviors, sprites, files) {
38
+ constructor(sceneData, behaviors, sprites, files, scenePath) {
39
+ // The file this scene was read from, so blueprint refs in it resolve the
40
+ // way its own deck writes them. Undefined when a caller hands over scene
41
+ // data with no file behind it; refs then resolve against the deck root,
42
+ // which is what they meant before this existed.
43
+ this.scenePath = scenePath;
39
44
  this.behaviors = new Map(behaviors.map((Behavior) => [Behavior.behaviorName, Behavior]));
40
45
  this.sprites = sprites ?? {};
41
46
  this.files = files ?? {};
@@ -87,7 +92,7 @@ export class SceneRuntime {
87
92
  for (const actor of this.data.actors ?? []) {
88
93
  actor.runtime = {};
89
94
  if (actor.blueprint) {
90
- const template = getBlueprintTemplate(this.files, actor.blueprint);
95
+ const template = getBlueprintTemplate(this.files, actor.blueprint, this.scenePath);
91
96
  actor.components = mergeComponents(template?.components, actor.components);
92
97
  }
93
98
  this.actors.set(actor.id, actor);
@@ -120,13 +125,20 @@ export class SceneRuntime {
120
125
  // Transition the running scene to the scene stored in the named file (read it
121
126
  // fresh, then load it). The building block for "go to scene" / restart.
122
127
  loadFromFile(name) {
128
+ this.scenePath = resolveSceneFileKey(name);
123
129
  this.load(this.readFromFile(name));
124
130
  }
125
131
 
126
132
  clone() {
127
133
  // Route through makeScene so the clone gets the same registered systems as a
128
134
  // freshly-made runtime.
129
- return makeScene(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
135
+ return makeScene(
136
+ this.serialize(),
137
+ [...this.behaviors.values()],
138
+ this.sprites,
139
+ this.files,
140
+ this.scenePath
141
+ );
130
142
  }
131
143
 
132
144
  serialize() {
@@ -426,8 +438,8 @@ export class SceneRuntime {
426
438
  }
427
439
  }
428
440
 
429
- export function makeScene(sceneData, behaviors, sprites, files) {
430
- const runtime = new SceneRuntime(sceneData, behaviors, sprites, files);
441
+ export function makeScene(sceneData, behaviors, sprites, files, scenePath) {
442
+ const runtime = new SceneRuntime(sceneData, behaviors, sprites, files, scenePath);
431
443
  // Install any runtime systems the kit provides (systems/*.js) -- no-op in a kit
432
444
  // with none. A system's engine is created lazily on first use, so draw-only
433
445
  // editor previews (which never call update) stay free.
@@ -43,9 +43,8 @@ bottom }`; any ref can pick a frame of a multi-frame pxart with
43
43
  near), `Lookable { text }` (hud caption when the player faces it nearby).
44
44
  - Hud: behaviors write `scene.hud = { score, look }`; the player behavior
45
45
  already does.
46
- - Scene switching: place a `Portal { scene: 'scenes/other.scene' }` actor (a
47
- sensor -- the portal blueprint is a glowing ring), or set
48
- `scene.requestedScene = 'scenes/other.scene'` from any behavior (applied
46
+ - Scene switching: set `scene.requestedScene = 'scenes/other.scene'` from any
47
+ behavior (applied
49
48
  between frames; safer than calling `scene.loadFromFile` mid-callback). A
50
49
  load rebuilds the physics world and resets the scene to its authored state;
51
50
  `scene.persistent` survives loads -- the player's score, collected-pickup
@@ -3,7 +3,7 @@
3
3
  // instead of blocking, which is what fires onCollisionEnter here.
4
4
  //
5
5
  // Collection is remembered in the persistent store keyed by scene + actor id,
6
- // so walking back through a portal doesn't respawn (and double-count) shards
6
+ // so re-entering a scene doesn't respawn (and double-count) shards
7
7
  // -- scene loads otherwise reset the world to its authored state.
8
8
  function pickupKey(scene, actor) {
9
9
  return `${scene.currentScenePath ?? ''}:${actor.id}`;
@@ -71,5 +71,5 @@
71
71
  "autoUpdateWhenImported": true,
72
72
  "deckId": "JH0SclbPVP0y",
73
73
  "cardId": "eXfAaUdbSU5A",
74
- "publishedVersion": "2026-08-21T22:11:54.424Z"
74
+ "publishedVersion": "2026-08-21T22:23:26.516Z"
75
75
  }
@@ -1,10 +1,14 @@
1
1
  // Play-mode entry point, the 3d counterpart of the imported kit's PlayOnly.
2
- // Files, assets, live reload, and the behavior registry all come from the
3
- // physics-2d import (they're renderer-agnostic); only the player is 3d.
2
+ // Files, assets and the behavior registry all come from the physics-2d import
3
+ // (they're renderer-agnostic); only the player is 3d.
4
+ //
5
+ // Nothing is folded into a run in progress: a file changing on disk can always
6
+ // have altered how the run would have gone up to this point. Getting current is
7
+ // a whole-panel reload, and the shell decides when.
4
8
  import React from 'react';
5
9
  import { Lifecycle } from 'castle-web-sdk';
6
10
  import { parseJsonFile } from '@imports/castle.physics-2d/engine/files';
7
- import { useLiveDeckFiles3D } from './modelFiles';
11
+ import { initialFiles3D } from './modelFiles';
8
12
  import { collectAssets } from '@imports/castle.physics-2d/engine/assets';
9
13
  import { behaviorClasses } from '@imports/castle.physics-2d/editors/behaviorRegistry';
10
14
  import { Scene3DPlayer } from './Scene3DPlayer';
@@ -12,7 +16,7 @@ import { Scene3DPlayer } from './Scene3DPlayer';
12
16
  const DEFAULT_SCENE = 'scenes/main.scene';
13
17
 
14
18
  export function PlayOnly3D({ initialScene } = {}) {
15
- const { files, dataVersion } = useLiveDeckFiles3D();
19
+ const files = initialFiles3D;
16
20
  const scenePath =
17
21
  initialScene && files[initialScene] !== undefined ? initialScene : DEFAULT_SCENE;
18
22
  const { value: sceneData } = parseJsonFile(scenePath, files[scenePath] ?? '');
@@ -20,7 +24,7 @@ export function PlayOnly3D({ initialScene } = {}) {
20
24
  const { sprites } = collectAssets(files);
21
25
  return (
22
26
  <Scene3DPlayer
23
- key={`${scenePath}:${dataVersion}`}
27
+ key={scenePath}
24
28
  sceneData={sceneData}
25
29
  sprites={sprites}
26
30
  files={files}
@@ -112,7 +112,7 @@ export function Scene3DEditor({ path }) {
112
112
  const sceneData = withTemplateId(parsed, isBlueprintFile);
113
113
  const sprites = useStableSprites(files);
114
114
  const previewSceneData = sceneData
115
- ? makeScene(sceneData, behaviorClasses, sprites, files).data
115
+ ? makeScene(sceneData, behaviorClasses, sprites, files, path).data
116
116
  : null;
117
117
  const serialize = (next) => formatJson(isBlueprintFile ? stripTemplateId(next) : next);
118
118
  const commitScene = (next, options) => history.commit(serialize(next), options);
@@ -168,7 +168,7 @@ export function Scene3DEditor({ path }) {
168
168
  if (ed.lastAppliedText === text && ed.lastDataVersion === dataVersion) return;
169
169
  ed.lastAppliedText = text;
170
170
  ed.lastDataVersion = dataVersion;
171
- setEditorScene(ed, sceneData, behaviorClasses, sprites, files);
171
+ setEditorScene(ed, sceneData, behaviorClasses, sprites, files, path);
172
172
  // eslint-disable-next-line react-hooks/exhaustive-deps
173
173
  }, [text, dataVersion]);
174
174
  useEffect(() => {
@@ -30,7 +30,7 @@ export function Scene3DPlayer({
30
30
  useEffect(() => {
31
31
  const canvas = canvasRef.current;
32
32
  if (!canvas) return undefined;
33
- const runtime = makeScene(sceneData, behaviorClasses, sprites, files);
33
+ const runtime = makeScene(sceneData, behaviorClasses, sprites, files, scenePath);
34
34
  const world = makeWorld3D(canvas);
35
35
  runtime.three = world;
36
36
  runtime.hud = {};
@@ -200,8 +200,8 @@ function startLoop(runtime, world, sprites, hudRefs, onFirstFrame, onError) {
200
200
  previousTime = now;
201
201
  try {
202
202
  runtime.update(dt);
203
- // A Portal (or any behavior) may have requested a scene switch; apply
204
- // it here, between frames, never mid physics dispatch. The load resets
203
+ // A behavior may have requested a scene switch; apply it here, between
204
+ // frames, never mid physics dispatch. The load resets
205
205
  // registered systems -- the rapier world frees and lazily rebuilds --
206
206
  // while `runtime.persistent` carries the player's cross-scene state.
207
207
  if (runtime.requestedScene) {
@@ -209,7 +209,7 @@ function startLoop(runtime, world, sprites, hudRefs, onFirstFrame, onError) {
209
209
  runtime.requestedScene = null;
210
210
  runtime.loadFromFile(next);
211
211
  runtime.currentScenePath = next;
212
- runtime.persistent.portalGraceUntil = runtime.time + 1;
212
+ runtime.persistent.sceneChangeGraceUntil = runtime.time + 1;
213
213
  }
214
214
  syncActors(world, runtime, sprites, dt);
215
215
  if (world.debugLines.visible) updatePhysicsDebug(world, runtime.physics3d?.debugRender());
@@ -50,9 +50,9 @@ export function createEditorWorld(canvas, callbacks) {
50
50
  // Swap in fresh scene data (parsed file JSON). The display runtime is never
51
51
  // stepped -- actors sit at their authored Transforms; blueprints and default
52
52
  // props merge exactly as in play.
53
- export function setEditorScene(ed, sceneData, behaviors, sprites, files) {
53
+ export function setEditorScene(ed, sceneData, behaviors, sprites, files, scenePath) {
54
54
  ed.runtime?.dispose();
55
- ed.runtime = makeScene(sceneData, behaviors, sprites, files);
55
+ ed.runtime = makeScene(sceneData, behaviors, sprites, files, scenePath);
56
56
  ed.sprites = sprites;
57
57
  }
58
58
 
@@ -6,7 +6,7 @@
6
6
  // the files API instead of forcing a context reload (the glob puts models in
7
7
  // vite's module graph, which the imported hook would read as "code changed").
8
8
  import { useEffect, useRef, useState } from 'react';
9
- import { onFilesChanged, requestReload } from 'castle-web-sdk';
9
+ import { onFilesChanged } from 'castle-web-sdk';
10
10
  import { initialFiles, initialMedia, isImageFile } from '@imports/castle.physics-2d/engine/files';
11
11
  import { parseFull } from '@imports/castle.physics-2d/engine/pxart';
12
12
  import { imageArt } from '@imports/castle.physics-2d/engine/art';
@@ -50,7 +50,7 @@ async function readDeckFile(path) {
50
50
  return typeof data.contents === 'string' ? data.contents : '';
51
51
  }
52
52
 
53
- async function applyDataChanges(changes, setFiles, setDataVersion) {
53
+ async function applyDataChanges(changes, setFiles) {
54
54
  const updates = await Promise.all(
55
55
  changes.map(async (change) => {
56
56
  if (change.event === 'delete') return { path: change.path, text: null };
@@ -71,36 +71,33 @@ async function applyDataChanges(changes, setFiles, setDataVersion) {
71
71
  }
72
72
  return next;
73
73
  });
74
- setDataVersion((version) => version + 1);
75
74
  }
76
75
 
77
76
  // Live deck files including models. Same contract as the imported
78
77
  // useLiveDeckFiles: `shouldSkipPath` keeps an editor's in-flight writes from
79
- // being clobbered by their own fs echo; `dataVersion` bumps on applied data
80
- // changes.
78
+ // being clobbered by their own fs echo.
79
+ //
80
+ // A code change does nothing here, matching the imported hook: only a page
81
+ // reload picks up new code, and throwing one at a panel someone is working in
82
+ // loses their place, so the panel header offers it instead.
81
83
  export function useLiveDeckFiles3D({ shouldSkipPath } = {}) {
82
84
  const [files, setFiles] = useState(initialFiles3D);
83
- const [dataVersion, setDataVersion] = useState(0);
84
85
  const skipRef = useRef(shouldSkipPath);
85
86
  skipRef.current = shouldSkipPath;
86
87
 
87
88
  useEffect(
88
89
  () =>
89
90
  onFilesChanged((event) => {
90
- if (event.changes.some(needsContextReload)) {
91
- requestReload();
92
- return;
93
- }
94
91
  const dataChanges = event.changes.filter(
95
92
  (change) => isDataFile3D(change.path) && !skipRef.current?.(change.path)
96
93
  );
97
94
  if (dataChanges.length === 0) return;
98
- void applyDataChanges(dataChanges, setFiles, setDataVersion);
95
+ void applyDataChanges(dataChanges, setFiles);
99
96
  }),
100
97
  []
101
98
  );
102
99
 
103
- return { files, setFiles, dataVersion };
100
+ return { files, setFiles };
104
101
  }
105
102
 
106
103
  // The sprites map with parsed pxarts CACHED BY TEXT: re-parsing every file on
@@ -372,22 +372,6 @@
372
372
  }
373
373
  }
374
374
  },
375
- {
376
- "id": "portal-lab",
377
- "blueprint": "blueprints/portal.scene",
378
- "components": {
379
- "Transform": {
380
- "x": -8,
381
- "z": 2
382
- },
383
- "Portal": {
384
- "scene": "scenes/model-lab.scene"
385
- },
386
- "Lookable": {
387
- "text": "a portal to the model lab."
388
- }
389
- }
390
- },
391
375
  {
392
376
  "id": "crate",
393
377
  "blueprint": "blueprints/crate.scene",
@@ -106,22 +106,6 @@
106
106
  "rotationY": 140
107
107
  }
108
108
  }
109
- },
110
- {
111
- "id": "portal-courtyard",
112
- "blueprint": "blueprints/portal.scene",
113
- "components": {
114
- "Transform": {
115
- "x": -2,
116
- "z": 8.6
117
- },
118
- "Portal": {
119
- "scene": "scenes/main.scene"
120
- },
121
- "Lookable": {
122
- "text": "a portal back to the courtyard."
123
- }
124
- }
125
109
  }
126
110
  ]
127
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.130",
3
+ "version": "0.4.131",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
@@ -1,19 +0,0 @@
1
- // Walk-through scene switching: a sensor actor (a glowing ring works well)
2
- // that asks the runtime to load another scene when the player touches it.
3
- // The request is applied by the player loop BETWEEN frames -- never mid
4
- // physics dispatch -- and a short grace after each switch keeps a portal from
5
- // instantly bouncing the player back where they came from.
6
- export class Portal {
7
- static behaviorName = 'Portal';
8
- static defaultProps = { scene: '' };
9
-
10
- constructor(props) {
11
- this.props = props;
12
- }
13
-
14
- onCollisionEnter(actor, scene, other) {
15
- if (!other.components.Player || !this.props.scene) return;
16
- if (scene.time < (scene.persistent?.portalGraceUntil ?? 0)) return;
17
- scene.requestedScene = this.props.scene;
18
- }
19
- }
@@ -1,21 +0,0 @@
1
- {
2
- "name": "Portal",
3
- "actors": [
4
- {
5
- "components": {
6
- "Transform": { "y": 1.05, "rotationX": 90 },
7
- "Shape": {
8
- "kind": "cylinder",
9
- "radius": 0.9,
10
- "height": 0.16,
11
- "color": "#0cf1ff",
12
- "emissive": "#0cf1ff",
13
- "emissiveIntensity": 0.65,
14
- "roughness": 0.3
15
- },
16
- "Portal": {},
17
- "Lookable": { "text": "a portal.", "range": 4.5 }
18
- }
19
- }
20
- ]
21
- }