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.
- 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 +125 -40
- 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-C5DS_7UM.js +447 -0
- package/dist/shell/assets/index-CoU3ETYM.css +1 -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
|
@@ -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,
|
|
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
|
|
144
|
+
// Clip cover's overflow to the snapped content box (undone by restore).
|
|
137
145
|
if (covering) {
|
|
138
|
-
const lx0 = Math.round(
|
|
139
|
-
const ly0 = Math.round(
|
|
140
|
-
const lx1 = Math.round((
|
|
141
|
-
const ly1 = Math.round((
|
|
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
|
|
162
|
+
// Rotated: clip cover's overflow to the content box in card units.
|
|
155
163
|
ctx.save();
|
|
156
164
|
ctx.beginPath();
|
|
157
|
-
ctx.rect(
|
|
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
|
|
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
|
}
|