@playcraft/cli 0.0.18 → 0.0.21

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.
@@ -5,6 +5,23 @@ import ora from 'ora';
5
5
  import { BaseBuilder, ViteBuilder, PlayableBuilder, PlayableAnalyzer, EngineDetector, detectAmmoUsage } from '@playcraft/build';
6
6
  import { loadBuildConfig } from '../build-config.js';
7
7
  import inquirer from 'inquirer';
8
+ /**
9
+ * 类型守卫:检查对象是否符合 PlayableScriptsConfig 类型
10
+ */
11
+ function isPlayableScriptsConfig(obj) {
12
+ if (typeof obj !== 'object' || obj === null) {
13
+ return false;
14
+ }
15
+ // 检查关键可选字段的类型(如果存在)
16
+ const config = obj;
17
+ if (config.channels !== undefined && !Array.isArray(config.channels)) {
18
+ return false;
19
+ }
20
+ if (config.themes !== undefined && (typeof config.themes !== 'object' || config.themes === null)) {
21
+ return false;
22
+ }
23
+ return true;
24
+ }
8
25
  /**
9
26
  * 检测是否是基础构建产物(多文件版本)
10
27
  */
@@ -32,6 +49,40 @@ async function detectBaseBuild(dir) {
32
49
  return false;
33
50
  }
34
51
  }
52
+ /**
53
+ * 构建 PlayableScriptsConfig,合并 CLI 选择的参数和文件配置
54
+ */
55
+ function buildPlayableScriptsConfig(fileConfigPlayableScripts, options) {
56
+ // 使用类型守卫检查 playableScripts 是否符合 PlayableScriptsConfig 类型
57
+ let config = fileConfigPlayableScripts && isPlayableScriptsConfig(fileConfigPlayableScripts)
58
+ ? { ...fileConfigPlayableScripts }
59
+ : undefined;
60
+ // 将 CLI 选择的 platform 转换为 channels
61
+ if (options.platform) {
62
+ if (!config) {
63
+ config = {};
64
+ }
65
+ config.channels = [options.platform];
66
+ }
67
+ // 将 CLI 选择的 storeUrls 合并到配置
68
+ if (options.storeUrls) {
69
+ if (!config) {
70
+ config = {};
71
+ }
72
+ config.storeUrls = options.storeUrls;
73
+ }
74
+ // 将选择的主题合并到配置
75
+ if (options.selectedThemes && options.selectedThemes.length > 0) {
76
+ if (!config) {
77
+ config = {};
78
+ }
79
+ config.themes = {
80
+ enabled: true,
81
+ whitelist: options.selectedThemes,
82
+ };
83
+ }
84
+ return config;
85
+ }
35
86
  /**
36
87
  * 检测项目场景列表(支持 PlayCanvas 和 PlayCraft 两种格式)
37
88
  */
@@ -43,7 +94,6 @@ async function detectProjectScenes(projectPath) {
43
94
  const manifestContent = await fs.readFile(manifestJsonPath, 'utf-8');
44
95
  const manifestData = JSON.parse(manifestContent);
45
96
  if (manifestData.scenes && Array.isArray(manifestData.scenes)) {
46
- // PlayCraft manifest.json 中的场景列表
47
97
  const scenes = manifestData.scenes.map((scene) => ({
48
98
  id: String(scene.id || scene.name),
49
99
  name: scene.name || `Scene ${scene.id}`,
@@ -201,25 +251,43 @@ export async function buildCommand(projectPath, options) {
201
251
  // 清理选项(默认为 false,使用覆盖模式)
202
252
  const shouldClean = options.clean === true;
203
253
  try {
204
- // ====== 引擎检测 ======
254
+ // ====== 引擎 + 构建工具检测 ======
205
255
  spinner.text = pc.cyan('检测项目引擎类型...');
206
256
  let detectedEngine;
207
- if (options.engine) {
257
+ let detectionResult;
258
+ if (options.usePlayableScripts) {
259
+ // 用户显式指定 --use-playable-scripts
260
+ detectionResult = { engine: 'generic', buildTool: 'playable-scripts' };
261
+ detectedEngine = 'generic';
262
+ console.log(pc.cyan(`\n🔧 使用 @playcraft/devkit 构建工具(用户显式指定)`));
263
+ }
264
+ else if (options.engine) {
208
265
  // 用户指定了引擎类型
209
266
  detectedEngine = options.engine;
267
+ detectionResult = {
268
+ engine: detectedEngine,
269
+ buildTool: detectedEngine === 'playcanvas' ? 'playcanvas-native' : 'generic',
270
+ };
210
271
  console.log(pc.dim(`\nℹ️ 使用指定引擎: ${detectedEngine}`));
211
272
  }
212
273
  else {
213
- // 自动检测引擎类型
214
- detectedEngine = await EngineDetector.detect(resolvedProjectPath);
215
- console.log(pc.dim(`\nℹ️ 检测到引擎: ${detectedEngine}`));
216
- // 对于外部引擎,显示提示
217
- if (detectedEngine !== 'playcanvas') {
274
+ // 自动检测引擎类型 + 构建工具
275
+ detectionResult = await EngineDetector.detectFull(resolvedProjectPath);
276
+ detectedEngine = detectionResult.engine;
277
+ console.log(pc.dim(`\nℹ️ 检测到引擎: ${detectedEngine}, 构建工具: ${detectionResult.buildTool}`));
278
+ // 对于 playable-scripts 项目,显示专用提示
279
+ if (detectionResult.buildTool === 'playable-scripts') {
280
+ console.log(pc.cyan(`\n🔧 检测到 @playcraft/devkit 构建工具`));
281
+ console.log(pc.dim(' 将使用 playable-scripts 进行构建(产物已含渠道适配和混淆压缩)'));
282
+ }
283
+ else if (detectedEngine !== 'playcanvas') {
284
+ // 对于外部引擎(非 playable-scripts),显示普通提示
218
285
  console.log(pc.cyan(`\n🎮 检测到 ${detectedEngine} 项目`));
219
286
  console.log(pc.dim(' 将使用 npm install + npm run build 进行构建'));
220
287
  console.log(pc.yellow(' ⚠️ 构建时间可能较长(约 2-5 分钟)\n'));
221
288
  }
222
289
  }
290
+ const isPlayableScripts = detectionResult.buildTool === 'playable-scripts';
223
291
  const isPlayCanvas = detectedEngine === 'playcanvas';
224
292
  const requiresNpmBuild = detectedEngine !== 'playcanvas';
225
293
  if (!options.baseOnly && !options.mode) {
@@ -262,12 +330,37 @@ export async function buildCommand(projectPath, options) {
262
330
  }
263
331
  // 解析场景选择参数(支持 PlayCanvas 和 PlayCraft 项目)
264
332
  let selectedScenes;
333
+ // 主题选择(仅用于 playable-scripts 项目)
334
+ let selectedThemes;
265
335
  if (isPlayCanvas) {
266
336
  if (options.scenes) {
267
337
  // 命令行指定了场景参数
268
- selectedScenes = options.scenes.split(',').map(s => s.trim()).filter(s => s);
269
- if (selectedScenes.length > 0) {
270
- console.log(`\n🎬 选中场景: ${selectedScenes.join(', ')}`);
338
+ const scenesInput = options.scenes.trim().toLowerCase();
339
+ // 检查特殊关键字
340
+ if (scenesInput === 'all') {
341
+ // 打包所有场景
342
+ selectedScenes = undefined; // undefined 表示所有场景
343
+ console.log(`\n🎬 选中场景: 全部场景`);
344
+ }
345
+ else if (scenesInput === 'default') {
346
+ // 打包默认场景
347
+ const projectScenes = await detectProjectScenes(resolvedProjectPath);
348
+ const defaultScene = projectScenes.find(s => s.isMain === true) || projectScenes[0];
349
+ if (defaultScene) {
350
+ selectedScenes = [defaultScene.name || String(defaultScene.id)];
351
+ console.log(`\n🎬 选中场景: ${defaultScene.name || defaultScene.id}${defaultScene.isMain ? ' (默认场景)' : ' (第一个场景)'}`);
352
+ }
353
+ else {
354
+ console.log(`\n⚠️ 未找到默认场景,将打包所有场景`);
355
+ selectedScenes = undefined;
356
+ }
357
+ }
358
+ else {
359
+ // 普通场景名称列表
360
+ selectedScenes = options.scenes.split(',').map(s => s.trim()).filter(s => s);
361
+ if (selectedScenes.length > 0) {
362
+ console.log(`\n🎬 选中场景: ${selectedScenes.join(', ')}`);
363
+ }
271
364
  }
272
365
  }
273
366
  else if (options.mode !== 'playable') {
@@ -287,7 +380,7 @@ export async function buildCommand(projectPath, options) {
287
380
  choices: projectScenes.map(scene => ({
288
381
  name: scene.isMain ? `${scene.name} (主场景)` : scene.name,
289
382
  value: scene.name,
290
- checked: scene.isMain === true || !projectScenes.some(s => s.isMain) // 有 isMain 标记则只默认选主场景,否则全选
383
+ checked: scene.isMain === true || !projectScenes.some(s => s.isMain)
291
384
  })),
292
385
  validate: (answer) => {
293
386
  if (answer.length === 0) {
@@ -329,12 +422,21 @@ export async function buildCommand(projectPath, options) {
329
422
  console.log(pc.dim(`\nℹ️ 使用覆盖模式,新文件将覆盖旧文件(使用 --clean 可清理旧目录)`));
330
423
  }
331
424
  spinner.text = pc.cyan('执行阶段1: 基础构建...');
425
+ // 构建 playableScriptsConfig,合并 CLI 选择的参数
426
+ const playableScriptsConfig = buildPlayableScriptsConfig(fileConfig?.playableScripts, {
427
+ platform: options.platform,
428
+ storeUrls: options.storeUrls,
429
+ selectedThemes,
430
+ });
332
431
  const baseBuilder = new BaseBuilder(resolvedProjectPath, {
333
432
  outputDir: baseBuildOutputDir,
334
433
  selectedScenes,
335
434
  clean: shouldClean && !isSameAsInput, // 清理逻辑由 BaseBuilder 内部处理
336
435
  analyze: options.analyze, // 传递 analyze 参数
337
436
  engine: detectedEngine, // 传递引擎类型
437
+ }, {
438
+ usePlayableScripts: isPlayableScripts,
439
+ playableScriptsConfig,
338
440
  });
339
441
  const baseBuild = await baseBuilder.build();
340
442
  spinner.succeed(pc.green('✅ 基础构建完成!'));
@@ -363,8 +465,8 @@ export async function buildCommand(projectPath, options) {
363
465
  ];
364
466
  // 渠道输出格式配置(根据各渠道Playable规格对照表)
365
467
  // 只支持 HTML: applovin, ironsource, unity, moloco, adikteev, remerge
366
- // 只支持 ZIP: google, tiktok, liftoff, bigo, snapchat, inmobi
367
- // 支持两种: facebook
468
+ // 只支持 ZIP: tiktok, liftoff, bigo, snapchat, inmobi
469
+ // 支持两种: facebook, google
368
470
  const platformFormatConfig = {
369
471
  facebook: {
370
472
  formats: ['html', 'zip'],
@@ -377,7 +479,11 @@ export async function buildCommand(projectPath, options) {
377
479
  moloco: { formats: ['html'], default: 'html' },
378
480
  adikteev: { formats: ['html'], default: 'html' },
379
481
  remerge: { formats: ['html'], default: 'html' },
380
- google: { formats: ['zip'], default: 'zip' },
482
+ google: {
483
+ formats: ['html', 'zip'],
484
+ default: 'html',
485
+ description: 'Google Ads 支持两种格式'
486
+ },
381
487
  tiktok: { formats: ['zip'], default: 'zip' },
382
488
  liftoff: { formats: ['zip'], default: 'zip' },
383
489
  bigo: { formats: ['zip'], default: 'zip' },
@@ -421,10 +527,10 @@ export async function buildCommand(projectPath, options) {
421
527
  : '选择输出格式:',
422
528
  choices: formatConfig.formats.map(f => ({
423
529
  name: f === 'html'
424
- ? (selectedPlatform === 'facebook'
530
+ ? (selectedPlatform === 'facebook' || selectedPlatform === 'google'
425
531
  ? 'HTML(单文件,最大 5MB,所有资源内联)'
426
532
  : 'HTML(单文件)')
427
- : (selectedPlatform === 'facebook'
533
+ : (selectedPlatform === 'facebook' || selectedPlatform === 'google'
428
534
  ? 'ZIP(多文件,最大 5MB,HTML 文件需 < 2MB)'
429
535
  : 'ZIP(多文件)'),
430
536
  value: f,
@@ -434,6 +540,47 @@ export async function buildCommand(projectPath, options) {
434
540
  ]);
435
541
  selectedFormat = formatAnswer.format;
436
542
  }
543
+ // 如果是 playable-scripts 项目,交互式选择主题
544
+ if (isPlayableScripts) {
545
+ const themeDir = path.join(resolvedProjectPath, 'src', 'theme');
546
+ try {
547
+ const themeEntries = await fs.readdir(themeDir, { withFileTypes: true });
548
+ const availableThemes = themeEntries
549
+ .filter(e => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith('tiles-'))
550
+ .map(e => e.name)
551
+ .sort();
552
+ if (availableThemes.length > 1) {
553
+ console.log(pc.cyan('\n🎨 选择要构建的主题:'));
554
+ const themeAnswer = await inquirer.prompt([
555
+ {
556
+ type: 'list',
557
+ name: 'theme',
558
+ message: '选择主题:',
559
+ choices: [
560
+ { name: '📌 使用当前默认主题 (src/theme/index.ts)', value: '__default__' },
561
+ ...availableThemes.map(t => ({ name: `🎨 ${t}`, value: t })),
562
+ ],
563
+ default: '__default__',
564
+ },
565
+ ]);
566
+ // 如果用户选择了具体主题(而非默认)
567
+ if (themeAnswer.theme !== '__default__') {
568
+ selectedThemes = [themeAnswer.theme];
569
+ console.log(pc.green(`✅ 已选择主题: ${themeAnswer.theme}`));
570
+ }
571
+ else {
572
+ console.log(pc.dim(' 使用当前默认主题'));
573
+ }
574
+ }
575
+ else if (availableThemes.length === 1) {
576
+ console.log(pc.dim(`\n🎨 检测到 1 个主题: ${availableThemes[0]}`));
577
+ }
578
+ }
579
+ catch (error) {
580
+ // 无主题目录,跳过
581
+ console.log(pc.dim(' 无主题目录,跳过主题选择'));
582
+ }
583
+ }
437
584
  // 输入商店跳转地址(CTA)- 如果配置文件中没有则必填
438
585
  if (!options.storeUrls) {
439
586
  console.log(pc.cyan('\n🔗 商店跳转地址(CTA 按钮目标)'));
@@ -587,15 +734,68 @@ export async function buildCommand(projectPath, options) {
587
734
  spinner.text = pc.cyan('执行阶段1: 基础构建...');
588
735
  // 将临时目录放在项目所在目录下,避免跨盘符路径问题(Windows 上 C:\Temp 和 D:\project 会导致 Vite 路径计算错误)
589
736
  const tempDir = path.join(resolvedProjectPath, '.playcraft-temp', `base-build-${Date.now()}`);
737
+ // 构建 playableScriptsConfig,合并 CLI 选择的参数
738
+ const playableScriptsConfig = buildPlayableScriptsConfig(fileConfig?.playableScripts, {
739
+ platform: options.platform,
740
+ storeUrls: options.storeUrls,
741
+ selectedThemes,
742
+ });
590
743
  const baseBuilder = new BaseBuilder(resolvedProjectPath, {
591
744
  outputDir: tempDir,
592
745
  selectedScenes,
593
746
  analyze: buildOptions.analyze,
594
747
  analyzeReportPath: buildOptions.analyze ? 'base-bundle-report.html' : undefined,
595
748
  engine: detectedEngine, // 传递引擎类型
749
+ }, {
750
+ usePlayableScripts: isPlayableScripts,
751
+ playableScriptsConfig,
596
752
  });
597
753
  const baseBuild = await baseBuilder.build();
598
754
  baseBuildDir = baseBuild.outputDir;
755
+ // 如果是 playable-scripts 构建,产物已是最终格式,跳过阶段2
756
+ if (baseBuild.metadata.skipChannelBuild) {
757
+ spinner.succeed(pc.green('📦 @playcraft/devkit 构建完成!'));
758
+ // 将产物从临时目录复制到用户指定的输出目录
759
+ const finalOutputDir = buildOptions.outputDir || path.resolve(options.output || './dist');
760
+ await fs.mkdir(finalOutputDir, { recursive: true });
761
+ // 复制所有产物到最终输出目录,保留相对目录结构
762
+ for (const asset of baseBuild.files.assets) {
763
+ // 计算相对于基础构建输出目录的相对路径,保留目录结构
764
+ const relativePath = path.relative(baseBuildDir, asset);
765
+ const destPath = path.join(finalOutputDir, relativePath);
766
+ // 确保目标目录存在
767
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
768
+ await fs.copyFile(asset, destPath);
769
+ }
770
+ // 显示产物信息
771
+ console.log('\n' + pc.bold('构建产物:'));
772
+ for (const asset of baseBuild.files.assets) {
773
+ // 使用相对路径显示,保持目录结构可见
774
+ const relativePath = path.relative(baseBuildDir, asset);
775
+ const finalPath = path.join(finalOutputDir, relativePath);
776
+ try {
777
+ const stat = await fs.stat(finalPath);
778
+ const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
779
+ console.log(` - ${relativePath}: ${sizeMB} MB`);
780
+ }
781
+ catch {
782
+ console.log(` - ${relativePath}`);
783
+ }
784
+ }
785
+ console.log('\n' + pc.green(`输出目录: ${finalOutputDir}`));
786
+ console.log(pc.dim('💡 产物已包含渠道适配、MRAID 注入、代码混淆和 fflate 压缩'));
787
+ // 清理临时目录
788
+ try {
789
+ const tempBaseDir = path.join(resolvedProjectPath, '.playcraft-temp');
790
+ await fs.rm(tempBaseDir, { recursive: true, force: true });
791
+ console.log(pc.dim(`\n🗑️ 已清理临时目录`));
792
+ }
793
+ catch (error) {
794
+ // 记录清理错误信息
795
+ console.log(pc.dim(`\n⚠️ 清理临时目录失败: ${error instanceof Error ? error.message : String(error)}`));
796
+ }
797
+ return;
798
+ }
599
799
  spinner.text = pc.cyan('✅ 阶段1完成,开始阶段2...');
600
800
  }
601
801
  // Ammo 检测和替换(仅 PlayCanvas 项目)