castle-web-cli 0.4.176 → 0.4.178

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 (41) hide show
  1. package/dist/agent-failures.js +4 -4
  2. package/dist/agent-prompts.d.ts +9 -1
  3. package/dist/agent-prompts.js +15 -2
  4. package/dist/agent.js +98 -34
  5. package/dist/deckLocatorShape.d.ts +16 -0
  6. package/dist/deckLocatorShape.js +28 -0
  7. package/dist/editorConfig.d.ts +1 -0
  8. package/dist/serve.js +3 -1
  9. package/dist/shell/assets/index-C890YbXX.css +1 -0
  10. package/dist/shell/assets/index-z6shfW4S.js +447 -0
  11. package/dist/shell/index.html +2 -2
  12. package/kits/base/CLAUDE.md +3 -0
  13. package/kits/base/castle.json +21 -11
  14. package/kits/physics-2d/CLAUDE.md +41 -8
  15. package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
  16. package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
  17. package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
  18. package/kits/physics-2d/behaviors/Style.jsx +270 -0
  19. package/kits/physics-2d/behaviors/Text.jsx +213 -0
  20. package/kits/physics-2d/behaviors/Video.jsx +8 -4
  21. package/kits/physics-2d/blueprints/text.scene +12 -0
  22. package/kits/physics-2d/castle.json +10 -6
  23. package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
  24. package/kits/physics-2d/editors/deckFont.js +65 -55
  25. package/kits/physics-2d/editors/fontPreview.js +6 -38
  26. package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
  27. package/kits/physics-2d/engine/blueprint.js +28 -0
  28. package/kits/physics-2d/engine/fonts.js +125 -23
  29. package/kits/physics-2d/engine/paletteField.jsx +235 -0
  30. package/kits/physics-2d/engine/physics/controls.js +5 -81
  31. package/kits/physics-2d/engine/popoverDismiss.js +17 -0
  32. package/kits/physics-2d/engine/scene.js +12 -3
  33. package/kits/physics-2d/engine/spriteField.jsx +2 -15
  34. package/kits/physics-2d/engine/tap.js +90 -0
  35. package/kits/physics-2d/engine/text.js +94 -0
  36. package/kits/physics-2d/engine/ui.jsx +20 -0
  37. package/kits/physics-2d/engine/ui.module.css +10 -2
  38. package/kits/physics-3d/castle.json +4 -2
  39. package/package.json +7 -3
  40. package/dist/shell/assets/index-BWOEraUy.js +0 -447
  41. package/dist/shell/assets/index-BkVF1OXc.css +0 -1
@@ -0,0 +1,270 @@
1
+ import React from 'react';
2
+ import { PaletteColorField } from '../engine/paletteField';
3
+ import { NumberField, Panel } from '../engine/ui';
4
+ import { AutoFields, overrideProps } from '../engine/autoInspector';
5
+ import { UNIT } from '../engine/propertyRanges';
6
+ import { inActorWorldSpace } from '../engine/physics/controls';
7
+
8
+ // Shared chrome for Text and Sprite: fill, border, radius, padding, drop
9
+ // shadow, and opacity. Draws behind content. Sprite/Video paint this themselves
10
+ // before their blit so Style can't land on top when its key is after theirs in
11
+ // JSON; Text relies on forEachBehavior running Style first.
12
+ // Padding insets Text wrap / Sprite blit; opacity multiplies on Style and is
13
+ // also read by Text/Sprite so content fades with the chrome.
14
+ //
15
+ // Drop shadow mirrors CSS `filter: drop-shadow()`: offset X/Y, blur, color.
16
+ // `shadowColor: ''` means no shadow.
17
+ export class Style {
18
+ static behaviorName = 'Style';
19
+
20
+ static defaultProps = {
21
+ background: '',
22
+ border: '',
23
+ borderWidth: 0,
24
+ borderRadius: 0,
25
+ padding: 0,
26
+ shadowX: 0,
27
+ shadowY: 0,
28
+ shadowBlur: 0,
29
+ shadowColor: '',
30
+ opacity: 1,
31
+ };
32
+
33
+ static propertyMeta = {
34
+ borderWidth: { min: 0, max: 24, step: 0.5 },
35
+ borderRadius: { min: 0, max: 200, step: 1 },
36
+ padding: { min: 0, step: 1 },
37
+ shadowX: { step: 1 },
38
+ shadowY: { step: 1 },
39
+ shadowBlur: { min: 0, max: 64, step: 1 },
40
+ opacity: UNIT,
41
+ };
42
+
43
+ constructor(props) {
44
+ this.props = props;
45
+ }
46
+
47
+ static summary(props) {
48
+ const bits = [];
49
+ if (props.background) bits.push('fill');
50
+ if (props.border && props.borderWidth > 0) bits.push('border');
51
+ if (props.borderRadius > 0) bits.push(`r${Math.round(props.borderRadius)}`);
52
+ if (resolveShadow(props)) bits.push('shadow');
53
+ if (props.opacity != null && props.opacity < 1) bits.push(`${Math.round(props.opacity * 100)}%`);
54
+ return bits.join(' · ');
55
+ }
56
+
57
+ paint(actor, ctx) {
58
+ paintStyleChrome(actor, ctx, this.props);
59
+ }
60
+
61
+ // Match Text's world/screen split so chrome stays on the selectable box in
62
+ // edit mode and on the HUD in play. An enabled Sprite/Video paints Style
63
+ // itself before its blit — skip here so we don't cover that blit when Style
64
+ // runs after in component order. A disabled Sprite still needs Style.draw.
65
+ draw(actor, scene, ctx, options) {
66
+ if (contentPaintsStyle(actor)) return;
67
+ if (actor.components.Text?.space === 'screen') {
68
+ if (!options?.editPlaceholders) return;
69
+ inActorWorldSpace(ctx, actor.components.Layout, (g) => this.paint(actor, g));
70
+ return;
71
+ }
72
+ this.paint(actor, ctx);
73
+ }
74
+
75
+ drawUi(actor, scene, ctx, options) {
76
+ if (contentPaintsStyle(actor)) return;
77
+ if (actor.components.Text?.space !== 'screen') return;
78
+ if (options?.editPlaceholders) return;
79
+ this.paint(actor, ctx);
80
+ }
81
+
82
+ static Inspector({ component, setComponent, override, files }) {
83
+ return (
84
+ <Panel title="Style" overridden={override?.anyOverridden()}>
85
+ <PaletteColorField
86
+ label="Background"
87
+ value={component.background}
88
+ onChange={(background) => setComponent({ background })}
89
+ files={files}
90
+ allowNone
91
+ {...overrideProps(override, 'background')}
92
+ />
93
+ <PaletteColorField
94
+ label="Border"
95
+ value={component.border}
96
+ onChange={(border) => setComponent({ border })}
97
+ files={files}
98
+ allowNone
99
+ {...overrideProps(override, 'border')}
100
+ />
101
+ <NumberField
102
+ label="Border Width"
103
+ value={component.borderWidth}
104
+ onChange={(borderWidth) => setComponent({ borderWidth })}
105
+ min={0}
106
+ max={24}
107
+ step={0.5}
108
+ {...overrideProps(override, 'borderWidth')}
109
+ />
110
+ <NumberField
111
+ label="Border Radius"
112
+ value={component.borderRadius}
113
+ onChange={(borderRadius) => setComponent({ borderRadius })}
114
+ min={0}
115
+ max={200}
116
+ step={1}
117
+ {...overrideProps(override, 'borderRadius')}
118
+ />
119
+ <PaletteColorField
120
+ label="Shadow Color"
121
+ value={component.shadowColor}
122
+ onChange={(shadowColor) => setComponent({ shadowColor })}
123
+ files={files}
124
+ allowNone
125
+ {...overrideProps(override, 'shadowColor')}
126
+ />
127
+ <AutoFields
128
+ defaultProps={Style.defaultProps}
129
+ meta={Style.propertyMeta}
130
+ component={component}
131
+ setComponent={setComponent}
132
+ only={['shadowX', 'shadowY', 'shadowBlur', 'padding', 'opacity']}
133
+ override={override}
134
+ />
135
+ </Panel>
136
+ );
137
+ }
138
+ }
139
+
140
+ /** Paint sibling Style chrome behind content. No-op when Style is absent/off. */
141
+ export function paintActorStyle(actor, ctx) {
142
+ const props = actor.components?.Style;
143
+ if (!props || props.$enabled === false) return;
144
+ paintStyleChrome(actor, ctx, props);
145
+ }
146
+
147
+ function contentPaintsStyle(actor) {
148
+ const sprite = actor.components?.Sprite;
149
+ const video = actor.components?.Video;
150
+ return (
151
+ (Boolean(sprite) && sprite.$enabled !== false) || (Boolean(video) && video.$enabled !== false)
152
+ );
153
+ }
154
+
155
+ function paintStyleChrome(actor, ctx, props) {
156
+ const layout = actor.components.Layout;
157
+ if (!layout || !props) return;
158
+ const { background, border, borderWidth, borderRadius, opacity } = props;
159
+ const alpha = Number.isFinite(opacity) ? Math.min(1, Math.max(0, opacity)) : 1;
160
+ const bw = Math.max(0, Number(borderWidth) || 0);
161
+ const radius = clampRadius(borderRadius, layout.width, layout.height);
162
+ const shadow = resolveShadow(props);
163
+ const hasFill = Boolean(background);
164
+ const hasBorder = Boolean(border) && bw > 0;
165
+ if (!hasFill && !hasBorder && !shadow) return;
166
+
167
+ ctx.save();
168
+ ctx.globalAlpha *= alpha;
169
+ if (shadow) {
170
+ ctx.shadowColor = shadow.color;
171
+ ctx.shadowBlur = shadow.blur;
172
+ ctx.shadowOffsetX = shadow.x;
173
+ ctx.shadowOffsetY = shadow.y;
174
+ }
175
+ if (hasFill) {
176
+ ctx.fillStyle = background;
177
+ fillRoundRect(ctx, layout.x, layout.y, layout.width, layout.height, radius);
178
+ } else if (shadow) {
179
+ // Shadow needs a filled path; use a near-invisible fill when there's no
180
+ // background so the drop shadow still casts.
181
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.01)';
182
+ fillRoundRect(ctx, layout.x, layout.y, layout.width, layout.height, radius);
183
+ }
184
+ if (shadow) {
185
+ ctx.shadowColor = 'transparent';
186
+ ctx.shadowBlur = 0;
187
+ ctx.shadowOffsetX = 0;
188
+ ctx.shadowOffsetY = 0;
189
+ }
190
+ if (hasBorder) {
191
+ ctx.strokeStyle = border;
192
+ ctx.lineWidth = bw;
193
+ const inset = bw / 2;
194
+ // Inset the stroke so it sits inside the fill edge; shrink the radius by
195
+ // the same amount so the outer curve still matches the fill's corner.
196
+ strokeRoundRect(
197
+ ctx,
198
+ layout.x + inset,
199
+ layout.y + inset,
200
+ Math.max(0, layout.width - bw),
201
+ Math.max(0, layout.height - bw),
202
+ Math.max(0, radius - inset)
203
+ );
204
+ }
205
+ ctx.restore();
206
+ }
207
+
208
+ // Non-empty shadowColor enables the drop shadow. Legacy `shadow: true` (pre
209
+ // drop-shadow props) keeps the old soft black defaults so existing decks
210
+ // don't go flat.
211
+ function resolveShadow(props) {
212
+ if (props.shadowColor) {
213
+ return {
214
+ color: props.shadowColor,
215
+ blur: Math.max(0, Number(props.shadowBlur) || 0),
216
+ x: Number(props.shadowX) || 0,
217
+ y: Number(props.shadowY) || 0,
218
+ };
219
+ }
220
+ if (props.shadow) {
221
+ return { color: 'rgba(0, 0, 0, 0.35)', blur: 14, x: 0, y: 5 };
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function clampRadius(value, width, height) {
227
+ const r = Math.max(0, Number(value) || 0);
228
+ if (!(width > 0) || !(height > 0)) return 0;
229
+ return Math.min(r, width / 2, height / 2);
230
+ }
231
+
232
+ function roundRectPath(ctx, x, y, width, height, radius) {
233
+ ctx.beginPath();
234
+ if (radius > 0 && ctx.roundRect) ctx.roundRect(x, y, width, height, radius);
235
+ else ctx.rect(x, y, width, height);
236
+ }
237
+
238
+ function fillRoundRect(ctx, x, y, width, height, radius) {
239
+ roundRectPath(ctx, x, y, width, height, radius);
240
+ ctx.fill();
241
+ }
242
+
243
+ function strokeRoundRect(ctx, x, y, width, height, radius) {
244
+ roundRectPath(ctx, x, y, width, height, radius);
245
+ ctx.stroke();
246
+ }
247
+
248
+ /** Padding from a sibling Style, or 0. */
249
+ export function stylePadding(actor) {
250
+ const pad = actor.components.Style?.padding;
251
+ return Number.isFinite(pad) ? Math.max(0, pad) : 0;
252
+ }
253
+
254
+ /** Layout box inset by Style.padding, or the layout itself when padding is 0. */
255
+ export function styleContentBox(actor, layout) {
256
+ const pad = stylePadding(actor);
257
+ if (!(pad > 0) || !layout) return layout;
258
+ return {
259
+ x: layout.x + pad,
260
+ y: layout.y + pad,
261
+ width: Math.max(0, layout.width - pad * 2),
262
+ height: Math.max(0, layout.height - pad * 2),
263
+ };
264
+ }
265
+
266
+ /** Opacity from a sibling Style, or 1. */
267
+ export function styleOpacity(actor) {
268
+ const opacity = actor.components.Style?.opacity;
269
+ return Number.isFinite(opacity) ? Math.min(1, Math.max(0, opacity)) : 1;
270
+ }
@@ -0,0 +1,213 @@
1
+ import { writeFile } from 'castle-web-sdk';
2
+ import React from 'react';
3
+ import { drawTextBlock } from '../engine/text';
4
+ import { PaletteColorField } from '../engine/paletteField';
5
+ import {
6
+ DECK_FONT_MODULE,
7
+ OFFICIAL_FONTS,
8
+ collectTextFontsFromFiles,
9
+ ensureOfficialFace,
10
+ fontsGeneratedSource,
11
+ generatedFontName,
12
+ orderedDeckFonts,
13
+ resolveTextFont,
14
+ themeFontName,
15
+ } from '../engine/fonts';
16
+ import { parseThemeData, THEME_STYLE_PATH } from '../engine/palettes';
17
+ import { FieldNote, NumberField, Panel, SelectField, TextAreaField } from '../engine/ui';
18
+ import { AutoFields, overrideProps } from '../engine/autoInspector';
19
+ import { inActorWorldSpace } from '../engine/physics/controls';
20
+ import { styleOpacity, stylePadding } from './Style';
21
+
22
+ // In-scene (or screen-fixed) text drawn into the Layout box. Word-wraps to the
23
+ // box minus Style.padding; lines may overflow vertically with no clipping.
24
+ // Colors are opaque deck-palette hexes. Chrome (fill/border/shadow/opacity)
25
+ // lives on the sibling Style behavior. Dynamic text is
26
+ // `actor.components.Text.text = …` from deck code — no $var interpolation.
27
+ export class Text {
28
+ static behaviorName = 'Text';
29
+
30
+ static defaultProps = {
31
+ text: 'Your text goes here',
32
+ font: '',
33
+ size: 24,
34
+ color: '#131313',
35
+ align: 'left',
36
+ verticalAlign: 'top',
37
+ lineHeight: 1.25,
38
+ space: 'world',
39
+ outlineWidth: 0,
40
+ outlineColor: '#ffffff',
41
+ };
42
+
43
+ static propertyMeta = {
44
+ size: { min: 4, max: 160, step: 1 },
45
+ lineHeight: { min: 0.8, max: 3, step: 0.05 },
46
+ outlineWidth: { min: 0, max: 12, step: 0.5 },
47
+ };
48
+
49
+ constructor(props) {
50
+ this.props = props;
51
+ }
52
+
53
+ static summary(props) {
54
+ const first = String(props?.text ?? '').split(/\r?\n/)[0] ?? '';
55
+ return first;
56
+ }
57
+
58
+ paint(actor, scene, ctx) {
59
+ const layout = actor.components.Layout;
60
+ if (!layout) return;
61
+ const {
62
+ text,
63
+ font,
64
+ size,
65
+ color,
66
+ align,
67
+ verticalAlign,
68
+ lineHeight,
69
+ outlineWidth,
70
+ outlineColor,
71
+ } = this.props;
72
+
73
+ ctx.save();
74
+ ctx.globalAlpha *= styleOpacity(actor);
75
+ drawTextBlock(ctx, {
76
+ x: layout.x,
77
+ y: layout.y,
78
+ width: layout.width,
79
+ height: layout.height,
80
+ text,
81
+ size,
82
+ font: resolveTextFont(font),
83
+ color,
84
+ align,
85
+ verticalAlign,
86
+ padding: stylePadding(actor),
87
+ lineHeight,
88
+ outlineWidth,
89
+ outlineColor,
90
+ });
91
+ ctx.restore();
92
+ }
93
+
94
+ // World-space: paint in the camera+rotation pass. Screen-space in edit mode
95
+ // also paints here (on the selectable Layout box) so pan/zoom don't detach
96
+ // the glyph from its handles — see drawUi for play-mode screen paint.
97
+ draw(actor, scene, ctx, options) {
98
+ if (this.props.space === 'screen') {
99
+ if (!options?.editPlaceholders) return;
100
+ inActorWorldSpace(ctx, actor.components.Layout, (g) => this.paint(actor, scene, g));
101
+ return;
102
+ }
103
+ this.paint(actor, scene, ctx);
104
+ }
105
+
106
+ drawUi(actor, scene, ctx, options) {
107
+ if (this.props.space !== 'screen') return;
108
+ if (options?.editPlaceholders) return;
109
+ this.paint(actor, scene, ctx);
110
+ }
111
+
112
+ static Inspector({ component, setComponent, override, files }) {
113
+ const fontOptions = [
114
+ { value: '', label: 'Deck default' },
115
+ ...OFFICIAL_FONTS.map((face) => ({ value: face.name, label: face.label || face.name })),
116
+ ];
117
+
118
+ function setFont(font) {
119
+ setComponent({ font });
120
+ // Install into document.fonts now — fontsReady() only runs the faces that
121
+ // were in fonts.generated.js at module load, so a newly picked face would
122
+ // otherwise stay as the fallback until the panel reloads (play is a fresh
123
+ // load, which is why it looks right there).
124
+ if (font) void ensureOfficialFace(font);
125
+ const primary =
126
+ themeFontName(parseThemeData(files?.[THEME_STYLE_PATH])) ?? generatedFontName;
127
+ const fonts = orderedDeckFonts(primary, [...collectTextFontsFromFiles(files), font]);
128
+ void writeFile(
129
+ DECK_FONT_MODULE,
130
+ fonts.length ? fontsGeneratedSource(primary ?? fonts[0], fonts) : fontsGeneratedSource(null, [])
131
+ ).catch(() => {});
132
+ }
133
+
134
+ return (
135
+ <Panel title="Text" overridden={override?.anyOverridden()}>
136
+ <TextAreaField
137
+ label="Text"
138
+ value={component.text}
139
+ onChange={(text) => setComponent({ text })}
140
+ {...overrideProps(override, 'text')}
141
+ />
142
+ <SelectField
143
+ label="Font"
144
+ value={component.font ?? ''}
145
+ onChange={setFont}
146
+ options={fontOptions}
147
+ {...overrideProps(override, 'font')}
148
+ />
149
+ <FieldNote>Picking a face adds it to fonts.generated.js for publish.</FieldNote>
150
+ <PaletteColorField
151
+ label="Color"
152
+ value={component.color}
153
+ onChange={(color) => setComponent({ color })}
154
+ files={files}
155
+ allowNone
156
+ {...overrideProps(override, 'color')}
157
+ />
158
+ <PaletteColorField
159
+ label="Text Outline"
160
+ value={component.outlineColor}
161
+ onChange={(outlineColor) => setComponent({ outlineColor })}
162
+ files={files}
163
+ allowNone
164
+ {...overrideProps(override, 'outlineColor')}
165
+ />
166
+ <NumberField
167
+ label="Outline Width"
168
+ value={component.outlineWidth}
169
+ onChange={(outlineWidth) => setComponent({ outlineWidth })}
170
+ min={0}
171
+ max={12}
172
+ step={0.5}
173
+ {...overrideProps(override, 'outlineWidth')}
174
+ />
175
+ <SelectField
176
+ label="Align"
177
+ value={component.align}
178
+ onChange={(align) => setComponent({ align })}
179
+ options={['left', 'center', 'right']}
180
+ {...overrideProps(override, 'align')}
181
+ />
182
+ <SelectField
183
+ label="Vertical"
184
+ value={component.verticalAlign}
185
+ onChange={(verticalAlign) => setComponent({ verticalAlign })}
186
+ options={['top', 'middle', 'bottom']}
187
+ {...overrideProps(override, 'verticalAlign')}
188
+ />
189
+ <SelectField
190
+ label="Space"
191
+ value={component.space}
192
+ onChange={(space) => setComponent({ space })}
193
+ options={[
194
+ { value: 'world', label: 'world' },
195
+ { value: 'screen', label: 'screen' },
196
+ ]}
197
+ {...overrideProps(override, 'space')}
198
+ />
199
+ <FieldNote>
200
+ world scrolls with the camera; screen stays fixed on the card (HUD).
201
+ </FieldNote>
202
+ <AutoFields
203
+ defaultProps={Text.defaultProps}
204
+ meta={Text.propertyMeta}
205
+ component={component}
206
+ setComponent={setComponent}
207
+ exclude={['text', 'font', 'color', 'outlineColor', 'outlineWidth', 'align', 'verticalAlign', 'space']}
208
+ override={override}
209
+ />
210
+ </Panel>
211
+ );
212
+ }
213
+ }
@@ -6,6 +6,7 @@ import { spriteDestRect } from '../engine/spriteGeometry';
6
6
  import { FileField, Panel, SelectField } from '../engine/ui';
7
7
  import { AutoFields, overrideProps } from '../engine/autoInspector';
8
8
  import { UNIT } from '../engine/propertyRanges';
9
+ import { paintActorStyle, styleContentBox, styleOpacity } from './Style';
9
10
 
10
11
  // Plays a video file (.mp4 / .webm / .mov / .m4v) inside the actor's Layout box.
11
12
  //
@@ -59,23 +60,26 @@ export class Video {
59
60
  if (actor.runtime?.collected) return;
60
61
  const layout = actor.components.Layout;
61
62
  if (!layout) return;
63
+ paintActorStyle(actor, ctx);
62
64
  const element = this.element(actor);
63
65
  // HAVE_CURRENT_DATA: there is a frame to draw. Below it the element has
64
66
  // nothing decoded yet and drawImage would throw.
65
67
  if (!element || element.readyState < 2) return;
66
68
  const size = { width: element.videoWidth, height: element.videoHeight };
67
69
  if (!size.width || !size.height) return;
68
- const dest = spriteDestRect(this.props.mode, layout, size);
70
+ const box = styleContentBox(actor, layout);
71
+ const dest = spriteDestRect(this.props.mode, box, size);
69
72
  const covering = this.props.mode === 'cover';
73
+ ctx.save();
74
+ ctx.globalAlpha *= styleOpacity(actor);
70
75
  if (covering) {
71
76
  // `cover` fills the box by overflowing it; the overflow is cropped.
72
- ctx.save();
73
77
  ctx.beginPath();
74
- ctx.rect(layout.x, layout.y, layout.width, layout.height);
78
+ ctx.rect(box.x, box.y, box.width, box.height);
75
79
  ctx.clip();
76
80
  }
77
81
  ctx.drawImage(element, dest.x, dest.y, dest.width, dest.height);
78
- if (covering) ctx.restore();
82
+ ctx.restore();
79
83
  }
80
84
 
81
85
  // Used by the inspector's "stop" affordance and by game code that wants the
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "Text",
3
+ "actors": [
4
+ {
5
+ "components": {
6
+ "Layout": { "width": 280, "height": 50, "z": 5 },
7
+ "Style": { "padding": 8 },
8
+ "Text": { "text": "Your text goes here" }
9
+ }
10
+ }
11
+ ]
12
+ }
@@ -34,7 +34,8 @@
34
34
  "new": "New scene",
35
35
  "icon": "globe",
36
36
  "editor": "kit",
37
- "data": true
37
+ "data": true,
38
+ "width": 800
38
39
  },
39
40
  {
40
41
  "ext": ".sprite",
@@ -42,14 +43,16 @@
42
43
  "new": "New sprite",
43
44
  "icon": "images-square",
44
45
  "editor": "kit",
45
- "data": true
46
+ "data": true,
47
+ "width": 360
46
48
  },
47
49
  {
48
50
  "ext": ".pxart",
49
51
  "label": "Sprite",
50
52
  "icon": "images-square",
51
53
  "editor": "kit",
52
- "data": true
54
+ "data": true,
55
+ "width": 360
53
56
  },
54
57
  {
55
58
  "ext": ".jsx",
@@ -62,7 +65,8 @@
62
65
  "label": "Theme",
63
66
  "icon": "palette",
64
67
  "editor": "kit",
65
- "data": true
68
+ "data": true,
69
+ "width": 400
66
70
  }
67
71
  ],
68
72
  "defaultPlayFile": "scenes/main.scene"
@@ -72,11 +76,11 @@
72
76
  "main": "main.jsx",
73
77
  "autoUpdateWhenImported": true,
74
78
  "title": "physics-2d",
75
- "publishedVersion": "2026-09-10T22:05:01.629Z",
79
+ "publishedVersion": "2026-09-11T20:21:14.346Z",
76
80
  "imports": {
77
81
  "castle.base": {
78
82
  "deckId": "yRcmH4_aYllE",
79
- "version": "2026-09-10T00:56:59.388Z"
83
+ "version": "2026-09-11T20:21:04.335Z"
80
84
  }
81
85
  }
82
86
  }
@@ -27,12 +27,14 @@ import {
27
27
  screenToCard,
28
28
  setActorComponent,
29
29
  } from '../engine/scene';
30
+ import { fontsReady, ensureOfficialFace } from '../engine/fonts';
30
31
  import {
31
32
  addActorWithBlueprint,
32
33
  blueprintDropXY,
33
34
  cascadeDeleteBlueprint,
34
35
  countBlueprintInstances,
35
36
  createBlueprint,
37
+ forkBlueprint,
36
38
  getBlueprintTemplate,
37
39
  importAliasOf,
38
40
  isBlueprintPath,
@@ -679,6 +681,22 @@ export function SceneEditor({
679
681
  void writeFile(result.blueprintFile.path, result.blueprintFile.text);
680
682
  commitScene(result.sceneData);
681
683
  };
684
+ const onForkBlueprint = () => {
685
+ // New deck-owned file from the selected blueprint's template. Instances of
686
+ // the source are untouched — unlike new-from-actor, which reparents one.
687
+ if (!selectedBlueprintPath) return;
688
+ const result = forkBlueprint(files, selectedBlueprintPath);
689
+ if (!result) return;
690
+ if (onChangeFile) {
691
+ if (result.drawingFile) onChangeFile(result.drawingFile.path, result.drawingFile.text);
692
+ onChangeFile(result.blueprintFile.path, result.blueprintFile.text);
693
+ } else {
694
+ if (result.drawingFile) void writeFile(result.drawingFile.path, result.drawingFile.text);
695
+ void writeFile(result.blueprintFile.path, result.blueprintFile.text);
696
+ }
697
+ onSelectBlueprint(result.blueprintFile.path);
698
+ setFocusName(result.blueprintFile.path);
699
+ };
682
700
  return (
683
701
  <>
684
702
  <EditorHeader
@@ -846,6 +864,7 @@ export function SceneEditor({
846
864
  files={files}
847
865
  onEditSprite={onEditSprite}
848
866
  onViewSource={onViewSource}
867
+ onFork={onForkBlueprint}
849
868
  autoFocusName={focusName === selectedBlueprintPath}
850
869
  onNameFocused={() => setFocusName(null)}
851
870
  onRename={blueprintActions.rename}
@@ -1745,6 +1764,17 @@ function useScenePlayLoop({
1745
1764
  const canvas = canvasRef.current;
1746
1765
  const ctx = canvas.getContext('2d');
1747
1766
  if (!ctx) return undefined;
1767
+ // Play pane awaits this before the first frame; the editor must kick the
1768
+ // same install or canvas text (Text actors, badges) keeps the fallback face.
1769
+ // The loop keeps drawing, so the face snaps in once document.fonts settles.
1770
+ void fontsReady();
1771
+ // Faces chosen mid-session (or present on actors before fonts.generated.js
1772
+ // was reloaded) are not in the eager module fontsReady reads — pull those
1773
+ // in so edit mode matches play without a panel reload.
1774
+ for (const actor of sceneData.actors ?? []) {
1775
+ const font = actor?.components?.Text?.font;
1776
+ if (font) void ensureOfficialFace(font);
1777
+ }
1748
1778
  const viewport = isPlaying ? undefined : editViewportForZoom(editCameraRef.current.zoom);
1749
1779
  configureSceneCanvas(canvas, ctx, viewport);
1750
1780
  let raf = 0;
@@ -1956,6 +1986,7 @@ function BlueprintInspector({
1956
1986
  files,
1957
1987
  onEditSprite,
1958
1988
  onViewSource,
1989
+ onFork,
1959
1990
  autoFocusName,
1960
1991
  onNameFocused,
1961
1992
  onRename,
@@ -2028,6 +2059,16 @@ function BlueprintInspector({
2028
2059
  </span>
2029
2060
  </div>
2030
2061
  </div>
2062
+ {onFork ? (
2063
+ <button
2064
+ type="button"
2065
+ className={styles.instanceForkButton}
2066
+ onClick={onFork}
2067
+ title="New deck blueprint copied from this one">
2068
+ <Icon name="clone" />
2069
+ <span>Fork blueprint</span>
2070
+ </button>
2071
+ ) : null}
2031
2072
  </div>
2032
2073
  <ActorInspector
2033
2074
  ownerKey={blueprintPath}