@playcraft/cli 0.0.9 → 0.0.11

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/README.md CHANGED
@@ -11,8 +11,10 @@ PlayCraft CLI 是一个功能强大的命令行工具,支持本地代码与云
11
11
 
12
12
  - 🚀 **本地开发隧道** - 在本地 IDE 编写代码,云端编辑器实时预览与热更新
13
13
  - 📦 **Playable Ads 打包** - 支持 10 个主流广告平台的一键打包
14
+ - 🎬 **智能场景选择** - 交互式场景选择,可减小 30-60% 文件大小(**新功能**)
14
15
  - 🎯 **两阶段构建** - Base Build + Channel Build,灵活可控
15
16
  - ⚡ **Vite 构建** - 使用 Vite 进行资源内联与压缩,性能优异
17
+ - 🖼️ **资源优化** - 自动图片压缩(PNG → WebP)、模型压缩(Draco)
16
18
  - 🔧 **交互式配置** - 友好的命令行交互,快速上手
17
19
  - 📊 **构建分析** - 生成详细的打包分析报告
18
20
 
@@ -140,6 +142,16 @@ done
140
142
  ### 构建优化
141
143
 
142
144
  ```bash
145
+ # 🎬 场景选择(推荐!可减小 30-60% 文件大小)
146
+ # 交互式选择(自动检测多个场景并提示)
147
+ playcraft build --platform facebook
148
+
149
+ # 指定特定场景
150
+ playcraft build --platform facebook --scenes BallGame
151
+
152
+ # 指定多个场景
153
+ playcraft build --platform facebook --scenes "MainMenu,Gameplay,Settings"
154
+
143
155
  # 启用图片压缩
144
156
  playcraft build --platform facebook --compress-images --image-quality 75
145
157
 
@@ -148,6 +160,9 @@ playcraft build --platform facebook --compress-models --model-compression draco
148
160
 
149
161
  # 生成分析报告
150
162
  playcraft build --platform facebook --analyze
163
+
164
+ # 组合使用(场景选择 + 图片压缩 + 分析)
165
+ playcraft build --platform facebook --scenes MainScene --compress-images --analyze
151
166
  ```
152
167
 
153
168
  ## ⚙️ 配置
@@ -190,13 +205,54 @@ CLI 会在 `~/.playcraft/` 目录下存储:
190
205
  - `pids/` - 进程文件
191
206
  - `logs/` - 日志文件
192
207
 
208
+ ## 🆕 最新功能
209
+
210
+ ### v0.0.9 - 场景选择优化 (2026-01-26)
211
+
212
+ 当项目包含多个场景时,CLI 会自动提供交互式场景选择:
213
+
214
+ ```bash
215
+ playcraft build --platform facebook
216
+ ```
217
+
218
+ **交互界面**:
219
+ ```
220
+ 🎬 检测到 20 个场景
221
+ 💡 提示: 只打包选中的场景可以显著减小文件大小(通常可减小 30-60%)
222
+
223
+ ? 选择要打包的场景(使用空格选择,回车确认):
224
+ ❯◉ MainScene
225
+ ◉ Gameplay
226
+ ◯ TestScene
227
+ ◯ DebugScene
228
+ ...
229
+ ```
230
+
231
+ **优化效果**:
232
+ - 🎯 单场景:减小 40-60%
233
+ - 🎯 2-3 个场景:减小 30-50%
234
+ - 🎯 自动过滤未使用的资源
235
+
236
+ **命令行模式**:
237
+ ```bash
238
+ # 指定单个场景
239
+ playcraft build --platform facebook --scenes MainScene
240
+
241
+ # 指定多个场景
242
+ playcraft build --platform facebook --scenes "MainScene,Gameplay"
243
+ ```
244
+
245
+ 详见:[场景选择功能文档](../../docs/cli/scene-selection-feature.md)
246
+
193
247
  ## 🔗 相关链接
194
248
 
195
249
  - [PlayCraft 项目](https://github.com/your-org/playcraft)
196
- - [完整文档](./docs/cli/README.md)
197
- - [快速开始指南](./docs/cli/quick-start.md)
198
- - [构建命令详解](./docs/cli/build.md)
199
- - [配置文件说明](./docs/cli/config.md)
250
+ - [完整文档](../../docs/cli/README.md)
251
+ - [快速开始指南](../../docs/cli/quick-start.md)
252
+ - [构建命令详解](../../docs/cli/build.md)
253
+ - [场景选择功能](../../docs/cli/scene-selection-feature.md)
254
+ - [配置文件说明](../../docs/cli/config.md)
255
+ - [平台规格对照](../../docs/cli/各渠道Playable规格对照表.md)
200
256
 
201
257
  ## 📝 许可证
202
258
 
@@ -25,6 +25,55 @@ async function detectBaseBuild(dir) {
25
25
  return false;
26
26
  }
27
27
  }
28
+ /**
29
+ * 检测项目场景列表
30
+ */
31
+ async function detectProjectScenes(projectPath) {
32
+ try {
33
+ // 尝试读取 scenes.json
34
+ const scenesJsonPath = path.join(projectPath, 'scenes.json');
35
+ try {
36
+ const scenesContent = await fs.readFile(scenesJsonPath, 'utf-8');
37
+ const scenesData = JSON.parse(scenesContent);
38
+ // scenes.json 可能是对象格式(key 是场景 ID)
39
+ if (typeof scenesData === 'object' && !Array.isArray(scenesData)) {
40
+ return Object.entries(scenesData).map(([id, scene]) => ({
41
+ id: scene.id || scene.scene || id,
42
+ name: scene.name || `Scene ${id}`
43
+ }));
44
+ }
45
+ // 或者是数组格式
46
+ if (Array.isArray(scenesData)) {
47
+ return scenesData.map(scene => ({
48
+ id: scene.id || scene.scene || scene.uniqueId,
49
+ name: scene.name || `Scene ${scene.id}`
50
+ }));
51
+ }
52
+ }
53
+ catch (error) {
54
+ // scenes.json 不存在或解析失败,尝试其他方法
55
+ }
56
+ // 尝试从 project.json 读取
57
+ const projectJsonPath = path.join(projectPath, 'project.json');
58
+ try {
59
+ const projectContent = await fs.readFile(projectJsonPath, 'utf-8');
60
+ const projectData = JSON.parse(projectContent);
61
+ if (projectData.scenes && Array.isArray(projectData.scenes)) {
62
+ return projectData.scenes.map((scene) => ({
63
+ id: scene.id || scene.scene || scene.uniqueId,
64
+ name: scene.name || `Scene ${scene.id}`
65
+ }));
66
+ }
67
+ }
68
+ catch (error) {
69
+ // project.json 不存在或解析失败
70
+ }
71
+ return [];
72
+ }
73
+ catch (error) {
74
+ return [];
75
+ }
76
+ }
28
77
  function resolveSuggestedPhysicsEngine(use3dPhysics) {
29
78
  return use3dPhysics ? 'cannon' : 'p2';
30
79
  }
@@ -35,7 +84,36 @@ async function detectAmmoUsage(baseBuildDir) {
35
84
  try {
36
85
  const configContent = await fs.readFile(configPath, 'utf-8');
37
86
  const configJson = JSON.parse(configContent);
87
+ // 检查 application_properties.use3dPhysics
38
88
  use3dPhysics = Boolean(configJson.application_properties?.use3dPhysics);
89
+ // 如果 use3dPhysics 未设置,通过检查场景内容来推断
90
+ // 检查场景是否包含 3D 物理组件(rigidbody, collision 等)
91
+ if (!use3dPhysics) {
92
+ const scenesDir = baseBuildDir;
93
+ try {
94
+ const files = await fs.readdir(scenesDir);
95
+ for (const file of files) {
96
+ if (file.endsWith('.json') && !['config.json', 'manifest.json'].includes(file)) {
97
+ try {
98
+ const sceneContent = await fs.readFile(path.join(scenesDir, file), 'utf-8');
99
+ // 检查场景是否包含 3D 物理组件
100
+ if (sceneContent.includes('"rigidbody"') ||
101
+ sceneContent.includes('"collision"') ||
102
+ sceneContent.includes('"joint"')) {
103
+ use3dPhysics = true;
104
+ break;
105
+ }
106
+ }
107
+ catch {
108
+ // 忽略单个场景读取失败
109
+ }
110
+ }
111
+ }
112
+ }
113
+ catch {
114
+ // 忽略目录读取失败
115
+ }
116
+ }
39
117
  if (configJson.assets) {
40
118
  for (const asset of Object.values(configJson.assets)) {
41
119
  const assetData = asset;
@@ -70,6 +148,7 @@ async function detectAmmoUsage(baseBuildDir) {
70
148
  }
71
149
  async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
72
150
  const assetSizes = [];
151
+ const assetPaths = new Set(); // 用于去重
73
152
  let engineSize = 0;
74
153
  const ammoCheck = await detectAmmoUsage(baseBuildDir);
75
154
  const engineCandidates = [
@@ -102,10 +181,15 @@ async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
102
181
  if (ammoReplacement && lowerUrl.includes('ammo')) {
103
182
  continue;
104
183
  }
184
+ // 跳过已经处理过的资源
185
+ if (assetPaths.has(cleanUrl)) {
186
+ continue;
187
+ }
105
188
  const assetPath = path.join(baseBuildDir, cleanUrl);
106
189
  try {
107
190
  const stats = await fs.stat(assetPath);
108
191
  assetSizes.push({ path: cleanUrl, size: stats.size });
192
+ assetPaths.add(cleanUrl); // 标记为已处理
109
193
  }
110
194
  catch (error) {
111
195
  // ignore missing assets
@@ -162,11 +246,64 @@ export async function buildCommand(projectPath, options) {
162
246
  options.output = fileConfig.outputDir;
163
247
  }
164
248
  }
249
+ // 解析场景选择参数(需要在 baseOnly 之前)
250
+ let selectedScenes;
251
+ if (options.scenes) {
252
+ // 命令行指定了场景参数
253
+ selectedScenes = options.scenes.split(',').map(s => s.trim()).filter(s => s);
254
+ if (selectedScenes.length > 0) {
255
+ console.log(`\n🎬 选中场景: ${selectedScenes.join(', ')}`);
256
+ }
257
+ }
258
+ else {
259
+ // 没有指定场景参数,检测项目场景并提供交互选择
260
+ spinner.stop();
261
+ const projectScenes = await detectProjectScenes(resolvedProjectPath);
262
+ if (projectScenes.length > 1) {
263
+ // 有多个场景,提示用户选择
264
+ console.log(pc.cyan(`\n🎬 检测到 ${projectScenes.length} 个场景`));
265
+ console.log(pc.dim('💡 提示: 只打包选中的场景可以显著减小文件大小(通常可减小 30-60%)\n'));
266
+ const sceneAnswer = await inquirer.prompt([
267
+ {
268
+ type: 'checkbox',
269
+ name: 'selectedScenes',
270
+ message: '选择要打包的场景(使用空格选择,回车确认):',
271
+ choices: projectScenes.map(scene => ({
272
+ name: scene.name,
273
+ value: scene.name,
274
+ checked: true // 默认全选
275
+ })),
276
+ validate: (answer) => {
277
+ if (answer.length === 0) {
278
+ return '请至少选择一个场景';
279
+ }
280
+ return true;
281
+ }
282
+ }
283
+ ]);
284
+ selectedScenes = sceneAnswer.selectedScenes;
285
+ if (selectedScenes && selectedScenes.length > 0) {
286
+ if (selectedScenes.length === projectScenes.length) {
287
+ console.log(pc.green(`✅ 已选择所有场景 (${selectedScenes.length} 个)`));
288
+ // 全选则不传递参数(向后兼容)
289
+ selectedScenes = undefined;
290
+ }
291
+ else {
292
+ console.log(pc.green(`✅ 已选择 ${selectedScenes.length} / ${projectScenes.length} 个场景: ${selectedScenes.join(', ')}`));
293
+ }
294
+ }
295
+ }
296
+ else if (projectScenes.length === 1) {
297
+ console.log(pc.dim(`ℹ️ 项目只有 1 个场景: ${projectScenes[0].name}`));
298
+ }
299
+ spinner.start(pc.cyan('🔨 开始打包 Playable Ad...'));
300
+ }
165
301
  // 如果只执行基础构建
166
302
  if (options.baseOnly || options.mode === 'base') {
167
303
  spinner.text = pc.cyan('执行阶段1: 基础构建...');
168
304
  const baseBuilder = new BaseBuilder(resolvedProjectPath, {
169
305
  outputDir: path.resolve(options.output || './build'),
306
+ selectedScenes,
170
307
  });
171
308
  const baseBuild = await baseBuilder.build();
172
309
  spinner.succeed(pc.green('✅ 基础构建完成!'));
@@ -188,6 +325,9 @@ export async function buildCommand(projectPath, options) {
188
325
  'liftoff',
189
326
  'moloco',
190
327
  'bigo',
328
+ 'inmobi',
329
+ 'adikteev',
330
+ 'remerge',
191
331
  ];
192
332
  if (!options.platform || !validPlatforms.includes(options.platform)) {
193
333
  spinner.stop();
@@ -243,6 +383,8 @@ export async function buildCommand(projectPath, options) {
243
383
  convertToWebP: options.convertToWebP,
244
384
  compressModels: options.compressModels,
245
385
  modelCompression: options.modelCompression,
386
+ // 场景选择
387
+ selectedScenes,
246
388
  };
247
389
  if (fileConfig) {
248
390
  Object.assign(buildOptions, fileConfig);
@@ -269,6 +411,9 @@ export async function buildCommand(projectPath, options) {
269
411
  const tempDir = path.join(os.tmpdir(), `playcraft-base-build-${Date.now()}`);
270
412
  const baseBuilder = new BaseBuilder(resolvedProjectPath, {
271
413
  outputDir: tempDir,
414
+ selectedScenes,
415
+ analyze: buildOptions.analyze,
416
+ analyzeReportPath: buildOptions.analyze ? 'base-bundle-report.html' : undefined,
272
417
  });
273
418
  const baseBuild = await baseBuilder.build();
274
419
  baseBuildDir = baseBuild.outputDir;
@@ -282,18 +427,48 @@ export async function buildCommand(projectPath, options) {
282
427
  else if (options.replaceAmmo && ammoCheck.hasAmmo && ammoCheck.suggestedEngine) {
283
428
  buildOptions.ammoReplacement = ammoCheck.suggestedEngine;
284
429
  }
285
- else if (ammoCheck.hasAmmo && ammoCheck.suggestedEngine && !buildOptions.ammoReplacement) {
430
+ else if (ammoCheck.hasAmmo && !buildOptions.ammoReplacement) {
286
431
  spinner.stop();
287
- const replaceAnswer = await inquirer.prompt([
288
- {
289
- type: 'confirm',
290
- name: 'replaceAmmo',
291
- message: `检测到 Ammo.js,是否替换为 ${ammoCheck.suggestedEngine} 并打包内置?`,
292
- default: false,
293
- },
294
- ]);
295
- if (replaceAnswer.replaceAmmo) {
296
- buildOptions.ammoReplacement = ammoCheck.suggestedEngine;
432
+ // 根据是否使用 3D 物理提供不同的选项
433
+ const physicsType = ammoCheck.use3dPhysics ? '3D' : '2D';
434
+ if (ammoCheck.use3dPhysics) {
435
+ // 3D 项目 - 可以使用 Cannon.js 替换
436
+ console.log(pc.yellow(`⚠️ 检测到 3D 物理项目,Ammo.js 约 1.8MB`));
437
+ console.log(pc.gray(` 提示: Cannon.js (~400KB) 可以减小 75% 的体积`));
438
+ const replaceAnswer = await inquirer.prompt([
439
+ {
440
+ type: 'list',
441
+ name: 'ammoAction',
442
+ message: `如何处理 Ammo.js?`,
443
+ choices: [
444
+ { name: '保留 Ammo.js(完整物理功能,文件较大)', value: 'keep' },
445
+ { name: '替换为 Cannon.js(推荐 3D 项目,~400KB,自动适配)', value: 'cannon' },
446
+ ],
447
+ default: 'cannon',
448
+ },
449
+ ]);
450
+ if (replaceAnswer.ammoAction === 'cannon') {
451
+ buildOptions.ammoReplacement = 'cannon';
452
+ console.log(pc.green(' ✓ 将使用 Cannon.js 替换 Ammo.js(自动兼容 rigidbody API)'));
453
+ }
454
+ }
455
+ else {
456
+ // 2D 项目 - 可以使用 p2.js 替换
457
+ const replaceAnswer = await inquirer.prompt([
458
+ {
459
+ type: 'list',
460
+ name: 'ammoAction',
461
+ message: `检测到 Ammo.js(项目使用 ${physicsType} 物理),如何处理?`,
462
+ choices: [
463
+ { name: '不替换(保留 Ammo.js,文件较大)', value: 'keep' },
464
+ { name: '替换为 p2.js(推荐 2D 项目,~60KB)', value: 'p2' },
465
+ ],
466
+ default: 'p2',
467
+ },
468
+ ]);
469
+ if (replaceAnswer.ammoAction === 'p2') {
470
+ buildOptions.ammoReplacement = 'p2';
471
+ }
297
472
  }
298
473
  spinner.start(pc.cyan('🔨 开始打包 Playable Ad...'));
299
474
  }
@@ -326,12 +501,21 @@ export async function buildCommand(projectPath, options) {
326
501
  console.log(` - 总计: ${totalMB} MB ${status} (限制: ${limitMB} MB)`);
327
502
  console.log('\n' + pc.green(`输出: ${outputPath}`));
328
503
  if (buildOptions.analyze) {
329
- const reportPath = buildOptions.analyzeReportPath
504
+ // Base Build 复制分析报告到最终输出目录
505
+ const baseReportPath = path.join(baseBuildDir, 'base-bundle-report.html');
506
+ const finalReportPath = buildOptions.analyzeReportPath
330
507
  ? buildOptions.analyzeReportPath
331
508
  : path.join(buildOptions.outputDir || './dist', 'bundle-report.html');
509
+ try {
510
+ await fs.access(baseReportPath);
511
+ await fs.copyFile(baseReportPath, finalReportPath);
512
+ }
513
+ catch (error) {
514
+ console.warn(pc.yellow(' ⚠️ 无法找到或复制分析报告'));
515
+ }
332
516
  const analysis = await analyzeBaseBuild(baseBuildDir, buildOptions.ammoReplacement);
333
517
  console.log('\n' + pc.bold('分析报告:'));
334
- console.log(` - 可视化报告: ${reportPath}`);
518
+ console.log(` - 可视化报告: ${finalReportPath}`);
335
519
  if (analysis.engineSize > 0) {
336
520
  const engineMB = (analysis.engineSize / 1024 / 1024).toFixed(2);
337
521
  console.log(` - 引擎大小: ${engineMB} MB`);
@@ -339,8 +523,13 @@ export async function buildCommand(projectPath, options) {
339
523
  if (analysis.topAssets.length > 0) {
340
524
  console.log(' - 资源 Top:');
341
525
  analysis.topAssets.forEach((asset) => {
342
- const sizeMB = (asset.size / 1024 / 1024).toFixed(2);
343
- console.log(` • ${asset.path}: ${sizeMB} MB`);
526
+ const sizeKB = asset.size / 1024;
527
+ const sizeMB = sizeKB / 1024;
528
+ // 自动选择合适的单位
529
+ const sizeStr = sizeMB >= 0.01
530
+ ? `${sizeMB.toFixed(2)} MB`
531
+ : `${sizeKB.toFixed(2)} KB`;
532
+ console.log(` • ${asset.path}: ${sizeStr}`);
344
533
  });
345
534
  }
346
535
  if (analysis.hasAmmo) {
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { readFileSync } from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
3
6
  import { initCommand } from './commands/init.js';
4
7
  import { startCommand, startInternal } from './commands/start.js';
5
8
  import { stopCommand } from './commands/stop.js';
@@ -7,11 +10,14 @@ import { statusCommand } from './commands/status.js';
7
10
  import { logsCommand } from './commands/logs.js';
8
11
  import { configCommand } from './commands/config.js';
9
12
  import { buildCommand } from './commands/build.js';
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
10
16
  const program = new Command();
11
17
  program
12
18
  .name('playcraft')
13
19
  .description('PlayCraft Local Dev Agent - 本地开发助手')
14
- .version('0.0.1');
20
+ .version(packageJson.version);
15
21
  // init 命令
16
22
  program
17
23
  .command('init')
@@ -85,6 +91,7 @@ program
85
91
  .option('-f, --format <format>', '输出格式 (html|zip)', 'html')
86
92
  .option('-o, --output <path>', '输出目录', './dist')
87
93
  .option('-c, --config <file>', '配置文件路径')
94
+ .option('-s, --scenes <scenes>', '选择场景(逗号分隔),例如: MainMenu,Gameplay')
88
95
  .option('--compress', '压缩引擎代码')
89
96
  .option('--analyze', '生成打包分析报告')
90
97
  .option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon')
@@ -115,6 +122,7 @@ program
115
122
  .description('生成可运行的多文件构建产物(阶段1)')
116
123
  .argument('<project-path>', '项目路径')
117
124
  .option('-o, --output <path>', '输出目录', './build')
125
+ .option('-s, --scenes <scenes>', '选择场景(逗号分隔),例如: MainMenu,Gameplay')
118
126
  .action(async (projectPath, options) => {
119
127
  await buildCommand(projectPath, {
120
128
  ...options,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,10 +15,12 @@
15
15
  "dev": "tsc -w",
16
16
  "build": "tsc",
17
17
  "start": "node dist/index.js",
18
+ "link": "pnpm build && npm link",
19
+ "unlink": "npm unlink -g @playcraft/cli",
18
20
  "release": "node scripts/release.js"
19
21
  },
20
22
  "dependencies": {
21
- "@playcraft/build": "^0.0.2",
23
+ "@playcraft/build": "^0.0.4",
22
24
  "chokidar": "^4.0.3",
23
25
  "commander": "^13.1.0",
24
26
  "cors": "^2.8.5",