@playcraft/build 0.0.17 → 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.
- package/dist/analyzers/scene-asset-collector.js +259 -135
- package/dist/audio-optimizer.d.ts +70 -0
- package/dist/audio-optimizer.js +226 -0
- package/dist/base-builder.d.ts +25 -13
- package/dist/base-builder.js +69 -29
- package/dist/engines/engine-detector.d.ts +13 -4
- package/dist/engines/engine-detector.js +74 -10
- package/dist/engines/generic-adapter.d.ts +12 -6
- package/dist/engines/generic-adapter.js +46 -15
- package/dist/engines/index.d.ts +1 -0
- package/dist/engines/index.js +1 -0
- package/dist/engines/playable-scripts-adapter.d.ts +148 -0
- package/dist/engines/playable-scripts-adapter.js +1084 -0
- package/dist/engines/playcanvas-adapter.js +3 -0
- package/dist/generators/config-generator.js +10 -17
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/platforms/google.d.ts +9 -0
- package/dist/platforms/google.js +68 -7
- package/dist/templates/__loading__.js +100 -0
- package/dist/templates/__modules__.js +47 -0
- package/dist/templates/__settings__.template.js +20 -0
- package/dist/templates/__start__.js +332 -0
- package/dist/templates/index.html +18 -0
- package/dist/templates/logo.png +0 -0
- package/dist/templates/manifest.json +1 -0
- package/dist/templates/patches/cannon.min.js +28 -0
- package/dist/templates/patches/lz4.js +10 -0
- package/dist/templates/patches/one-page-http-get.js +20 -0
- package/dist/templates/patches/one-page-inline-game-scripts.js +52 -0
- package/dist/templates/patches/one-page-mraid-resize-canvas.js +46 -0
- package/dist/templates/patches/p2.min.js +27 -0
- package/dist/templates/patches/playcraft-no-xhr.js +76 -0
- package/dist/templates/playcanvas-stable.min.js +16363 -0
- package/dist/templates/styles.css +43 -0
- package/dist/types.d.ts +60 -13
- package/dist/utils/build-mode-detector.js +2 -0
- package/dist/vite/plugin-playcanvas.js +14 -19
- package/dist/vite/plugin-source-builder.js +383 -97
- package/package.json +7 -4
- package/dist/utils/obfuscate.d.ts +0 -42
- package/dist/utils/obfuscate.js +0 -216
- package/dist/vite/plugin-obfuscate.d.ts +0 -22
- package/dist/vite/plugin-obfuscate.js +0 -52
- package/dist/vite/plugin-template-minifier.d.ts +0 -20
- 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
|
+
};
|