castle-web-cli 0.4.123 → 0.4.125
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/castleJson.d.ts +2 -0
- package/dist/castleJson.js +39 -0
- package/dist/headlessCover.d.ts +13 -0
- package/dist/headlessCover.js +144 -0
- package/dist/ide.js +12 -42
- package/dist/imports.js +42 -2
- package/dist/index.js +1 -9
- package/dist/init.d.ts +1 -0
- package/dist/init.js +15 -1
- package/dist/preview.d.ts +0 -1
- package/dist/preview.js +0 -58
- package/dist/save-deck.js +52 -1
- package/dist/serve.js +16 -0
- package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
- package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
- package/dist/shell/assets/index-BfOPkSej.css +1 -0
- package/dist/shell/assets/index-u0nYFqbF.js +434 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +2 -2
- package/kits/physics-2d/castle.json +10 -2
- package/kits/physics-2d/docs/pxart-format.md +33 -26
- package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
- package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
- package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
- package/kits/physics-2d/editors/pathOverlay.js +1 -1
- package/kits/physics-2d/editors/pathTools.js +9 -1
- package/kits/physics-2d/editors/pixelGeometry.js +14 -13
- package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
- package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
- package/kits/physics-2d/editors/pxArtTools.js +3 -43
- package/kits/physics-2d/editors/styleEditor.module.css +105 -0
- package/kits/physics-2d/editors/styleTheme.js +16 -0
- package/kits/physics-2d/engine/files.js +2 -1
- package/kits/physics-2d/engine/liveReload.js +4 -3
- package/kits/physics-2d/engine/palettes.js +636 -0
- package/kits/physics-2d/engine/pxart.js +6 -6
- package/kits/physics-2d/engine/svgImport.js +1056 -0
- package/kits/physics-2d/engine/ui.jsx +2 -0
- package/kits/physics-2d/engine/ui.module.css +54 -9
- package/kits/physics-2d/package.json +1 -0
- package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
- package/kits/physics-2d/scripts/draw.mjs +5 -3
- package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
- package/kits/physics-2d/scripts/palette.mjs +10 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
- package/kits/physics-2d/theme.style +3 -0
- package/package.json +1 -1
- package/dist/shell/assets/index-CARLHafh.css +0 -1
- package/dist/shell/assets/index-DWgt4KzC.js +0 -434
package/dist/castleJson.d.ts
CHANGED
|
@@ -20,3 +20,5 @@ export declare const DEFAULT_MAIN = "main.jsx";
|
|
|
20
20
|
export declare function deckMainFile(dir: string): string;
|
|
21
21
|
export declare function readCastleJson(dir: string): CastleJson | null;
|
|
22
22
|
export declare function readCastleJsonOrThrow(dir: string): CastleJson | null;
|
|
23
|
+
export declare function ensureVisiblePath(deckDir: string, rel: string): boolean;
|
|
24
|
+
export declare function ensureVisibleGlob(deckDir: string, glob: string, probe: string): boolean;
|
package/dist/castleJson.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import picomatch from 'picomatch';
|
|
3
4
|
export const DEFAULT_STARTER_SCENE = 'scenes/main.scene';
|
|
4
5
|
export function deckStarterScene(dir) {
|
|
5
6
|
const declared = readCastleJson(dir)?.starterScene;
|
|
@@ -32,3 +33,41 @@ export function readCastleJsonOrThrow(dir) {
|
|
|
32
33
|
throw new Error(`Could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
33
34
|
});
|
|
34
35
|
}
|
|
36
|
+
// Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
|
|
37
|
+
// newly-made folder show in the curated Files tree.
|
|
38
|
+
export function ensureVisiblePath(deckDir, rel) {
|
|
39
|
+
return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
|
|
40
|
+
}
|
|
41
|
+
// Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
|
|
42
|
+
// ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
|
|
43
|
+
// everything is visible, and adding a glob would wrongly start hiding things.
|
|
44
|
+
// No-op if an existing glob already covers `probe`, a path the new glob would
|
|
45
|
+
// match. Returns whether it wrote.
|
|
46
|
+
export function ensureVisibleGlob(deckDir, glob, probe) {
|
|
47
|
+
const file = path.join(deckDir, 'castle.json');
|
|
48
|
+
let data;
|
|
49
|
+
try {
|
|
50
|
+
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false; // no castle.json yet (deck never saved) -> treat as not curated
|
|
54
|
+
}
|
|
55
|
+
const visible = data.editor && Array.isArray(data.editor.visiblePaths)
|
|
56
|
+
? data.editor.visiblePaths.filter((v) => typeof v === 'string')
|
|
57
|
+
: null;
|
|
58
|
+
if (!visible || visible.length === 0)
|
|
59
|
+
return false; // not curated -> all visible
|
|
60
|
+
if (visible.includes(glob))
|
|
61
|
+
return false;
|
|
62
|
+
if (picomatch(visible)(probe))
|
|
63
|
+
return false; // already covered
|
|
64
|
+
visible.push(glob);
|
|
65
|
+
data.editor.visiblePaths = visible;
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type PlaytestBrowserManager } from "./native/playtest-browser.js";
|
|
2
|
+
export interface HeadlessCoverResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
outPath?: string;
|
|
5
|
+
error?: string;
|
|
6
|
+
installedMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function captureCoverHeadless(opts: {
|
|
9
|
+
serveUrl: string;
|
|
10
|
+
outPath: string;
|
|
11
|
+
manager?: PlaytestBrowserManager;
|
|
12
|
+
onProgress?: (message: string) => void;
|
|
13
|
+
}): Promise<HeadlessCoverResult>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Capture a deck's cover with a real browser instead of asking the page to
|
|
2
|
+
// draw itself.
|
|
3
|
+
//
|
|
4
|
+
// The in-page path (the SDK's html2canvas compositing) re-implements CSS
|
|
5
|
+
// rendering in JS, so it inherits a support list: a WebGL canvas is blank
|
|
6
|
+
// unless the deck happened to pass `preserveDrawingBuffer`, and any element
|
|
7
|
+
// using `filter` / `clip-path` / `mask-image` / a blend mode is dropped. Both
|
|
8
|
+
// fail SILENTLY -- the capture succeeds and returns a plausible image that is
|
|
9
|
+
// missing part of the game -- and both put the burden on the deck to know a
|
|
10
|
+
// platform quirk.
|
|
11
|
+
//
|
|
12
|
+
// Screenshotting through the browser removes that whole class: the engine
|
|
13
|
+
// composites the frame it was already going to composite, so whatever the deck
|
|
14
|
+
// legitimately renders is what lands in the cover. This is the same move the
|
|
15
|
+
// mobile app made for its exp-web snapshots (WKWebView takeSnapshot / PixelCopy)
|
|
16
|
+
// -- ask the platform for its pixels rather than re-deriving them.
|
|
17
|
+
//
|
|
18
|
+
// It buys correctness, not judgment: a cold headless load sees the deck's FIRST
|
|
19
|
+
// moments, where the in-page path saw whatever the creator had on screen. That
|
|
20
|
+
// is why this is the fallback for an unattended save rather than a replacement
|
|
21
|
+
// for the editor's capture button -- see `SETTLE_MS`.
|
|
22
|
+
import fs from "fs";
|
|
23
|
+
import path from "path";
|
|
24
|
+
import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
|
|
25
|
+
// The card is 5:7; this is the same fixed viewport the playtest tool uses, so a
|
|
26
|
+
// deck laid out for one is laid out for the other.
|
|
27
|
+
const VIEWPORT = { width: 500, height: 700 };
|
|
28
|
+
// Retina-ish, so a cover holds up scaled down in a feed and blown up on a deck
|
|
29
|
+
// page. 2x of a 500x700 card is 1000x1400.
|
|
30
|
+
const SCALE = 2;
|
|
31
|
+
const NAV_TIMEOUT_MS = 20_000;
|
|
32
|
+
// Long enough for a deck to boot, load assets and render real frames -- a cover
|
|
33
|
+
// of frame zero is a loading screen. Deliberately generous: this runs once per
|
|
34
|
+
// save, never in a player's path.
|
|
35
|
+
const SETTLE_MS = 3_500;
|
|
36
|
+
// A ceiling on the whole capture, because this runs inside somebody's "Push to
|
|
37
|
+
// Castle". The steps below are individually bounded, but a browser is a big
|
|
38
|
+
// dependency and this is a cover -- nothing here is worth making a creator
|
|
39
|
+
// watch a spinner for. Whatever is unfinished at this point is abandoned and
|
|
40
|
+
// the save proceeds without a cover.
|
|
41
|
+
const CAPTURE_BUDGET_MS = 60_000;
|
|
42
|
+
// The card the SDK mounts (`initCard`) or a deck marks itself. Falling back to
|
|
43
|
+
// the whole viewport keeps a deck that does neither from getting no cover at
|
|
44
|
+
// all -- the viewport IS the card in play mode.
|
|
45
|
+
const CARD_SELECTOR = "#castle-card, [data-castle-card]";
|
|
46
|
+
// Grab the deck's cover from `serveUrl` and write it to `<projectDir>/.castle`.
|
|
47
|
+
// Never throws; a failure is reported, not raised.
|
|
48
|
+
export async function captureCoverHeadless(opts) {
|
|
49
|
+
// A manager we made is a manager we must close. `withBrowser` deliberately
|
|
50
|
+
// leaves the browser RUNNING so the next call reuses it -- right for the
|
|
51
|
+
// agent, which holds one manager for its whole session, and fatal here:
|
|
52
|
+
// `save-deck` is one-shot, and a live browser connection keeps node's event
|
|
53
|
+
// loop alive, so the command finishes its work and then never exits. The
|
|
54
|
+
// caller waits out its own timeout on a process that is done. (The manager's
|
|
55
|
+
// idle timer is unref'd precisely so it isn't what holds a one-shot open --
|
|
56
|
+
// the browser connection is.)
|
|
57
|
+
//
|
|
58
|
+
// A manager passed IN belongs to the caller; closing it would kill a browser
|
|
59
|
+
// they are still using.
|
|
60
|
+
const owned = !opts.manager;
|
|
61
|
+
const manager = opts.manager ?? createPlaytestBrowserManager();
|
|
62
|
+
try {
|
|
63
|
+
const outcome = await withBudget(manager.withBrowser((browser) => shoot(browser, opts.serveUrl), {
|
|
64
|
+
onProgress: opts.onProgress,
|
|
65
|
+
}), CAPTURE_BUDGET_MS);
|
|
66
|
+
if (!outcome)
|
|
67
|
+
return { ok: false, error: `capture gave up after ${CAPTURE_BUDGET_MS / 1000}s` };
|
|
68
|
+
if (!outcome.ok)
|
|
69
|
+
return { ok: false, error: outcome.error };
|
|
70
|
+
if (!outcome.value.png) {
|
|
71
|
+
return { ok: false, error: outcome.value.error ?? "capture produced no image" };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(path.dirname(opts.outPath), { recursive: true });
|
|
75
|
+
fs.writeFileSync(opts.outPath, outcome.value.png);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, outPath: opts.outPath, installedMs: outcome.installedMs };
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (owned)
|
|
84
|
+
await manager.shutdown().catch(() => undefined);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Resolve with `work`, or with null if the budget runs out first. The loser is
|
|
88
|
+
// abandoned rather than cancelled -- the shutdown in the caller's `finally` is
|
|
89
|
+
// what actually stops the browser it was using.
|
|
90
|
+
function withBudget(work, ms) {
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
const timer = setTimeout(() => resolve(null), ms);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
void work.then((v) => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
resolve(v);
|
|
97
|
+
}, () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
resolve(null);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function shoot(browser, serveUrl) {
|
|
104
|
+
const context = await browser.newContext({
|
|
105
|
+
viewport: { ...VIEWPORT },
|
|
106
|
+
deviceScaleFactor: SCALE,
|
|
107
|
+
// A cover is a still of a game: give it a touch-shaped context, since decks
|
|
108
|
+
// are phone-first and some branch their controls on it.
|
|
109
|
+
hasTouch: true,
|
|
110
|
+
isMobile: true,
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
const page = await context.newPage();
|
|
114
|
+
// `/index.html`, NOT `/`: the serve's root is the editor shell, and the deck
|
|
115
|
+
// itself is what the shell loads into its iframe (see serve.ts). Pointing at
|
|
116
|
+
// `/` photographs the editor. `?edit=0` then asks for the play surface, so
|
|
117
|
+
// the deck renders in its card with no editor UI -- which is what a cover is.
|
|
118
|
+
const url = `${serveUrl.replace(/\/$/, "")}/index.html?edit=0`;
|
|
119
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
|
|
120
|
+
// Let it boot and actually draw. `waitForTimeout` rather than a load event:
|
|
121
|
+
// a deck is a game, and "loaded" says nothing about whether it has rendered
|
|
122
|
+
// anything worth showing.
|
|
123
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
124
|
+
// A deck that failed to build renders vite's error overlay, and a browser
|
|
125
|
+
// screenshots that just as faithfully as it screenshots a game -- so without
|
|
126
|
+
// this, a broken deck publishes a cover of its own stack trace. The in-page
|
|
127
|
+
// path could not do this (nothing renders, so nothing is captured), so it is
|
|
128
|
+
// a failure mode this approach introduces and has to close itself.
|
|
129
|
+
const broken = await page.$("vite-error-overlay");
|
|
130
|
+
if (broken)
|
|
131
|
+
return { error: "the deck is showing a build error, so there is nothing to photograph" };
|
|
132
|
+
const card = await page.$(CARD_SELECTOR);
|
|
133
|
+
// Element screenshot clips to the card for us. Without a card, the viewport
|
|
134
|
+
// IS the card in play mode, so shoot that.
|
|
135
|
+
const png = card ? await card.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
|
|
136
|
+
return { png };
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await context.close().catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
}
|
package/dist/ide.js
CHANGED
|
@@ -16,6 +16,7 @@ import headlessPkg from '@xterm/headless';
|
|
|
16
16
|
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
17
17
|
import { WebSocketServer } from 'ws';
|
|
18
18
|
import { IMPORTS_DIR, importStatuses, updateImport } from './imports.js';
|
|
19
|
+
import { ensureVisibleGlob, ensureVisiblePath } from './castleJson.js';
|
|
19
20
|
import { readEditorConfig, resolveFileTypes, } from './editorConfig.js';
|
|
20
21
|
import { UNSUPPORTED_MEDIA } from './unsupportedMedia.js';
|
|
21
22
|
import { IMPORT_API_PREFIX, handleImportApi } from './importBrowse.js';
|
|
@@ -137,10 +138,17 @@ export const FAVICON_LINK_TAGS = [
|
|
|
137
138
|
// hands those files to the kit iframe and keeps the builtin editor as the
|
|
138
139
|
// default for everything else).
|
|
139
140
|
export const FILES_API_PREFIX = '/__castle/files/';
|
|
140
|
-
// The deck's local cover image.
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
141
|
+
// The deck's local cover image. Nothing in THIS shell posts here any more --
|
|
142
|
+
// covers are captured headlessly by `save-deck` (see headlessCover.ts), which
|
|
143
|
+
// photographs the deck with a real browser instead of asking the page to
|
|
144
|
+
// rasterize itself. The endpoint stays because the shell is baked into the
|
|
145
|
+
// sandbox image: a warm sandbox keeps running an older shell that still posts
|
|
146
|
+
// its automatic captures here, and accepting them is cheaper than making those
|
|
147
|
+
// requests 404. `save-deck` still reads preview.png as a last-resort fallback.
|
|
148
|
+
//
|
|
149
|
+
// Its own endpoint rather than a files/upload because this file is written over
|
|
150
|
+
// and over and upload deliberately refuses to clobber. A cover is platform
|
|
151
|
+
// vocabulary, not a kit's, so it belongs here.
|
|
144
152
|
export const COVER_API_PATH = '/__castle/cover';
|
|
145
153
|
// Re-exported so `save-deck` and anything else that already asks the serve for
|
|
146
154
|
// the cover's name keeps working.
|
|
@@ -437,44 +445,6 @@ function handleFilesWrite(deckDir, req, res) {
|
|
|
437
445
|
}
|
|
438
446
|
});
|
|
439
447
|
}
|
|
440
|
-
// Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
|
|
441
|
-
// newly-made folder show in the curated Files tree.
|
|
442
|
-
function ensureVisiblePath(deckDir, rel) {
|
|
443
|
-
return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
|
|
444
|
-
}
|
|
445
|
-
// Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
|
|
446
|
-
// ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
|
|
447
|
-
// everything is visible, and adding a glob would wrongly start hiding things.
|
|
448
|
-
// No-op if an existing glob already covers `probe`, a path the new glob would
|
|
449
|
-
// match. Returns whether it wrote.
|
|
450
|
-
function ensureVisibleGlob(deckDir, glob, probe) {
|
|
451
|
-
const file = path.join(deckDir, 'castle.json');
|
|
452
|
-
let data;
|
|
453
|
-
try {
|
|
454
|
-
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
455
|
-
}
|
|
456
|
-
catch {
|
|
457
|
-
return false; // no castle.json yet (deck never saved) -> treat as not curated
|
|
458
|
-
}
|
|
459
|
-
const visible = data.editor && Array.isArray(data.editor.visiblePaths)
|
|
460
|
-
? data.editor.visiblePaths.filter((v) => typeof v === 'string')
|
|
461
|
-
: null;
|
|
462
|
-
if (!visible || visible.length === 0)
|
|
463
|
-
return false; // not curated -> all visible
|
|
464
|
-
if (visible.includes(glob))
|
|
465
|
-
return false;
|
|
466
|
-
if (picomatch(visible)(probe))
|
|
467
|
-
return false; // already covered
|
|
468
|
-
visible.push(glob);
|
|
469
|
-
data.editor.visiblePaths = visible;
|
|
470
|
-
try {
|
|
471
|
-
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
472
|
-
return true;
|
|
473
|
-
}
|
|
474
|
-
catch {
|
|
475
|
-
return false;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
448
|
function handleFilesMkdir(deckDir, req, res) {
|
|
479
449
|
withMutationPath(deckDir, req, res, (target) => {
|
|
480
450
|
try {
|
package/dist/imports.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as api from './api.js';
|
|
|
5
5
|
import { runTar } from './save-deck.js';
|
|
6
6
|
import { normalizeDeckPackageJson } from './normalize.js';
|
|
7
7
|
import { getKitsDir } from './localPaths.js';
|
|
8
|
-
import { readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
|
|
8
|
+
import { ensureVisibleGlob, readCastleJson as tryReadCastleJson, readCastleJsonOrThrow as readCastleJson, } from './castleJson.js';
|
|
9
9
|
// Adding another deck as a dependency (`castle-web add-import`; removing one is a
|
|
10
10
|
// later command). Deliberately NOT `get-deck`: that one
|
|
11
11
|
// replaces THIS deck's own source from the server (and carries guards for the
|
|
@@ -485,7 +485,7 @@ export async function addImportTo(dir, options = {}) {
|
|
|
485
485
|
const declaredMain = tryReadCastleJson(importDir)?.main;
|
|
486
486
|
let adoptedMain = null;
|
|
487
487
|
if (typeof declaredMain === 'string' && declaredMain.trim()) {
|
|
488
|
-
const { isPristineBareDeck, adoptImportEntry, writeStarterScene } = await import('./init.js');
|
|
488
|
+
const { isPristineBareDeck, adoptImportEntry, writeStarterScene, writeStarterTheme } = await import('./init.js');
|
|
489
489
|
if (isPristineBareDeck(targetDir)) {
|
|
490
490
|
adoptImportEntry(targetDir, alias, declaredMain.trim());
|
|
491
491
|
adoptedMain = declaredMain.trim();
|
|
@@ -500,6 +500,14 @@ export async function addImportTo(dir, options = {}) {
|
|
|
500
500
|
console.warn(`Adopted ${alias} but could not write its starter scene: ${e instanceof Error ? e.message : String(e)}`);
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
|
+
// Same for the kit's starter theme.style (skips on its own if the deck
|
|
504
|
+
// already has one or the kit ships none).
|
|
505
|
+
try {
|
|
506
|
+
writeStarterTheme(targetDir, importDir);
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
console.warn(`Adopted ${alias} but could not write its starter theme.style: ${e instanceof Error ? e.message : String(e)}`);
|
|
510
|
+
}
|
|
503
511
|
}
|
|
504
512
|
}
|
|
505
513
|
// An import's packages have to be ON DISK, not just named in package.json:
|
|
@@ -557,9 +565,41 @@ export async function addImport(dir, options = {}) {
|
|
|
557
565
|
// `update-import` and the serve-start auto update come through here, so there
|
|
558
566
|
// is one path that replaces an import's files rather than two that have to be
|
|
559
567
|
// kept in step. `log` is how the caller labels the lines it produces.
|
|
568
|
+
// A kit update can introduce deck-level starter content that an existing deck
|
|
569
|
+
// predates. Same additive spirit as syncImportDependencies (which adds a kit's
|
|
570
|
+
// new package deps to the deck's own package.json on update): seed the kit's
|
|
571
|
+
// `theme.style` at the deck root when the deck has none. The engine reads it
|
|
572
|
+
// from the deck root only -- it is the creator's file, like scenes/ -- so
|
|
573
|
+
// without this, decks scaffolded before the kit shipped one would never get
|
|
574
|
+
// it. Never overwrites: a deck that has the file keeps it, whatever the kit
|
|
575
|
+
// ships. Also registers the file in a curated deck's editor.visiblePaths so it
|
|
576
|
+
// shows in the Files tree (the same courtesy mkdir/upload extend).
|
|
577
|
+
function seedKitTheme(deckDir, alias, log) {
|
|
578
|
+
const importDir = path.join(deckDir, IMPORTS_DIR, alias);
|
|
579
|
+
// Only kits (imports that declare an entry in castle.json `main`) seed deck
|
|
580
|
+
// content -- a plain deck import that happens to carry a theme.style of its
|
|
581
|
+
// own must not set this deck's palette.
|
|
582
|
+
const main = tryReadCastleJson(importDir)?.main;
|
|
583
|
+
if (typeof main !== 'string' || !main.trim())
|
|
584
|
+
return;
|
|
585
|
+
const src = path.join(importDir, 'theme.style');
|
|
586
|
+
const dest = path.join(deckDir, 'theme.style');
|
|
587
|
+
if (!fs.existsSync(src) || fs.existsSync(dest))
|
|
588
|
+
return;
|
|
589
|
+
try {
|
|
590
|
+
fs.copyFileSync(src, dest);
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
console.warn(`Could not seed theme.style from ${alias}: ${e instanceof Error ? e.message : String(e)}`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
ensureVisibleGlob(deckDir, 'theme.style', 'theme.style');
|
|
597
|
+
log(`Seeded theme.style from ${alias} (new in this version; the deck's to edit)`);
|
|
598
|
+
}
|
|
560
599
|
async function applyImportUpdate(deckDir, alias, update, source, log) {
|
|
561
600
|
await placeImport(deckDir, alias, update.deckId, source, update.via);
|
|
562
601
|
log(`Updated ${alias} (${update.from} -> ${source.updatedAt})`);
|
|
602
|
+
seedKitTheme(deckDir, alias, log);
|
|
563
603
|
// Its own dependencies may have moved too.
|
|
564
604
|
const transitive = await addTransitiveImports(deckDir, alias, importedDeckIds(deckDir));
|
|
565
605
|
for (const t of transitive)
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { addImport, updateImport } from './imports.js';
|
|
|
9
9
|
import { listDecks } from './list-decks.js';
|
|
10
10
|
import { getCliVersion, init } from './init.js';
|
|
11
11
|
import { install } from './install.js';
|
|
12
|
-
import { connectWS,
|
|
12
|
+
import { connectWS, takeScreenshot } from './preview.js';
|
|
13
13
|
import { listVersions, restoreVersion, saveVersion, showVersion } from './versions.js';
|
|
14
14
|
const args = process.argv.slice(2);
|
|
15
15
|
const command = args[0];
|
|
@@ -91,7 +91,6 @@ function usage() {
|
|
|
91
91
|
castle-web serve [dir] [--port PORT] [--host HOST] [--open] [--detach]
|
|
92
92
|
castle-web restart [--port PORT]
|
|
93
93
|
castle-web screenshot [--out FILE] [--port PORT]
|
|
94
|
-
castle-web save-preview-image [dir] [--port PORT] [--no-restart]
|
|
95
94
|
castle-web save-deck [dir] [--title TITLE] [--caption TEXT] [--visibility unlisted|private]
|
|
96
95
|
castle-web get-deck [dir] [--deck-id ID] [--force] (replaces the source if the deck is already there)
|
|
97
96
|
castle-web list-decks [dir] [--kind mine|saved|kits] (decks this one could import; ids are what add-import takes)
|
|
@@ -242,13 +241,6 @@ async function main() {
|
|
|
242
241
|
console.log(`Saved ${outFile}`);
|
|
243
242
|
return;
|
|
244
243
|
}
|
|
245
|
-
case 'save-preview-image': {
|
|
246
|
-
const dir = findPositionalDir();
|
|
247
|
-
const wsPort = getWsPort(dir);
|
|
248
|
-
const noRestart = args.includes('--no-restart');
|
|
249
|
-
await savePreviewImage(dir, wsPort, noRestart);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
244
|
case 'login':
|
|
253
245
|
await login();
|
|
254
246
|
break;
|
package/dist/init.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare function isPristineBareDeck(dir: string): boolean;
|
|
|
9
9
|
export declare function adoptImportEntry(deckDir: string, alias: string, mainFile: string): void;
|
|
10
10
|
export declare function getCliVersion(): string;
|
|
11
11
|
export declare function writeStarterScene(projectDir: string, kitDir: string, alias: string): void;
|
|
12
|
+
export declare function writeStarterTheme(projectDir: string, kitDir: string): void;
|
|
12
13
|
export declare function init(dir: string, opts?: {
|
|
13
14
|
kit?: string;
|
|
14
15
|
serve?: boolean;
|
package/dist/init.js
CHANGED
|
@@ -38,7 +38,7 @@ const DEFAULT_KIT = 'physics-2d';
|
|
|
38
38
|
// Registry version of castle-web-sdk to inject when scaffolding from a
|
|
39
39
|
// globally-installed castle-web (not from inside the workspace). Bumped
|
|
40
40
|
// alongside cli/sdk version bumps.
|
|
41
|
-
const PUBLISHED_SDK_VERSION = '0.4.
|
|
41
|
+
const PUBLISHED_SDK_VERSION = '0.4.14';
|
|
42
42
|
// The account the first-party kits are (to be) published under, so a kit
|
|
43
43
|
// imported from the CLI's copy is named the same as one fetched from the server.
|
|
44
44
|
const KIT_AUTHOR = 'castle';
|
|
@@ -281,6 +281,19 @@ export function writeStarterScene(projectDir, kitDir, alias) {
|
|
|
281
281
|
fs.mkdirSync(path.join(projectDir, 'scenes'), { recursive: true });
|
|
282
282
|
writeJsonFile(path.join(projectDir, 'scenes', 'main.scene'), scene ? rewrite(scene) : { name: 'Main', actors: [] });
|
|
283
283
|
}
|
|
284
|
+
// A kit may ship a starter `theme.style` (deck-level styling, e.g. the color
|
|
285
|
+
// palette). Like the starter scene, it is deck content: the engine reads it
|
|
286
|
+
// from the DECK root, and it is the creator's to edit -- so it gets seeded once
|
|
287
|
+
// at scaffold time rather than living read-only in the import. Copied verbatim
|
|
288
|
+
// (its values are palette ids/hexes, never kit file paths, so no ref
|
|
289
|
+
// rewriting). Exported for the same two scaffold paths as writeStarterScene.
|
|
290
|
+
export function writeStarterTheme(projectDir, kitDir) {
|
|
291
|
+
const src = path.join(kitDir, 'theme.style');
|
|
292
|
+
const dest = path.join(projectDir, 'theme.style');
|
|
293
|
+
if (!fs.existsSync(src) || fs.existsSync(dest))
|
|
294
|
+
return;
|
|
295
|
+
fs.copyFileSync(src, dest);
|
|
296
|
+
}
|
|
284
297
|
function makeImportedKitClaudeMd(alias) {
|
|
285
298
|
return `# Castle deck
|
|
286
299
|
|
|
@@ -354,6 +367,7 @@ function scaffoldFromKitImport(kit, projectDir) {
|
|
|
354
367
|
const title = typeof kitConfig.title === 'string' ? kitConfig.title : kit;
|
|
355
368
|
writeDeckIndexHtml(projectDir, alias, title, deckMainFile(kitDir));
|
|
356
369
|
writeStarterScene(projectDir, kitDir, alias);
|
|
370
|
+
writeStarterTheme(projectDir, kitDir);
|
|
357
371
|
// The deck's own castle.json: the kit's editor config (panel layout, file
|
|
358
372
|
// filters) as a starting point -- it is the deck's to edit from here -- plus
|
|
359
373
|
// the import pin.
|
package/dist/preview.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
import type { WebSocket as WSClient } from 'ws';
|
|
2
2
|
export declare function connectWS(wsPort: number): Promise<WSClient>;
|
|
3
3
|
export declare function takeScreenshot(ws: WSClient): Promise<string>;
|
|
4
|
-
export declare function savePreviewImage(dir: string, wsPort: number, noRestart?: boolean): Promise<void>;
|
package/dist/preview.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import * as api from './api.js';
|
|
4
|
-
import * as config from './config.js';
|
|
5
1
|
export async function connectWS(wsPort) {
|
|
6
2
|
const { default: WS } = await import('ws');
|
|
7
3
|
return new Promise((resolve, reject) => {
|
|
@@ -45,57 +41,3 @@ export function takeScreenshot(ws) {
|
|
|
45
41
|
ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
|
|
46
42
|
});
|
|
47
43
|
}
|
|
48
|
-
function waitForRestart(ws) {
|
|
49
|
-
return new Promise((resolve) => {
|
|
50
|
-
ws.send(JSON.stringify({ type: 'restart' }));
|
|
51
|
-
setTimeout(resolve, 2000);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
// The shared declaration (castleJson.ts) rather than a local copy of the two
|
|
55
|
-
// fields this file reads, so it can't drift from what the rest of the CLI
|
|
56
|
-
// believes castle.json contains.
|
|
57
|
-
function readCastleJson(projectDir) {
|
|
58
|
-
const castleJsonPath = path.join(projectDir, 'castle.json');
|
|
59
|
-
if (!fs.existsSync(castleJsonPath))
|
|
60
|
-
return null;
|
|
61
|
-
return JSON.parse(fs.readFileSync(castleJsonPath, 'utf-8'));
|
|
62
|
-
}
|
|
63
|
-
async function captureAndUpload(projectDir, ws, castleJson) {
|
|
64
|
-
// A deck has no cardId until its first save, and the cover IS that card's
|
|
65
|
-
// background -- so there is nothing to set it on yet. Both callers reach here
|
|
66
|
-
// right after a save, so this is the never-saved edge, not the normal path.
|
|
67
|
-
// Reported back so a caller doesn't record a cover that was never set.
|
|
68
|
-
const { cardId } = castleJson;
|
|
69
|
-
if (!cardId) {
|
|
70
|
-
ws.close();
|
|
71
|
-
console.log('Preview skipped: save the deck first.');
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
const base64 = await takeScreenshot(ws);
|
|
75
|
-
ws.close();
|
|
76
|
-
fs.writeFileSync(path.join(projectDir, 'preview.png'), Buffer.from(base64, 'base64'));
|
|
77
|
-
console.log('Saved preview.png');
|
|
78
|
-
const file = await api.uploadBase64(base64, 'preview.png');
|
|
79
|
-
await api.updateCardCustomBackgroundImage(cardId, file.fileId);
|
|
80
|
-
console.log('Set deck preview image.');
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
export async function savePreviewImage(dir, wsPort, noRestart = false) {
|
|
84
|
-
const projectDir = path.resolve(dir);
|
|
85
|
-
const castleJson = readCastleJson(projectDir);
|
|
86
|
-
if (!castleJson) {
|
|
87
|
-
console.error('No castle.json found. Save the deck first.');
|
|
88
|
-
process.exit(1);
|
|
89
|
-
}
|
|
90
|
-
if (!config.getToken()) {
|
|
91
|
-
console.error('Not logged in. Run `castle-web login` first.');
|
|
92
|
-
process.exit(1);
|
|
93
|
-
}
|
|
94
|
-
const ws = await connectWS(wsPort);
|
|
95
|
-
if (!noRestart)
|
|
96
|
-
await waitForRestart(ws);
|
|
97
|
-
// Asking for this by name is choosing a cover, so it goes to the card's
|
|
98
|
-
// CUSTOM slot -- the one that takes precedence over the cover a save
|
|
99
|
-
// publishes. Clearing it is an editor action (Deck settings -> Clear cover).
|
|
100
|
-
await captureAndUpload(projectDir, ws, castleJson);
|
|
101
|
-
}
|
package/dist/save-deck.js
CHANGED
|
@@ -79,8 +79,59 @@ async function uploadSource(projectDir, deckId) {
|
|
|
79
79
|
//
|
|
80
80
|
// Never fatal: a deck that saves but whose cover upload failed is a deck that
|
|
81
81
|
// saved. Losing the push over a thumbnail would be the wrong trade.
|
|
82
|
+
// The deck's own serve, if one is running -- the URL a headless cover capture
|
|
83
|
+
// loads. No serve means no deck to photograph, which is a normal state (a save
|
|
84
|
+
// from a script), not an error.
|
|
85
|
+
function runningServeUrl(projectDir) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = fs.readFileSync(path.join(projectDir, '.castle', 'serve.json'), 'utf8');
|
|
88
|
+
const port = JSON.parse(raw).port;
|
|
89
|
+
return typeof port === 'number' ? `http://localhost:${port}` : null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Photograph the running deck with a real browser, into a scratch file.
|
|
96
|
+
//
|
|
97
|
+
// This OUTRANKS the editor's `preview.png`, and deliberately: both are the
|
|
98
|
+
// AUTOMATIC cover (a creator's hand-picked one lives in the card's custom slot
|
|
99
|
+
// and outranks both), so the question is only which automatic capture is better
|
|
100
|
+
// -- and the in-page one silently drops a WebGL canvas that lacks
|
|
101
|
+
// `preserveDrawingBuffer`, plus anything using `filter`/`clip-path`/`mask`. A
|
|
102
|
+
// browser compositing its own frame has no such list.
|
|
103
|
+
//
|
|
104
|
+
// `preview.png` does have one real advantage -- it is whatever the creator last
|
|
105
|
+
// had on screen, where this is a cold load -- so it stays the fallback. But a
|
|
106
|
+
// cover that is missing the game beats a cover of the opening seconds only if
|
|
107
|
+
// you never look at it, and nobody looks at an unattended capture. Writing to a
|
|
108
|
+
// scratch file rather than over `preview.png` keeps that fallback intact.
|
|
109
|
+
async function captureAutomaticCover(projectDir) {
|
|
110
|
+
const serveUrl = runningServeUrl(projectDir);
|
|
111
|
+
if (!serveUrl)
|
|
112
|
+
return null;
|
|
113
|
+
const outPath = path.join(projectDir, '.castle', 'headless-cover.png');
|
|
114
|
+
const { captureCoverHeadless } = await import('./headlessCover.js');
|
|
115
|
+
const result = await captureCoverHeadless({
|
|
116
|
+
serveUrl,
|
|
117
|
+
outPath,
|
|
118
|
+
onProgress: (message) => console.log(message),
|
|
119
|
+
});
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
// Never fatal, same as the upload below: a deck that saved without a cover
|
|
122
|
+
// is a deck that saved.
|
|
123
|
+
console.log(`Cover capture skipped: ${result.error}`);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return outPath;
|
|
127
|
+
}
|
|
82
128
|
async function uploadLocalCover(projectDir) {
|
|
83
|
-
const
|
|
129
|
+
const captured = await captureAutomaticCover(projectDir);
|
|
130
|
+
const coverPath = captured ?? path.join(projectDir, COVER_FILE);
|
|
131
|
+
if (captured)
|
|
132
|
+
console.log('Captured a cover from the running deck.');
|
|
133
|
+
else if (fs.existsSync(coverPath))
|
|
134
|
+
console.log('Using the cover captured in the editor.');
|
|
84
135
|
if (!fs.existsSync(coverPath))
|
|
85
136
|
return null;
|
|
86
137
|
try {
|
package/dist/serve.js
CHANGED
|
@@ -239,6 +239,22 @@ export async function serve(dir, options = {}) {
|
|
|
239
239
|
console.warn(`auto-update: skipped -- ${e instanceof Error ? e.message : String(e)}`);
|
|
240
240
|
});
|
|
241
241
|
}
|
|
242
|
+
// Start pulling the headless browser now, in the background.
|
|
243
|
+
//
|
|
244
|
+
// `save-deck` photographs the deck with it to make the deck's cover, and the
|
|
245
|
+
// binary is a ~250MB one-time download. Left lazy, that download lands inside
|
|
246
|
+
// the creator's first "Push to Castle" -- the one moment they are watching a
|
|
247
|
+
// spinner and least want an unexplained wait. A serve means someone is editing
|
|
248
|
+
// a deck they will probably push, so this is the cheapest place to get ahead
|
|
249
|
+
// of it.
|
|
250
|
+
//
|
|
251
|
+
// Fire-and-forget and single-flight: it shares one download with the agent's
|
|
252
|
+
// playtest tool, so this is free when that already pulled it, and every
|
|
253
|
+
// failure is swallowed -- the capture re-detects and reports properly at the
|
|
254
|
+
// point it actually matters.
|
|
255
|
+
void import('./native/playtest-browser.js')
|
|
256
|
+
.then((m) => m.createPlaytestBrowserManager().prewarm())
|
|
257
|
+
.catch(() => undefined);
|
|
242
258
|
// Close stdin when not attached to a TTY so the process can be cleanly
|
|
243
259
|
// backgrounded with `&` (otherwise stdin reads can block or hold the parent).
|
|
244
260
|
if (!process.stdin.isTTY) {
|
|
Binary file
|
|
Binary file
|