@playcraft/cli 0.0.28 → 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 +132 -2
- package/dist/commands/prefab.js +130 -47
- package/dist/commands/tools.js +43 -16
- 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 */
|
|
@@ -228,10 +228,75 @@ function parseResizeFitOption(raw) {
|
|
|
228
228
|
console.error('Error: --fit must be contain|cover|fill|inside|outside');
|
|
229
229
|
process.exit(1);
|
|
230
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
|
+
}
|
|
231
296
|
export function registerImageCommands(program) {
|
|
232
297
|
const img = program
|
|
233
298
|
.command('image')
|
|
234
|
-
.description('素材工具:本地图片处理(sharp);remove-background
|
|
299
|
+
.description('素材工具:本地图片处理(sharp);remove-background / sprite-split --auto-detect 可调用后端 API');
|
|
235
300
|
// ─── resize ──────────────────────────────────────────────────
|
|
236
301
|
img.command('resize')
|
|
237
302
|
.description('缩放图片(按比例或指定宽高)')
|
|
@@ -563,6 +628,71 @@ export function registerImageCommands(program) {
|
|
|
563
628
|
handleError(e);
|
|
564
629
|
}
|
|
565
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
|
+
});
|
|
566
696
|
// ─── sprite-sheet ────────────────────────────────────────────
|
|
567
697
|
img.command('sprite-sheet')
|
|
568
698
|
.description('将多张图合并为精灵图,并输出帧坐标 JSON(完整本地实现)')
|
package/dist/commands/prefab.js
CHANGED
|
@@ -2,7 +2,7 @@ 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, getPrefabFieldDescriptors, getPrefabFieldDiffs, prefabHasSchemaDiff, } 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
6
|
const DEFAULT_PAGE_LIMIT = 50;
|
|
7
7
|
/** 无法识别 External / PlayCanvas 项目类型(供测试在 mock process.exit 后通过 instanceof 识别)。 */
|
|
8
8
|
export class ProjectDetectError extends Error {
|
|
@@ -266,22 +266,58 @@ function scanExtensionMetaData(projectDir) {
|
|
|
266
266
|
}
|
|
267
267
|
return map;
|
|
268
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
|
+
}
|
|
269
297
|
function resolveGameConfigPathForVariant(projectDir, variantId) {
|
|
270
298
|
if (!variantId)
|
|
271
|
-
return
|
|
299
|
+
return null;
|
|
272
300
|
if (!fileExists(projectDir, scenePath(variantId)))
|
|
273
|
-
return
|
|
301
|
+
return null;
|
|
274
302
|
const sceneJson = JSON.parse(readLocalFile(projectDir, scenePath(variantId)));
|
|
275
303
|
if (!fileExists(projectDir, ASSETS_JSON_PATH))
|
|
276
|
-
return
|
|
304
|
+
return null;
|
|
277
305
|
const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
|
|
278
|
-
|
|
279
|
-
return resolved ?? DEFAULT_GAME_PATH;
|
|
306
|
+
return resolveGameConfigPath(sceneJson, assetsJson);
|
|
280
307
|
}
|
|
281
308
|
function listLocalScenes(projectDir) {
|
|
282
309
|
if (!fileExists(projectDir, MANIFEST_PATH))
|
|
283
310
|
return [];
|
|
284
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
|
+
}
|
|
285
321
|
return listScenesFromManifest(manifestJson);
|
|
286
322
|
}
|
|
287
323
|
function loadPrefabs(projectDir, systemType, opts) {
|
|
@@ -291,11 +327,22 @@ function loadPrefabs(projectDir, systemType, opts) {
|
|
|
291
327
|
const data = loadThemeData(projectDir, themeId);
|
|
292
328
|
return parseThemePrefabs(schema, data, themeId);
|
|
293
329
|
}
|
|
294
|
-
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
295
|
-
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
296
|
-
const { extensions, config } = parseGameConfig(gameConfigJson);
|
|
297
330
|
const metaMap = scanExtensionMetaData(projectDir);
|
|
298
|
-
|
|
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: {} }, '');
|
|
299
346
|
}
|
|
300
347
|
// ─── dotpath helper ──────────────────────────────────────────────────────────
|
|
301
348
|
function getByDotPath(obj, path) {
|
|
@@ -818,18 +865,12 @@ export function registerPrefabCommands(program) {
|
|
|
818
865
|
});
|
|
819
866
|
}
|
|
820
867
|
else {
|
|
821
|
-
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
822
|
-
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
823
|
-
const { config } = parseGameConfig(gameConfigJson);
|
|
824
868
|
const metaMap = scanExtensionMetaData(projectDir);
|
|
825
869
|
const meta = metaMap.get(key);
|
|
826
870
|
if (!meta) {
|
|
827
871
|
console.error(`Error: extension "${key}" 不存在。`);
|
|
828
872
|
process.exit(1);
|
|
829
873
|
}
|
|
830
|
-
const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
|
|
831
|
-
const existingKey = findConfigKeyCaseInsensitive(config, configKey);
|
|
832
|
-
const currentValues = (existingKey ? config[existingKey] : {}) ?? {};
|
|
833
874
|
const fieldDef = meta.configSchema?.[field];
|
|
834
875
|
const fieldType = fieldDef?.type ?? 'string';
|
|
835
876
|
const coerced = coerceValue(rawValue, fieldType);
|
|
@@ -840,9 +881,22 @@ export function registerPrefabCommands(program) {
|
|
|
840
881
|
process.exit(1);
|
|
841
882
|
}
|
|
842
883
|
}
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
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
|
+
}
|
|
846
900
|
const payload = { success: true, key, field, value: coerced };
|
|
847
901
|
outputResult(asJson, payload, () => {
|
|
848
902
|
console.log(`已更新 ${key}.${field} = ${formatHumanValue(coerced)}`);
|
|
@@ -940,38 +994,67 @@ export function registerPrefabCommands(program) {
|
|
|
940
994
|
}
|
|
941
995
|
else {
|
|
942
996
|
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
943
|
-
let gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
944
997
|
const metaMap = scanExtensionMetaData(projectDir);
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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);
|
|
952
1029
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
+
}
|
|
968
1052
|
}
|
|
1053
|
+
coercedValues[field] = coerced;
|
|
969
1054
|
}
|
|
970
|
-
|
|
1055
|
+
writeMetaDataDefaults(projectDir, extKey, coercedValues);
|
|
971
1056
|
}
|
|
972
|
-
gameConfigJson = updateGameConfigValue(gameConfigJson, configKey, currentValues);
|
|
973
1057
|
}
|
|
974
|
-
writeGameConfig(projectDir, configPath, gameConfigJson);
|
|
975
1058
|
}
|
|
976
1059
|
const payload = { success: true, updatedKeys: Object.keys(batch) };
|
|
977
1060
|
outputResult(asJson, payload, () => {
|
|
@@ -1011,7 +1094,7 @@ export function registerPrefabCommands(program) {
|
|
|
1011
1094
|
});
|
|
1012
1095
|
}
|
|
1013
1096
|
else {
|
|
1014
|
-
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
1097
|
+
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant) ?? DEFAULT_GAME_PATH;
|
|
1015
1098
|
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
1016
1099
|
const { extensions } = parseGameConfig(gameConfigJson);
|
|
1017
1100
|
if (extensions.includes(key)) {
|
|
@@ -1055,7 +1138,7 @@ export function registerPrefabCommands(program) {
|
|
|
1055
1138
|
});
|
|
1056
1139
|
}
|
|
1057
1140
|
else {
|
|
1058
|
-
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
|
|
1141
|
+
const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant) ?? DEFAULT_GAME_PATH;
|
|
1059
1142
|
const gameConfigJson = loadGameConfig(projectDir, configPath);
|
|
1060
1143
|
const { extensions } = parseGameConfig(gameConfigJson);
|
|
1061
1144
|
const filtered = extensions.filter((n) => n !== key);
|
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,21 +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.`);
|
|
104
117
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
}
|
|
111
137
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : '';
|
|
115
|
-
const hint = code ? ` (${code})` : '';
|
|
116
|
-
throw new Error(`Failed to read reference image: ${filePath}${hint}\n ${detail}`);
|
|
138
|
+
if (paths.length > 0 && out.length === 0) {
|
|
139
|
+
console.warn('All reference images failed to load; continuing with text-only generation.');
|
|
117
140
|
}
|
|
141
|
+
return out.length > 0 ? out : undefined;
|
|
118
142
|
}
|
|
119
143
|
export function registerToolsCommands(program) {
|
|
120
144
|
const tools = program
|
|
@@ -131,7 +155,10 @@ export function registerToolsCommands(program) {
|
|
|
131
155
|
.action(async (opts) => {
|
|
132
156
|
try {
|
|
133
157
|
const paths = opts.referenceImage ?? [];
|
|
134
|
-
|
|
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);
|
|
135
162
|
const client = new AgentApiClient();
|
|
136
163
|
const result = await client.post('/generate-image', {
|
|
137
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",
|