castle-web-cli 0.4.177 → 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.
- package/dist/agent-failures.js +4 -4
- package/dist/agent-prompts.d.ts +9 -1
- package/dist/agent-prompts.js +15 -2
- package/dist/agent.js +98 -34
- package/dist/deckLocatorShape.d.ts +16 -0
- package/dist/deckLocatorShape.js +28 -0
- package/dist/editorConfig.d.ts +1 -0
- package/dist/shell/assets/index-C890YbXX.css +1 -0
- package/dist/shell/assets/index-z6shfW4S.js +447 -0
- package/dist/shell/index.html +2 -2
- package/kits/base/CLAUDE.md +3 -0
- package/kits/base/castle.json +21 -11
- package/kits/physics-2d/CLAUDE.md +41 -8
- package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
- package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
- package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
- package/kits/physics-2d/behaviors/Style.jsx +270 -0
- package/kits/physics-2d/behaviors/Text.jsx +213 -0
- package/kits/physics-2d/behaviors/Video.jsx +8 -4
- package/kits/physics-2d/blueprints/text.scene +12 -0
- package/kits/physics-2d/castle.json +10 -6
- package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
- package/kits/physics-2d/editors/deckFont.js +65 -55
- package/kits/physics-2d/editors/fontPreview.js +6 -38
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
- package/kits/physics-2d/engine/blueprint.js +28 -0
- package/kits/physics-2d/engine/fonts.js +125 -23
- package/kits/physics-2d/engine/paletteField.jsx +235 -0
- package/kits/physics-2d/engine/physics/controls.js +5 -81
- package/kits/physics-2d/engine/popoverDismiss.js +17 -0
- package/kits/physics-2d/engine/scene.js +12 -3
- package/kits/physics-2d/engine/spriteField.jsx +2 -15
- package/kits/physics-2d/engine/tap.js +90 -0
- package/kits/physics-2d/engine/text.js +94 -0
- package/kits/physics-2d/engine/ui.jsx +20 -0
- package/kits/physics-2d/engine/ui.module.css +10 -2
- package/kits/physics-3d/castle.json +4 -2
- package/package.json +1 -1
- package/dist/shell/assets/index-BWOEraUy.js +0 -447
- 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
|
|
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(
|
|
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
|
-
|
|
82
|
+
ctx.restore();
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// Used by the inspector's "stop" affordance and by game code that wants the
|
|
@@ -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-
|
|
79
|
+
"publishedVersion": "2026-09-11T20:21:14.346Z",
|
|
76
80
|
"imports": {
|
|
77
81
|
"castle.base": {
|
|
78
82
|
"deckId": "yRcmH4_aYllE",
|
|
79
|
-
"version": "2026-09-
|
|
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}
|