castle-web-cli 0.4.93 → 0.4.95

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.
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useEffect, useState } from 'react';
2
2
  import { NumberField, Panel, SelectField } from '../engine/ui';
3
3
  import { AutoFields } from '../engine/autoInspector';
4
4
  import { computeAutoFit, getColliderRect, getColliderShapes, intersects } from '../engine/collider';
@@ -61,6 +61,13 @@ function drawShape(ctx, s) {
61
61
  for (let k = 1; k < s.points.length; k++) ctx.lineTo(s.points[k].x, s.points[k].y);
62
62
  ctx.closePath();
63
63
  ctx.stroke();
64
+ } else if (s.angle) {
65
+ // Rotate the outline about the box center to match the (angled) matter part.
66
+ ctx.save();
67
+ ctx.translate(s.cx, s.cy);
68
+ ctx.rotate((s.angle * Math.PI) / 180);
69
+ ctx.strokeRect(-s.width / 2 + 1, -s.height / 2 + 1, s.width - 2, s.height - 2);
70
+ ctx.restore();
64
71
  } else {
65
72
  ctx.strokeRect(s.x + 1, s.y + 1, s.width - 2, s.height - 2);
66
73
  }
@@ -113,14 +120,21 @@ export class Collider {
113
120
  const shapes = getColliderShapes(actor);
114
121
  if (!shapes) return;
115
122
  const isSensor = Boolean(this.props.isTrigger) || this.props.kind === 'pickup';
123
+ // When editing this blueprint (hotbar), highlight the shape the inspector has
124
+ // selected so you can tell which chip is which on its lit instances -- the
125
+ // selected-instance path does this via the SelectionOverlay instead.
126
+ const highlight = blueprintSelected ? options.colliderShapeSel ?? -1 : -1;
116
127
  ctx.save();
117
- ctx.strokeStyle = isSensor ? '#ffe17a' : '#8db7ff';
118
- ctx.lineWidth = 2;
119
- for (const s of shapes) drawShape(ctx, s);
128
+ shapes.forEach((s, k) => {
129
+ const on = k === highlight;
130
+ ctx.strokeStyle = on ? '#ffd24a' : isSensor ? '#ffe17a' : '#8db7ff';
131
+ ctx.lineWidth = on ? 3.5 : 2;
132
+ drawShape(ctx, s);
133
+ });
120
134
  ctx.restore();
121
135
  }
122
136
 
123
- static Inspector({ actor, component, sprites, setComponent, override }) {
137
+ static Inspector({ actor, component, sprites, setComponent, override, onSelectShape }) {
124
138
  const [sel, setSel] = useState(0);
125
139
  const [addOpen, setAddOpen] = useState(false);
126
140
  const layout = actor?.components?.Layout ?? {};
@@ -129,6 +143,9 @@ export class Collider {
129
143
 
130
144
  const shapes = currentShapes(component);
131
145
  const i = Math.min(sel, shapes.length - 1);
146
+ // Report the selected shape index up so the on-canvas overlay can highlight
147
+ // it (fires on mount with 0, and on every chip click / add / remove).
148
+ useEffect(() => onSelectShape?.(i), [i, onSelectShape]);
132
149
  const shape = shapes[i];
133
150
  const isCircle = shape.type === 'circle';
134
151
  const isPoly = shape.type === 'triangle' || shape.type === 'polygon';
@@ -160,6 +177,22 @@ export class Collider {
160
177
  const offXPx = Math.round(((shape.x ?? 0.5) - 0.5) * bw);
161
178
  const offYPx = Math.round(((shape.y ?? 0.5) - 0.5) * bh);
162
179
 
180
+ // Per-subfield override state vs the blueprint's version of THIS shape. The
181
+ // shape fields don't go through AutoFields, so without this an instance that
182
+ // changed e.g. box 3's offset X wouldn't show the tint / Default / Reset.
183
+ // Null override (editing the blueprint template itself) shows nothing.
184
+ const bpShapes = override?.baseline?.('shapes');
185
+ const bShape = Array.isArray(bpShapes) ? bpShapes[i] : null;
186
+ const shapeOv = (key, dflt, toPx) => {
187
+ if (!bShape || bShape.type !== shape.type) return {};
188
+ const bv = bShape[key] ?? dflt;
189
+ return {
190
+ overridden: Math.abs((shape[key] ?? dflt) - bv) > 1e-4,
191
+ defaultValue: toPx(bv),
192
+ onReset: () => patchSel({ [key]: bv }),
193
+ };
194
+ };
195
+
163
196
  const density = component.density > 0 ? component.density : 0.001;
164
197
 
165
198
  // Auto-fit (box/circle) for the selected shape.
@@ -205,17 +238,50 @@ export class Collider {
205
238
  <>
206
239
  <SelectField label="Shape" value={shape.type} onChange={setType} options={['box', 'circle']} />
207
240
  {isCircle ? (
208
- <NumberField label="Radius" value={radiusPx} onChange={(v) => patchSel({ r: v / Math.min(bw, bh) })} />
241
+ <NumberField
242
+ label="Radius"
243
+ value={radiusPx}
244
+ onChange={(v) => patchSel({ r: v / Math.min(bw, bh) })}
245
+ {...shapeOv('r', 0.5, (f) => Math.round(f * Math.min(bw, bh)))}
246
+ />
209
247
  ) : (
210
248
  <>
211
- <NumberField label="Width" value={widthPx} onChange={(v) => patchSel({ w: v / bw })} />
212
- <NumberField label="Height" value={heightPx} onChange={(v) => patchSel({ h: v / bh })} />
249
+ <NumberField
250
+ label="Width"
251
+ value={widthPx}
252
+ onChange={(v) => patchSel({ w: v / bw })}
253
+ {...shapeOv('w', 1, (f) => Math.round(f * bw))}
254
+ />
255
+ <NumberField
256
+ label="Height"
257
+ value={heightPx}
258
+ onChange={(v) => patchSel({ h: v / bh })}
259
+ {...shapeOv('h', 1, (f) => Math.round(f * bh))}
260
+ />
261
+ <NumberField
262
+ label="Angle"
263
+ value={Math.round(shape.angle ?? 0)}
264
+ min={-180}
265
+ max={180}
266
+ onChange={(v) => patchSel({ angle: v })}
267
+ {...shapeOv('angle', 0, (f) => Math.round(f))}
268
+ />
213
269
  </>
214
270
  )}
215
271
  </>
216
272
  )}
217
- <NumberField label="Offset X" value={offXPx} onChange={(v) => patchSel({ x: 0.5 + v / bw })} />
218
- <NumberField label="Offset Y" value={offYPx} onChange={(v) => patchSel({ y: 0.5 + v / bh })} />
273
+ <NumberField
274
+ label="Offset X"
275
+ value={offXPx}
276
+ onChange={(v) => patchSel({ x: 0.5 + v / bw })}
277
+ {...shapeOv('x', 0.5, (f) => Math.round((f - 0.5) * bw))}
278
+ />
279
+ <NumberField
280
+ label="Offset Y"
281
+ value={offYPx}
282
+ onChange={(v) => patchSel({ y: 0.5 + v / bh })}
283
+ {...shapeOv('y', 0.5, (f) => Math.round((f - 0.5) * bh))}
284
+ />
219
285
  <div style={actionRow}>
220
286
  {autoFit && !fitted ? (
221
287
  <button type="button" onClick={() => writeShapes(shapes.map((s, k) => (k === i ? autoFit : s)))} style={linkBtn}>
@@ -1,3 +1,4 @@
1
+ import { resolveDeckFile } from 'castle-web-sdk';
1
2
  import React from 'react';
2
3
  import { frameCount, renderSpriteFrame } from '../engine/pxart';
3
4
  import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
@@ -37,7 +38,11 @@ export class Sprite {
37
38
  }
38
39
 
39
40
  resolveSprite(scene) {
40
- return scene.sprites?.[this.props.file] ?? scene.sprites?.[FALLBACK_FILE] ?? null;
41
+ // `drawings/x.pxart` is this deck's; `@imports/<alias>/drawings/x.pxart` is
42
+ // an import's. (A value inherited from an imported blueprint arrives already
43
+ // resolved against that blueprint's deck -- see engine/blueprint.js.)
44
+ const file = resolveDeckFile(this.props.file);
45
+ return scene.sprites?.[file] ?? scene.sprites?.[FALLBACK_FILE] ?? null;
41
46
  }
42
47
 
43
48
  update(actor, scene, dt) {
@@ -0,0 +1,26 @@
1
+ {
2
+ "format": "compact",
3
+ "palette": {
4
+ "a": "#8a4836",
5
+ "b": "#bf6f4a",
6
+ "c": "#e69c69"
7
+ },
8
+ "grid": [
9
+ "................",
10
+ "................",
11
+ "................",
12
+ "..cc........cc..",
13
+ ".b..b......b..b.",
14
+ "a....a....a....a",
15
+ "......a..a......",
16
+ ".......aa.......",
17
+ ".......aa.......",
18
+ "......a..a......",
19
+ "a....a....a....a",
20
+ ".b..b......b..b.",
21
+ "..cc........cc..",
22
+ "................",
23
+ "................",
24
+ "................"
25
+ ]
26
+ }
@@ -161,6 +161,27 @@ export function SceneEditor({
161
161
  const editCameraRef = useRef(reloadStash?.editCamera ?? { x: 0, y: 0, zoom: 1 });
162
162
  const selectedActorIdsRef = useRef(selectedActorIds);
163
163
  selectedActorIdsRef.current = selectedActorIds;
164
+ // Transient "click an actor to pick it" mode, triggered by an inspector field
165
+ // (e.g. Joint's target picker). Holds `{ onPick }`; the next edit-canvas click
166
+ // resolves it with the clicked actor's id instead of selecting. A ref mirror
167
+ // lets the pointer gesture read it live without re-creating handlers.
168
+ const [pickRequest, setPickRequest] = useState(null);
169
+ const pickRequestRef = useRef(null);
170
+ pickRequestRef.current = pickRequest;
171
+ const beginPick = useCallback((onPick) => setPickRequest(onPick ? { onPick } : null), []);
172
+ useEffect(() => {
173
+ if (!pickRequest) return undefined;
174
+ const onKey = (e) => e.key === 'Escape' && setPickRequest(null);
175
+ window.addEventListener('keydown', onKey);
176
+ return () => window.removeEventListener('keydown', onKey);
177
+ }, [pickRequest]);
178
+ // Which Collider shape the inspector has selected, so the on-canvas overlay can
179
+ // highlight it. The Collider inspector reports its index up via onSelectShape.
180
+ // A ref mirror feeds the draw loop (blueprint-lit instances highlight via the
181
+ // canvas draw); the SelectionOverlay reads the state directly.
182
+ const [colliderShapeSel, setColliderShapeSel] = useState(0);
183
+ const colliderShapeSelRef = useRef(colliderShapeSel);
184
+ colliderShapeSelRef.current = colliderShapeSel;
164
185
  const [isPlaying, setIsPlaying] = useState(reloadStash?.isPlaying ?? false);
165
186
  // Hotbar blueprint selection (tap a slot -> inspect/edit the blueprint in
166
187
  // the sidebar; drag remains the placement gesture). Mutually exclusive with
@@ -239,9 +260,19 @@ export function SceneEditor({
239
260
  marqueeRef,
240
261
  selectedActorIdsRef,
241
262
  selectedBlueprintPathRef,
263
+ colliderShapeSelRef,
242
264
  });
243
265
  const panGesture = usePanGesture({ canvasRef, editCameraRef, isPlaying });
244
266
  useScenePlayKeys(runtimeRef);
267
+ // Resolve an in-progress target pick: a click on the owner itself is ignored
268
+ // (a self-joint is meaningless); a click on empty space cancels.
269
+ const onPickTarget = (actorId) => {
270
+ const req = pickRequestRef.current;
271
+ if (!req) return;
272
+ if (actorId && actorId === selectedActorIdsRef.current[0]) return;
273
+ setPickRequest(null);
274
+ if (actorId) req.onPick(actorId);
275
+ };
245
276
  const gesture = useSelectionGesture({
246
277
  canvasRef,
247
278
  sceneData,
@@ -254,6 +285,8 @@ export function SceneEditor({
254
285
  onSetMultiSelectMode,
255
286
  marqueeRef,
256
287
  editCameraRef,
288
+ pickRequestRef,
289
+ onPickTarget,
257
290
  applyScene: (next) => onChange(serialize(next)),
258
291
  recordSceneSnapshot: history.recordSnapshot,
259
292
  });
@@ -549,6 +582,7 @@ export function SceneEditor({
549
582
  previewSceneData={previewSceneData}
550
583
  sprites={sprites}
551
584
  selectedActorIds={selectedActorIds}
585
+ selectedColliderShape={colliderShapeSel}
552
586
  snap={snapSettings}
553
587
  onArrange={isBlueprintFile ? undefined : actions.arrangeSelection}
554
588
  onClone={isBlueprintFile ? undefined : actions.duplicateSelection}
@@ -594,6 +628,7 @@ export function SceneEditor({
594
628
  onSetComponent={blueprintActions.setComponent}
595
629
  onAddBehavior={blueprintActions.addBehavior}
596
630
  onRemoveBehavior={blueprintActions.removeBehavior}
631
+ onSelectShape={setColliderShapeSel}
597
632
  />
598
633
  ) : showMulti ? (
599
634
  <MultiSelectInspector
@@ -622,6 +657,9 @@ export function SceneEditor({
622
657
  ? undefined
623
658
  : () => onSelectBlueprint(rawSelectedActor.blueprint)
624
659
  }
660
+ onBeginPick={beginPick}
661
+ pickActive={Boolean(pickRequest)}
662
+ onSelectShape={setColliderShapeSel}
625
663
  />
626
664
  ) : (
627
665
  <SceneInspector
@@ -800,6 +838,13 @@ function useSelectionGesture(args) {
800
838
  const stack = scene.actorsAt(point.x, point.y);
801
839
  const stackIds = stack.map((a) => a.id);
802
840
  const actor = stack[0] ?? null;
841
+ // Target-pick mode swallows this click: resolve the pick with the clicked
842
+ // actor (or cancel on empty space) instead of selecting/dragging.
843
+ if (current.pickRequestRef?.current) {
844
+ current.canvasRef.current.releasePointerCapture?.(event.pointerId);
845
+ current.onPickTarget(actor?.id ?? null);
846
+ return;
847
+ }
803
848
  const drag = {
804
849
  pointerId: event.pointerId,
805
850
  startPoint: point,
@@ -1339,6 +1384,7 @@ function useScenePlayLoop({
1339
1384
  marqueeRef,
1340
1385
  selectedActorIdsRef,
1341
1386
  selectedBlueprintPathRef,
1387
+ colliderShapeSelRef,
1342
1388
  }) {
1343
1389
  // Spin up / tear down the play-mode runtime as the user toggles play. `text`
1344
1390
  // is the stable identity for `sceneData` (which is re-parsed every render);
@@ -1398,6 +1444,9 @@ function useScenePlayLoop({
1398
1444
  useCamera: true,
1399
1445
  editPlaceholders: !isPlaying,
1400
1446
  dimBlueprintPath: isPlaying ? null : selectedBlueprintPathRef.current,
1447
+ // Highlight the collider shape the blueprint inspector has selected on
1448
+ // its lit instances.
1449
+ colliderShapeSel: isPlaying ? -1 : colliderShapeSelRef?.current ?? -1,
1401
1450
  });
1402
1451
  }
1403
1452
  raf = requestAnimationFrame(frame);
@@ -1415,6 +1464,7 @@ function useScenePlayLoop({
1415
1464
  marqueeRef,
1416
1465
  selectedActorIdsRef,
1417
1466
  selectedBlueprintPathRef,
1467
+ colliderShapeSelRef,
1418
1468
  ]);
1419
1469
  }
1420
1470
  function useScenePlayKeys(runtimeRef) {
@@ -1523,6 +1573,7 @@ function BlueprintInspector({
1523
1573
  onSetComponent,
1524
1574
  onAddBehavior,
1525
1575
  onRemoveBehavior,
1576
+ onSelectShape,
1526
1577
  }) {
1527
1578
  const templateActor = { id: '__blueprint__', components: template.components };
1528
1579
  const instanceCount = countBlueprintInstances(files, blueprintPath);
@@ -1552,6 +1603,7 @@ function BlueprintInspector({
1552
1603
  onSetComponent={(actorId, behaviorName, nextProps) => onSetComponent(behaviorName, nextProps)}
1553
1604
  onAddBehavior={(actorId, behaviorName) => onAddBehavior(behaviorName)}
1554
1605
  onRemoveBehavior={(actorId, behaviorName) => onRemoveBehavior(behaviorName)}
1606
+ onSelectShape={onSelectShape}
1555
1607
  />
1556
1608
  </>
1557
1609
  );
@@ -1593,6 +1645,9 @@ function ActorInspector({
1593
1645
  onRemoveBehavior,
1594
1646
  onFork,
1595
1647
  onEditBlueprint,
1648
+ onBeginPick,
1649
+ pickActive,
1650
+ onSelectShape,
1596
1651
  }) {
1597
1652
  const isInstance = Boolean(rawActor?.blueprint);
1598
1653
  const presentNames = new Set(
@@ -1687,6 +1742,9 @@ function ActorInspector({
1687
1742
  sprites={sprites}
1688
1743
  setComponent={setComponent}
1689
1744
  override={override}
1745
+ beginPick={onBeginPick}
1746
+ pickActive={pickActive}
1747
+ onSelectShape={onSelectShape}
1690
1748
  />
1691
1749
  ) : (
1692
1750
  <AutoInspector
@@ -68,6 +68,7 @@ export function SelectionOverlay({
68
68
  previewSceneData,
69
69
  sprites,
70
70
  selectedActorIds,
71
+ selectedColliderShape,
71
72
  snap,
72
73
  onArrange,
73
74
  onClone,
@@ -198,6 +199,9 @@ export function SelectionOverlay({
198
199
 
199
200
  const geometry = getOverlayGeometry(frame, box, camera);
200
201
  const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds, sprites);
202
+ // The collider inspector edits one shape of a single-selected actor; highlight
203
+ // that shape's box so you can see which one you're editing at a glance.
204
+ const editedColliderActor = selectedActorIds.length === 1 ? selectedActorIds[0] : null;
201
205
 
202
206
  return (
203
207
  <div ref={rootRef} className={styles.selOverlayRoot}>
@@ -211,24 +215,34 @@ export function SelectionOverlay({
211
215
  <div
212
216
  className={styles.selChromeWorld}
213
217
  style={{ transform: `translate(${-geometry.camX}px, ${-geometry.camY}px)` }}>
214
- {colliderFrames.map((collider, i) => (
215
- <div
216
- key={`${collider.actorId}:${i}`}
217
- className={cx(
218
- styles.selColliderBox,
219
- collider.isTrigger && styles.selColliderPickup
220
- )}
221
- style={{
222
- left: collider.x,
223
- top: collider.y,
224
- width: collider.width,
225
- height: collider.height,
226
- borderRadius: collider.shape === 'circle' ? '50%' : undefined,
227
- transformOrigin: `${collider.originX}px ${collider.originY}px`,
228
- transform: `rotate(${collider.rotation}deg)`,
229
- }}
230
- />
231
- ))}
218
+ {colliderFrames.map((collider, i) => {
219
+ const highlighted =
220
+ collider.actorId === editedColliderActor && collider.shapeIndex === selectedColliderShape;
221
+ return (
222
+ <div
223
+ key={`${collider.actorId}:${i}`}
224
+ className={cx(
225
+ styles.selColliderBox,
226
+ collider.isTrigger && styles.selColliderPickup
227
+ )}
228
+ style={{
229
+ left: collider.x,
230
+ top: collider.y,
231
+ width: collider.width,
232
+ height: collider.height,
233
+ borderRadius: collider.shape === 'circle' ? '50%' : undefined,
234
+ transformOrigin: '0 0',
235
+ transform: colliderTransform(collider),
236
+ // The shape being edited: a bright border + tint, lifted above
237
+ // the others so an overlapping (e.g. skateboard) shape reads clearly.
238
+ borderColor: highlighted ? '#ffd24a' : undefined,
239
+ borderWidth: highlighted ? 3 : undefined,
240
+ background: highlighted ? 'rgba(255, 210, 74, 0.14)' : undefined,
241
+ zIndex: highlighted ? 1 : 0,
242
+ }}
243
+ />
244
+ );
245
+ })}
232
246
  </div>
233
247
  </div>
234
248
 
@@ -456,6 +470,19 @@ function getActionAnchor({ centerX, centerY, halfH, rotateAnchor }) {
456
470
  };
457
471
  }
458
472
 
473
+ // Compose the collider box's on-screen transform (origin = box top-left): rotate
474
+ // by the shape's own `angle` about its center, then by the actor's Layout.rotation
475
+ // about the actor center. Both pivots explicit since they differ.
476
+ function colliderTransform(c) {
477
+ const ax = c.originX;
478
+ const ay = c.originY;
479
+ const layout = `translate(${ax}px, ${ay}px) rotate(${c.rotation}deg) translate(${-ax}px, ${-ay}px)`;
480
+ if (!c.shapeAngle) return layout;
481
+ const cx = c.width / 2;
482
+ const cy = c.height / 2;
483
+ return `${layout} translate(${cx}px, ${cy}px) rotate(${c.shapeAngle}deg) translate(${-cx}px, ${-cy}px)`;
484
+ }
485
+
459
486
  function getSelectedColliderFrames(sceneData, actorIds, sprites) {
460
487
  if (!sceneData || !actorIds || actorIds.length === 0) return [];
461
488
  const wanted = new Set(actorIds);
@@ -470,7 +497,7 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
470
497
  // One preview box per collider shape, from the shared geometry so the
471
498
  // preview matches the physics body. A circle renders as a radius-sized
472
499
  // square with border-radius; a triangle/polygon as its AABB (v1).
473
- return shapes.map((s) => {
500
+ return shapes.map((s, shapeIndex) => {
474
501
  let kind = 'box';
475
502
  let x;
476
503
  let y;
@@ -505,6 +532,7 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
505
532
  }
506
533
  return {
507
534
  actorId: actor.id,
535
+ shapeIndex,
508
536
  shape: kind,
509
537
  isTrigger,
510
538
  x,
@@ -512,6 +540,7 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
512
540
  width,
513
541
  height,
514
542
  rotation: layout.rotation ?? 0,
543
+ shapeAngle: s.type === 'box' ? s.angle ?? 0 : 0,
515
544
  originX: layout.x + layout.width / 2 - x,
516
545
  originY: layout.y + layout.height / 2 - y,
517
546
  };
@@ -3,9 +3,15 @@
3
3
  // picked up on the next reload/restart. The physics glob is what lets the
4
4
  // self-contained physics module ship its behaviors without polluting the core
5
5
  // `behaviors/` folder — a kit adopts physics by copying `physics/` in.
6
+ // Root-anchored so the kit finds the DECK's behaviors even when the kit is
7
+ // itself an import (see engine/files.js). Imports are swept first and the deck's
8
+ // own behaviors last: collectBehaviors keys by behaviorName, so a behavior the
9
+ // deck defines wins over one of the same name from a dependency.
6
10
  const modules = {
7
- ...import.meta.glob('../behaviors/*.jsx', { eager: true }),
8
- ...import.meta.glob('../physics/behaviors/*.jsx', { eager: true }),
11
+ ...import.meta.glob('/imports/*/behaviors/*.jsx', { eager: true }),
12
+ ...import.meta.glob('/imports/*/physics/behaviors/*.jsx', { eager: true }),
13
+ ...import.meta.glob('/behaviors/*.jsx', { eager: true }),
14
+ ...import.meta.glob('/physics/behaviors/*.jsx', { eager: true }),
9
15
  };
10
16
  function isBehaviorClass(value) {
11
17
  return typeof value === 'function' && typeof value.behaviorName === 'string';
@@ -6,6 +6,7 @@
6
6
  // panel propagates to every scene that places it. Shared by the runtime
7
7
  // (`scene.js`, so play mode and `PlayOnly` resolve blueprints too) and the
8
8
  // editors (which also mint/fork/migrate/cascade-delete blueprint files).
9
+ import { resolveDeckFile } from 'castle-web-sdk';
9
10
  import { formatJson } from './files';
10
11
  import { DEFAULT_RESOLUTION, TRANSPARENT, serializeCompact } from './pxart';
11
12
  import { cardSize, dedupeActorId } from './scene';
@@ -13,8 +14,19 @@ import { cardSize, dedupeActorId } from './scene';
13
14
  export const BLUEPRINTS_DIR = 'blueprints';
14
15
  export const DRAWINGS_DIR = 'drawings';
15
16
 
17
+ // A blueprint of this deck (`blueprints/x.scene`) OR of one it imports
18
+ // (`imports/<alias>/blueprints/x.scene`) -- an imported deck's blueprints are
19
+ // placeable like any other, which is most of the point of importing one.
20
+ const BLUEPRINT_PATH_RE = new RegExp(`^(imports/[^/]+/)?${BLUEPRINTS_DIR}/`);
21
+
16
22
  export function isBlueprintPath(path) {
17
- return typeof path === 'string' && path.startsWith(`${BLUEPRINTS_DIR}/`) && path.endsWith('.scene');
23
+ return typeof path === 'string' && path.endsWith('.scene') && BLUEPRINT_PATH_RE.test(path);
24
+ }
25
+
26
+ // Blueprints an import owns: placeable, but not editable or deletable from here
27
+ // (they belong to the deck they came from).
28
+ export function isImportedPath(path) {
29
+ return typeof path === 'string' && path.startsWith('imports/');
18
30
  }
19
31
 
20
32
  // Per-property inherit metadata for a behavior prop. Behaviors opt a prop out
@@ -49,12 +61,33 @@ function parseBlueprintText(text) {
49
61
  return { name, components: actor.components };
50
62
  }
51
63
 
64
+ // A reference names a file the way its own deck sees it -- `drawings/rock.pxart`
65
+ // for one of its own, `@imports/<alias>/...` for one of a deck it imports (the
66
+ // SDK's resolveDeckFile is the single definition of that rule). Templates are
67
+ // resolved against the file they were written in, so a kit's blueprint keeps
68
+ // meaning the kit's drawing after the kit is imported by somebody else.
69
+ function resolveTemplateRefs(template, files, fromPath) {
70
+ if (!template) return template;
71
+ for (const props of Object.values(template.components ?? {})) {
72
+ for (const [key, value] of Object.entries(props ?? {})) {
73
+ if (typeof value !== 'string' || !value) continue;
74
+ const resolved = resolveDeckFile(value, fromPath);
75
+ if (resolved !== value && files?.[resolved] !== undefined) props[key] = resolved;
76
+ }
77
+ }
78
+ return template;
79
+ }
80
+
52
81
  // Resolve a blueprint's template from the LIVE files map -- the load-bearing
53
82
  // call for live propagation. Returns null when the file is missing, deleted,
54
83
  // or malformed (a dangling `blueprint` ref degrades to "no template" rather
55
84
  // than throwing).
56
85
  export function getBlueprintTemplate(files, blueprintPath) {
57
- return parseBlueprintText(files?.[blueprintPath]);
86
+ // The ref may name this deck's blueprint or an import's; the template's own
87
+ // refs then resolve against whichever deck the blueprint turned out to be in.
88
+ const key = resolveDeckFile(blueprintPath);
89
+ const template = parseBlueprintText(files?.[key]);
90
+ return resolveTemplateRefs(template, files, key);
58
91
  }
59
92
 
60
93
  // Every blueprint currently in the deck, for the library/hotbar. Skips
@@ -63,7 +96,10 @@ export function listBlueprints(files) {
63
96
  const out = [];
64
97
  for (const path of Object.keys(files ?? {})) {
65
98
  if (!isBlueprintPath(path)) continue;
66
- const template = parseBlueprintText(files[path]);
99
+ // Same origin resolution getBlueprintTemplate does: an imported blueprint's
100
+ // `drawings/x.pxart` means the import's drawing, and the library previews
101
+ // these templates -- unresolved, the slot renders with no art.
102
+ const template = resolveTemplateRefs(parseBlueprintText(files[path]), files, path);
67
103
  if (!template) continue;
68
104
  out.push({ path, name: template.name, components: template.components });
69
105
  }
@@ -5,7 +5,7 @@
5
5
  // A Collider is a LIST of shapes (`Collider.shapes`), each stored as a FRACTION
6
6
  // of the actor's Layout box -- so a collider keeps its relative size and position
7
7
  // when the box is resized (blueprint default OR per-instance override). Shapes:
8
- // box: { type:'box', x, y, w, h } center + size, fractions of box
8
+ // box: { type:'box', x, y, w, h, angle? } center + size (frac); angle deg
9
9
  // circle: { type:'circle', x, y, r } center frac; r = frac of min(box side)
10
10
  // triangle: { type:'triangle', points:[{x,y}×3] } fractions of box
11
11
  // polygon: { type:'polygon', points:[{x,y}×N] }
@@ -140,7 +140,10 @@ function resolveShape(shape, layout) {
140
140
  const h = (shape.h ?? 1) * bh;
141
141
  const cx = wx(shape.x ?? 0.5);
142
142
  const cy = wy(shape.y ?? 0.5);
143
- return { type: 'box', cx, cy, width: w, height: h, x: cx - w / 2, y: cy - h / 2 };
143
+ // `angle` (deg) rotates the box about its own center, within the unrotated box
144
+ // frame; Layout.rotation composes on top (matter body + draw ctx). `x`/`y` are
145
+ // the UNROTATED top-left -- shapeAabb widens them when angled.
146
+ return { type: 'box', cx, cy, width: w, height: h, x: cx - w / 2, y: cy - h / 2, angle: shape.angle ?? 0 };
144
147
  }
145
148
 
146
149
  // World-space shapes for an actor's collider (list), or null if no Layout/Collider.
@@ -172,7 +175,14 @@ function shapeAabb(s) {
172
175
  }
173
176
  return [x0, y0, x1, y1];
174
177
  }
175
- return [s.x, s.y, s.x + s.width, s.y + s.height];
178
+ const ang = ((s.angle ?? 0) * Math.PI) / 180;
179
+ if (!ang) return [s.x, s.y, s.x + s.width, s.y + s.height];
180
+ // AABB of a box rotated about its center: project the half-extents onto the axes.
181
+ const c = Math.abs(Math.cos(ang));
182
+ const sn = Math.abs(Math.sin(ang));
183
+ const ex = (s.width / 2) * c + (s.height / 2) * sn;
184
+ const ey = (s.width / 2) * sn + (s.height / 2) * c;
185
+ return [s.cx - ex, s.cy - ey, s.cx + ex, s.cy + ey];
176
186
  }
177
187
 
178
188
  // AABB of the whole collider (union of all shapes), world units -- for
@@ -1,12 +1,33 @@
1
1
  // Seed the file map by scanning the deck dir. Vite module-cache is invalidated
2
2
  // on restart, so a newly-created file just shows up on the next reload.
3
- const rawModules = import.meta.glob(
4
- ['../scenes/*.scene', '../blueprints/*.scene', '../drawings/*.pxart', '../behaviors/*.jsx'],
5
- { query: '?raw', import: 'default', eager: true }
6
- );
3
+ // Anchored at the DECK ROOT (`/scenes/...`) rather than relative to this module,
4
+ // because the kit itself may be an import: living at `imports/<alias>/`, a
5
+ // `../scenes/*.scene` would find the KIT's scenes instead of the deck's. Anchored
6
+ // at the root, one pattern works whether the kit is the deck or a dependency.
7
+ //
8
+ // The first glob adds every import's content, keyed by its full path
9
+ // (`imports/<alias>/drawings/rock.pxart`) -- which is exactly how a scene or
10
+ // blueprint refers to a dependency's file, so no lookup elsewhere changes.
11
+ // (Patterns and options must be literals: import.meta.glob is a compile-time
12
+ // transform, so these can't be hoisted into shared constants.)
13
+ const rawModules = {
14
+ ...import.meta.glob(
15
+ [
16
+ '/imports/*/scenes/*.scene',
17
+ '/imports/*/blueprints/*.scene',
18
+ '/imports/*/drawings/*.pxart',
19
+ '/imports/*/behaviors/*.jsx',
20
+ ],
21
+ { query: '?raw', import: 'default', eager: true }
22
+ ),
23
+ ...import.meta.glob(
24
+ ['/scenes/*.scene', '/blueprints/*.scene', '/drawings/*.pxart', '/behaviors/*.jsx'],
25
+ { query: '?raw', import: 'default', eager: true }
26
+ ),
27
+ };
7
28
  export const initialFiles = Object.fromEntries(
8
29
  Object.entries(rawModules)
9
- .map(([globPath, text]) => [globPath.replace(/^\.\.\//, ''), text])
30
+ .map(([globPath, text]) => [globPath.replace(/^\//, ''), text])
10
31
  .sort(([a], [b]) => a.localeCompare(b))
11
32
  );
12
33
  export function getFileKind(path) {
@@ -1,3 +1,4 @@
1
+ import { resolveDeckFile } from 'castle-web-sdk';
1
2
  import { initialFiles, parseJsonFile } from './files';
2
3
  import { getBlueprintTemplate, mergeComponents } from './blueprint';
3
4
  import { getColliderRect, intersects, spriteIsEmpty } from './collider';
@@ -18,7 +19,9 @@ export const cardSize = { width: CARD_WIDTH, height: CARD_HEIGHT };
18
19
  // 'main.scene', 'scenes/main.scene', './scenes/main.scene', or 'main' -- the
19
20
  // `.scene` extension and a leading `scenes/` are both optional.
20
21
  function resolveSceneFileKey(name) {
21
- const key = String(name).replace(/^\.?\//, '');
22
+ // `@imports/<alias>/scenes/x.scene` names an import's scene; anything else is
23
+ // this deck's, with the `scenes/` folder and `.scene` suffix both optional.
24
+ const key = resolveDeckFile(String(name));
22
25
  const candidates = [key];
23
26
  if (!key.endsWith('.scene')) candidates.push(`${key}.scene`);
24
27
  for (const candidate of [...candidates]) {
@@ -59,6 +62,11 @@ export class SceneRuntime {
59
62
  }
60
63
 
61
64
  load(sceneData) {
65
+ // Reset registered systems (e.g. physics) so a reload/restart/scene
66
+ // transition starts from the authored layout -- otherwise a system holding
67
+ // simulation state (body positions, joints) would carry it across the load.
68
+ // No-op on the constructor's first load, before any system is registered.
69
+ for (const system of this.systems) system.reset?.(this);
62
70
  this.data = structuredClone(sceneData);
63
71
  this.actors = new Map();
64
72
  for (const actor of this.data.actors ?? []) {
@@ -6,7 +6,11 @@
6
6
  // updates. Empty in a kit with no `systems/` dir (e.g. basic-2d); a kit adds a
7
7
  // system by dropping a file here -- no edits to the engine required. Symmetric
8
8
  // with editors/behaviorRegistry.js.
9
- const modules = import.meta.glob('../systems/*.js', { eager: true });
9
+ // Root-anchored, deck + imports (see engine/files.js).
10
+ const modules = {
11
+ ...import.meta.glob('/imports/*/systems/*.js', { eager: true }),
12
+ ...import.meta.glob('/systems/*.js', { eager: true }),
13
+ };
10
14
  export const systemInstallers = Object.values(modules)
11
15
  .map((mod) => mod.installSystem)
12
16
  .filter((fn) => typeof fn === 'function');