castle-web-cli 0.4.177 → 0.4.179

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 (40) 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 +125 -40
  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/shell/assets/index-C5DS_7UM.js +447 -0
  9. package/dist/shell/assets/index-CoU3ETYM.css +1 -0
  10. package/dist/shell/index.html +2 -2
  11. package/kits/base/CLAUDE.md +3 -0
  12. package/kits/base/castle.json +21 -11
  13. package/kits/physics-2d/CLAUDE.md +41 -8
  14. package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
  15. package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
  16. package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
  17. package/kits/physics-2d/behaviors/Style.jsx +270 -0
  18. package/kits/physics-2d/behaviors/Text.jsx +213 -0
  19. package/kits/physics-2d/behaviors/Video.jsx +8 -4
  20. package/kits/physics-2d/blueprints/text.scene +12 -0
  21. package/kits/physics-2d/castle.json +10 -6
  22. package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
  23. package/kits/physics-2d/editors/deckFont.js +65 -55
  24. package/kits/physics-2d/editors/fontPreview.js +6 -38
  25. package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
  26. package/kits/physics-2d/engine/blueprint.js +28 -0
  27. package/kits/physics-2d/engine/fonts.js +125 -23
  28. package/kits/physics-2d/engine/paletteField.jsx +235 -0
  29. package/kits/physics-2d/engine/physics/controls.js +5 -81
  30. package/kits/physics-2d/engine/popoverDismiss.js +17 -0
  31. package/kits/physics-2d/engine/scene.js +12 -3
  32. package/kits/physics-2d/engine/spriteField.jsx +2 -15
  33. package/kits/physics-2d/engine/tap.js +90 -0
  34. package/kits/physics-2d/engine/text.js +94 -0
  35. package/kits/physics-2d/engine/ui.jsx +20 -0
  36. package/kits/physics-2d/engine/ui.module.css +10 -2
  37. package/kits/physics-3d/castle.json +4 -2
  38. package/package.json +1 -1
  39. package/dist/shell/assets/index-BWOEraUy.js +0 -447
  40. package/dist/shell/assets/index-BkVF1OXc.css +0 -1
@@ -7,6 +7,7 @@ import { Panel, SelectField } from '../engine/ui';
7
7
  import { SpriteField } from '../engine/spriteField';
8
8
  import { AutoFields, overrideProps } from '../engine/autoInspector';
9
9
  import { parseTint, tintCanvas } from './tint';
10
+ import { paintActorStyle, styleContentBox, styleOpacity } from './Style';
10
11
 
11
12
  // Runtime behavior for a deck's 2D art: the pixel-art `.pxart` format, or an
12
13
  // image file (png / jpg / gif / webp / svg) the deck uploaded. Both come out of
@@ -81,6 +82,9 @@ export class Sprite {
81
82
  if (actor.runtime?.collected) return;
82
83
  const layout = actor.components.Layout;
83
84
  if (!layout) return;
85
+ // Style chrome first so background/border sit behind the blit even when
86
+ // the Style key is after Sprite on the components object.
87
+ paintActorStyle(actor, ctx);
84
88
  const sprite = this.resolveSprite(scene);
85
89
  if (!sprite) return;
86
90
  const total = artFrameCount(sprite);
@@ -99,6 +103,8 @@ export class Sprite {
99
103
  const smoothing =
100
104
  isVectorRenderer(sprite.renderer) || sprite.cornerRadius > 0 || isImageArt(sprite);
101
105
 
106
+ const box = styleContentBox(actor, layout);
107
+
102
108
  // Snap the destination rect to whole device pixels so tiles that abut
103
109
  // exactly in card units (e.g. adjacent 100-unit-wide tiles) land on the
104
110
  // same device-pixel edge instead of each getting its own antialiased
@@ -117,7 +123,9 @@ export class Sprite {
117
123
  // Everything downstream (snapping, tiling's clip box) works off `dest`: the
118
124
  // Layout box itself for every mode except `fit`/`cover`, which resize to the
119
125
  // art's own aspect ratio (see `spriteDestRect`).
120
- const dest = spriteDestRect(this.props.mode, layout, artSize);
126
+ const dest = spriteDestRect(this.props.mode, box, artSize);
127
+ ctx.save();
128
+ ctx.globalAlpha *= styleOpacity(actor);
121
129
  if (transform.b === 0 && transform.c === 0) {
122
130
  const x0 = dest.x * transform.a + transform.e;
123
131
  const y0 = dest.y * transform.d + transform.f;
@@ -133,12 +141,12 @@ export class Sprite {
133
141
  if (tiling) {
134
142
  drawTilesSnapped(ctx, canvas, artSize, this.props.tileSize, dest, transform, x0, y0, rx0, ry0, rx1, ry1);
135
143
  } else {
136
- // Clip cover's overflow to the snapped Layout box (undone by restore).
144
+ // Clip cover's overflow to the snapped content box (undone by restore).
137
145
  if (covering) {
138
- const lx0 = Math.round(layout.x * transform.a + transform.e);
139
- const ly0 = Math.round(layout.y * transform.d + transform.f);
140
- const lx1 = Math.round((layout.x + layout.width) * transform.a + transform.e);
141
- const ly1 = Math.round((layout.y + layout.height) * transform.d + transform.f);
146
+ const lx0 = Math.round(box.x * transform.a + transform.e);
147
+ const ly0 = Math.round(box.y * transform.d + transform.f);
148
+ const lx1 = Math.round((box.x + box.width) * transform.a + transform.e);
149
+ const ly1 = Math.round((box.y + box.height) * transform.d + transform.f);
142
150
  ctx.beginPath();
143
151
  ctx.rect(lx0, ly0, lx1 - lx0, ly1 - ly0);
144
152
  ctx.clip();
@@ -151,10 +159,10 @@ export class Sprite {
151
159
  if (tiling) {
152
160
  drawTilesUnsnapped(ctx, canvas, artSize, this.props.tileSize, dest);
153
161
  } else if (covering) {
154
- // Rotated: clip cover's overflow to the Layout box in card units.
162
+ // Rotated: clip cover's overflow to the content box in card units.
155
163
  ctx.save();
156
164
  ctx.beginPath();
157
- ctx.rect(layout.x, layout.y, layout.width, layout.height);
165
+ ctx.rect(box.x, box.y, box.width, box.height);
158
166
  ctx.clip();
159
167
  ctx.drawImage(canvas, dest.x, dest.y, dest.width, dest.height);
160
168
  ctx.restore();
@@ -163,6 +171,7 @@ export class Sprite {
163
171
  }
164
172
  ctx.imageSmoothingEnabled = prevSmoothing;
165
173
  }
174
+ ctx.restore();
166
175
  }
167
176
 
168
177
  static Inspector({ component, setComponent, sprites, override }) {
@@ -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
  }