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.
- 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/serve.js +3 -1
- 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 +7 -3
- package/dist/shell/assets/index-BWOEraUy.js +0 -447
- package/dist/shell/assets/index-BkVF1OXc.css +0 -1
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
// Writing the deck's
|
|
1
|
+
// Writing the deck's fonts: the generated module, and keeping it honest.
|
|
2
2
|
//
|
|
3
|
-
// `theme.style` records
|
|
4
|
-
// the
|
|
5
|
-
// writes both together
|
|
3
|
+
// `theme.style` records the PRIMARY face; `fonts.generated.js` carries bytes
|
|
4
|
+
// for that face plus any `Text.font` values used in the deck (engine/fonts.js).
|
|
5
|
+
// The Theme picker writes both together for the primary; the Text inspector
|
|
6
|
+
// rewrites the module when a per-actor face is chosen.
|
|
6
7
|
//
|
|
7
8
|
// They can still drift -- someone edits `theme.style` in the code editor, or
|
|
8
9
|
// takes a scene file from another deck. `syncDeckFontModule` is the repair, run
|
|
9
|
-
// once per edit-mode panel load
|
|
10
|
-
// what the record asks for and rewrites the module when they disagree. Play
|
|
11
|
-
// mode never repairs, and never needs to: a published deck's module was on disk
|
|
12
|
-
// when `save-deck` bundled it.
|
|
10
|
+
// once per edit-mode panel load.
|
|
13
11
|
|
|
14
12
|
import * as castleSdk from 'castle-web-sdk';
|
|
15
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DECK_FONT_MODULE,
|
|
15
|
+
collectTextFontsFromFiles,
|
|
16
|
+
fontsGeneratedSource,
|
|
17
|
+
generatedFontName,
|
|
18
|
+
generatedFontNames,
|
|
19
|
+
orderedDeckFonts,
|
|
20
|
+
themeFontName,
|
|
21
|
+
} from '../engine/fonts';
|
|
16
22
|
import { initialFiles } from '../engine/files';
|
|
17
23
|
import { parseThemeData, THEME_STYLE_PATH } from '../engine/palettes';
|
|
18
24
|
|
|
@@ -22,67 +28,71 @@ import { parseThemeData, THEME_STYLE_PATH } from '../engine/palettes';
|
|
|
22
28
|
// nothing, which renders the same and just leaves a stale file behind.
|
|
23
29
|
const deleteFile = castleSdk.deleteFile ?? null;
|
|
24
30
|
|
|
25
|
-
/** The whole content of `fonts.generated.js` for
|
|
26
|
-
export function deckFontModuleSource(name) {
|
|
27
|
-
return
|
|
28
|
-
//
|
|
29
|
-
// \`theme.style\` records that this deck's font is ${name}; this module is what
|
|
30
|
-
// puts ${name}'s bytes in the published bundle. It has to name the face in a
|
|
31
|
-
// static import: reaching it through the name in \`theme.style\` instead would
|
|
32
|
-
// inline all nine Castle faces into the deck, since no bundler can tell which
|
|
33
|
-
// one a runtime lookup reads.
|
|
34
|
-
export { install${name} as installDeckFont } from 'castle-web-fonts';
|
|
35
|
-
export const deckFont = '${name}';
|
|
36
|
-
`;
|
|
31
|
+
/** The whole content of `fonts.generated.js` for a primary + extras. */
|
|
32
|
+
export function deckFontModuleSource(name, extras = []) {
|
|
33
|
+
return fontsGeneratedSource(name, extras);
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
/** An empty module, for the SDK too old to delete a file. Renders as no deck
|
|
40
|
-
* font, same as the file being absent. */
|
|
41
36
|
function emptyModuleSource() {
|
|
42
|
-
return
|
|
43
|
-
//
|
|
44
|
-
// This deck has no font of its own; text renders in the default sans stack.
|
|
45
|
-
export const deckFont = null;
|
|
46
|
-
`;
|
|
37
|
+
return fontsGeneratedSource(null, []);
|
|
47
38
|
}
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
function textFontsFromDeck() {
|
|
41
|
+
return collectTextFontsFromFiles(initialFiles);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Point the deck at `name` as primary, or at no primary when null. Preserves
|
|
45
|
+
* faces Text actors already use. Writes through the host's debounced saver. */
|
|
52
46
|
export function writeDeckFontModule(onChangeFile, name) {
|
|
47
|
+
const extras = textFontsFromDeck();
|
|
53
48
|
if (name) {
|
|
54
|
-
onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(name));
|
|
49
|
+
onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(name, extras));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Primary cleared: keep Text-only faces if any, else remove / empty the module.
|
|
53
|
+
const remaining = orderedDeckFonts(null, extras);
|
|
54
|
+
if (remaining.length) {
|
|
55
|
+
onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(remaining[0], remaining));
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
57
58
|
if (deleteFile) void deleteFile(DECK_FONT_MODULE);
|
|
58
59
|
else onChangeFile(DECK_FONT_MODULE, emptyModuleSource());
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
/** After a Text.font change: ensure that face (plus theme + other Text fonts)
|
|
63
|
+
* is in fonts.generated.js. `pendingFont` covers the value not yet on disk. */
|
|
64
|
+
export function ensureTextFontsInModule(files, pendingFont) {
|
|
65
|
+
const primary = themeFontName(parseThemeData(files?.[THEME_STYLE_PATH])) ?? generatedFontName;
|
|
66
|
+
const extras = collectTextFontsFromFiles(files);
|
|
67
|
+
if (pendingFont) extras.push(pendingFont);
|
|
68
|
+
const next = orderedDeckFonts(primary, extras);
|
|
69
|
+
const prev = generatedFontNames;
|
|
70
|
+
if (next.length === prev.length && next.every((name, i) => name === prev[i])) {
|
|
71
|
+
if ((primary ?? null) === (generatedFontName ?? null)) return null;
|
|
72
|
+
}
|
|
73
|
+
const source = next.length
|
|
74
|
+
? fontsGeneratedSource(primary ?? next[0], next)
|
|
75
|
+
: emptyModuleSource();
|
|
76
|
+
void castleSdk.writeFile(DECK_FONT_MODULE, source).catch(() => {});
|
|
77
|
+
return `Text fonts -- rewriting ${DECK_FONT_MODULE} (${next.join(', ') || 'none'})`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Edit mode only: make `fonts.generated.js` match theme primary + Text fonts.
|
|
67
81
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* file, so the repair goes through the durable save queue like every other
|
|
71
|
-
* write -- see below. Picking "None" in the editor still deletes the file
|
|
72
|
-
* outright; that is a person clicking, with a connection that is up. */
|
|
82
|
+
* Both sides are read from the build-time snapshot. Returns what it did, or
|
|
83
|
+
* null when there was nothing to do. */
|
|
73
84
|
export function syncDeckFontModule() {
|
|
74
85
|
const wanted = themeFontName(parseThemeData(initialFiles[THEME_STYLE_PATH]));
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
void castleSdk
|
|
82
|
-
DECK_FONT_MODULE,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
? `${THEME_STYLE_PATH} asks for ${wanted} -- rewriting ${DECK_FONT_MODULE}`
|
|
86
|
+
const extras = textFontsFromDeck();
|
|
87
|
+
const next = orderedDeckFonts(wanted, extras);
|
|
88
|
+
const prev = generatedFontNames;
|
|
89
|
+
const primaryMatch = (wanted ?? null) === (generatedFontName ?? null);
|
|
90
|
+
const listMatch = next.length === prev.length && next.every((name, i) => name === prev[i]);
|
|
91
|
+
if (primaryMatch && listMatch) return null;
|
|
92
|
+
void castleSdk
|
|
93
|
+
.writeFile(DECK_FONT_MODULE, next.length ? fontsGeneratedSource(wanted ?? next[0], next) : emptyModuleSource())
|
|
94
|
+
.catch(() => {});
|
|
95
|
+
return next.length
|
|
96
|
+
? `fonts -- rewriting ${DECK_FONT_MODULE} (primary ${wanted ?? next[0]}; ${next.join(', ')})`
|
|
87
97
|
: `${THEME_STYLE_PATH} sets no font -- clearing ${DECK_FONT_MODULE}`;
|
|
88
98
|
}
|
|
@@ -4,48 +4,16 @@
|
|
|
4
4
|
// deck must never do: importing all nine would inline 495 KB into every
|
|
5
5
|
// published deck, and the whole physics-2d bundle is under 900 KB.
|
|
6
6
|
//
|
|
7
|
-
// So the specimens are not imported, they are FETCHED
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// place an editor runs, so nothing is lost. A face that doesn't arrive leaves
|
|
12
|
-
// its card in the UI font, and the picker still picks.
|
|
7
|
+
// So the specimens are not imported, they are FETCHED (see ensureOfficialFace).
|
|
8
|
+
// That only works while editing locally — which is the only place an editor
|
|
9
|
+
// runs, so nothing is lost. A face that doesn't arrive leaves its card in the
|
|
10
|
+
// UI font, and the picker still picks.
|
|
13
11
|
|
|
14
12
|
import { useEffect, useState } from 'react';
|
|
15
|
-
import { OFFICIAL_FONTS } from '../engine/fonts';
|
|
13
|
+
import { OFFICIAL_FONTS, ensureOfficialFace } from '../engine/fonts';
|
|
16
14
|
|
|
17
15
|
const FACE_NAMES = OFFICIAL_FONTS.map((face) => face.name);
|
|
18
16
|
|
|
19
|
-
const faceUrl = (name) => `/node_modules/castle-web-fonts/woff2/${name}.woff2`;
|
|
20
|
-
|
|
21
|
-
// Module-scope so the panel's re-renders don't refetch, and so switching files
|
|
22
|
-
// and back is instant. Values are promises resolving to true / false.
|
|
23
|
-
const requests = new Map();
|
|
24
|
-
|
|
25
|
-
function request(name) {
|
|
26
|
-
const pending = requests.get(name);
|
|
27
|
-
if (pending) return pending;
|
|
28
|
-
const load = loadFace(name);
|
|
29
|
-
requests.set(name, load);
|
|
30
|
-
return load;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function loadFace(name) {
|
|
34
|
-
if (typeof FontFace === 'undefined') return false;
|
|
35
|
-
try {
|
|
36
|
-
// Registered under the face's real family name, so a card just asks for
|
|
37
|
-
// `font-family: Tektur`. When the deck's own font is already installed this
|
|
38
|
-
// adds a second face for that family from the identical bytes — which
|
|
39
|
-
// renders identically, and costs one entry in document.fonts.
|
|
40
|
-
const face = new FontFace(name, `url("${faceUrl(name)}") format("woff2")`);
|
|
41
|
-
await face.load();
|
|
42
|
-
document.fonts.add(face);
|
|
43
|
-
return true;
|
|
44
|
-
} catch {
|
|
45
|
-
return false; // no serve, or an install without the package: no specimen.
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
17
|
/** Load every Castle face's specimen and report which have arrived. Cards
|
|
50
18
|
* re-render into their own face as each one lands. */
|
|
51
19
|
export function usePreviewFaces() {
|
|
@@ -53,7 +21,7 @@ export function usePreviewFaces() {
|
|
|
53
21
|
useEffect(() => {
|
|
54
22
|
let live = true;
|
|
55
23
|
for (const name of FACE_NAMES) {
|
|
56
|
-
void
|
|
24
|
+
void ensureOfficialFace(name).then((ok) => {
|
|
57
25
|
if (!ok || !live) return;
|
|
58
26
|
setLoaded((current) => (current.has(name) ? current : new Set(current).add(name)));
|
|
59
27
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import { createPortal } from 'react-dom';
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
3
2
|
import { GRID_RENDERER, RESOLUTION_STEPS, VECTOR_RENDERER } from '../engine/pxart';
|
|
3
|
+
import { PaletteGrid, PalettePager, PalettePopover } from '../engine/paletteField';
|
|
4
4
|
import { cx, Icon, IconButton, Panel, styles } from '../engine/ui';
|
|
5
5
|
|
|
6
|
+
export { NoneSwatch, PaletteGrid, PalettePager, PalettePopover, sameHex } from '../engine/paletteField';
|
|
7
|
+
|
|
6
8
|
// Brush/erase diameters offered by the pixel editors' size sliders.
|
|
7
9
|
export const BRUSH_SIZES = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32];
|
|
8
10
|
|
|
@@ -223,86 +225,6 @@ function ResolutionSelect({ label, value, onChange, disabled = false }) {
|
|
|
223
225
|
);
|
|
224
226
|
}
|
|
225
227
|
|
|
226
|
-
function sameHex(a, b) {
|
|
227
|
-
return !!a && !!b && a.toLowerCase() === b.toLowerCase();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function NoneSwatch({ selected, onSelect }) {
|
|
231
|
-
return (
|
|
232
|
-
<button
|
|
233
|
-
type="button"
|
|
234
|
-
className={cx(styles.swatch, selected && styles.swatchSelected)}
|
|
235
|
-
title="None"
|
|
236
|
-
aria-label="None"
|
|
237
|
-
style={{ position: 'relative', background: 'transparent' }}
|
|
238
|
-
onClick={onSelect}>
|
|
239
|
-
<span
|
|
240
|
-
aria-hidden
|
|
241
|
-
style={{
|
|
242
|
-
position: 'absolute',
|
|
243
|
-
inset: 4,
|
|
244
|
-
background:
|
|
245
|
-
'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
|
|
246
|
-
}}
|
|
247
|
-
/>
|
|
248
|
-
</button>
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Swatch strip shared by the docked sidebar and the compact popover.
|
|
253
|
-
// `keys` paints a sprite's working palette; `hexes` paints a picker list.
|
|
254
|
-
// `wrap` is the unlabeled sprite row; the named official page is an 8-col grid.
|
|
255
|
-
export function PaletteGrid({
|
|
256
|
-
keys,
|
|
257
|
-
hexes,
|
|
258
|
-
palette,
|
|
259
|
-
activeKey,
|
|
260
|
-
activeHex,
|
|
261
|
-
onSelectKey,
|
|
262
|
-
onSelectHex,
|
|
263
|
-
allowNone = false,
|
|
264
|
-
noneSelected = false,
|
|
265
|
-
onSelectNone,
|
|
266
|
-
wrap = false,
|
|
267
|
-
}) {
|
|
268
|
-
const items = hexes
|
|
269
|
-
? hexes.map((hex) => ({ id: hex, hex, selected: sameHex(activeHex, hex), onClick: () => onSelectHex?.(hex) }))
|
|
270
|
-
: (keys ?? []).map((key) => ({
|
|
271
|
-
id: key,
|
|
272
|
-
hex: palette?.[key],
|
|
273
|
-
selected: activeKey === key || sameHex(activeHex, palette?.[key]),
|
|
274
|
-
onClick: () => onSelectKey?.(key),
|
|
275
|
-
}));
|
|
276
|
-
if (!allowNone && !items.length) return null;
|
|
277
|
-
return (
|
|
278
|
-
<div className={wrap ? styles.paletteSpriteRow : styles.palette}>
|
|
279
|
-
{allowNone ? <NoneSwatch selected={noneSelected} onSelect={onSelectNone} /> : null}
|
|
280
|
-
{items.map((item) => (
|
|
281
|
-
<button
|
|
282
|
-
key={item.id}
|
|
283
|
-
type="button"
|
|
284
|
-
className={cx(styles.swatch, item.selected && styles.swatchSelected)}
|
|
285
|
-
title={item.hex}
|
|
286
|
-
style={{ background: item.hex }}
|
|
287
|
-
onClick={item.onClick}
|
|
288
|
-
/>
|
|
289
|
-
))}
|
|
290
|
-
</div>
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function PalettePager({ name, onPage }) {
|
|
295
|
-
return (
|
|
296
|
-
<div className={styles.palettePager}>
|
|
297
|
-
<span className={styles.palettePagerName}>{name}</span>
|
|
298
|
-
<div className={styles.palettePagerBtns}>
|
|
299
|
-
<IconButton icon="chevron-left" label="Previous palette" onClick={() => onPage(-1)} />
|
|
300
|
-
<IconButton icon="chevron-right" label="Next palette" onClick={() => onPage(1)} />
|
|
301
|
-
</div>
|
|
302
|
-
</div>
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
228
|
// Unlabeled sprite-color row (when the sprite has colors, or when fill/stroke
|
|
307
229
|
// needs a none swatch) plus a paged official-palette grid. Paging only changes
|
|
308
230
|
// which named page is shown — it does not write theme.style.
|
|
@@ -349,78 +271,6 @@ export function PaletteSections({
|
|
|
349
271
|
);
|
|
350
272
|
}
|
|
351
273
|
|
|
352
|
-
// Anchored palette + eyedropper popover for compact/mobile paint-strip color tap.
|
|
353
|
-
export function PalettePopover({ open, anchorRef, onClose, children }) {
|
|
354
|
-
const popoverRef = useRef(null);
|
|
355
|
-
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
356
|
-
|
|
357
|
-
useLayoutEffect(() => {
|
|
358
|
-
if (!open || !anchorRef.current) return;
|
|
359
|
-
|
|
360
|
-
function positionPopover() {
|
|
361
|
-
const anchor = anchorRef.current.getBoundingClientRect();
|
|
362
|
-
const rect = popoverRef.current?.getBoundingClientRect();
|
|
363
|
-
const margin = 8;
|
|
364
|
-
const width = rect?.width ?? 231;
|
|
365
|
-
const height = rect?.height ?? 320;
|
|
366
|
-
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
|
|
367
|
-
const maxTop = Math.max(margin, window.innerHeight - height - margin);
|
|
368
|
-
const leftSide = anchor.left - width - margin;
|
|
369
|
-
const rightSide = anchor.right + margin;
|
|
370
|
-
// Prefer the side with room, then clamp a too-wide picker inside this
|
|
371
|
-
// iframe rather than sending it beyond either viewport edge.
|
|
372
|
-
const left =
|
|
373
|
-
leftSide >= margin
|
|
374
|
-
? leftSide
|
|
375
|
-
: rightSide <= maxLeft
|
|
376
|
-
? rightSide
|
|
377
|
-
: Math.min(maxLeft, Math.max(margin, anchor.left + anchor.width / 2 - width / 2));
|
|
378
|
-
const top = Math.min(maxTop, Math.max(margin, anchor.top));
|
|
379
|
-
setPosition({ top, left });
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
positionPopover();
|
|
383
|
-
window.addEventListener('resize', positionPopover);
|
|
384
|
-
window.addEventListener('scroll', positionPopover, true);
|
|
385
|
-
return () => {
|
|
386
|
-
window.removeEventListener('resize', positionPopover);
|
|
387
|
-
window.removeEventListener('scroll', positionPopover, true);
|
|
388
|
-
};
|
|
389
|
-
}, [open, anchorRef]);
|
|
390
|
-
|
|
391
|
-
useEffect(() => {
|
|
392
|
-
if (!open) return undefined;
|
|
393
|
-
function onKeyDown(event) {
|
|
394
|
-
if (event.key === 'Escape') onClose();
|
|
395
|
-
}
|
|
396
|
-
function onPointerDown(event) {
|
|
397
|
-
if (popoverRef.current?.contains(event.target) || anchorRef.current?.contains(event.target)) {
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
onClose();
|
|
401
|
-
}
|
|
402
|
-
window.addEventListener('keydown', onKeyDown);
|
|
403
|
-
window.addEventListener('pointerdown', onPointerDown);
|
|
404
|
-
return () => {
|
|
405
|
-
window.removeEventListener('keydown', onKeyDown);
|
|
406
|
-
window.removeEventListener('pointerdown', onPointerDown);
|
|
407
|
-
};
|
|
408
|
-
}, [open, onClose, anchorRef]);
|
|
409
|
-
|
|
410
|
-
if (!open) return null;
|
|
411
|
-
return createPortal(
|
|
412
|
-
<div
|
|
413
|
-
ref={popoverRef}
|
|
414
|
-
className={styles.palettePopover}
|
|
415
|
-
style={{ top: position.top, left: position.left }}
|
|
416
|
-
role="dialog"
|
|
417
|
-
aria-label="Palette">
|
|
418
|
-
{children}
|
|
419
|
-
</div>,
|
|
420
|
-
document.body
|
|
421
|
-
);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
274
|
// The shape tool's sub-mode picker, shared by the compact paint strip and the
|
|
425
275
|
// wide-layout settings panel. `buttonClass` styles each glyph button for its
|
|
426
276
|
// host strip; `radio` switches the a11y semantics to a radiogroup.
|
|
@@ -696,6 +696,34 @@ export function newBlueprintFromActor(files, behaviors, sceneData, actorId) {
|
|
|
696
696
|
return { sceneData: next, blueprintFile };
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
+
// "Fork blueprint": mint a NEW deck-owned blueprint whose template is a copy of
|
|
700
|
+
// the selected one. Additive — existing instances keep pointing at the source.
|
|
701
|
+
// An import's art is copied into the deck (same as creating from a kit preset)
|
|
702
|
+
// so the fork is editable; a deck-owned source keeps sharing its art refs.
|
|
703
|
+
export function forkBlueprint(files, blueprintPath) {
|
|
704
|
+
const template = getBlueprintTemplate(files, blueprintPath);
|
|
705
|
+
if (!template) return null;
|
|
706
|
+
const existingPaths = new Set(Object.keys(files ?? {}));
|
|
707
|
+
const existingNames = listBlueprints(files)
|
|
708
|
+
.filter((blueprint) => !isImportedPath(blueprint.path))
|
|
709
|
+
.map((blueprint) => blueprint.name);
|
|
710
|
+
const base = `${template.name || 'Blueprint'} (forked)`;
|
|
711
|
+
const name = mintNameFromBase(existingNames, base);
|
|
712
|
+
const path =
|
|
713
|
+
mintPathFromName(existingPaths, name, BLUEPRINTS_DIR, '.scene') ??
|
|
714
|
+
mintBlueprintPath([...existingPaths]);
|
|
715
|
+
const components = structuredClone(template.components ?? {});
|
|
716
|
+
let drawingFile = null;
|
|
717
|
+
if (isImportedPath(blueprintPath)) {
|
|
718
|
+
drawingFile = adoptBuiltinArt(files, components, existingPaths, name);
|
|
719
|
+
}
|
|
720
|
+
for (const props of Object.values(components ?? {})) reprojectRefsIn(props, files, path);
|
|
721
|
+
return {
|
|
722
|
+
blueprintFile: { path, name, components, text: formatBlueprintFileText(name, components) },
|
|
723
|
+
drawingFile,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
699
727
|
// Every `scenes/*.scene` file's parsed data, for callers that need to scan
|
|
700
728
|
// every scene in the deck (cascade delete, the instance-count badge/confirm).
|
|
701
729
|
// Skips unparseable files rather than throwing -- a mid-edit scene with
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
// The deck's
|
|
1
|
+
// The deck's fonts.
|
|
2
2
|
//
|
|
3
|
-
// Two files hold
|
|
3
|
+
// Two files hold them, and the split is the whole point:
|
|
4
4
|
//
|
|
5
|
-
// theme.style { "font": "Piazzolla" } the RECORD --
|
|
6
|
-
// fonts.generated.js
|
|
5
|
+
// theme.style { "font": "Piazzolla" } the RECORD -- primary face
|
|
6
|
+
// fonts.generated.js static re-exports the BYTES
|
|
7
7
|
//
|
|
8
8
|
// `theme.style` is data the engine reads at RUNTIME, so a font name in it is
|
|
9
9
|
// just a string, and turning a runtime string into font bytes means a lookup
|
|
10
10
|
// keyed by that name -- which no bundler can narrow, so it would inline all
|
|
11
|
-
// nine Castle faces (495 KB) into every physics-2d deck
|
|
12
|
-
//
|
|
13
|
-
// instead, so exactly one face ships. See castle-web-fonts' README.
|
|
11
|
+
// nine Castle faces (495 KB) into every physics-2d deck. The generated module
|
|
12
|
+
// names each face in a static import instead, so only the faces in use ship.
|
|
14
13
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// the module is on disk when `save-deck` bundles it.
|
|
14
|
+
// Faces in use = the theme primary plus every `Text.font` on actors in the
|
|
15
|
+
// deck's scenes/blueprints. Edit mode keeps the module honest (editors/deckFont.js).
|
|
18
16
|
|
|
19
17
|
import { FACES, faceInfo } from 'castle-web-fonts';
|
|
20
18
|
|
|
@@ -34,11 +32,19 @@ export const DEFAULT_FONT_FAMILY = 'sans-serif';
|
|
|
34
32
|
const generated = import.meta.glob('/fonts.generated.js', { eager: true });
|
|
35
33
|
const deckFontModule = generated['/fonts.generated.js'];
|
|
36
34
|
|
|
37
|
-
/** The face the generated module actually carries, or null
|
|
38
|
-
* This is what the deck RENDERS in, as against what `theme.style` asks for. */
|
|
35
|
+
/** The face the generated module actually carries as primary, or null. */
|
|
39
36
|
export const generatedFontName =
|
|
40
37
|
typeof deckFontModule?.deckFont === 'string' ? deckFontModule.deckFont : null;
|
|
41
38
|
|
|
39
|
+
/** Every face the generated module ships (primary + Text extras), ordered. */
|
|
40
|
+
export const generatedFontNames = (() => {
|
|
41
|
+
const listed = deckFontModule?.deckFonts;
|
|
42
|
+
if (Array.isArray(listed) && listed.length) {
|
|
43
|
+
return listed.filter((name) => typeof name === 'string' && faceInfo(name));
|
|
44
|
+
}
|
|
45
|
+
return generatedFontName ? [generatedFontName] : [];
|
|
46
|
+
})();
|
|
47
|
+
|
|
42
48
|
/** A CSS `font-family` value that is always safe to use: the deck's face when
|
|
43
49
|
* it has one, a plain sans stack when it doesn't. */
|
|
44
50
|
export const deckFontFamily = generatedFontName ?? DEFAULT_FONT_FAMILY;
|
|
@@ -50,30 +56,126 @@ export function themeFontName(themeData) {
|
|
|
50
56
|
return typeof raw === 'string' && faceInfo(raw) ? raw : null;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
/** Resolve a Text.font prop to a CSS family: empty/unknown → deck primary. */
|
|
60
|
+
export function resolveTextFont(font) {
|
|
61
|
+
if (typeof font === 'string' && font && faceInfo(font)) return font;
|
|
62
|
+
return deckFontFamily;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Unique official face names from every Text actor in scene/blueprint files. */
|
|
66
|
+
export function collectTextFontsFromFiles(files) {
|
|
67
|
+
const found = new Set();
|
|
68
|
+
for (const [path, text] of Object.entries(files ?? {})) {
|
|
69
|
+
if (!path.endsWith('.scene') || typeof text !== 'string') continue;
|
|
70
|
+
let data;
|
|
71
|
+
try {
|
|
72
|
+
data = JSON.parse(text);
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const actor of data?.actors ?? []) {
|
|
77
|
+
const name = actor?.components?.Text?.font;
|
|
78
|
+
if (typeof name === 'string' && faceInfo(name)) found.add(name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...found];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Ordered unique official faces: primary first, then extras. */
|
|
85
|
+
export function orderedDeckFonts(primary, extras = []) {
|
|
86
|
+
const out = [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
for (const name of [primary, ...extras]) {
|
|
89
|
+
if (!name || !faceInfo(name) || seen.has(name)) continue;
|
|
90
|
+
seen.add(name);
|
|
91
|
+
out.push(name);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Source for `fonts.generated.js` carrying exactly `fonts` (primary = deckFont). */
|
|
97
|
+
export function fontsGeneratedSource(primary, fonts) {
|
|
98
|
+
const list = orderedDeckFonts(primary, fonts);
|
|
99
|
+
if (!list.length) {
|
|
100
|
+
return `// Generated by the Theme editor. Do not edit.
|
|
101
|
+
//
|
|
102
|
+
// This deck has no font of its own; text renders in the default sans stack.
|
|
103
|
+
export const deckFont = null;
|
|
104
|
+
export const deckFonts = [];
|
|
105
|
+
export const installDeckFonts = [];
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
const primaryName = list.includes(primary) ? primary : list[0];
|
|
109
|
+
const imports = list.map((name) => `install${name}`).join(', ');
|
|
110
|
+
const installers = list.map((name) => `install${name}`).join(', ');
|
|
111
|
+
const namesLit = list.map((name) => `'${name}'`).join(', ');
|
|
112
|
+
return `// Generated by the Theme editor / Text font picker. Do not edit.
|
|
113
|
+
//
|
|
114
|
+
// \`theme.style\` records the deck's primary font as ${primaryName}; Text actors
|
|
115
|
+
// may use additional faces. This module is what puts their bytes in the
|
|
116
|
+
// published bundle — each face must be named in a static import so the bundler
|
|
117
|
+
// can tree-shake the rest.
|
|
118
|
+
import { ${imports} } from 'castle-web-fonts';
|
|
119
|
+
export { install${primaryName} as installDeckFont };
|
|
120
|
+
export const deckFont = '${primaryName}';
|
|
121
|
+
export const deckFonts = [${namesLit}];
|
|
122
|
+
export const installDeckFonts = [${installers}];
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
|
|
53
126
|
let ready = null;
|
|
54
127
|
|
|
55
128
|
/** Resolve once every face this deck registered can be drawn with -- the deck
|
|
56
|
-
* font, and any a behavior imported for itself.
|
|
129
|
+
* font(s) in fonts.generated.js, and any a behavior imported for itself.
|
|
57
130
|
*
|
|
58
131
|
* Wait for this before the first frame. Canvas 2D does not trigger a font load,
|
|
59
132
|
* so a `draw` hook setting `ctx.font` before the face is ready silently falls
|
|
60
|
-
* back to the default -- and never repaints when the font lands
|
|
61
|
-
* looks right after a warm reload and wrong on a cold first paint.
|
|
62
|
-
*
|
|
63
|
-
* `document.fonts.ready` is what covers the extras, and it is the only thing
|
|
64
|
-
* that can: a deck wanting a display face beside the deck font imports it
|
|
65
|
-
* itself, and nothing here knows its name. A behavior module's `installBore()`
|
|
66
|
-
* runs while the module graph loads, which is before the player mounts, so that
|
|
67
|
-
* load is already pending by the time this is first called.
|
|
133
|
+
* back to the default -- and never repaints when the font lands.
|
|
68
134
|
*
|
|
69
135
|
* Never rejects: a deck whose font failed to load should still play. */
|
|
70
136
|
export function fontsReady() {
|
|
71
137
|
if (!ready) {
|
|
72
|
-
const
|
|
138
|
+
const list = deckFontModule?.installDeckFonts;
|
|
139
|
+
const installers = Array.isArray(list) && list.length
|
|
140
|
+
? list
|
|
141
|
+
: deckFontModule?.installDeckFont
|
|
142
|
+
? [deckFontModule.installDeckFont]
|
|
143
|
+
: [];
|
|
73
144
|
ready = Promise.all([
|
|
74
|
-
install ? install() : null,
|
|
145
|
+
...installers.map((install) => (typeof install === 'function' ? install() : null)),
|
|
75
146
|
typeof document === 'undefined' ? null : document.fonts?.ready,
|
|
76
147
|
]).catch(() => null);
|
|
77
148
|
}
|
|
78
149
|
return ready;
|
|
79
150
|
}
|
|
151
|
+
|
|
152
|
+
// Edit-time loads for faces not yet in the eager `fonts.generated.js` module
|
|
153
|
+
// (e.g. the author just picked Text.font). Same woff2 URLs the font picker
|
|
154
|
+
// uses -- served from node_modules while editing, never inlined into a publish.
|
|
155
|
+
const faceLoads = new Map();
|
|
156
|
+
|
|
157
|
+
/** Install one Castle face into `document.fonts` so canvas text can use it
|
|
158
|
+
* without a panel reload. No-op for unknown / empty names. Idempotent. */
|
|
159
|
+
export function ensureOfficialFace(name) {
|
|
160
|
+
if (!name || !faceInfo(name)) return Promise.resolve(false);
|
|
161
|
+
if (typeof FontFace === 'undefined' || typeof document === 'undefined') {
|
|
162
|
+
return Promise.resolve(false);
|
|
163
|
+
}
|
|
164
|
+
const pending = faceLoads.get(name);
|
|
165
|
+
if (pending) return pending;
|
|
166
|
+
const load = (async () => {
|
|
167
|
+
try {
|
|
168
|
+
const face = new FontFace(
|
|
169
|
+
name,
|
|
170
|
+
`url("/node_modules/castle-web-fonts/woff2/${name}.woff2") format("woff2")`
|
|
171
|
+
);
|
|
172
|
+
await face.load();
|
|
173
|
+
document.fonts.add(face);
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
faceLoads.set(name, load);
|
|
180
|
+
return load;
|
|
181
|
+
}
|