@playcraft/cli 0.0.27 → 0.0.29
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/commands/image.js +152 -6
- package/dist/commands/prefab.js +675 -145
- package/dist/commands/tools.js +46 -10
- package/package.json +4 -3
package/dist/commands/image.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
|
|
2
|
-
import { basename, dirname } from 'path';
|
|
2
|
+
import { basename, dirname, join } from 'path';
|
|
3
3
|
import sharp from 'sharp';
|
|
4
4
|
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
5
5
|
/** 单文件输入上限(字节),防止误处理巨型文件导致 OOM */
|
|
@@ -48,6 +48,21 @@ function handleError(err) {
|
|
|
48
48
|
console.error(`Error: ${msg}`);
|
|
49
49
|
process.exit(1);
|
|
50
50
|
}
|
|
51
|
+
/** 多圈边缘羽化默认 alpha;若 edgeLayers 更大则自最后一档按衰减补齐。 */
|
|
52
|
+
const DEFAULT_EDGE_LAYER_ALPHAS = [200, 120, 60];
|
|
53
|
+
function buildEdgeLayerAlphas(edgeLayers) {
|
|
54
|
+
const base = DEFAULT_EDGE_LAYER_ALPHAS;
|
|
55
|
+
if (edgeLayers <= base.length) {
|
|
56
|
+
return Array.from(base.slice(0, edgeLayers));
|
|
57
|
+
}
|
|
58
|
+
const out = Array.from(base);
|
|
59
|
+
let last = base[base.length - 1];
|
|
60
|
+
while (out.length < edgeLayers) {
|
|
61
|
+
last = Math.max(8, Math.round(last * 0.62));
|
|
62
|
+
out.push(last);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
51
66
|
/**
|
|
52
67
|
* 从图片四条边向内 BFS flood-fill,自动检测边缘主色并移除连通的同色背景区域。
|
|
53
68
|
* 适用于 AI 生成的素材(纯色、灰色、棋盘格背景),前景纹理零损失。
|
|
@@ -143,7 +158,7 @@ async function floodFillRemoveBg(inputPath, opts) {
|
|
|
143
158
|
}
|
|
144
159
|
// ── 3. Edge smoothing (multi-layer alpha gradient) ──
|
|
145
160
|
if (opts.edgeLayers > 0) {
|
|
146
|
-
const layerAlphas =
|
|
161
|
+
const layerAlphas = buildEdgeLayerAlphas(opts.edgeLayers);
|
|
147
162
|
let borderSet = new Set();
|
|
148
163
|
for (let y = 1; y < h - 1; y++) {
|
|
149
164
|
for (let x = 1; x < w - 1; x++) {
|
|
@@ -155,12 +170,13 @@ async function floodFillRemoveBg(inputPath, opts) {
|
|
|
155
170
|
borderSet.add(pos);
|
|
156
171
|
}
|
|
157
172
|
}
|
|
158
|
-
for (let layer = 0; layer <
|
|
173
|
+
for (let layer = 0; layer < layerAlphas.length; layer++) {
|
|
159
174
|
const alpha = layerAlphas[layer];
|
|
160
175
|
for (const pos of borderSet) {
|
|
161
176
|
result[pos * 4 + 3] = Math.min(result[pos * 4 + 3], alpha);
|
|
162
177
|
}
|
|
163
|
-
if (layer <
|
|
178
|
+
if (layer < layerAlphas.length - 1) {
|
|
179
|
+
const nextAlpha = layerAlphas[layer + 1];
|
|
164
180
|
const nextBorder = new Set();
|
|
165
181
|
for (const pos of borderSet) {
|
|
166
182
|
const x = pos % w;
|
|
@@ -172,7 +188,7 @@ async function floodFillRemoveBg(inputPath, opts) {
|
|
|
172
188
|
continue;
|
|
173
189
|
const npos = ny * w + nx;
|
|
174
190
|
if (result[npos * 4 + 3] === 0 && !borderSet.has(npos)) {
|
|
175
|
-
result[npos * 4 + 3] = Math.min(60,
|
|
191
|
+
result[npos * 4 + 3] = Math.min(60, nextAlpha);
|
|
176
192
|
nextBorder.add(npos);
|
|
177
193
|
}
|
|
178
194
|
}
|
|
@@ -212,10 +228,75 @@ function parseResizeFitOption(raw) {
|
|
|
212
228
|
console.error('Error: --fit must be contain|cover|fill|inside|outside');
|
|
213
229
|
process.exit(1);
|
|
214
230
|
}
|
|
231
|
+
/** 不规则子图文件名(避免路径分隔符与非法字符) */
|
|
232
|
+
function safeElementFileBase(name, fallback) {
|
|
233
|
+
const s = name.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^\.+/, '').slice(0, 120);
|
|
234
|
+
const base = (s || fallback).replace(/\.png$/i, '');
|
|
235
|
+
return base || fallback;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 按分析结果将精灵图裁成逐帧 PNG,并写入 split-meta.json(供 sprite-sheet 重组参考)
|
|
239
|
+
*/
|
|
240
|
+
async function splitSpriteSheetToDir(inputPath, outputDir, analysis) {
|
|
241
|
+
mkdirSync(outputDir, { recursive: true });
|
|
242
|
+
// 与后端 analyze-sprite-sheet 一致:按 EXIF 摆正后再按 bounds 裁剪
|
|
243
|
+
const sheetBuf = await sharp(inputPath).rotate().toBuffer();
|
|
244
|
+
const sheetMeta = await sharp(sheetBuf).metadata();
|
|
245
|
+
const iw = sheetMeta.width ?? 0;
|
|
246
|
+
const ih = sheetMeta.height ?? 0;
|
|
247
|
+
const frames = [];
|
|
248
|
+
let written = 0;
|
|
249
|
+
if (analysis.gridType === 'irregular' && analysis.elements?.length) {
|
|
250
|
+
for (let i = 0; i < analysis.elements.length; i++) {
|
|
251
|
+
const el = analysis.elements[i];
|
|
252
|
+
const fname = `${safeElementFileBase(el.name, `frame_${i}`)}.png`;
|
|
253
|
+
const outPath = join(outputDir, fname);
|
|
254
|
+
const { x, y, w, h } = el.bounds;
|
|
255
|
+
await sharp(sheetBuf).extract({ left: x, top: y, width: w, height: h }).png().toFile(outPath);
|
|
256
|
+
frames.push({ path: fname, name: el.name, x, y, w, h });
|
|
257
|
+
written++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const { rows, columns, frameWidth, frameHeight, padding } = analysis;
|
|
262
|
+
for (let r = 0; r < rows; r++) {
|
|
263
|
+
for (let c = 0; c < columns; c++) {
|
|
264
|
+
const x = c * (frameWidth + padding);
|
|
265
|
+
const y = r * (frameHeight + padding);
|
|
266
|
+
if (x + frameWidth > iw || y + frameHeight > ih)
|
|
267
|
+
continue;
|
|
268
|
+
const fname = `frame_r${r}_c${c}.png`;
|
|
269
|
+
const outPath = join(outputDir, fname);
|
|
270
|
+
await sharp(sheetBuf)
|
|
271
|
+
.extract({ left: x, top: y, width: frameWidth, height: frameHeight })
|
|
272
|
+
.png()
|
|
273
|
+
.toFile(outPath);
|
|
274
|
+
frames.push({ path: fname, row: r, col: c, x, y, w: frameWidth, h: frameHeight });
|
|
275
|
+
written++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const splitMeta = {
|
|
280
|
+
source: inputPath,
|
|
281
|
+
imageSize: { w: iw, h: ih },
|
|
282
|
+
gridType: analysis.gridType,
|
|
283
|
+
rows: analysis.rows,
|
|
284
|
+
columns: analysis.columns,
|
|
285
|
+
frameWidth: analysis.frameWidth,
|
|
286
|
+
frameHeight: analysis.frameHeight,
|
|
287
|
+
padding: analysis.padding,
|
|
288
|
+
frameCount: analysis.frameCount,
|
|
289
|
+
description: analysis.description,
|
|
290
|
+
elements: analysis.elements,
|
|
291
|
+
frames,
|
|
292
|
+
};
|
|
293
|
+
writeFileSync(join(outputDir, 'split-meta.json'), JSON.stringify(splitMeta, null, 2), 'utf-8');
|
|
294
|
+
return written;
|
|
295
|
+
}
|
|
215
296
|
export function registerImageCommands(program) {
|
|
216
297
|
const img = program
|
|
217
298
|
.command('image')
|
|
218
|
-
.description('素材工具:本地图片处理(sharp);remove-background
|
|
299
|
+
.description('素材工具:本地图片处理(sharp);remove-background / sprite-split --auto-detect 可调用后端 API');
|
|
219
300
|
// ─── resize ──────────────────────────────────────────────────
|
|
220
301
|
img.command('resize')
|
|
221
302
|
.description('缩放图片(按比例或指定宽高)')
|
|
@@ -547,6 +628,71 @@ export function registerImageCommands(program) {
|
|
|
547
628
|
handleError(e);
|
|
548
629
|
}
|
|
549
630
|
});
|
|
631
|
+
// ─── sprite-split ──────────────────────────────────────────────
|
|
632
|
+
img.command('sprite-split')
|
|
633
|
+
.description('拆分精灵图为逐帧 PNG + split-meta.json;--auto-detect 调用后端多模态分析网格(需 token)')
|
|
634
|
+
.requiredOption('--input <path>', '精灵图路径')
|
|
635
|
+
.requiredOption('--output-dir <dir>', '输出目录(将自动创建)')
|
|
636
|
+
.option('--auto-detect', '由后端 Vision 推断行列、帧尺寸与间距')
|
|
637
|
+
.option('--hint <text>', '传给 Vision 的上下文(如:麻将牌面,等大小网格)')
|
|
638
|
+
.option('--model <id>', '覆盖 Vision 所用 Google GenAI 模型 ID(仅与 --auto-detect 一起使用)')
|
|
639
|
+
.option('--rows <n>', '行数(手动模式必填,除非使用 --auto-detect)', cliParseInt)
|
|
640
|
+
.option('--columns <n>', '列数(手动模式必填)', cliParseInt)
|
|
641
|
+
.option('--frame-width <n>', '单帧宽度(像素,手动模式必填)', cliParseInt)
|
|
642
|
+
.option('--frame-height <n>', '单帧高度(像素,手动模式必填)', cliParseInt)
|
|
643
|
+
.option('--padding <n>', '帧间距(像素,默认 0)', cliParseInt, 0)
|
|
644
|
+
.action(async (opts) => {
|
|
645
|
+
try {
|
|
646
|
+
assertInputWithinLimit(opts.input);
|
|
647
|
+
let analysis;
|
|
648
|
+
if (opts.autoDetect) {
|
|
649
|
+
const imageBase64 = readFileSync(opts.input).toString('base64');
|
|
650
|
+
const client = new AgentApiClient();
|
|
651
|
+
analysis = await client.post('/analyze-sprite-sheet', {
|
|
652
|
+
imageBase64,
|
|
653
|
+
hint: typeof opts.hint === 'string' ? opts.hint : undefined,
|
|
654
|
+
model: typeof opts.model === 'string' && opts.model.trim() ? opts.model.trim() : undefined,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
const rows = opts.rows;
|
|
659
|
+
const columns = opts.columns;
|
|
660
|
+
const frameWidth = opts.frameWidth;
|
|
661
|
+
const frameHeight = opts.frameHeight;
|
|
662
|
+
if (rows === undefined ||
|
|
663
|
+
columns === undefined ||
|
|
664
|
+
frameWidth === undefined ||
|
|
665
|
+
frameHeight === undefined) {
|
|
666
|
+
console.error('Error: manual mode requires --rows, --columns, --frame-width, and --frame-height (or use --auto-detect)');
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
669
|
+
if (rows < 1 || columns < 1 || frameWidth < 1 || frameHeight < 1) {
|
|
670
|
+
console.error('Error: rows, columns, frame-width, frame-height must be positive integers');
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
const padding = opts.padding ?? 0;
|
|
674
|
+
analysis = {
|
|
675
|
+
gridType: 'regular',
|
|
676
|
+
rows,
|
|
677
|
+
columns,
|
|
678
|
+
frameWidth,
|
|
679
|
+
frameHeight,
|
|
680
|
+
padding,
|
|
681
|
+
frameCount: rows * columns,
|
|
682
|
+
description: 'Manual grid (playcraft image sprite-split)',
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const written = await splitSpriteSheetToDir(opts.input, opts.outputDir, analysis);
|
|
686
|
+
if (written === 0) {
|
|
687
|
+
console.error('Error: no frames extracted (check grid vs image size)');
|
|
688
|
+
process.exit(1);
|
|
689
|
+
}
|
|
690
|
+
console.log(`Sprite split: ${written} frame(s) → ${opts.outputDir} (split-meta.json); grid=${analysis.gridType} ${analysis.gridType === 'regular' ? `${analysis.rows}x${analysis.columns}` : `${analysis.elements?.length ?? 0} elements`}`);
|
|
691
|
+
}
|
|
692
|
+
catch (e) {
|
|
693
|
+
handleError(e);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
550
696
|
// ─── sprite-sheet ────────────────────────────────────────────
|
|
551
697
|
img.command('sprite-sheet')
|
|
552
698
|
.description('将多张图合并为精灵图,并输出帧坐标 JSON(完整本地实现)')
|
package/dist/commands/prefab.js
CHANGED
|
@@ -2,7 +2,19 @@ import { inspect } from 'node:util';
|
|
|
2
2
|
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
3
3
|
import { join, resolve } from 'path';
|
|
4
4
|
import JSON5 from 'json5';
|
|
5
|
-
import { THEME_SCHEMA_PATH, THEME_INDEX_PATH, THEME_DIR, DEFAULT_GAME_PATH, MANIFEST_PATH, ASSETS_JSON_PATH, LITE_CREATOR_EXTENSIONS_DIR, themeDataPath, scenePath, defaultValueFromJsonSchemaProperty, buildThemeIndexTs, parseThemeIndexImportPath, parseThemePrefabs, mergeThemeDataKey, deleteThemeDataKey, parseExtensionMetaData, parseGameConfig, buildExtensionPrefabs, updateGameConfigValue, updateGameConfigExtensions, findConfigKeyCaseInsensitive, parseConfigKey, listScenesFromManifest, resolveGameConfigPath, validateThemeValue, validateConfigValue, describeJsonSchemaFields, describeConfigSchemaFields, } from '@playcraft/common/prefab';
|
|
5
|
+
import { THEME_SCHEMA_PATH, THEME_INDEX_PATH, THEME_DIR, DEFAULT_GAME_PATH, MANIFEST_PATH, ASSETS_JSON_PATH, LITE_CREATOR_EXTENSIONS_DIR, themeDataPath, scenePath, defaultValueFromJsonSchemaProperty, buildThemeIndexTs, parseThemeIndexImportPath, parseThemePrefabs, mergeThemeDataKey, deleteThemeDataKey, parseExtensionMetaData, parseGameConfig, buildExtensionPrefabs, updateGameConfigValue, updateGameConfigExtensions, findConfigKeyCaseInsensitive, parseConfigKey, listScenesFromManifest, listScenesWithGameConfig, resolveGameConfigPath, validateThemeValue, validateConfigValue, describeJsonSchemaFields, describeConfigSchemaFields, getPrefabFieldDescriptors, getPrefabFieldDiffs, prefabHasSchemaDiff, } from '@playcraft/common/prefab';
|
|
6
|
+
const DEFAULT_PAGE_LIMIT = 50;
|
|
7
|
+
/** 无法识别 External / PlayCanvas 项目类型(供测试在 mock process.exit 后通过 instanceof 识别)。 */
|
|
8
|
+
export class ProjectDetectError extends Error {
|
|
9
|
+
name = 'ProjectDetectError';
|
|
10
|
+
constructor(message = 'Project type could not be detected') {
|
|
11
|
+
super(message);
|
|
12
|
+
Error.captureStackTrace?.(this, ProjectDetectError);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isProjectDetectError(e) {
|
|
16
|
+
return e instanceof ProjectDetectError;
|
|
17
|
+
}
|
|
6
18
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
7
19
|
function resolveProjectDir(opts) {
|
|
8
20
|
return opts.projectDir ? resolve(opts.projectDir) : process.cwd();
|
|
@@ -21,11 +33,10 @@ function detectProjectType(projectDir) {
|
|
|
21
33
|
console.error('Error: 无法识别项目类型。当前仅支持 External(需 src/theme/theme.schema.json5)' +
|
|
22
34
|
'和 PlayCanvas(需 assets/DefaultGame.json)两种项目类型。');
|
|
23
35
|
process.exit(1);
|
|
24
|
-
throw new
|
|
36
|
+
throw new ProjectDetectError();
|
|
25
37
|
}
|
|
26
38
|
const HUMAN_INSPECT = { colors: false, depth: 10, maxArrayLength: 200 };
|
|
27
39
|
const JSON_OPT_DESC = '以 JSON 输出(便于脚本解析;默认为人可读文本)';
|
|
28
|
-
/** 为子命令追加 `--json`(默认人类可读) */
|
|
29
40
|
function withJsonOption(cmd) {
|
|
30
41
|
return cmd.option('--json', JSON_OPT_DESC);
|
|
31
42
|
}
|
|
@@ -67,6 +78,116 @@ function describeFieldLine(f) {
|
|
|
67
78
|
parts.push(`默认: ${formatHumanValue(f.default)}`);
|
|
68
79
|
return parts.join(' | ');
|
|
69
80
|
}
|
|
81
|
+
function isFieldDescriptor(v) {
|
|
82
|
+
if (v === null || typeof v !== 'object')
|
|
83
|
+
return false;
|
|
84
|
+
const o = v;
|
|
85
|
+
return typeof o.path === 'string' && typeof o.type === 'string';
|
|
86
|
+
}
|
|
87
|
+
/** 规范化 describe 的 fields,剔除不符合 FieldDescriptor 形态的项(避免 JSON/终端输出依赖不安全断言)。 */
|
|
88
|
+
function normalizeDescribeFields(raw) {
|
|
89
|
+
if (!Array.isArray(raw))
|
|
90
|
+
return [];
|
|
91
|
+
return raw.filter(isFieldDescriptor);
|
|
92
|
+
}
|
|
93
|
+
function isDescribeJsonWithFields(b) {
|
|
94
|
+
return 'fields' in b && Array.isArray(b.fields);
|
|
95
|
+
}
|
|
96
|
+
function slicePage(arr, limit, offset) {
|
|
97
|
+
const total = arr.length;
|
|
98
|
+
const slice = arr.slice(offset, offset + limit);
|
|
99
|
+
const hasMore = offset + slice.length < total;
|
|
100
|
+
return { slice, page: { limit, offset, total, hasMore } };
|
|
101
|
+
}
|
|
102
|
+
function tryCompileRegex(pattern, matchCase) {
|
|
103
|
+
try {
|
|
104
|
+
return new RegExp(pattern, matchCase ? '' : 'i');
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
108
|
+
throw new Error(`Invalid regex pattern "${pattern}": ${msg}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function prefabMetaMatches(p, re) {
|
|
112
|
+
if (re.test(p.key))
|
|
113
|
+
return true;
|
|
114
|
+
if (re.test(p.name))
|
|
115
|
+
return true;
|
|
116
|
+
if (p.description && re.test(p.description))
|
|
117
|
+
return true;
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
function filterPrefabsList(all, o) {
|
|
121
|
+
let list = all;
|
|
122
|
+
if (o.usedOnly)
|
|
123
|
+
list = list.filter((p) => p.isUsed);
|
|
124
|
+
if (o.unusedOnly)
|
|
125
|
+
list = list.filter((p) => !p.isUsed);
|
|
126
|
+
if (o.changedOnly)
|
|
127
|
+
list = list.filter((p) => prefabHasSchemaDiff(p));
|
|
128
|
+
if (o.match) {
|
|
129
|
+
const re = tryCompileRegex(o.match, Boolean(o.matchCase));
|
|
130
|
+
list = list.filter((p) => prefabMetaMatches(p, re));
|
|
131
|
+
}
|
|
132
|
+
return list;
|
|
133
|
+
}
|
|
134
|
+
function buildSummary(systemType, variantResolved, all) {
|
|
135
|
+
const enabled = all.filter((p) => p.isUsed).length;
|
|
136
|
+
const changed = all.filter((p) => prefabHasSchemaDiff(p)).length;
|
|
137
|
+
return {
|
|
138
|
+
projectType: systemType === 'external-theme' ? 'external' : 'playcanvas',
|
|
139
|
+
variant: variantResolved,
|
|
140
|
+
total: all.length,
|
|
141
|
+
enabled,
|
|
142
|
+
disabled: all.length - enabled,
|
|
143
|
+
changed,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function buildDescribeGuide(prefabKey, isUsed) {
|
|
147
|
+
return {
|
|
148
|
+
workflow: [
|
|
149
|
+
'playcraft prefab list --json --limit 50 --offset 0',
|
|
150
|
+
`playcraft prefab diff --json --limit ${DEFAULT_PAGE_LIMIT} --offset 0`,
|
|
151
|
+
`playcraft prefab get ${prefabKey} --json`,
|
|
152
|
+
],
|
|
153
|
+
setExample: `playcraft prefab set ${prefabKey} <field> <value>`,
|
|
154
|
+
whenDisabled: isUsed ? undefined : `playcraft prefab enable ${prefabKey}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function valuePreview(p, maxLen = 120) {
|
|
158
|
+
if (!p.isUsed)
|
|
159
|
+
return null;
|
|
160
|
+
const s = formatHumanValue(p.currentValue);
|
|
161
|
+
if (s.length <= maxLen)
|
|
162
|
+
return s;
|
|
163
|
+
return `${s.slice(0, maxLen)}…`;
|
|
164
|
+
}
|
|
165
|
+
function withPagingOptions(cmd) {
|
|
166
|
+
return cmd
|
|
167
|
+
.option('--limit <n>', '分页条数', (v) => parseInt(v, 10), DEFAULT_PAGE_LIMIT)
|
|
168
|
+
.option('--offset <n>', '分页偏移', (v) => parseInt(v, 10), 0);
|
|
169
|
+
}
|
|
170
|
+
function withFilterOptions(cmd) {
|
|
171
|
+
return cmd
|
|
172
|
+
.option('--match <regex>', 'prefab 级正则过滤(key/name/description)')
|
|
173
|
+
.option('--match-case', '正则区分大小写(默认忽略大小写)')
|
|
174
|
+
.option('--used-only', '仅已启用')
|
|
175
|
+
.option('--unused-only', '仅未启用')
|
|
176
|
+
.option('--changed-only', '相对 schema 默认值有差异的 prefab');
|
|
177
|
+
}
|
|
178
|
+
function parsePaging(opts) {
|
|
179
|
+
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : DEFAULT_PAGE_LIMIT;
|
|
180
|
+
const offset = Number.isFinite(opts.offset) && opts.offset >= 0 ? opts.offset : 0;
|
|
181
|
+
return { limit, offset };
|
|
182
|
+
}
|
|
183
|
+
function resolveVariantResolved(projectDir, systemType, opts) {
|
|
184
|
+
if (opts.variant)
|
|
185
|
+
return opts.variant;
|
|
186
|
+
if (systemType === 'external-theme') {
|
|
187
|
+
return resolveThemeId(projectDir, opts);
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
70
191
|
// ─── External Theme helpers ──────────────────────────────────────────────────
|
|
71
192
|
function loadThemeSchema(projectDir) {
|
|
72
193
|
const raw = readLocalFile(projectDir, THEME_SCHEMA_PATH);
|
|
@@ -116,6 +237,7 @@ function resolveThemeId(projectDir, opts) {
|
|
|
116
237
|
return themes[0];
|
|
117
238
|
console.error('Error: 没有找到可用的主题。请使用 --variant 指定主题 ID。');
|
|
118
239
|
process.exit(1);
|
|
240
|
+
throw new Error('unreachable');
|
|
119
241
|
}
|
|
120
242
|
// ─── PlayCanvas / LiteCreator helpers ────────────────────────────────────────
|
|
121
243
|
function loadGameConfig(projectDir, configPath) {
|
|
@@ -144,25 +266,60 @@ function scanExtensionMetaData(projectDir) {
|
|
|
144
266
|
}
|
|
145
267
|
return map;
|
|
146
268
|
}
|
|
269
|
+
/** Write a single field's value into MetaData.json configSchema.{field}.default */
|
|
270
|
+
function writeMetaDataDefault(projectDir, extensionName, field, value) {
|
|
271
|
+
const metaPath = join(projectDir, LITE_CREATOR_EXTENSIONS_DIR, extensionName, 'MetaData.json');
|
|
272
|
+
const raw = readFileSync(metaPath, 'utf-8');
|
|
273
|
+
const metaData = JSON.parse(raw);
|
|
274
|
+
const configSchema = metaData.configSchema;
|
|
275
|
+
if (!configSchema?.[field]) {
|
|
276
|
+
throw new Error(`MetaData.json for "${extensionName}" has no configSchema.${field}`);
|
|
277
|
+
}
|
|
278
|
+
configSchema[field].default = value;
|
|
279
|
+
writeFileSync(metaPath, JSON.stringify(metaData, null, 2), 'utf-8');
|
|
280
|
+
}
|
|
281
|
+
/** Write multiple fields' values into MetaData.json configSchema.*.default */
|
|
282
|
+
function writeMetaDataDefaults(projectDir, extensionName, values) {
|
|
283
|
+
const metaPath = join(projectDir, LITE_CREATOR_EXTENSIONS_DIR, extensionName, 'MetaData.json');
|
|
284
|
+
const raw = readFileSync(metaPath, 'utf-8');
|
|
285
|
+
const metaData = JSON.parse(raw);
|
|
286
|
+
const configSchema = metaData.configSchema;
|
|
287
|
+
if (!configSchema) {
|
|
288
|
+
throw new Error(`MetaData.json for "${extensionName}" has no configSchema`);
|
|
289
|
+
}
|
|
290
|
+
for (const [field, value] of Object.entries(values)) {
|
|
291
|
+
if (configSchema[field]) {
|
|
292
|
+
configSchema[field].default = value;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
writeFileSync(metaPath, JSON.stringify(metaData, null, 2), 'utf-8');
|
|
296
|
+
}
|
|
147
297
|
function resolveGameConfigPathForVariant(projectDir, variantId) {
|
|
148
298
|
if (!variantId)
|
|
149
|
-
return
|
|
299
|
+
return null;
|
|
150
300
|
if (!fileExists(projectDir, scenePath(variantId)))
|
|
151
|
-
return
|
|
301
|
+
return null;
|
|
152
302
|
const sceneJson = JSON.parse(readLocalFile(projectDir, scenePath(variantId)));
|
|
153
303
|
if (!fileExists(projectDir, ASSETS_JSON_PATH))
|
|
154
|
-
return
|
|
304
|
+
return null;
|
|
155
305
|
const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
|
|
156
|
-
|
|
157
|
-
return resolved ?? DEFAULT_GAME_PATH;
|
|
306
|
+
return resolveGameConfigPath(sceneJson, assetsJson);
|
|
158
307
|
}
|
|
159
308
|
function listLocalScenes(projectDir) {
|
|
160
309
|
if (!fileExists(projectDir, MANIFEST_PATH))
|
|
161
310
|
return [];
|
|
162
311
|
const manifestJson = JSON.parse(readLocalFile(projectDir, MANIFEST_PATH));
|
|
312
|
+
if (fileExists(projectDir, ASSETS_JSON_PATH)) {
|
|
313
|
+
const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
|
|
314
|
+
return listScenesWithGameConfig(manifestJson, (sceneId) => {
|
|
315
|
+
const sp = scenePath(sceneId);
|
|
316
|
+
if (!fileExists(projectDir, sp))
|
|
317
|
+
return null;
|
|
318
|
+
return JSON.parse(readLocalFile(projectDir, sp));
|
|
319
|
+
}, assetsJson);
|
|
320
|
+
}
|
|
163
321
|
return listScenesFromManifest(manifestJson);
|
|
164
322
|
}
|
|
165
|
-
// ─── Unified prefab loading ──────────────────────────────────────────────────
|
|
166
323
|
function loadPrefabs(projectDir, systemType, opts) {
|
|
167
324
|
if (systemType === 'external-theme') {
|
|
168
325
|
const themeId = resolveThemeId(projectDir, opts);
|
|
@@ -170,11 +327,22 @@ function loadPrefabs(projectDir, systemType, opts) {
|
|
|
170
327
|
const data = loadThemeData(projectDir, themeId);
|
|
171
328
|
return parseThemePrefabs(schema, data, themeId);
|
|
172
329
|
}
|
|
173
|
-
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
174
|
-
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
175
|
-
const { extensions, config } = parseGameConfig(gameConfigJson);
|
|
176
330
|
const metaMap = scanExtensionMetaData(projectDir);
|
|
177
|
-
|
|
331
|
+
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
332
|
+
if (configPath) {
|
|
333
|
+
// 有场景级 GameConfig → 从中读取 extensions 和 config
|
|
334
|
+
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
335
|
+
const { extensions, config } = parseGameConfig(gameConfigJson);
|
|
336
|
+
return buildExtensionPrefabs(metaMap, { extensions, config }, configPath);
|
|
337
|
+
}
|
|
338
|
+
// 无场景级 GameConfig → 从 DefaultGame.json 只读 Extensions 列表(不读 Config 值)
|
|
339
|
+
if (fileExists(projectDir, DEFAULT_GAME_PATH)) {
|
|
340
|
+
const defaultGameJson = loadGameConfig(projectDir, DEFAULT_GAME_PATH);
|
|
341
|
+
const { extensions } = parseGameConfig(defaultGameJson);
|
|
342
|
+
return buildExtensionPrefabs(metaMap, { extensions, config: {} }, DEFAULT_GAME_PATH);
|
|
343
|
+
}
|
|
344
|
+
// 完全无 GameConfig → 仅根据 MetaData 构建(全部标记为未启用)
|
|
345
|
+
return buildExtensionPrefabs(metaMap, { extensions: [], config: {} }, '');
|
|
178
346
|
}
|
|
179
347
|
// ─── dotpath helper ──────────────────────────────────────────────────────────
|
|
180
348
|
function getByDotPath(obj, path) {
|
|
@@ -222,12 +390,58 @@ function coerceValue(rawValue, fieldType) {
|
|
|
222
390
|
return rawValue;
|
|
223
391
|
}
|
|
224
392
|
}
|
|
393
|
+
/** Coerce batch JSON value: strings go through coerceValue; other types used as-is. */
|
|
394
|
+
function coerceBatchLeaf(raw, fieldType) {
|
|
395
|
+
if (typeof raw === 'string')
|
|
396
|
+
return coerceValue(raw, fieldType);
|
|
397
|
+
return raw;
|
|
398
|
+
}
|
|
399
|
+
function readBatchJson(filePath) {
|
|
400
|
+
if (filePath)
|
|
401
|
+
return readFileSync(filePath, 'utf-8');
|
|
402
|
+
return readFileSync(0, 'utf-8');
|
|
403
|
+
}
|
|
404
|
+
function buildSingleDescribeJson(found) {
|
|
405
|
+
if (found.systemType === 'external-theme' && found.jsonSchema) {
|
|
406
|
+
const fields = normalizeDescribeFields(describeJsonSchemaFields(found.jsonSchema, found.currentValue));
|
|
407
|
+
return {
|
|
408
|
+
key: found.key,
|
|
409
|
+
type: found.systemType,
|
|
410
|
+
isUsed: found.isUsed,
|
|
411
|
+
description: found.description,
|
|
412
|
+
fields,
|
|
413
|
+
guide: buildDescribeGuide(found.key, found.isUsed),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
if (found.configSchema) {
|
|
417
|
+
const currentValues = (found.currentValue && typeof found.currentValue === 'object')
|
|
418
|
+
? found.currentValue
|
|
419
|
+
: {};
|
|
420
|
+
const fields = normalizeDescribeFields(describeConfigSchemaFields(found.configSchema, currentValues));
|
|
421
|
+
return {
|
|
422
|
+
key: found.key,
|
|
423
|
+
type: found.systemType,
|
|
424
|
+
isUsed: found.isUsed,
|
|
425
|
+
description: found.description,
|
|
426
|
+
configPath: found.configPath,
|
|
427
|
+
fields,
|
|
428
|
+
guide: buildDescribeGuide(found.key, found.isUsed),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
key: found.key,
|
|
433
|
+
type: found.systemType,
|
|
434
|
+
isUsed: found.isUsed,
|
|
435
|
+
description: found.description,
|
|
436
|
+
currentValue: found.currentValue,
|
|
437
|
+
guide: buildDescribeGuide(found.key, found.isUsed),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
225
440
|
// ─── Command registration ────────────────────────────────────────────────────
|
|
226
441
|
export function registerPrefabCommands(program) {
|
|
227
442
|
const prefab = program
|
|
228
443
|
.command('prefab')
|
|
229
444
|
.description('Remix prefab 配置管理(自动检测 External Theme / PlayCanvas 项目类型)');
|
|
230
|
-
// ─── variants / themes / scenes (aliases) ────────────────────
|
|
231
445
|
const variantsHandler = (opts) => {
|
|
232
446
|
const projectDir = resolveProjectDir(opts);
|
|
233
447
|
const systemType = detectProjectType(projectDir);
|
|
@@ -283,144 +497,294 @@ export function registerPrefabCommands(program) {
|
|
|
283
497
|
.description('variants 别名(对齐前端「场景列表」术语)')
|
|
284
498
|
.option('--project-dir <path>', '项目根目录(默认 cwd)')
|
|
285
499
|
.action(variantsHandler);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
.description('列出所有 prefab 及状态')
|
|
500
|
+
withPagingOptions(withFilterOptions(withJsonOption(prefab.command('list'))))
|
|
501
|
+
.description('列出 prefab 及状态(支持分页与正则过滤)')
|
|
289
502
|
.option('--variant <id>', '指定变体(External: themeId;PlayCanvas: sceneId)')
|
|
290
503
|
.option('--project-dir <path>', '项目根目录(默认 cwd)')
|
|
504
|
+
.option('--with-values', '附带当前值摘要(已启用项)')
|
|
291
505
|
.action((opts) => {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
variantNote =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
})
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const label = p.key.padEnd(keyW);
|
|
323
|
-
console.log(` ${label} ${status}`);
|
|
324
|
-
const detailIndent = ' ';
|
|
325
|
-
if (p.name && p.name !== p.key) {
|
|
326
|
-
console.log(`${detailIndent}名称: ${p.name}`);
|
|
327
|
-
}
|
|
328
|
-
const desc = p.description?.trim();
|
|
329
|
-
if (desc) {
|
|
330
|
-
console.log(`${detailIndent}说明: ${desc}`);
|
|
506
|
+
try {
|
|
507
|
+
const projectDir = resolveProjectDir(opts);
|
|
508
|
+
const systemType = detectProjectType(projectDir);
|
|
509
|
+
const all = loadPrefabs(projectDir, systemType, opts);
|
|
510
|
+
const asJson = Boolean(opts.json);
|
|
511
|
+
const variantResolved = resolveVariantResolved(projectDir, systemType, opts);
|
|
512
|
+
let variantNote = opts.variant ?? null;
|
|
513
|
+
if (systemType === 'external-theme' && !opts.variant) {
|
|
514
|
+
variantNote = resolveThemeId(projectDir, opts);
|
|
515
|
+
}
|
|
516
|
+
const summary = buildSummary(systemType, variantResolved, all);
|
|
517
|
+
const filtered = filterPrefabsList(all, {
|
|
518
|
+
match: opts.match,
|
|
519
|
+
matchCase: Boolean(opts.matchCase),
|
|
520
|
+
usedOnly: Boolean(opts.usedOnly),
|
|
521
|
+
unusedOnly: Boolean(opts.unusedOnly),
|
|
522
|
+
changedOnly: Boolean(opts.changedOnly),
|
|
523
|
+
});
|
|
524
|
+
const { limit, offset } = parsePaging(opts);
|
|
525
|
+
const { slice, page } = slicePage(filtered, limit, offset);
|
|
526
|
+
const prefabRows = slice.map((p) => {
|
|
527
|
+
const row = {
|
|
528
|
+
key: p.key,
|
|
529
|
+
name: p.name,
|
|
530
|
+
description: p.description,
|
|
531
|
+
isUsed: p.isUsed,
|
|
532
|
+
type: p.systemType,
|
|
533
|
+
};
|
|
534
|
+
if (opts.withValues) {
|
|
535
|
+
row.valuePreview = valuePreview(p);
|
|
331
536
|
}
|
|
332
|
-
|
|
333
|
-
|
|
537
|
+
return row;
|
|
538
|
+
});
|
|
539
|
+
const payload = {
|
|
540
|
+
summary,
|
|
541
|
+
page,
|
|
542
|
+
prefabs: prefabRows,
|
|
543
|
+
};
|
|
544
|
+
outputResult(asJson, payload, () => {
|
|
545
|
+
console.log(`项目类型: ${projectTypeLabel(systemType)}`);
|
|
546
|
+
console.log(`变体上下文: ${variantNote ?? '(默认)'}`);
|
|
547
|
+
console.log(`概况: 共 ${summary.total} 个 prefab,已启用 ${summary.enabled},未启用 ${summary.disabled},` +
|
|
548
|
+
`相对默认值有改动 ${summary.changed} 个。`);
|
|
549
|
+
console.log(`本页: ${filtered.length === 0 ? 0 : offset + 1}-${offset + slice.length} / 过滤后 ${page.total} 条` +
|
|
550
|
+
(page.hasMore ? `(尚有更多,使用 --offset ${offset + limit})` : ''));
|
|
551
|
+
console.log('');
|
|
552
|
+
const maxKeyLen = slice.reduce((m, p) => Math.max(m, p.key.length), 0);
|
|
553
|
+
const keyW = Math.min(28, Math.max(8, maxKeyLen));
|
|
554
|
+
for (let i = 0; i < slice.length; i++) {
|
|
555
|
+
const p = slice[i];
|
|
556
|
+
const status = p.isUsed ? '已启用' : '未启用';
|
|
557
|
+
const label = p.key.padEnd(keyW);
|
|
558
|
+
console.log(` ${label} ${status}`);
|
|
559
|
+
const detailIndent = ' ';
|
|
560
|
+
if (opts.withValues && p.isUsed) {
|
|
561
|
+
const pv = valuePreview(p, 200);
|
|
562
|
+
if (pv)
|
|
563
|
+
console.log(`${detailIndent}值摘要: ${pv}`);
|
|
564
|
+
}
|
|
565
|
+
if (p.name && p.name !== p.key) {
|
|
566
|
+
console.log(`${detailIndent}名称: ${p.name}`);
|
|
567
|
+
}
|
|
568
|
+
const desc = p.description?.trim();
|
|
569
|
+
if (desc) {
|
|
570
|
+
console.log(`${detailIndent}说明: ${desc}`);
|
|
571
|
+
}
|
|
572
|
+
if (i < slice.length - 1)
|
|
573
|
+
console.log('');
|
|
334
574
|
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
catch (e) {
|
|
578
|
+
if (isProjectDetectError(e))
|
|
579
|
+
throw e;
|
|
580
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
581
|
+
console.error(`Error: ${msg}`);
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
337
584
|
});
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
.description('显示 prefab 完整字段信息')
|
|
585
|
+
withPagingOptions(withFilterOptions(withJsonOption(prefab.command('diff'))))
|
|
586
|
+
.description('仅显示相对 schema 默认值有差异的字段')
|
|
341
587
|
.option('--variant <id>', '指定变体')
|
|
342
588
|
.option('--project-dir <path>', '项目根目录(默认 cwd)')
|
|
343
|
-
.action((
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
589
|
+
.action((opts) => {
|
|
590
|
+
try {
|
|
591
|
+
const projectDir = resolveProjectDir(opts);
|
|
592
|
+
const systemType = detectProjectType(projectDir);
|
|
593
|
+
const all = loadPrefabs(projectDir, systemType, opts);
|
|
594
|
+
const asJson = Boolean(opts.json);
|
|
595
|
+
const variantResolved = resolveVariantResolved(projectDir, systemType, opts);
|
|
596
|
+
const summary = buildSummary(systemType, variantResolved, all);
|
|
597
|
+
const withDiff = all.filter((p) => getPrefabFieldDiffs(p).length > 0);
|
|
598
|
+
const filtered = filterPrefabsList(withDiff, {
|
|
599
|
+
match: opts.match,
|
|
600
|
+
matchCase: Boolean(opts.matchCase),
|
|
601
|
+
usedOnly: Boolean(opts.usedOnly),
|
|
602
|
+
unusedOnly: Boolean(opts.unusedOnly),
|
|
603
|
+
changedOnly: Boolean(opts.changedOnly),
|
|
604
|
+
});
|
|
605
|
+
const { limit, offset } = parsePaging(opts);
|
|
606
|
+
const { slice, page } = slicePage(filtered, limit, offset);
|
|
607
|
+
const items = slice.map((p) => ({
|
|
608
|
+
key: p.key,
|
|
609
|
+
isUsed: p.isUsed,
|
|
610
|
+
diffs: getPrefabFieldDiffs(p),
|
|
611
|
+
}));
|
|
612
|
+
const payload = { summary, page, prefabs: items };
|
|
362
613
|
outputResult(asJson, payload, () => {
|
|
363
|
-
console.log(
|
|
364
|
-
console.log(
|
|
365
|
-
console.log(`状态: ${found.isUsed ? '已启用' : '未启用'}`);
|
|
366
|
-
if (found.description)
|
|
367
|
-
console.log(`说明: ${found.description}`);
|
|
614
|
+
console.log(`概况: ${summary.total} 个 prefab,其中 ${withDiff.length} 个有字段级差异。`);
|
|
615
|
+
console.log(`本页: ${page.total === 0 ? 0 : offset + 1}-${offset + slice.length} / ${page.total}`);
|
|
368
616
|
console.log('');
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
617
|
+
for (const it of items) {
|
|
618
|
+
console.log(` ${it.key}:`);
|
|
619
|
+
for (const d of it.diffs) {
|
|
620
|
+
console.log(` ${d.path}: 当前 ${formatHumanValue(d.current)} | 默认 ${formatHumanValue(d.default)}`);
|
|
621
|
+
}
|
|
622
|
+
console.log('');
|
|
372
623
|
}
|
|
373
624
|
});
|
|
374
625
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
626
|
+
catch (e) {
|
|
627
|
+
if (isProjectDetectError(e))
|
|
628
|
+
throw e;
|
|
629
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
630
|
+
console.error(`Error: ${msg}`);
|
|
631
|
+
process.exit(1);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
withPagingOptions(withFilterOptions(withJsonOption(prefab.command('describe [key]'))))
|
|
635
|
+
.description('显示 prefab 字段信息;省略 key 时需 --all')
|
|
636
|
+
.option('--all', '列出(过滤后)全部 prefab 的字段详情,分页输出')
|
|
637
|
+
.option('--variant <id>', '指定变体')
|
|
638
|
+
.option('--project-dir <path>', '项目根目录(默认 cwd)')
|
|
639
|
+
.action((key, opts) => {
|
|
640
|
+
try {
|
|
641
|
+
const projectDir = resolveProjectDir(opts);
|
|
642
|
+
const systemType = detectProjectType(projectDir);
|
|
643
|
+
const prefabs = loadPrefabs(projectDir, systemType, opts);
|
|
644
|
+
const asJson = Boolean(opts.json);
|
|
645
|
+
const variantResolved = resolveVariantResolved(projectDir, systemType, opts);
|
|
646
|
+
const summary = buildSummary(systemType, variantResolved, prefabs);
|
|
647
|
+
const allMode = Boolean(opts.all);
|
|
648
|
+
if (allMode && key) {
|
|
649
|
+
console.error('Error: 不能同时使用位置参数 key 与 --all。');
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
if (!allMode && !key) {
|
|
653
|
+
console.error('Error: 请指定 <key> 或传入 --all。');
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
if (!allMode && key) {
|
|
657
|
+
const found = prefabs.find((p) => p.key === key);
|
|
658
|
+
if (!found) {
|
|
659
|
+
console.error(`Error: prefab "${key}" 不存在。可用的 key: ${prefabs.map((p) => p.key).join(', ')}`);
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
const payload = buildSingleDescribeJson(found);
|
|
663
|
+
outputResult(asJson, payload, () => {
|
|
664
|
+
console.log(`Prefab: ${found.key}`);
|
|
665
|
+
console.log(`类型: ${projectTypeLabel(found.systemType)}`);
|
|
666
|
+
console.log(`状态: ${found.isUsed ? '已启用' : '未启用'}`);
|
|
667
|
+
if (found.description)
|
|
668
|
+
console.log(`说明: ${found.description}`);
|
|
669
|
+
console.log('');
|
|
670
|
+
if (isDescribeJsonWithFields(payload)) {
|
|
671
|
+
console.log('字段:');
|
|
672
|
+
for (const f of payload.fields) {
|
|
673
|
+
console.log(` • ${describeFieldLine(f)}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
console.log('当前值:');
|
|
678
|
+
console.log(formatHumanValue(payload.currentValue));
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const filtered = filterPrefabsList(prefabs, {
|
|
684
|
+
match: opts.match,
|
|
685
|
+
matchCase: Boolean(opts.matchCase),
|
|
686
|
+
usedOnly: Boolean(opts.usedOnly),
|
|
687
|
+
unusedOnly: Boolean(opts.unusedOnly),
|
|
688
|
+
changedOnly: Boolean(opts.changedOnly),
|
|
689
|
+
});
|
|
690
|
+
const { limit, offset } = parsePaging(opts);
|
|
691
|
+
const { slice, page } = slicePage(filtered, limit, offset);
|
|
692
|
+
const blocks = slice.map((p) => buildSingleDescribeJson(p));
|
|
380
693
|
const payload = {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
694
|
+
summary,
|
|
695
|
+
page,
|
|
696
|
+
guide: {
|
|
697
|
+
workflow: [
|
|
698
|
+
'playcraft prefab list --json --limit 20 --offset 0',
|
|
699
|
+
'playcraft prefab describe --all --json --match "<regex>" --limit 20 --offset 0',
|
|
700
|
+
],
|
|
701
|
+
},
|
|
702
|
+
prefabs: blocks,
|
|
387
703
|
};
|
|
388
704
|
outputResult(asJson, payload, () => {
|
|
389
|
-
console.log(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
705
|
+
console.log(`概况: ${summary.total} 个 prefab;本页描述 ${blocks.length} 个(过滤后共 ${page.total})。`);
|
|
706
|
+
for (const b of blocks) {
|
|
707
|
+
console.log('');
|
|
708
|
+
console.log(`=== ${b.key} ===`);
|
|
709
|
+
if (isDescribeJsonWithFields(b)) {
|
|
710
|
+
for (const f of b.fields) {
|
|
711
|
+
console.log(` • ${describeFieldLine(f)}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
console.log(formatHumanValue(b.currentValue));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (page.hasMore) {
|
|
719
|
+
console.log('');
|
|
720
|
+
console.log(`下一页: --offset ${offset + limit}`);
|
|
400
721
|
}
|
|
401
722
|
});
|
|
402
723
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
724
|
+
catch (e) {
|
|
725
|
+
if (isProjectDetectError(e))
|
|
726
|
+
throw e;
|
|
727
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
728
|
+
console.error(`Error: ${msg}`);
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
withPagingOptions(withJsonOption(prefab.command('search <pattern>')))
|
|
733
|
+
.description('按正则搜索字段 path /说明 / 枚举(所有 prefab)')
|
|
734
|
+
.option('--match-case', '区分大小写')
|
|
735
|
+
.option('--variant <id>', '指定变体')
|
|
736
|
+
.option('--project-dir <path>', '项目根目录(默认 cwd)')
|
|
737
|
+
.action((pattern, opts) => {
|
|
738
|
+
try {
|
|
739
|
+
const projectDir = resolveProjectDir(opts);
|
|
740
|
+
const systemType = detectProjectType(projectDir);
|
|
741
|
+
const prefabs = loadPrefabs(projectDir, systemType, opts);
|
|
742
|
+
const asJson = Boolean(opts.json);
|
|
743
|
+
const variantResolved = resolveVariantResolved(projectDir, systemType, opts);
|
|
744
|
+
const summary = buildSummary(systemType, variantResolved, prefabs);
|
|
745
|
+
const re = tryCompileRegex(pattern, Boolean(opts.matchCase));
|
|
746
|
+
const hits = [];
|
|
747
|
+
for (const p of prefabs) {
|
|
748
|
+
for (const f of getPrefabFieldDescriptors(p)) {
|
|
749
|
+
const hay = [
|
|
750
|
+
f.path,
|
|
751
|
+
f.description ?? '',
|
|
752
|
+
...(f.enumOptions ?? []),
|
|
753
|
+
].join('\n');
|
|
754
|
+
if (re.test(hay)) {
|
|
755
|
+
hits.push({
|
|
756
|
+
prefabKey: p.key,
|
|
757
|
+
fieldPath: f.path,
|
|
758
|
+
type: f.type,
|
|
759
|
+
description: f.description,
|
|
760
|
+
currentValue: f.currentValue,
|
|
761
|
+
default: f.default,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const { limit, offset } = parsePaging(opts);
|
|
767
|
+
const { slice, page } = slicePage(hits, limit, offset);
|
|
768
|
+
const payload = { summary, page, hits: slice };
|
|
411
769
|
outputResult(asJson, payload, () => {
|
|
412
|
-
console.log(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
770
|
+
console.log(`命中 ${hits.length} 条字段(本页 ${slice.length} 条)`);
|
|
771
|
+
for (const h of slice) {
|
|
772
|
+
console.log(` ${h.prefabKey}.${h.fieldPath} (${h.type})`);
|
|
773
|
+
if (h.description)
|
|
774
|
+
console.log(` ${h.description}`);
|
|
775
|
+
}
|
|
776
|
+
if (page.hasMore)
|
|
777
|
+
console.log(`下一页: --offset ${offset + limit}`);
|
|
420
778
|
});
|
|
421
779
|
}
|
|
780
|
+
catch (e) {
|
|
781
|
+
if (isProjectDetectError(e))
|
|
782
|
+
throw e;
|
|
783
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
784
|
+
console.error(`Error: ${msg}`);
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
422
787
|
});
|
|
423
|
-
// ─── get ─────────────────────────────────────────────────────
|
|
424
788
|
withJsonOption(prefab.command('get <key> [field]'))
|
|
425
789
|
.description('获取 prefab 或子字段的当前值')
|
|
426
790
|
.option('--variant <id>', '指定变体')
|
|
@@ -454,7 +818,6 @@ export function registerPrefabCommands(program) {
|
|
|
454
818
|
}
|
|
455
819
|
});
|
|
456
820
|
});
|
|
457
|
-
// ─── set ─────────────────────────────────────────────────────
|
|
458
821
|
withJsonOption(prefab.command('set <key> <field> <value>'))
|
|
459
822
|
.description('修改 prefab 子字段值(带校验)')
|
|
460
823
|
.option('--variant <id>', '指定变体')
|
|
@@ -502,18 +865,12 @@ export function registerPrefabCommands(program) {
|
|
|
502
865
|
});
|
|
503
866
|
}
|
|
504
867
|
else {
|
|
505
|
-
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
506
|
-
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
507
|
-
const { config } = parseGameConfig(gameConfigJson);
|
|
508
868
|
const metaMap = scanExtensionMetaData(projectDir);
|
|
509
869
|
const meta = metaMap.get(key);
|
|
510
870
|
if (!meta) {
|
|
511
871
|
console.error(`Error: extension "${key}" 不存在。`);
|
|
512
872
|
process.exit(1);
|
|
513
873
|
}
|
|
514
|
-
const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
|
|
515
|
-
const existingKey = findConfigKeyCaseInsensitive(config, configKey);
|
|
516
|
-
const currentValues = (existingKey ? config[existingKey] : {}) ?? {};
|
|
517
874
|
const fieldDef = meta.configSchema?.[field];
|
|
518
875
|
const fieldType = fieldDef?.type ?? 'string';
|
|
519
876
|
const coerced = coerceValue(rawValue, fieldType);
|
|
@@ -524,16 +881,192 @@ export function registerPrefabCommands(program) {
|
|
|
524
881
|
process.exit(1);
|
|
525
882
|
}
|
|
526
883
|
}
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
884
|
+
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
885
|
+
if (configPath) {
|
|
886
|
+
// 有场景级 GameConfig → 写入 GameConfig 文件
|
|
887
|
+
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
888
|
+
const { config } = parseGameConfig(gameConfigJson);
|
|
889
|
+
const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
|
|
890
|
+
const existingKey = findConfigKeyCaseInsensitive(config, configKey);
|
|
891
|
+
const currentValues = (existingKey ? config[existingKey] : {}) ?? {};
|
|
892
|
+
const newValues = { ...currentValues, [field]: coerced };
|
|
893
|
+
const updated = updateGameConfigValue(gameConfigJson, configKey, newValues);
|
|
894
|
+
writeGameConfig(projectDir, configPath, updated);
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
// 无场景级 GameConfig → 写 MetaData.json 的 configSchema.{field}.default
|
|
898
|
+
writeMetaDataDefault(projectDir, key, field, coerced);
|
|
899
|
+
}
|
|
530
900
|
const payload = { success: true, key, field, value: coerced };
|
|
531
901
|
outputResult(asJson, payload, () => {
|
|
532
902
|
console.log(`已更新 ${key}.${field} = ${formatHumanValue(coerced)}`);
|
|
533
903
|
});
|
|
534
904
|
}
|
|
535
905
|
});
|
|
536
|
-
|
|
906
|
+
withJsonOption(prefab.command('set-batch'))
|
|
907
|
+
.description('从文件或 stdin 读取 JSON,一次修改多个 prefab 字段(校验全部通过后写盘)')
|
|
908
|
+
.option('--file <path>', 'JSON 文件路径(默认读 stdin)')
|
|
909
|
+
.option('--variant <id>', '指定变体')
|
|
910
|
+
.option('--project-dir <path>', '项目根目录(默认 cwd)')
|
|
911
|
+
.action((opts) => {
|
|
912
|
+
const projectDir = resolveProjectDir(opts);
|
|
913
|
+
const systemType = detectProjectType(projectDir);
|
|
914
|
+
const asJson = Boolean(opts.json);
|
|
915
|
+
let raw;
|
|
916
|
+
try {
|
|
917
|
+
raw = readBatchJson(opts.file);
|
|
918
|
+
}
|
|
919
|
+
catch (e) {
|
|
920
|
+
console.error(`Error: 无法读取 batch输入: ${e instanceof Error ? e.message : e}`);
|
|
921
|
+
process.exit(1);
|
|
922
|
+
}
|
|
923
|
+
const trimmed = raw.trim();
|
|
924
|
+
if (!trimmed) {
|
|
925
|
+
console.error('Error: batch 输入为空。请使用 --file 或管道传入 JSON。');
|
|
926
|
+
process.exit(1);
|
|
927
|
+
}
|
|
928
|
+
let batch;
|
|
929
|
+
try {
|
|
930
|
+
batch = JSON.parse(trimmed);
|
|
931
|
+
}
|
|
932
|
+
catch (e) {
|
|
933
|
+
console.error(`Error: batch JSON 解析失败: ${e instanceof Error ? e.message : e}`);
|
|
934
|
+
process.exit(1);
|
|
935
|
+
}
|
|
936
|
+
if (!batch || typeof batch !== 'object' || Array.isArray(batch)) {
|
|
937
|
+
console.error('Error: batch 必须是形如 { "prefabKey": { "field": value } } 的对象。');
|
|
938
|
+
process.exit(1);
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
if (systemType === 'external-theme') {
|
|
942
|
+
const themeId = resolveThemeId(projectDir, opts);
|
|
943
|
+
const schema = loadThemeSchema(projectDir);
|
|
944
|
+
let data = loadThemeData(projectDir, themeId);
|
|
945
|
+
const prefabs = parseThemePrefabs(schema, data, themeId);
|
|
946
|
+
for (const [prefabKey, fieldMap] of Object.entries(batch)) {
|
|
947
|
+
if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
|
|
948
|
+
throw new Error(`prefab "${prefabKey}" 的值必须是对象(字段 map)`);
|
|
949
|
+
}
|
|
950
|
+
const found = prefabs.find((p) => p.key === prefabKey);
|
|
951
|
+
if (!found) {
|
|
952
|
+
throw new Error(`prefab "${prefabKey}" 不存在`);
|
|
953
|
+
}
|
|
954
|
+
const currentValue = data[prefabKey];
|
|
955
|
+
let newValue;
|
|
956
|
+
if (found.jsonSchema?.type === 'object') {
|
|
957
|
+
let obj = (currentValue && typeof currentValue === 'object' && !Array.isArray(currentValue))
|
|
958
|
+
? { ...currentValue }
|
|
959
|
+
: {};
|
|
960
|
+
for (const [field, rawVal] of Object.entries(fieldMap)) {
|
|
961
|
+
const fieldSchema = resolveJsonSchemaField(found.jsonSchema, field);
|
|
962
|
+
const fieldType = fieldSchema?.type ?? 'string';
|
|
963
|
+
const coerced = coerceBatchLeaf(rawVal, fieldType);
|
|
964
|
+
if (fieldSchema) {
|
|
965
|
+
const result = validateThemeValue(fieldSchema, coerced);
|
|
966
|
+
if (!result.valid) {
|
|
967
|
+
throw new Error(`${prefabKey}.${field}: ${result.errors.join('; ')}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
obj = setByDotPath(obj, field, coerced);
|
|
971
|
+
}
|
|
972
|
+
newValue = obj;
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
const entries = Object.entries(fieldMap);
|
|
976
|
+
if (entries.length !== 1) {
|
|
977
|
+
throw new Error(`prefab "${prefabKey}" 非 object schema,batch 内只能包含一个字段`);
|
|
978
|
+
}
|
|
979
|
+
const [field, rawVal] = entries[0];
|
|
980
|
+
const fieldSchema = resolveJsonSchemaField(found.jsonSchema, field);
|
|
981
|
+
const fieldType = fieldSchema?.type ?? 'string';
|
|
982
|
+
const coerced = coerceBatchLeaf(rawVal, fieldType);
|
|
983
|
+
if (fieldSchema) {
|
|
984
|
+
const result = validateThemeValue(fieldSchema, coerced);
|
|
985
|
+
if (!result.valid) {
|
|
986
|
+
throw new Error(`${prefabKey}.${field}: ${result.errors.join('; ')}`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
newValue = coerced;
|
|
990
|
+
}
|
|
991
|
+
data = mergeThemeDataKey(data, prefabKey, newValue);
|
|
992
|
+
}
|
|
993
|
+
writeThemeData(projectDir, themeId, data);
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
997
|
+
const metaMap = scanExtensionMetaData(projectDir);
|
|
998
|
+
if (configPath) {
|
|
999
|
+
// 有场景级 GameConfig → 写入 GameConfig 文件
|
|
1000
|
+
let gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
1001
|
+
for (const [extKey, fieldMap] of Object.entries(batch)) {
|
|
1002
|
+
if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
|
|
1003
|
+
throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
|
|
1004
|
+
}
|
|
1005
|
+
const meta = metaMap.get(extKey);
|
|
1006
|
+
if (!meta) {
|
|
1007
|
+
throw new Error(`extension "${extKey}" 不存在`);
|
|
1008
|
+
}
|
|
1009
|
+
const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
|
|
1010
|
+
const { config } = parseGameConfig(gameConfigJson);
|
|
1011
|
+
const existingKey = findConfigKeyCaseInsensitive(config, configKey);
|
|
1012
|
+
let currentValues = (existingKey ? config[existingKey] : {}) ?? {};
|
|
1013
|
+
currentValues = typeof currentValues === 'object' && !Array.isArray(currentValues)
|
|
1014
|
+
? { ...currentValues }
|
|
1015
|
+
: {};
|
|
1016
|
+
for (const [field, rawVal] of Object.entries(fieldMap)) {
|
|
1017
|
+
const fieldDef = meta.configSchema?.[field];
|
|
1018
|
+
const fieldType = fieldDef?.type ?? 'string';
|
|
1019
|
+
const coerced = coerceBatchLeaf(rawVal, fieldType);
|
|
1020
|
+
if (fieldDef) {
|
|
1021
|
+
const result = validateConfigValue(fieldDef, coerced);
|
|
1022
|
+
if (!result.valid) {
|
|
1023
|
+
throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
currentValues = setByDotPath(currentValues, field, coerced);
|
|
1027
|
+
}
|
|
1028
|
+
gameConfigJson = updateGameConfigValue(gameConfigJson, configKey, currentValues);
|
|
1029
|
+
}
|
|
1030
|
+
writeGameConfig(projectDir, configPath, gameConfigJson);
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
// 无场景级 GameConfig → 逐个写 MetaData.json defaults
|
|
1034
|
+
for (const [extKey, fieldMap] of Object.entries(batch)) {
|
|
1035
|
+
if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
|
|
1036
|
+
throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
|
|
1037
|
+
}
|
|
1038
|
+
const meta = metaMap.get(extKey);
|
|
1039
|
+
if (!meta) {
|
|
1040
|
+
throw new Error(`extension "${extKey}" 不存在`);
|
|
1041
|
+
}
|
|
1042
|
+
const coercedValues = {};
|
|
1043
|
+
for (const [field, rawVal] of Object.entries(fieldMap)) {
|
|
1044
|
+
const fieldDef = meta.configSchema?.[field];
|
|
1045
|
+
const fieldType = fieldDef?.type ?? 'string';
|
|
1046
|
+
const coerced = coerceBatchLeaf(rawVal, fieldType);
|
|
1047
|
+
if (fieldDef) {
|
|
1048
|
+
const result = validateConfigValue(fieldDef, coerced);
|
|
1049
|
+
if (!result.valid) {
|
|
1050
|
+
throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
coercedValues[field] = coerced;
|
|
1054
|
+
}
|
|
1055
|
+
writeMetaDataDefaults(projectDir, extKey, coercedValues);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const payload = { success: true, updatedKeys: Object.keys(batch) };
|
|
1060
|
+
outputResult(asJson, payload, () => {
|
|
1061
|
+
console.log(`已批量更新: ${Object.keys(batch).join(', ')}`);
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
catch (e) {
|
|
1065
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1066
|
+
console.error(`Error: ${msg}`);
|
|
1067
|
+
process.exit(1);
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
537
1070
|
withJsonOption(prefab.command('enable <key>'))
|
|
538
1071
|
.description('启用 prefab')
|
|
539
1072
|
.option('--variant <id>', '指定变体')
|
|
@@ -561,7 +1094,7 @@ export function registerPrefabCommands(program) {
|
|
|
561
1094
|
});
|
|
562
1095
|
}
|
|
563
1096
|
else {
|
|
564
|
-
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
1097
|
+
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant) ?? DEFAULT_GAME_PATH;
|
|
565
1098
|
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
566
1099
|
const { extensions } = parseGameConfig(gameConfigJson);
|
|
567
1100
|
if (extensions.includes(key)) {
|
|
@@ -579,7 +1112,6 @@ export function registerPrefabCommands(program) {
|
|
|
579
1112
|
});
|
|
580
1113
|
}
|
|
581
1114
|
});
|
|
582
|
-
// ─── disable ─────────────────────────────────────────────────
|
|
583
1115
|
withJsonOption(prefab.command('disable <key>'))
|
|
584
1116
|
.description('禁用 prefab')
|
|
585
1117
|
.option('--variant <id>', '指定变体')
|
|
@@ -606,7 +1138,7 @@ export function registerPrefabCommands(program) {
|
|
|
606
1138
|
});
|
|
607
1139
|
}
|
|
608
1140
|
else {
|
|
609
|
-
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
1141
|
+
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant) ?? DEFAULT_GAME_PATH;
|
|
610
1142
|
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
611
1143
|
const { extensions } = parseGameConfig(gameConfigJson);
|
|
612
1144
|
const filtered = extensions.filter((n) => n !== key);
|
|
@@ -625,7 +1157,6 @@ export function registerPrefabCommands(program) {
|
|
|
625
1157
|
});
|
|
626
1158
|
}
|
|
627
1159
|
});
|
|
628
|
-
// ─── switch ──────────────────────────────────────────────────
|
|
629
1160
|
withJsonOption(prefab.command('switch <variant>'))
|
|
630
1161
|
.description('切换活跃变体(External: 修改 index.ts;PlayCanvas: 指定场景上下文)')
|
|
631
1162
|
.option('--project-dir <path>', '项目根目录(默认 cwd)')
|
|
@@ -669,7 +1200,6 @@ export function registerPrefabCommands(program) {
|
|
|
669
1200
|
}
|
|
670
1201
|
});
|
|
671
1202
|
}
|
|
672
|
-
// ─── JSON Schema field resolver ──────────────────────────────────────────────
|
|
673
1203
|
function resolveJsonSchemaField(schema, dotPath) {
|
|
674
1204
|
const parts = dotPath.split('.');
|
|
675
1205
|
let cur = schema;
|
package/dist/commands/tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join, parse } from 'path';
|
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
5
5
|
const TMP_DIR = join(tmpdir(), 'playcraft');
|
|
6
|
+
const MAX_REFERENCE_IMAGES = 8;
|
|
6
7
|
function ensureTmpDir() {
|
|
7
8
|
mkdirSync(TMP_DIR, { recursive: true });
|
|
8
9
|
}
|
|
@@ -85,11 +86,18 @@ function sniffImageExtension(buf) {
|
|
|
85
86
|
}
|
|
86
87
|
/** Replace output extension when it does not match the chosen format extension. */
|
|
87
88
|
function resolveImageOutputPath(outputPath, wantExt) {
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
try {
|
|
90
|
+
const { dir, name, ext } = parse(outputPath);
|
|
91
|
+
if (ext.toLowerCase() === wantExt) {
|
|
92
|
+
return outputPath;
|
|
93
|
+
}
|
|
94
|
+
return join(dir, `${name}${wantExt}`);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
98
|
+
console.warn(`Could not adjust output path for extension ${wantExt} (${detail}); using path as given: ${outputPath}`);
|
|
90
99
|
return outputPath;
|
|
91
100
|
}
|
|
92
|
-
return join(dir, `${name}${wantExt}`);
|
|
93
101
|
}
|
|
94
102
|
function collectReferenceImagePaths(value, previous) {
|
|
95
103
|
return previous.concat([value]);
|
|
@@ -100,7 +108,37 @@ function mimeTypeForImagePath(filePath) {
|
|
|
100
108
|
return 'image/png';
|
|
101
109
|
if (ext === 'webp')
|
|
102
110
|
return 'image/webp';
|
|
103
|
-
|
|
111
|
+
if (ext === 'jpg' || ext === 'jpeg')
|
|
112
|
+
return 'image/jpeg';
|
|
113
|
+
if (ext === undefined || ext === '') {
|
|
114
|
+
throw new Error(`Reference image path has no file extension: ${filePath}. Use .png, .webp, .jpg, or .jpeg.`);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, and jpeg are allowed.`);
|
|
117
|
+
}
|
|
118
|
+
/** Load reference images for generate-image; skip unreadable paths after console.warn (same tolerance as output path resolve). */
|
|
119
|
+
function collectReferenceImagePayloads(paths) {
|
|
120
|
+
if (paths.length === 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const p of paths) {
|
|
124
|
+
try {
|
|
125
|
+
const mimeType = mimeTypeForImagePath(p);
|
|
126
|
+
out.push({
|
|
127
|
+
base64: readFileSync(p).toString('base64'),
|
|
128
|
+
mimeType,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
133
|
+
const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : '';
|
|
134
|
+
const hint = code ? ` (${code})` : '';
|
|
135
|
+
console.warn(`Skipping reference image (read failed): ${p}${hint}\n ${detail}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (paths.length > 0 && out.length === 0) {
|
|
139
|
+
console.warn('All reference images failed to load; continuing with text-only generation.');
|
|
140
|
+
}
|
|
141
|
+
return out.length > 0 ? out : undefined;
|
|
104
142
|
}
|
|
105
143
|
export function registerToolsCommands(program) {
|
|
106
144
|
const tools = program
|
|
@@ -117,12 +155,10 @@ export function registerToolsCommands(program) {
|
|
|
117
155
|
.action(async (opts) => {
|
|
118
156
|
try {
|
|
119
157
|
const paths = opts.referenceImage ?? [];
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}))
|
|
125
|
-
: undefined;
|
|
158
|
+
if (paths.length > MAX_REFERENCE_IMAGES) {
|
|
159
|
+
throw new Error(`Too many reference images: ${paths.length} paths given; maximum is ${MAX_REFERENCE_IMAGES}.`);
|
|
160
|
+
}
|
|
161
|
+
const referenceImages = collectReferenceImagePayloads(paths);
|
|
126
162
|
const client = new AgentApiClient();
|
|
127
163
|
const result = await client.post('/generate-image', {
|
|
128
164
|
prompt: opts.prompt,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.29",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
"build": "tsc",
|
|
18
18
|
"start": "node dist/index.js",
|
|
19
19
|
"test": "vitest run",
|
|
20
|
+
"verify-sprite-overlay": "node scripts/verify-sprite-overlay.mjs",
|
|
20
21
|
"link": "node scripts/bump-local.js && pnpm build && npm link",
|
|
21
22
|
"unlink": "npm unlink -g @playcraft/cli",
|
|
22
23
|
"release": "node scripts/release.js"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
|
-
"@playcraft/build": "^0.0.
|
|
26
|
-
"@playcraft/common": "^0.0.
|
|
26
|
+
"@playcraft/build": "^0.0.29",
|
|
27
|
+
"@playcraft/common": "^0.0.18",
|
|
27
28
|
"chokidar": "^4.0.3",
|
|
28
29
|
"commander": "^13.1.0",
|
|
29
30
|
"cors": "^2.8.6",
|