@playcraft/build 0.0.17 → 0.0.19

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.
Files changed (46) hide show
  1. package/dist/analyzers/scene-asset-collector.js +259 -135
  2. package/dist/audio-optimizer.d.ts +70 -0
  3. package/dist/audio-optimizer.js +226 -0
  4. package/dist/base-builder.d.ts +25 -13
  5. package/dist/base-builder.js +69 -29
  6. package/dist/engines/engine-detector.d.ts +13 -4
  7. package/dist/engines/engine-detector.js +74 -10
  8. package/dist/engines/generic-adapter.d.ts +12 -6
  9. package/dist/engines/generic-adapter.js +46 -15
  10. package/dist/engines/index.d.ts +1 -0
  11. package/dist/engines/index.js +1 -0
  12. package/dist/engines/playable-scripts-adapter.d.ts +148 -0
  13. package/dist/engines/playable-scripts-adapter.js +1084 -0
  14. package/dist/engines/playcanvas-adapter.js +3 -0
  15. package/dist/generators/config-generator.js +10 -17
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.js +3 -1
  18. package/dist/platforms/google.d.ts +9 -0
  19. package/dist/platforms/google.js +68 -7
  20. package/dist/templates/__loading__.js +100 -0
  21. package/dist/templates/__modules__.js +47 -0
  22. package/dist/templates/__settings__.template.js +20 -0
  23. package/dist/templates/__start__.js +332 -0
  24. package/dist/templates/index.html +18 -0
  25. package/dist/templates/logo.png +0 -0
  26. package/dist/templates/manifest.json +1 -0
  27. package/dist/templates/patches/cannon.min.js +28 -0
  28. package/dist/templates/patches/lz4.js +10 -0
  29. package/dist/templates/patches/one-page-http-get.js +20 -0
  30. package/dist/templates/patches/one-page-inline-game-scripts.js +52 -0
  31. package/dist/templates/patches/one-page-mraid-resize-canvas.js +46 -0
  32. package/dist/templates/patches/p2.min.js +27 -0
  33. package/dist/templates/patches/playcraft-no-xhr.js +76 -0
  34. package/dist/templates/playcanvas-stable.min.js +16363 -0
  35. package/dist/templates/styles.css +43 -0
  36. package/dist/types.d.ts +60 -13
  37. package/dist/utils/build-mode-detector.js +2 -0
  38. package/dist/vite/plugin-playcanvas.js +14 -19
  39. package/dist/vite/plugin-source-builder.js +383 -97
  40. package/package.json +7 -4
  41. package/dist/utils/obfuscate.d.ts +0 -42
  42. package/dist/utils/obfuscate.js +0 -216
  43. package/dist/vite/plugin-obfuscate.d.ts +0 -22
  44. package/dist/vite/plugin-obfuscate.js +0 -52
  45. package/dist/vite/plugin-template-minifier.d.ts +0 -20
  46. package/dist/vite/plugin-template-minifier.js +0 -392
@@ -0,0 +1,1084 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import { createRequire } from 'module';
6
+ // ESM 兼容:获取当前模块的目录路径
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ // ESM 兼容:创建 require 函数用于动态加载 JSON
10
+ const require = createRequire(import.meta.url);
11
+ /**
12
+ * PlayCraft 平台名 → playable-scripts 渠道名映射
13
+ * 大部分一致,少数需要映射
14
+ */
15
+ const CHANNEL_MAP = {
16
+ bigo: 'bigoads',
17
+ playcraft: 'preview',
18
+ };
19
+ /**
20
+ * PlayCraft 不支持的渠道(playable-scripts 不认识的 PlayCraft 平台)
21
+ */
22
+ const UNSUPPORTED_CHANNELS = new Set(['remerge', 'mintegral']);
23
+ /**
24
+ * 将 PlayCraft 平台名映射为 playable-scripts 渠道名
25
+ */
26
+ function mapChannel(playcraftPlatform) {
27
+ return CHANNEL_MAP[playcraftPlatform] ?? playcraftPlatform;
28
+ }
29
+ /**
30
+ * PlayableScripts 构建工具适配器
31
+ *
32
+ * 职责:作为 PlayCraft 和 @playcraft/devkit 之间的中间层
33
+ * - @playcraft/devkit 是 PlayCraft 平台内置的构建工具(已集成到 @playcraft/cli)
34
+ * - 用户项目只需依赖 @playcraft/adsdk(运行时 SDK),不需要安装构建工具
35
+ * - 通过平台内置的 devkit 调用 playable-scripts build/builds 命令
36
+ * - 将 playcraft.config.json 的参数转换为 playable-scripts CLI 参数
37
+ * - 收集构建产物并标记 skipChannelBuild
38
+ *
39
+ * 注意:产物已是最终格式(单 HTML),无需 ViteBuilder 二次处理
40
+ */
41
+ export class PlayableScriptsAdapter {
42
+ constructor(projectDir, options, config) {
43
+ this.projectDir = projectDir;
44
+ this.options = options;
45
+ this.config = config;
46
+ console.log(`[PlayableScriptsAdapter] 初始化: projectDir=${projectDir}`);
47
+ if (config?.channels) {
48
+ console.log(`[PlayableScriptsAdapter] 目标渠道: ${config.channels.join(', ')}`);
49
+ }
50
+ }
51
+ /**
52
+ * 执行 playable-scripts 构建
53
+ *
54
+ * 流程:
55
+ * 1. 合并配置(playcraft.config.json → builds.config.js)
56
+ * 2. 构建 CLI 参数
57
+ * 3. 调用平台内置的 playable-scripts build/builds
58
+ * 4. 收集构建产物
59
+ * 5. 复制到输出目录
60
+ * 6. 写入元数据(skipChannelBuild: true)
61
+ *
62
+ * 注意:不再在用户项目中执行 npm install,
63
+ * devkit 是 PlayCraft 平台内置的构建工具。
64
+ */
65
+ async baseBuild() {
66
+ console.log('[PlayableScriptsAdapter] 开始 playable-scripts 构建(使用平台内置 devkit)');
67
+ // 1. 合并配置
68
+ await this.mergeConfig();
69
+ // 2. 构建 CLI 参数
70
+ const cliArgs = this.buildCLIArgs();
71
+ // 3. 打印构建参数信息
72
+ this.logBuildParams();
73
+ // 4. 清理输出目录(在 devkit 构建前清理,避免旧产物干扰)
74
+ // 判断是否为批量构建模式(多个渠道或有主题)
75
+ const isBatchMode = (this.config?.channels && this.config.channels.length > 1) ||
76
+ (this.config?.themes?.enabled);
77
+ if (isBatchMode) {
78
+ // 批量构建:不预清理,让 devkit 来清理,避免 EBUSY 错误
79
+ console.log('[PlayableScriptsAdapter] 批量构建模式:跳过预清理(由 devkit 负责)...');
80
+ }
81
+ else {
82
+ // 单渠道构建:清理项目目录下的 dist 目录(playable-scripts 实际输出目录)
83
+ const devkitOutputDir = path.join(this.projectDir, this.config?.outputDir ?? 'dist');
84
+ console.log('[PlayableScriptsAdapter] 单渠道构建模式:清理 devkit 输出目录...');
85
+ await this.cleanOutputDir(devkitOutputDir);
86
+ }
87
+ // 5. 执行 playable-scripts 构建(使用平台内置的 devkit)
88
+ console.log('[PlayableScriptsAdapter] 执行 playable-scripts 构建...');
89
+ await this.executePlayableScripts(cliArgs);
90
+ // 6. 收集产物
91
+ console.log('[PlayableScriptsAdapter] 收集构建产物...');
92
+ const outputs = await this.collectOutputs();
93
+ // 7. 复制产物到 PlayCraft outputDir
94
+ const files = await this.copyToOutputDir(outputs);
95
+ // 7. 构建元数据
96
+ const metadata = {
97
+ mode: 'classic',
98
+ engine: 'phaser', // adsdk 项目通常是 Phaser 游戏
99
+ buildTool: 'playable-scripts',
100
+ skipChannelBuild: true, // 产物已是最终格式,跳过 ViteBuilder 阶段2
101
+ };
102
+ // 8. 保存元数据
103
+ await this.saveBuildMetadata(metadata);
104
+ console.log('[PlayableScriptsAdapter] 构建完成');
105
+ return {
106
+ outputDir: this.options.outputDir,
107
+ metadata,
108
+ files,
109
+ };
110
+ }
111
+ // ==================== 1. 合并配置 ====================
112
+ /**
113
+ * 扫描项目中的可用主题
114
+ * 主题目录: src/theme/ 下的子目录(排除 index.ts 等文件)
115
+ */
116
+ async scanThemes() {
117
+ const themeDir = path.join(this.projectDir, 'src', 'theme');
118
+ const themes = [];
119
+ try {
120
+ const entries = await fs.readdir(themeDir, { withFileTypes: true });
121
+ for (const entry of entries) {
122
+ // 只包含目录,且排除常见非主题目录
123
+ if (entry.isDirectory() && !entry.name.startsWith('.') && !entry.name.startsWith('tiles-')) {
124
+ themes.push(entry.name);
125
+ }
126
+ }
127
+ }
128
+ catch {
129
+ // 目录不存在或无法读取
130
+ console.log('[PlayableScriptsAdapter] 未找到主题目录: src/theme/');
131
+ }
132
+ return themes.sort();
133
+ }
134
+ /**
135
+ * 将 playcraft.config.json 的参数注入到项目的 builds.config.js
136
+ * - storeUrls → googlePlayUrl / appStoreUrl
137
+ * - naming → naming
138
+ * - themes.enabled → 批量构建时自动禁用主题
139
+ * - build.outputDir → 强制与 PlayCraft 输出目录一致
140
+ */
141
+ async mergeConfig() {
142
+ // 判断是否为批量构建模式
143
+ const isBatchMode = (this.config?.channels && this.config.channels.length > 1) ||
144
+ (this.config?.themes?.enabled);
145
+ // 读取或创建 builds.config.js
146
+ const configPath = path.join(this.projectDir, 'builds.config.js');
147
+ let configContent;
148
+ try {
149
+ configContent = await fs.readFile(configPath, 'utf-8');
150
+ console.log('[PlayableScriptsAdapter] 读取现有 builds.config.js');
151
+ }
152
+ catch {
153
+ configContent = 'module.exports = {};';
154
+ console.log('[PlayableScriptsAdapter] builds.config.js 不存在,将创建');
155
+ }
156
+ // 构建注入内容
157
+ const injections = [];
158
+ // 强制覆盖 build.outputDir,确保与 PlayCraft 输出目录一致
159
+ // devkit 把 build.outputDir 当作相对路径,与 projectDir 拼接
160
+ // 所以需要传入相对路径,而不是绝对路径
161
+ const outputDir = this.config?.outputDir ?? 'dist';
162
+ const relativeOutputDir = path.isAbsolute(outputDir)
163
+ ? path.relative(this.projectDir, outputDir) || 'dist'
164
+ : outputDir;
165
+ injections.push(` build: { outputDir: ${JSON.stringify(relativeOutputDir)} }`);
166
+ console.log(`[PlayableScriptsAdapter] 强制设置输出目录: ${relativeOutputDir}`);
167
+ if (this.config?.naming) {
168
+ injections.push(` naming: ${JSON.stringify(this.config.naming)}`);
169
+ }
170
+ // 批量构建时处理主题配置
171
+ if (isBatchMode) {
172
+ // 扫描可用主题
173
+ const availableThemes = await this.scanThemes();
174
+ if (this.config?.themes?.whitelist && this.config.themes.whitelist.length > 0) {
175
+ // 用户指定了主题白名单:启用主题切换
176
+ const validThemes = this.config.themes.whitelist.filter(t => availableThemes.includes(t));
177
+ if (validThemes.length > 0) {
178
+ injections.push(` themes: { enabled: true, sourceDir: 'src/theme', entryFile: 'src/theme/index.ts' }`);
179
+ console.log(`[PlayableScriptsAdapter] 批量构建模式:启用主题切换 (${validThemes.length} 个主题)`);
180
+ }
181
+ else {
182
+ // 指定的主题不存在,禁用主题切换
183
+ injections.push(` themes: { enabled: false }`);
184
+ console.log('[PlayableScriptsAdapter] 批量构建模式:指定主题未找到,使用默认主题');
185
+ }
186
+ }
187
+ else {
188
+ // 用户未指定主题:禁用主题切换,使用当前默认主题
189
+ injections.push(` themes: { enabled: false }`);
190
+ console.log('[PlayableScriptsAdapter] 批量构建模式:使用当前默认主题');
191
+ }
192
+ }
193
+ // 始终注入配置,确保 build.outputDir 被覆盖
194
+ // 在 module.exports 的对象末尾注入配置
195
+ // 策略:创建一个 wrapper 文件来合并配置
196
+ // 注意:将 module.exports = {...} 转换为 return {...}
197
+ const modifiedContent = configContent
198
+ .replace(/module\.exports\s*=\s*/, 'return ')
199
+ .replace(/;\s*$/, '');
200
+ const wrapperContent = [
201
+ `// PlayCraft 自动生成 - 合并 playcraft.config.json 配置`,
202
+ `const originalConfig = (() => { ${modifiedContent} })();`,
203
+ `module.exports = {`,
204
+ ` ...originalConfig,`,
205
+ ...injections.map(i => `${i},`),
206
+ `};`,
207
+ ].join('\n');
208
+ // 备份原文件
209
+ try {
210
+ await fs.copyFile(configPath, `${configPath}.bak`);
211
+ }
212
+ catch {
213
+ // 原文件不存在,无需备份
214
+ }
215
+ await fs.writeFile(configPath, wrapperContent, 'utf-8');
216
+ console.log('[PlayableScriptsAdapter] 已注入配置到 builds.config.js');
217
+ }
218
+ // ==================== 4. 构建 CLI 参数 ====================
219
+ /**
220
+ * 将 PlayCraft 配置转换为 playable-scripts CLI 参数
221
+ * ★ 始终显式传递 --obfuscate-level 和 --fflate-compression
222
+ */
223
+ buildCLIArgs() {
224
+ const config = this.config ?? {};
225
+ const args = [];
226
+ // 混淆等级:config 指定 > PlayCraft 强制默认 4
227
+ const obfuscateLevel = config.obfuscateLevel ?? PlayableScriptsAdapter.DEFAULTS.obfuscateLevel;
228
+ args.push('--obfuscate-level', `${obfuscateLevel}`);
229
+ // fflate 压缩:config 指定 > PlayCraft 强制默认 true
230
+ const fflate = config.fflateCompression ?? PlayableScriptsAdapter.DEFAULTS.fflateCompression;
231
+ args.push('--fflate-compression', `${fflate}`);
232
+ // 渠道列表
233
+ if (config.channels && config.channels.length > 0) {
234
+ const mappedChannels = config.channels
235
+ .filter(ch => !UNSUPPORTED_CHANNELS.has(ch))
236
+ .map(ch => mapChannel(ch));
237
+ if (mappedChannels.length > 0) {
238
+ args.push('--channels', mappedChannels.join(','));
239
+ }
240
+ }
241
+ // 商店跳转地址
242
+ if (config.storeUrls) {
243
+ if (config.storeUrls.ios) {
244
+ args.push('--app-store-url', config.storeUrls.ios);
245
+ }
246
+ if (config.storeUrls.android) {
247
+ args.push('--google-play-url', config.storeUrls.android);
248
+ }
249
+ }
250
+ // 主题配置
251
+ if (config.themes?.enabled && config.themes.whitelist && config.themes.whitelist.length > 0) {
252
+ args.push('--themes', config.themes.whitelist.join(','));
253
+ }
254
+ // 并发数
255
+ if (config.parallel) {
256
+ args.push('--parallel', `${config.parallel}`);
257
+ }
258
+ // 输出目录 - 统一使用 dist,与 PlayCraft 标准一致
259
+ const outputDir = config.outputDir ?? 'dist';
260
+ args.push('--out-dir', outputDir);
261
+ // ZIP 输出格式 - 根据平台自动判断
262
+ const isZipFormat = this.isZipFormatPlatform();
263
+ if (isZipFormat || config.zip) {
264
+ args.push('--zip');
265
+ }
266
+ // 自定义文件名格式 - 根据平台使用 PlayCraft 标准命名
267
+ const platformFilename = this.getPlatformFilename(isZipFormat);
268
+ const filename = config.filename || platformFilename;
269
+ if (filename) {
270
+ args.push('--filename', filename);
271
+ }
272
+ // 所有渠道输出为 ZIP
273
+ if (config.isChannelFold2zip) {
274
+ args.push('--is-channel-fold2zip');
275
+ }
276
+ return args;
277
+ }
278
+ // ==================== 3. 日志输出 ====================
279
+ logBuildParams() {
280
+ const config = this.config ?? {};
281
+ const obfuscateLevel = config.obfuscateLevel ?? PlayableScriptsAdapter.DEFAULTS.obfuscateLevel;
282
+ const fflate = config.fflateCompression ?? PlayableScriptsAdapter.DEFAULTS.fflateCompression;
283
+ const levelLabels = {
284
+ 1: 'minimum - 基础',
285
+ 2: 'medium - 中等',
286
+ 3: 'high - 较高',
287
+ 4: 'maximum - 全部拉满',
288
+ };
289
+ console.log(`[PlayCraft] 🔧 使用 @playcraft/devkit 构建工具`);
290
+ console.log(`[PlayCraft] 📋 构建参数:`);
291
+ console.log(`[PlayCraft] - 混淆等级: ${obfuscateLevel} (${levelLabels[obfuscateLevel] ?? 'unknown'})`);
292
+ console.log(`[PlayCraft] - fflate 压缩: ${fflate ? '开启 (压缩率 ~66-68%)' : '关闭'}`);
293
+ if (config.channels && config.channels.length > 0) {
294
+ console.log(`[PlayCraft] - 目标渠道: ${config.channels.join(', ')}`);
295
+ }
296
+ if (config.themes?.enabled) {
297
+ console.log(`[PlayCraft] - 主题: ${config.themes.whitelist?.join(', ') ?? '全部'}`);
298
+ }
299
+ console.log(`[PlayCraft] - 输出目录: ${config.outputDir ?? 'dist'}`);
300
+ // 警告:如果用户覆盖了默认值
301
+ if (config.obfuscateLevel !== undefined && config.obfuscateLevel < PlayableScriptsAdapter.DEFAULTS.obfuscateLevel) {
302
+ console.log(`[PlayCraft] ⚠️ 混淆等级已从默认值 ${PlayableScriptsAdapter.DEFAULTS.obfuscateLevel} 降级到 ${config.obfuscateLevel} (来自 playcraft.config.json)`);
303
+ console.log(`[PlayCraft] ⚠️ 注意: 降级混淆会减弱代码保护,仅建议在体积超限时使用`);
304
+ }
305
+ if (config.fflateCompression === false) {
306
+ console.log(`[PlayCraft] ⚠️ fflate 压缩已关闭 (来自 playcraft.config.json)`);
307
+ console.log(`[PlayCraft] ⚠️ 注意: 关闭压缩会增大产物体积,仅建议在调试时使用`);
308
+ }
309
+ }
310
+ /**
311
+ * 自动迁移用户项目中的旧包名引用
312
+ * 将源码中的旧包名替换为新包名
313
+ */
314
+ async migratePackageNames() {
315
+ const srcDir = path.join(this.projectDir, 'src');
316
+ try {
317
+ await fs.access(srcDir);
318
+ }
319
+ catch {
320
+ // src 目录不存在,跳过
321
+ return;
322
+ }
323
+ let hasMigration = false;
324
+ const migrations = PlayableScriptsAdapter.PACKAGE_MIGRATIONS;
325
+ // 递归查找所有 .ts 和 .js 文件
326
+ const files = await this.findSourceFiles(srcDir);
327
+ for (const filePath of files) {
328
+ try {
329
+ const content = await fs.readFile(filePath, 'utf-8');
330
+ let newContent = content;
331
+ let fileMigrated = false;
332
+ for (const [oldPkg, newPkg] of Object.entries(migrations)) {
333
+ if (newContent.includes(oldPkg)) {
334
+ // 使用 split/join 替代 replaceAll 以兼容旧版 Node.js
335
+ newContent = newContent.split(oldPkg).join(newPkg);
336
+ fileMigrated = true;
337
+ }
338
+ }
339
+ if (fileMigrated) {
340
+ await fs.writeFile(filePath, newContent, 'utf-8');
341
+ console.log(`[PlayableScriptsAdapter] 📝 已迁移: ${path.relative(this.projectDir, filePath)}`);
342
+ hasMigration = true;
343
+ }
344
+ }
345
+ catch (error) {
346
+ // 读取或写入失败,跳过
347
+ console.log(`[PlayableScriptsAdapter] ⚠️ 无法处理文件: ${filePath}`);
348
+ }
349
+ }
350
+ if (hasMigration) {
351
+ console.log('[PlayableScriptsAdapter] ✅ 包名迁移完成');
352
+ }
353
+ }
354
+ /**
355
+ * 递归查找源码文件
356
+ */
357
+ async findSourceFiles(dir) {
358
+ const files = [];
359
+ const entries = await fs.readdir(dir, { withFileTypes: true });
360
+ for (const entry of entries) {
361
+ const fullPath = path.join(dir, entry.name);
362
+ if (entry.isDirectory()) {
363
+ // 排除 node_modules 和 .git
364
+ if (entry.name !== 'node_modules' && entry.name !== '.git') {
365
+ files.push(...await this.findSourceFiles(fullPath));
366
+ }
367
+ }
368
+ else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) {
369
+ files.push(fullPath);
370
+ }
371
+ }
372
+ return files;
373
+ }
374
+ /**
375
+ * 准备构建环境
376
+ * - 执行包名迁移
377
+ * - 确保项目本地 devkit 和 loader 可用
378
+ */
379
+ async prepareBuildEnvironment() {
380
+ // 1. 执行包名迁移
381
+ await this.migratePackageNames();
382
+ // 2. 确保项目本地存在 devkit 和构建依赖
383
+ const localDevkit = await this.ensureLocalDevkit();
384
+ if (!localDevkit) {
385
+ throw new Error('[PlayableScriptsAdapter] 无法安装项目本地的 @playcraft/devkit。\n' +
386
+ '请检查网络连接或手动运行: npm install --save-dev @playcraft/devkit');
387
+ }
388
+ console.log('[PlayableScriptsAdapter] ✅ 使用项目本地 devkit');
389
+ // 3. 确保构建 loader 存在
390
+ await this.ensureBuildLoaders();
391
+ }
392
+ /**
393
+ * 调用 playable-scripts 命令
394
+ *
395
+ * 流程:
396
+ * 1. 准备构建环境(包名迁移、安装本地 devkit 和 loader)
397
+ * 2. 执行构建(使用项目本地 devkit)
398
+ *
399
+ * 单渠道模式: <local-devkit> build <channel> [options]
400
+ * 批量模式: <local-devkit> builds [options]
401
+ */
402
+ async executePlayableScripts(cliArgs) {
403
+ // 1. 执行包名迁移
404
+ await this.migratePackageNames();
405
+ // 2. 确保项目本地存在 devkit 和构建依赖
406
+ const localDevkit = await this.ensureLocalDevkit();
407
+ if (!localDevkit) {
408
+ throw new Error('[PlayableScriptsAdapter] 无法安装或找到项目本地的 @playcraft/devkit。\n' +
409
+ '请检查网络连接或手动运行: npm install --save-dev @playcraft/devkit');
410
+ }
411
+ console.log(`[PlayableScriptsAdapter] ✅ 使用项目本地 devkit: ${localDevkit}`);
412
+ // 3. 确保构建 loader 存在
413
+ await this.ensureBuildLoaders();
414
+ const config = this.config ?? {};
415
+ const isBatchMode = (config.channels && config.channels.length > 1) ||
416
+ (config.themes?.enabled);
417
+ // 构建命令参数
418
+ let args;
419
+ let modeLabel;
420
+ if (isBatchMode) {
421
+ // 批量模式: <devkit> builds [options]
422
+ args = ['builds', ...cliArgs];
423
+ // 扫描可用主题
424
+ const availableThemes = await this.scanThemes();
425
+ // 确定要构建的主题列表
426
+ let selectedThemes = [];
427
+ if (config.themes?.whitelist && config.themes.whitelist.length > 0) {
428
+ // 使用配置中指定的主题白名单,清理可能的引号
429
+ const cleanWhitelist = config.themes.whitelist.map(t => t.replace(/^["']|["']$/g, ''));
430
+ selectedThemes = cleanWhitelist.filter(t => availableThemes.includes(t));
431
+ if (selectedThemes.length === 0) {
432
+ console.log(`[PlayableScriptsAdapter] ⚠️ 配置的主题未找到,使用当前默认主题`);
433
+ selectedThemes = [];
434
+ }
435
+ }
436
+ // 如果有指定主题,传入 -t 参数
437
+ if (selectedThemes.length > 0) {
438
+ args.push('-t', selectedThemes.join(','));
439
+ console.log(`[PlayableScriptsAdapter] 构建主题: ${selectedThemes.join(', ')}`);
440
+ }
441
+ else {
442
+ // 未指定主题:使用 devkit 默认行为(当前 index.ts 指向的主题)
443
+ console.log(`[PlayableScriptsAdapter] 使用当前默认主题 (src/theme/index.ts)`);
444
+ }
445
+ modeLabel = '批量构建模式';
446
+ }
447
+ else {
448
+ // 单渠道模式: <devkit> build <channel> [options]
449
+ const channel = config.channels?.[0]
450
+ ? mapChannel(config.channels[0])
451
+ : 'google';
452
+ const filteredArgs = this.removeArg(cliArgs, '--channels');
453
+ args = ['build', channel, ...filteredArgs];
454
+ modeLabel = `单渠道构建模式: ${channel}`;
455
+ }
456
+ console.log(`[PlayableScriptsAdapter] ${modeLabel}`);
457
+ // devkit 必须通过 npm script 调用,不能直接执行 .js 文件
458
+ // 1. 确保 package.json 中有 build/builds 脚本
459
+ await this.ensureBuildScripts();
460
+ // 2. 通过 pnpm run 执行
461
+ const scriptName = isBatchMode ? 'builds' : 'build';
462
+ // 单渠道: args = ['build', channel, ...options],需要传递 [channel, ...options]
463
+ // 批量: args = ['builds', ...options],需要传递 [...options]
464
+ const runArgs = isBatchMode
465
+ ? args.slice(1) // builds 命令,去掉第一个 'builds'
466
+ : args.slice(1); // build 命令,去掉第一个 'build',保留 channel 和所有参数
467
+ console.log(`[PlayableScriptsAdapter] 运行: pnpm run ${scriptName} ${runArgs.length > 0 ? runArgs.join(' ') : ''}`);
468
+ try {
469
+ // 注意: package.json 的 scripts 已经是 "playable-scripts build/builds"
470
+ // 所以不需要 '--' 分隔符,直接传参数即可
471
+ const pnpmArgs = runArgs.length > 0
472
+ ? ['run', scriptName, ...runArgs]
473
+ : ['run', scriptName];
474
+ await this.runCommandWithTimeout('pnpm', pnpmArgs, {
475
+ cwd: this.projectDir,
476
+ timeout: 30 * 60 * 1000, // 30 分钟
477
+ env: {
478
+ ...process.env,
479
+ NODE_ENV: 'production',
480
+ CI: 'true',
481
+ },
482
+ });
483
+ console.log('[PlayableScriptsAdapter] playable-scripts 构建命令执行完成');
484
+ }
485
+ catch (error) {
486
+ console.error('[PlayableScriptsAdapter] ❌ playable-scripts 构建失败:');
487
+ console.error(error.message || error);
488
+ throw error;
489
+ }
490
+ // 检查输出目录是否存在
491
+ const outputDir = config.outputDir ?? 'dist';
492
+ const fullOutputDir = path.isAbsolute(outputDir)
493
+ ? outputDir
494
+ : path.join(this.projectDir, outputDir);
495
+ try {
496
+ await fs.access(fullOutputDir);
497
+ const entries = await fs.readdir(fullOutputDir);
498
+ console.log(`[PlayableScriptsAdapter] ✓ 输出目录存在: ${fullOutputDir} (${entries.length} 项)`);
499
+ if (entries.length === 0) {
500
+ console.warn(`[PlayableScriptsAdapter] ⚠️ 输出目录为空,构建可能失败`);
501
+ }
502
+ }
503
+ catch {
504
+ console.error(`[PlayableScriptsAdapter] ❌ 输出目录不存在: ${fullOutputDir}`);
505
+ console.error(`[PlayableScriptsAdapter] 请检查 devkit 构建日志,确认构建是否成功`);
506
+ }
507
+ }
508
+ /**
509
+ * 从参数列表中移除指定参数及其值
510
+ */
511
+ removeArg(args, argName) {
512
+ const result = [];
513
+ for (let i = 0; i < args.length; i++) {
514
+ if (args[i] === argName) {
515
+ i++; // 跳过参数值
516
+ }
517
+ else {
518
+ result.push(args[i]);
519
+ }
520
+ }
521
+ return result;
522
+ }
523
+ /**
524
+ * 查找项目本地的 playable-scripts 可执行文件
525
+ * 优先查找项目 node_modules 中的版本,确保 webpack 能正确解析 loader
526
+ */
527
+ async findLocalPlayableScripts() {
528
+ // 可能的可执行文件路径(Windows 和非 Windows)
529
+ const possiblePaths = [
530
+ path.join(this.projectDir, 'node_modules', '.bin', 'playable-scripts.cmd'), // Windows cmd
531
+ path.join(this.projectDir, 'node_modules', '.bin', 'playable-scripts.ps1'), // Windows PowerShell
532
+ path.join(this.projectDir, 'node_modules', '.bin', 'playable-scripts'), // Unix/Mac
533
+ path.join(this.projectDir, 'node_modules', '@playcraft', 'devkit', 'cli', 'bin', 'playable-scripts.js'),
534
+ ];
535
+ for (const executablePath of possiblePaths) {
536
+ try {
537
+ await fs.access(executablePath);
538
+ console.log(`[PlayableScriptsAdapter] 找到本地 playable-scripts: ${executablePath}`);
539
+ return executablePath;
540
+ }
541
+ catch {
542
+ // 文件不存在,继续尝试下一个
543
+ }
544
+ }
545
+ // 尝试通过 require.resolve 查找(处理 pnpm 等特殊情况)
546
+ try {
547
+ const devkitPath = require.resolve('@playcraft/devkit/package.json', {
548
+ paths: [this.projectDir],
549
+ });
550
+ const devkitDir = path.dirname(devkitPath);
551
+ // 尝试常见的 CLI 路径
552
+ const possiblePaths = [
553
+ path.join(devkitDir, 'cli', 'bin', 'playable-scripts.js'),
554
+ path.join(devkitDir, 'dist', 'cli.js'),
555
+ path.join(devkitDir, 'bin', 'playable-scripts.js'),
556
+ ];
557
+ for (const binPath of possiblePaths) {
558
+ try {
559
+ await fs.access(binPath);
560
+ console.log(`[PlayableScriptsAdapter] 通过 require.resolve 找到本地 playable-scripts: ${binPath}`);
561
+ return binPath;
562
+ }
563
+ catch {
564
+ // 继续尝试下一个
565
+ }
566
+ }
567
+ }
568
+ catch {
569
+ // 未找到
570
+ }
571
+ console.log('[PlayableScriptsAdapter] 未在项目 node_modules 中找到 playable-scripts');
572
+ return null;
573
+ }
574
+ /**
575
+ * 确保项目本地存在 playable-scripts(@playcraft/devkit)
576
+ * 若缺失则执行 npm install 安装 package.json 中定义的所有依赖
577
+ */
578
+ async ensureLocalDevkit() {
579
+ const found = await this.findLocalPlayableScripts();
580
+ if (found) {
581
+ return found;
582
+ }
583
+ console.log('[PlayableScriptsAdapter] 未找到本地 playable-scripts,执行 pnpm install 安装项目依赖');
584
+ try {
585
+ // 执行 pnpm install 安装 package.json 中定义的所有依赖
586
+ // 包括 @playcraft/devkit 和 babel-loader 等构建工具
587
+ // 注意:不要设置 NODE_ENV=production,否则会跳过 devDependencies
588
+ // 创建 .npmrc 文件,允许所有构建脚本执行
589
+ // 这样可以让 esbuild 等 native 模块正确编译
590
+ const npmrcPath = path.join(this.projectDir, '.npmrc');
591
+ const npmrcContent = [
592
+ '# Temporary config for PlayCraft build',
593
+ 'enable-pre-post-scripts=true',
594
+ 'shamefully-hoist=true',
595
+ ].join('\n');
596
+ try {
597
+ await fs.writeFile(npmrcPath, npmrcContent, 'utf-8');
598
+ console.log('[PlayableScriptsAdapter] 已创建 .npmrc 配置(允许构建脚本)');
599
+ }
600
+ catch (error) {
601
+ console.log('[PlayableScriptsAdapter] ⚠️ 无法创建 .npmrc:', error.message);
602
+ }
603
+ await this.runCommandWithTimeout('pnpm', ['install'], {
604
+ cwd: this.projectDir,
605
+ timeout: 5 * 60 * 1000,
606
+ env: process.env,
607
+ });
608
+ // 清理临时 .npmrc
609
+ try {
610
+ await fs.unlink(npmrcPath);
611
+ console.log('[PlayableScriptsAdapter] 已清理临时 .npmrc');
612
+ }
613
+ catch {
614
+ // 忽略删除错误
615
+ }
616
+ // 安装完成后,尝试通过 require.resolve 查找 devkit 路径
617
+ try {
618
+ const devkitPath = require.resolve('@playcraft/devkit/package.json', {
619
+ paths: [this.projectDir],
620
+ });
621
+ const devkitDir = path.dirname(devkitPath);
622
+ // 尝试常见的 CLI 路径
623
+ const possiblePaths = [
624
+ path.join(devkitDir, 'cli', 'bin', 'playable-scripts.js'),
625
+ path.join(devkitDir, 'dist', 'cli.js'),
626
+ path.join(devkitDir, 'bin', 'playable-scripts.js'),
627
+ ];
628
+ for (const binPath of possiblePaths) {
629
+ try {
630
+ await fs.access(binPath);
631
+ console.log(`[PlayableScriptsAdapter] 安装后找到 playable-scripts: ${binPath}`);
632
+ return binPath;
633
+ }
634
+ catch {
635
+ // 继续尝试下一个
636
+ }
637
+ }
638
+ console.error(`[PlayableScriptsAdapter] ❌ 找到 devkit 但无法定位 CLI: ${devkitDir}`);
639
+ return null;
640
+ }
641
+ catch {
642
+ // 如果 require.resolve 失败,回退到 findLocalPlayableScripts
643
+ }
644
+ // 再次尝试查找
645
+ return await this.findLocalPlayableScripts();
646
+ }
647
+ catch (error) {
648
+ console.log(`[PlayableScriptsAdapter] ⚠️ npm install 失败: ${error.message}`);
649
+ return null;
650
+ }
651
+ }
652
+ /**
653
+ * 检查构建所需 loader 是否存在(babel-loader)
654
+ * 注意:babel-loader 是必须的,esbuild-loader/esbuild 是 devkit 内部可选依赖
655
+ */
656
+ async ensureBuildLoaders() {
657
+ const required = ['babel-loader'];
658
+ const missing = [];
659
+ for (const name of required) {
660
+ const modulePath = path.join(this.projectDir, 'node_modules', name);
661
+ try {
662
+ await fs.access(modulePath);
663
+ }
664
+ catch {
665
+ missing.push(name);
666
+ }
667
+ }
668
+ if (missing.length > 0) {
669
+ console.log(`[PlayableScriptsAdapter] ⚠️ 以下构建依赖缺失: ${missing.join(', ')}`);
670
+ console.log(`[PlayableScriptsAdapter] 请确保这些依赖已在 package.json 的 devDependencies 中定义`);
671
+ }
672
+ }
673
+ /**
674
+ * 确保 package.json 中有 build/builds 脚本
675
+ * devkit 必须通过 npm script 调用,不能直接执行 .js 文件
676
+ */
677
+ async ensureBuildScripts() {
678
+ const pkgPath = path.join(this.projectDir, 'package.json');
679
+ try {
680
+ const pkgContent = await fs.readFile(pkgPath, 'utf-8');
681
+ const pkg = JSON.parse(pkgContent);
682
+ if (!pkg.scripts) {
683
+ pkg.scripts = {};
684
+ }
685
+ let modified = false;
686
+ // 确保有 build 脚本
687
+ if (!pkg.scripts.build) {
688
+ pkg.scripts.build = 'playable-scripts build';
689
+ modified = true;
690
+ console.log('[PlayableScriptsAdapter] 注入 package.json script: build');
691
+ }
692
+ // 确保有 builds 脚本
693
+ if (!pkg.scripts.builds) {
694
+ pkg.scripts.builds = 'playable-scripts builds';
695
+ modified = true;
696
+ console.log('[PlayableScriptsAdapter] 注入 package.json script: builds');
697
+ }
698
+ if (modified) {
699
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2), 'utf-8');
700
+ console.log('[PlayableScriptsAdapter] ✓ package.json 已更新');
701
+ }
702
+ }
703
+ catch (error) {
704
+ console.warn('[PlayableScriptsAdapter] ⚠️ 无法更新 package.json:', error.message);
705
+ }
706
+ }
707
+ // ==================== 7. 收集产物 ====================
708
+ /**
709
+ * 扫描 playable-scripts 的输出目录,收集构建产物
710
+ *
711
+ * 单渠道输出: dist/<file>.html
712
+ * 批量输出: dist/<theme>/<channel>/<file>.html
713
+ */
714
+ async collectOutputs() {
715
+ const config = this.config ?? {};
716
+ // 统一使用 dist 作为默认输出目录
717
+ // 注意:config.outputDir 可能是绝对路径,需要判断
718
+ const outputDir = config.outputDir ?? 'dist';
719
+ const fullOutputDir = path.isAbsolute(outputDir)
720
+ ? outputDir
721
+ : path.join(this.projectDir, outputDir);
722
+ const outputs = [];
723
+ console.log(`[PlayableScriptsAdapter] 开始收集产物,目标目录: ${fullOutputDir}`);
724
+ // 检查目录是否存在
725
+ try {
726
+ await fs.access(fullOutputDir);
727
+ console.log(`[PlayableScriptsAdapter] 目录存在: ${fullOutputDir}`);
728
+ }
729
+ catch {
730
+ console.log(`[PlayableScriptsAdapter] 目录不存在: ${fullOutputDir}`);
731
+ throw new Error(`[PlayableScriptsAdapter] 未找到构建输出目录\n` +
732
+ `目录: ${fullOutputDir}\n` +
733
+ `请确保 playable-scripts 构建正确完成。`);
734
+ }
735
+ // 递归扫描输出目录
736
+ await this.scanOutputDir(fullOutputDir, fullOutputDir, outputs);
737
+ if (outputs.length === 0) {
738
+ // 目录存在但没有产物,列出目录内容帮助调试
739
+ try {
740
+ const entries = await fs.readdir(fullOutputDir, { withFileTypes: true });
741
+ console.log(`[PlayableScriptsAdapter] 目录内容 (${entries.length} 项):`);
742
+ for (const entry of entries) {
743
+ console.log(` - ${entry.name} (${entry.isDirectory() ? 'dir' : 'file'})`);
744
+ // 如果是目录,列出其子目录
745
+ if (entry.isDirectory()) {
746
+ const subDir = path.join(fullOutputDir, entry.name);
747
+ const subEntries = await fs.readdir(subDir, { withFileTypes: true });
748
+ for (const subEntry of subEntries) {
749
+ console.log(` - ${subEntry.name} (${subEntry.isDirectory() ? 'dir' : 'file'})`);
750
+ }
751
+ }
752
+ }
753
+ }
754
+ catch (e) {
755
+ console.log(`[PlayableScriptsAdapter] 无法读取目录: ${e}`);
756
+ }
757
+ throw new Error(`[PlayableScriptsAdapter] 构建输出目录为空\n` +
758
+ `目录: ${fullOutputDir}\n` +
759
+ `请确保 playable-scripts 构建正确完成。`);
760
+ }
761
+ console.log(`[PlayableScriptsAdapter] 找到 ${outputs.length} 个构建产物:`);
762
+ for (const output of outputs) {
763
+ console.log(` - ${output.fileName} (channel: ${output.channel}, theme: ${output.theme || 'none'})`);
764
+ }
765
+ return outputs;
766
+ }
767
+ /**
768
+ * 递归扫描输出目录,收集 HTML/ZIP 文件
769
+ *
770
+ * devkit 的输出结构:
771
+ * - 批量构建: dist/<theme>/<channel>/<file>.html|zip
772
+ * - 单渠道: dist/<file>.html|zip
773
+ *
774
+ * 注意:只收集目标渠道的产物,过滤掉其他渠道
775
+ */
776
+ async scanOutputDir(dir, baseDir, outputs) {
777
+ const entries = await fs.readdir(dir, { withFileTypes: true });
778
+ // 获取目标渠道列表(已映射)
779
+ const targetChannels = (this.config?.channels ?? [])
780
+ .filter(ch => !UNSUPPORTED_CHANNELS.has(ch))
781
+ .map(ch => mapChannel(ch));
782
+ for (const entry of entries) {
783
+ const fullPath = path.join(dir, entry.name);
784
+ if (entry.isDirectory()) {
785
+ await this.scanOutputDir(fullPath, baseDir, outputs);
786
+ }
787
+ else if (entry.name.endsWith('.html') || entry.name.endsWith('.zip')) {
788
+ // 从相对路径推断 theme 和 channel
789
+ const relativePath = path.relative(baseDir, fullPath);
790
+ // 统一使用正斜杠分割,兼容 Windows 和 Unix
791
+ const parts = relativePath.split(/[\\/]/);
792
+ let theme;
793
+ let channel = 'unknown';
794
+ if (parts.length >= 3) {
795
+ // dist/<theme>/<channel>/<file>.html
796
+ theme = parts[0];
797
+ channel = parts[1];
798
+ }
799
+ else if (parts.length === 2) {
800
+ // dist/<channel>/<file>.html 或 dist/<theme>/<file>.html
801
+ // 判断第一个部分是渠道还是主题
802
+ const firstPart = parts[0];
803
+ if (targetChannels.includes(firstPart)) {
804
+ channel = firstPart;
805
+ }
806
+ else {
807
+ // 可能是主题目录(没有渠道子目录)
808
+ // 这种情况不太可能,但保留兼容性
809
+ theme = firstPart;
810
+ }
811
+ }
812
+ // parts.length === 1: 直接在 dist 下的文件(单渠道模式)
813
+ // 过滤:只收集目标渠道的产物
814
+ if (targetChannels.length > 0 && channel !== 'unknown' && !targetChannels.includes(channel)) {
815
+ console.log(`[PlayableScriptsAdapter] 跳过非目标渠道: ${channel} (文件: ${relativePath})`);
816
+ continue;
817
+ }
818
+ outputs.push({
819
+ filePath: fullPath,
820
+ fileName: entry.name,
821
+ channel,
822
+ theme,
823
+ });
824
+ }
825
+ }
826
+ }
827
+ // ==================== 6. 复制到输出目录 ====================
828
+ /**
829
+ * 清理输出目录,删除所有旧产物
830
+ */
831
+ async cleanOutputDir(dir) {
832
+ try {
833
+ await fs.access(dir);
834
+ // 目录存在,递归删除所有内容
835
+ const entries = await fs.readdir(dir, { withFileTypes: true });
836
+ for (const entry of entries) {
837
+ const fullPath = path.join(dir, entry.name);
838
+ if (entry.isDirectory()) {
839
+ await this.removeDirRecursive(fullPath);
840
+ }
841
+ else {
842
+ await fs.unlink(fullPath);
843
+ }
844
+ }
845
+ console.log(`[PlayableScriptsAdapter] 已清理输出目录: ${dir}`);
846
+ }
847
+ catch {
848
+ // 目录不存在,无需清理
849
+ }
850
+ }
851
+ /**
852
+ * 递归删除目录
853
+ */
854
+ async removeDirRecursive(dir) {
855
+ const entries = await fs.readdir(dir, { withFileTypes: true });
856
+ for (const entry of entries) {
857
+ const fullPath = path.join(dir, entry.name);
858
+ if (entry.isDirectory()) {
859
+ await this.removeDirRecursive(fullPath);
860
+ }
861
+ else {
862
+ await fs.unlink(fullPath);
863
+ }
864
+ }
865
+ await fs.rmdir(dir);
866
+ }
867
+ async copyToOutputDir(outputs) {
868
+ const files = {
869
+ html: '',
870
+ engine: null,
871
+ config: '',
872
+ settings: null,
873
+ modules: null,
874
+ start: '',
875
+ scenes: [],
876
+ assets: [],
877
+ };
878
+ // 判断构建模式
879
+ const isBatchMode = (this.config?.channels && this.config.channels.length > 1) ||
880
+ (this.config?.themes?.enabled);
881
+ // 按渠道分组产物(每个渠道只保留一个产物,优先 HTML,其次 ZIP)
882
+ const channelOutputs = new Map();
883
+ for (const output of outputs) {
884
+ const channelList = channelOutputs.get(output.channel) || [];
885
+ channelList.push(output);
886
+ channelOutputs.set(output.channel, channelList);
887
+ }
888
+ console.log(`[PlayableScriptsAdapter] ${isBatchMode ? '批量构建' : '单渠道构建'}模式:处理 ${channelOutputs.size} 个渠道的产物`);
889
+ // 记录需要清理的所有主题目录(从所有产物中收集,不只是最佳产物)
890
+ const themeDirsToClean = new Set();
891
+ for (const output of outputs) {
892
+ if (output.theme) {
893
+ const themeDir = path.join(this.options.outputDir, output.theme);
894
+ themeDirsToClean.add(themeDir);
895
+ }
896
+ }
897
+ for (const [channel, channelFiles] of channelOutputs) {
898
+ // 选择最佳产物:优先带渠道名的文件,其次 HTML,最后 ZIP
899
+ let bestOutput = channelFiles[0];
900
+ for (const output of channelFiles) {
901
+ // 优先选择带有完整命名的文件(如 xxx-facebook.html)
902
+ if (output.fileName.includes(channel) || output.fileName.includes('-')) {
903
+ bestOutput = output;
904
+ break;
905
+ }
906
+ // 其次优先 HTML
907
+ if (output.fileName.endsWith('.html') && bestOutput.fileName.endsWith('.zip')) {
908
+ bestOutput = output;
909
+ }
910
+ }
911
+ // 确定目标文件名
912
+ let destFileName;
913
+ // HTML 文件统一重命名为 index.html(与 PlayCanvas 项目保持一致)
914
+ if (bestOutput.fileName.endsWith('.html')) {
915
+ destFileName = 'index.html';
916
+ }
917
+ else if (bestOutput.fileName.endsWith('.zip')) {
918
+ // ZIP 文件统一重命名为 playable.zip(与 PlayCanvas 项目保持一致)
919
+ destFileName = 'playable.zip';
920
+ }
921
+ else {
922
+ destFileName = bestOutput.fileName;
923
+ }
924
+ // 确定目标路径
925
+ let destPath;
926
+ if (isBatchMode) {
927
+ // 批量构建:按渠道分目录 outputDir/<channel>/<file>
928
+ const subDir = path.join(this.options.outputDir, channel);
929
+ await fs.mkdir(subDir, { recursive: true });
930
+ destPath = path.join(subDir, destFileName);
931
+ }
932
+ else {
933
+ // 单渠道构建:直接放到 outputDir/<file>
934
+ // 确保输出目录存在
935
+ await fs.mkdir(this.options.outputDir, { recursive: true });
936
+ destPath = path.join(this.options.outputDir, destFileName);
937
+ }
938
+ await fs.copyFile(bestOutput.filePath, destPath);
939
+ console.log(`[PlayableScriptsAdapter] 复制: ${path.relative(this.projectDir, bestOutput.filePath)} -> ${path.relative(this.projectDir, destPath)}`);
940
+ // 第一个 HTML 文件作为主产物
941
+ if (!files.html && destFileName.endsWith('.html')) {
942
+ files.html = destPath;
943
+ }
944
+ // 所有产物记录到 assets
945
+ files.assets.push(destPath);
946
+ }
947
+ // 批量构建模式:清理 devkit 的原始主题目录
948
+ if (isBatchMode && themeDirsToClean.size > 0) {
949
+ console.log('[PlayableScriptsAdapter] 批量构建模式:清理 devkit 原始目录...');
950
+ for (const dir of themeDirsToClean) {
951
+ try {
952
+ await this.removeDirRecursive(dir);
953
+ console.log(`[PlayableScriptsAdapter] 已清理主题目录: ${path.relative(this.projectDir, dir)}`);
954
+ }
955
+ catch (err) {
956
+ console.log(`[PlayableScriptsAdapter] 清理目录失败: ${dir} - ${err}`);
957
+ }
958
+ }
959
+ }
960
+ console.log(`[PlayableScriptsAdapter] 产物已复制到: ${this.options.outputDir}`);
961
+ return files;
962
+ }
963
+ /**
964
+ * 判断目标平台是否为 ZIP 格式
965
+ */
966
+ isZipFormatPlatform() {
967
+ const config = this.config ?? {};
968
+ const channels = config.channels ?? ['google'];
969
+ // ZIP 格式的平台列表
970
+ const zipFormatPlatforms = new Set([
971
+ 'google', 'tiktok', 'snapchat', 'liftoff', 'bigo', 'inmobi', 'mintegral'
972
+ ]);
973
+ // 如果任一目标渠道是 ZIP 格式,返回 true
974
+ return channels.some(ch => zipFormatPlatforms.has(ch));
975
+ }
976
+ /**
977
+ * 获取平台默认的文件名格式
978
+ */
979
+ getPlatformFilename(isZipFormat) {
980
+ const config = this.config ?? {};
981
+ const channels = config.channels ?? ['google'];
982
+ const channel = channels[0] || 'google';
983
+ if (isZipFormat) {
984
+ // ZIP 格式统一使用 playable.zip
985
+ return 'playable';
986
+ }
987
+ // HTML 格式根据平台使用不同的文件名
988
+ const platformFileNames = {
989
+ 'facebook': 'index',
990
+ 'snapchat': 'ad',
991
+ 'applovin': 'index',
992
+ 'ironsource': 'index',
993
+ 'unity': 'index',
994
+ 'moloco': 'index',
995
+ 'adikteev': 'index',
996
+ 'remerge': 'index',
997
+ };
998
+ return platformFileNames[channel] || 'index';
999
+ }
1000
+ // ==================== 9. 保存元数据 ====================
1001
+ async saveBuildMetadata(metadata) {
1002
+ // 不再保存 .build-metadata.json 文件,元数据通过返回值传递
1003
+ console.log(`[PlayableScriptsAdapter] 构建完成(buildTool: playable-scripts, skipChannelBuild: true)`);
1004
+ }
1005
+ // ==================== 工具方法 ====================
1006
+ /**
1007
+ * 执行命令并设置超时
1008
+ */
1009
+ runCommandWithTimeout(cmd, args, options) {
1010
+ return new Promise((resolve, reject) => {
1011
+ const controller = new AbortController();
1012
+ const { signal } = controller;
1013
+ const timeoutId = setTimeout(() => {
1014
+ controller.abort();
1015
+ reject(new Error(`[PlayableScriptsAdapter] 命令超时(${options.timeout / 1000}秒)\n` +
1016
+ `命令: ${cmd} ${args.join(' ')}\n` +
1017
+ `playable-scripts 批量构建可能需要更长时间,请考虑减少渠道/主题数量。`));
1018
+ }, options.timeout);
1019
+ const child = spawn(cmd, args, {
1020
+ cwd: options.cwd,
1021
+ env: options.env,
1022
+ stdio: 'pipe',
1023
+ signal,
1024
+ shell: true, // Windows 下必须,否则无法找到 pnpm/yarn 等命令
1025
+ });
1026
+ let stdout = '';
1027
+ let stderr = '';
1028
+ child.stdout?.on('data', (data) => {
1029
+ const text = data.toString();
1030
+ stdout += text;
1031
+ // 实时输出 playable-scripts 的日志
1032
+ process.stdout.write(text);
1033
+ if (stdout.length > 50000) {
1034
+ stdout = stdout.slice(-25000);
1035
+ }
1036
+ });
1037
+ child.stderr?.on('data', (data) => {
1038
+ const text = data.toString();
1039
+ stderr += text;
1040
+ process.stderr.write(text);
1041
+ if (stderr.length > 50000) {
1042
+ stderr = stderr.slice(-25000);
1043
+ }
1044
+ });
1045
+ child.on('error', (error) => {
1046
+ clearTimeout(timeoutId);
1047
+ if (error.name === 'AbortError') {
1048
+ reject(new Error(`命令被中止: ${cmd} ${args.join(' ')}`));
1049
+ }
1050
+ else {
1051
+ reject(new Error(`[PlayableScriptsAdapter] 命令执行失败\n` +
1052
+ `命令: ${cmd} ${args.join(' ')}\n` +
1053
+ `错误: ${error.message}`));
1054
+ }
1055
+ });
1056
+ child.on('close', (code) => {
1057
+ clearTimeout(timeoutId);
1058
+ if (code === 0) {
1059
+ resolve();
1060
+ }
1061
+ else {
1062
+ reject(new Error(`[PlayableScriptsAdapter] 命令退出码非零: ${code}\n` +
1063
+ `命令: ${cmd} ${args.join(' ')}\n` +
1064
+ `stdout: ${stdout.slice(-2000)}\n` +
1065
+ `stderr: ${stderr.slice(-2000)}`));
1066
+ }
1067
+ });
1068
+ });
1069
+ }
1070
+ }
1071
+ // PlayCraft 强制默认值(不依赖 playable-scripts 的默认值)
1072
+ PlayableScriptsAdapter.DEFAULTS = {
1073
+ obfuscateLevel: 4,
1074
+ fflateCompression: true,
1075
+ };
1076
+ // ==================== 4. 执行 playable-scripts ====================
1077
+ /**
1078
+ * 包名迁移映射表
1079
+ * 旧包名 -> 新包名
1080
+ */
1081
+ PlayableScriptsAdapter.PACKAGE_MIGRATIONS = {
1082
+ '@tencent/playable-sdk': '@playcraft/adsdk',
1083
+ '@tencent/playable-scripts': '@playcraft/devkit',
1084
+ };