@playcraft/cli 0.0.1

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 (37) hide show
  1. package/README.md +12 -0
  2. package/dist/build-config.js +26 -0
  3. package/dist/commands/build.js +363 -0
  4. package/dist/commands/config.js +133 -0
  5. package/dist/commands/init.js +86 -0
  6. package/dist/commands/inspect.js +209 -0
  7. package/dist/commands/logs.js +121 -0
  8. package/dist/commands/start.js +284 -0
  9. package/dist/commands/status.js +106 -0
  10. package/dist/commands/stop.js +58 -0
  11. package/dist/config.js +31 -0
  12. package/dist/fs-handler.js +83 -0
  13. package/dist/index.js +200 -0
  14. package/dist/logger.js +122 -0
  15. package/dist/playable/base-builder.js +265 -0
  16. package/dist/playable/builder.js +1462 -0
  17. package/dist/playable/converter.js +150 -0
  18. package/dist/playable/index.js +3 -0
  19. package/dist/playable/platforms/base.js +12 -0
  20. package/dist/playable/platforms/facebook.js +37 -0
  21. package/dist/playable/platforms/index.js +24 -0
  22. package/dist/playable/platforms/snapchat.js +59 -0
  23. package/dist/playable/playable-builder.js +521 -0
  24. package/dist/playable/types.js +1 -0
  25. package/dist/playable/vite/config-builder.js +136 -0
  26. package/dist/playable/vite/platform-configs.js +102 -0
  27. package/dist/playable/vite/plugin-model-compression.js +63 -0
  28. package/dist/playable/vite/plugin-platform.js +65 -0
  29. package/dist/playable/vite/plugin-playcanvas.js +454 -0
  30. package/dist/playable/vite-builder.js +125 -0
  31. package/dist/port-utils.js +27 -0
  32. package/dist/process-manager.js +96 -0
  33. package/dist/server.js +128 -0
  34. package/dist/socket.js +117 -0
  35. package/dist/watcher.js +33 -0
  36. package/package.json +41 -0
  37. package/templates/playable-ad.html +59 -0
@@ -0,0 +1,521 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { createPlatformAdapter } from './platforms/index.js';
4
+ /**
5
+ * Playable Ads 打包器 - 将多文件版本转换为单HTML
6
+ *
7
+ * 职责:
8
+ * 1. 读取阶段1的多文件构建产物
9
+ * 2. 将所有文件内联到单个HTML
10
+ * 3. 应用渠道特定配置和限制
11
+ * 4. 优化文件大小
12
+ */
13
+ export class PlayableBuilder {
14
+ baseBuildDir;
15
+ options;
16
+ platformAdapter;
17
+ sizeReport;
18
+ constructor(baseBuildDir, options) {
19
+ this.baseBuildDir = baseBuildDir;
20
+ this.options = options;
21
+ this.platformAdapter = createPlatformAdapter(options);
22
+ this.sizeReport = {
23
+ engine: 0,
24
+ assets: {},
25
+ total: 0,
26
+ limit: this.platformAdapter.getSizeLimit(),
27
+ };
28
+ }
29
+ /**
30
+ * 执行打包
31
+ */
32
+ async build() {
33
+ // 1. 验证输入是有效的基础构建
34
+ await this.validateBaseBuild();
35
+ // 2. 转换为单HTML
36
+ const html = await this.convertToSingleHTML();
37
+ // 3. 应用渠道配置
38
+ const finalHtml = this.platformAdapter.modifyHTML(html, []);
39
+ // 4. 验证大小限制
40
+ const htmlSize = Buffer.from(finalHtml, 'utf-8').length;
41
+ this.sizeReport.total = htmlSize;
42
+ this.sizeReport.assets['index.html'] = htmlSize;
43
+ if (htmlSize > this.sizeReport.limit) {
44
+ console.warn(`⚠️ 警告: 文件大小 ${(htmlSize / 1024 / 1024).toFixed(2)} MB ` +
45
+ `超过限制 ${(this.sizeReport.limit / 1024 / 1024).toFixed(2)} MB`);
46
+ }
47
+ // 5. 输出文件
48
+ const outputDir = this.options.outputDir || './dist';
49
+ await fs.mkdir(outputDir, { recursive: true });
50
+ const outputPath = path.join(outputDir, 'index.html');
51
+ await fs.writeFile(outputPath, finalHtml, 'utf-8');
52
+ return outputPath;
53
+ }
54
+ /**
55
+ * 验证基础构建
56
+ */
57
+ async validateBaseBuild() {
58
+ const requiredFiles = [
59
+ 'index.html',
60
+ 'config.json',
61
+ '__start__.js',
62
+ ];
63
+ const missingFiles = [];
64
+ for (const file of requiredFiles) {
65
+ try {
66
+ await fs.access(path.join(this.baseBuildDir, file));
67
+ }
68
+ catch (error) {
69
+ missingFiles.push(file);
70
+ }
71
+ }
72
+ if (missingFiles.length > 0) {
73
+ throw new Error(`基础构建产物缺少必需文件: ${missingFiles.join(', ')}\n` +
74
+ `请确保输入目录包含完整的多文件构建产物。`);
75
+ }
76
+ }
77
+ /**
78
+ * 转换为单HTML
79
+ */
80
+ async convertToSingleHTML() {
81
+ // 1. 读取 index.html 模板
82
+ let html = await fs.readFile(path.join(this.baseBuildDir, 'index.html'), 'utf-8');
83
+ // 2. 内联 CSS 文件
84
+ html = await this.inlineCSS(html);
85
+ // 3. 内联 manifest.json
86
+ html = await this.inlineManifest(html);
87
+ // 4. 内联 PlayCanvas Engine
88
+ html = await this.inlineEngine(html);
89
+ // 5. 内联 __settings__.js,并转换资源URL为data URLs
90
+ html = await this.inlineSettings(html);
91
+ // 6. 内联 __modules__.js
92
+ html = await this.inlineModules(html);
93
+ // 7. 内联 __game-scripts.js(如果存在)
94
+ html = await this.inlineGameScripts(html);
95
+ // 8. 内联 __start__.js
96
+ html = await this.inlineStart(html);
97
+ // 9. 内联 __loading__.js(如果存在)
98
+ html = await this.inlineLoading(html);
99
+ return html;
100
+ }
101
+ /**
102
+ * 内联 CSS 文件
103
+ */
104
+ async inlineCSS(html) {
105
+ // 匹配 <link rel="stylesheet" href="...">
106
+ const cssPattern = /<link[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
107
+ const matches = Array.from(html.matchAll(cssPattern));
108
+ for (const match of matches) {
109
+ const cssPath = match[1];
110
+ if (cssPath.startsWith('data:') || cssPath.startsWith('http://') || cssPath.startsWith('https://')) {
111
+ continue; // 跳过已经是 data URL 或外部 URL
112
+ }
113
+ const fullCssPath = path.join(this.baseBuildDir, cssPath);
114
+ try {
115
+ await fs.access(fullCssPath);
116
+ const cssContent = await fs.readFile(fullCssPath, 'utf-8');
117
+ // 替换 link 标签为 style 标签
118
+ html = html.replace(match[0], `<style>${cssContent}</style>`);
119
+ }
120
+ catch (error) {
121
+ console.warn(`警告: CSS 文件不存在: ${cssPath},移除引用`);
122
+ // 移除不存在的 CSS 引用
123
+ html = html.replace(match[0], '');
124
+ }
125
+ }
126
+ return html;
127
+ }
128
+ /**
129
+ * 内联 manifest.json
130
+ */
131
+ async inlineManifest(html) {
132
+ // 匹配 <link rel="manifest" href="...">
133
+ const manifestPattern = /<link[^>]*rel=["']manifest["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
134
+ const matches = Array.from(html.matchAll(manifestPattern));
135
+ for (const match of matches) {
136
+ const manifestPath = match[1];
137
+ if (manifestPath.startsWith('data:') || manifestPath.startsWith('http://') || manifestPath.startsWith('https://')) {
138
+ continue; // 跳过已经是 data URL 或外部 URL
139
+ }
140
+ const fullManifestPath = path.join(this.baseBuildDir, manifestPath);
141
+ try {
142
+ await fs.access(fullManifestPath);
143
+ const manifestContent = await fs.readFile(fullManifestPath, 'utf-8');
144
+ // 将 manifest 转换为 data URL 并内联到 meta 标签
145
+ const manifestDataUrl = `data:application/manifest+json;base64,${Buffer.from(manifestContent).toString('base64')}`;
146
+ html = html.replace(match[0], `<link rel="manifest" href="${manifestDataUrl}">`);
147
+ }
148
+ catch (error) {
149
+ console.warn(`警告: manifest 文件不存在: ${manifestPath},移除引用`);
150
+ // 移除不存在的 manifest 引用
151
+ html = html.replace(match[0], '');
152
+ }
153
+ }
154
+ return html;
155
+ }
156
+ /**
157
+ * 内联 PlayCanvas Engine
158
+ */
159
+ async inlineEngine(html) {
160
+ // 查找可能的引擎文件名
161
+ const engineNames = [
162
+ 'playcanvas-stable.min.js',
163
+ 'playcanvas.min.js',
164
+ '__lib__.js',
165
+ ];
166
+ for (const engineName of engineNames) {
167
+ const enginePath = path.join(this.baseBuildDir, engineName);
168
+ try {
169
+ await fs.access(enginePath);
170
+ const engineCode = await fs.readFile(enginePath, 'utf-8');
171
+ this.sizeReport.engine = Buffer.from(engineCode, 'utf-8').length;
172
+ // 替换 script 标签
173
+ const scriptPattern = new RegExp(`<script[^>]*src=["']${engineName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*></script>`, 'i');
174
+ html = html.replace(scriptPattern, `<script>${engineCode}</script>`);
175
+ return html;
176
+ }
177
+ catch (error) {
178
+ // 继续尝试下一个
179
+ }
180
+ }
181
+ throw new Error('未找到 PlayCanvas Engine 文件');
182
+ }
183
+ /**
184
+ * 内联 __settings__.js 并转换资源URL
185
+ */
186
+ async inlineSettings(html) {
187
+ const settingsPath = path.join(this.baseBuildDir, '__settings__.js');
188
+ try {
189
+ await fs.access(settingsPath);
190
+ }
191
+ catch (error) {
192
+ // __settings__.js 不存在,尝试从 config.json 生成
193
+ return await this.generateAndInlineSettings(html);
194
+ }
195
+ let settingsCode = await fs.readFile(settingsPath, 'utf-8');
196
+ // 转换 CONFIG_FILENAME 为 data URL
197
+ settingsCode = await this.convertConfigFilename(settingsCode);
198
+ // 转换 SCENE_PATH 为 data URL
199
+ settingsCode = await this.convertScenePath(settingsCode);
200
+ // 转换 PRELOAD_MODULES 中的资源URL
201
+ settingsCode = await this.convertPreloadModules(settingsCode);
202
+ // 替换 script 标签
203
+ const scriptPattern = /<script[^>]*src=["']__settings__\.js["'][^>]*><\/script>/i;
204
+ html = html.replace(scriptPattern, `<script>${settingsCode}</script>`);
205
+ return html;
206
+ }
207
+ /**
208
+ * 生成并内联 settings(如果 __settings__.js 不存在)
209
+ */
210
+ async generateAndInlineSettings(html) {
211
+ const configPath = path.join(this.baseBuildDir, 'config.json');
212
+ const configContent = await fs.readFile(configPath, 'utf-8');
213
+ const configJson = JSON.parse(configContent);
214
+ // 生成 config data URL
215
+ const configDataUrl = `data:application/json;base64,${Buffer.from(configContent).toString('base64')}`;
216
+ // 生成 scene data URL
217
+ let sceneDataUrl = '';
218
+ if (configJson.scenes && configJson.scenes.length > 0) {
219
+ const sceneUrl = configJson.scenes[0].url;
220
+ if (sceneUrl && !sceneUrl.startsWith('data:')) {
221
+ const scenePath = path.join(this.baseBuildDir, sceneUrl);
222
+ try {
223
+ const sceneContent = await fs.readFile(scenePath, 'utf-8');
224
+ sceneDataUrl = `data:application/json;base64,${Buffer.from(sceneContent).toString('base64')}`;
225
+ }
226
+ catch (error) {
227
+ console.warn(`警告: 场景文件不存在: ${sceneUrl}`);
228
+ }
229
+ }
230
+ else {
231
+ sceneDataUrl = sceneUrl;
232
+ }
233
+ }
234
+ const appProps = configJson.application_properties || {};
235
+ const scripts = appProps.scripts || [];
236
+ const preloadModules = this.extractPreloadModules(configJson);
237
+ const settingsCode = `
238
+ window.ASSET_PREFIX = "";
239
+ window.SCRIPT_PREFIX = "";
240
+ window.SCENE_PATH = "${sceneDataUrl}";
241
+ window.CONTEXT_OPTIONS = {
242
+ 'antialias': ${appProps.antiAlias !== false},
243
+ 'alpha': ${appProps.transparentCanvas === true},
244
+ 'preserveDrawingBuffer': ${appProps.preserveDrawingBuffer === true},
245
+ 'deviceTypes': ['webgl2', 'webgl1'],
246
+ 'powerPreference': "default"
247
+ };
248
+ window.SCRIPTS = [${scripts.join(', ')}];
249
+ window.CONFIG_FILENAME = "${configDataUrl}";
250
+ window.INPUT_SETTINGS = {
251
+ useKeyboard: ${appProps.useKeyboard !== false},
252
+ useMouse: ${appProps.useMouse !== false},
253
+ useGamepads: ${appProps.useGamepads === true},
254
+ useTouch: ${appProps.useTouch !== false}
255
+ };
256
+ pc.script.legacy = ${appProps.useLegacyScripts === true};
257
+ window.PRELOAD_MODULES = ${JSON.stringify(preloadModules)};
258
+ `;
259
+ // 在 </head> 之前插入
260
+ html = html.replace('</head>', `<script>${settingsCode}</script>\n</head>`);
261
+ return html;
262
+ }
263
+ /**
264
+ * 转换 CONFIG_FILENAME 为 data URL
265
+ */
266
+ async convertConfigFilename(settingsCode) {
267
+ const configMatch = settingsCode.match(/window\.CONFIG_FILENAME\s*=\s*"([^"]+)"/);
268
+ if (!configMatch) {
269
+ return settingsCode;
270
+ }
271
+ const configPath = configMatch[1];
272
+ if (configPath.startsWith('data:')) {
273
+ return settingsCode; // 已经是 data URL
274
+ }
275
+ const fullConfigPath = path.join(this.baseBuildDir, configPath);
276
+ try {
277
+ const configContent = await fs.readFile(fullConfigPath, 'utf-8');
278
+ const configDataUrl = `data:application/json;base64,${Buffer.from(configContent).toString('base64')}`;
279
+ return settingsCode.replace(/window\.CONFIG_FILENAME\s*=\s*"[^"]+"/, `window.CONFIG_FILENAME = "${configDataUrl}"`);
280
+ }
281
+ catch (error) {
282
+ console.warn(`警告: 无法读取配置文件: ${configPath}`);
283
+ return settingsCode;
284
+ }
285
+ }
286
+ /**
287
+ * 转换 SCENE_PATH 为 data URL
288
+ */
289
+ async convertScenePath(settingsCode) {
290
+ const sceneMatch = settingsCode.match(/window\.SCENE_PATH\s*=\s*"([^"]+)"/);
291
+ if (!sceneMatch) {
292
+ return settingsCode;
293
+ }
294
+ const scenePath = sceneMatch[1];
295
+ if (scenePath.startsWith('data:') || !scenePath) {
296
+ return settingsCode; // 已经是 data URL 或为空
297
+ }
298
+ const fullScenePath = path.join(this.baseBuildDir, scenePath);
299
+ try {
300
+ const sceneContent = await fs.readFile(fullScenePath, 'utf-8');
301
+ const sceneDataUrl = `data:application/json;base64,${Buffer.from(sceneContent).toString('base64')}`;
302
+ return settingsCode.replace(/window\.SCENE_PATH\s*=\s*"[^"]+"/, `window.SCENE_PATH = "${sceneDataUrl}"`);
303
+ }
304
+ catch (error) {
305
+ console.warn(`警告: 无法读取场景文件: ${scenePath}`);
306
+ return settingsCode;
307
+ }
308
+ }
309
+ /**
310
+ * 转换 PRELOAD_MODULES 中的资源URL
311
+ * 对于 Playable Ads,优先使用 fallback JS 版本(跳过 WASM)
312
+ */
313
+ async convertPreloadModules(settingsCode) {
314
+ // 匹配 PRELOAD_MODULES 数组(支持单引号和双引号)
315
+ const modulesMatch = settingsCode.match(/window\.PRELOAD_MODULES\s*=\s*(\[[\s\S]*?\]);/);
316
+ if (!modulesMatch) {
317
+ return settingsCode;
318
+ }
319
+ try {
320
+ // 使用 Function 构造函数安全地解析 JavaScript 对象字面量
321
+ // 这样可以处理单引号、双引号等格式
322
+ const modulesStr = modulesMatch[1];
323
+ const modules = new Function(`return ${modulesStr}`)();
324
+ // 转换每个模块的URL为data URL
325
+ for (const module of modules) {
326
+ // 对于 Playable Ads,优先使用 fallback JS 版本,跳过 WASM
327
+ // 因为很多平台不支持 WASM,且 WASM 文件很大
328
+ if (module.fallbackUrl && !module.fallbackUrl.startsWith('data:')) {
329
+ const fallbackPath = path.join(this.baseBuildDir, module.fallbackUrl);
330
+ try {
331
+ const fallbackCode = await fs.readFile(fallbackPath, 'utf-8');
332
+ module.fallbackUrl = `data:text/javascript;base64,${Buffer.from(fallbackCode).toString('base64')}`;
333
+ // 清空 WASM 相关 URL,强制使用 fallback
334
+ module.glueUrl = '';
335
+ module.wasmUrl = '';
336
+ }
337
+ catch (error) {
338
+ console.warn(`警告: 无法读取 fallback 文件: ${module.fallbackUrl}`);
339
+ // 如果 fallback 读取失败,尝试读取 WASM 相关文件
340
+ if (module.glueUrl && !module.glueUrl.startsWith('data:')) {
341
+ const gluePath = path.join(this.baseBuildDir, module.glueUrl);
342
+ try {
343
+ const glueCode = await fs.readFile(gluePath, 'utf-8');
344
+ module.glueUrl = `data:text/javascript;base64,${Buffer.from(glueCode).toString('base64')}`;
345
+ }
346
+ catch (error) {
347
+ console.warn(`警告: 无法读取 glue 文件: ${module.glueUrl}`);
348
+ }
349
+ }
350
+ if (module.wasmUrl && !module.wasmUrl.startsWith('data:')) {
351
+ const wasmPath = path.join(this.baseBuildDir, module.wasmUrl);
352
+ try {
353
+ const wasmBinary = await fs.readFile(wasmPath);
354
+ module.wasmUrl = `data:application/wasm;base64,${wasmBinary.toString('base64')}`;
355
+ }
356
+ catch (error) {
357
+ console.warn(`警告: 无法读取 WASM 文件: ${module.wasmUrl}`);
358
+ }
359
+ }
360
+ }
361
+ }
362
+ else {
363
+ // 如果没有 fallback,尝试转换 WASM 相关文件
364
+ if (module.glueUrl && !module.glueUrl.startsWith('data:')) {
365
+ const gluePath = path.join(this.baseBuildDir, module.glueUrl);
366
+ try {
367
+ const glueCode = await fs.readFile(gluePath, 'utf-8');
368
+ module.glueUrl = `data:text/javascript;base64,${Buffer.from(glueCode).toString('base64')}`;
369
+ }
370
+ catch (error) {
371
+ console.warn(`警告: 无法读取 glue 文件: ${module.glueUrl}`);
372
+ }
373
+ }
374
+ if (module.wasmUrl && !module.wasmUrl.startsWith('data:')) {
375
+ const wasmPath = path.join(this.baseBuildDir, module.wasmUrl);
376
+ try {
377
+ const wasmBinary = await fs.readFile(wasmPath);
378
+ module.wasmUrl = `data:application/wasm;base64,${wasmBinary.toString('base64')}`;
379
+ }
380
+ catch (error) {
381
+ console.warn(`警告: 无法读取 WASM 文件: ${module.wasmUrl}`);
382
+ }
383
+ }
384
+ }
385
+ }
386
+ // 重新生成 PRELOAD_MODULES 配置(使用双引号 JSON 格式)
387
+ const newModulesStr = JSON.stringify(modules, null, 4);
388
+ return settingsCode.replace(/window\.PRELOAD_MODULES\s*=\s*\[[\s\S]*?\];/, `window.PRELOAD_MODULES = ${newModulesStr};`);
389
+ }
390
+ catch (error) {
391
+ console.warn(`警告: 无法解析 PRELOAD_MODULES: ${error instanceof Error ? error.message : String(error)}`);
392
+ return settingsCode;
393
+ }
394
+ }
395
+ /**
396
+ * 从 config.json 提取 PRELOAD_MODULES
397
+ */
398
+ extractPreloadModules(configJson) {
399
+ const modules = [];
400
+ if (configJson.assets) {
401
+ for (const [id, asset] of Object.entries(configJson.assets)) {
402
+ const assetData = asset;
403
+ if (assetData.type === 'wasm' && assetData.data) {
404
+ const moduleData = assetData.data;
405
+ const fallbackAssetId = moduleData.fallbackScriptId;
406
+ let fallbackUrl = '';
407
+ if (fallbackAssetId && configJson.assets[fallbackAssetId]) {
408
+ const fallbackAsset = configJson.assets[fallbackAssetId];
409
+ fallbackUrl = fallbackAsset.file?.url || '';
410
+ }
411
+ if (moduleData.moduleName && fallbackUrl) {
412
+ modules.push({
413
+ moduleName: moduleData.moduleName,
414
+ glueUrl: '',
415
+ wasmUrl: '',
416
+ fallbackUrl: fallbackUrl,
417
+ preload: true,
418
+ });
419
+ }
420
+ }
421
+ }
422
+ }
423
+ return modules;
424
+ }
425
+ /**
426
+ * 内联 __modules__.js
427
+ */
428
+ async inlineModules(html) {
429
+ const modulesPath = path.join(this.baseBuildDir, '__modules__.js');
430
+ try {
431
+ await fs.access(modulesPath);
432
+ }
433
+ catch (error) {
434
+ // __modules__.js 不存在,跳过
435
+ return html;
436
+ }
437
+ const modulesCode = await fs.readFile(modulesPath, 'utf-8');
438
+ // 替换 script 标签
439
+ const scriptPattern = /<script[^>]*src=["']__modules__\.js["'][^>]*><\/script>/i;
440
+ html = html.replace(scriptPattern, `<script>${modulesCode}</script>`);
441
+ // 如果 HTML 中没有找到 script 标签,在 <body> 开头添加
442
+ if (!scriptPattern.test(html)) {
443
+ html = html.replace('<body>', `<body>\n<script>${modulesCode}</script>\n`);
444
+ }
445
+ return html;
446
+ }
447
+ /**
448
+ * 内联 __game-scripts.js
449
+ * 游戏脚本需要在内联到HTML中,以便PlayCanvas可以加载它们
450
+ */
451
+ async inlineGameScripts(html) {
452
+ const gameScriptsPath = path.join(this.baseBuildDir, '__game-scripts.js');
453
+ try {
454
+ await fs.access(gameScriptsPath);
455
+ }
456
+ catch (error) {
457
+ // __game-scripts.js 不存在,跳过
458
+ return html;
459
+ }
460
+ const gameScriptsCode = await fs.readFile(gameScriptsPath, 'utf-8');
461
+ // 在 </head> 之前或第一个 <script> 标签之后插入游戏脚本
462
+ // 确保在 PlayCanvas Engine 加载之后,但在 __start__.js 之前
463
+ if (html.includes('</head>')) {
464
+ html = html.replace('</head>', `<script>${gameScriptsCode}</script>\n</head>`);
465
+ }
466
+ else {
467
+ // 如果没有 </head>,在第一个 <body> 标签之后插入
468
+ html = html.replace('<body>', `<body>\n<script>${gameScriptsCode}</script>\n`);
469
+ }
470
+ return html;
471
+ }
472
+ /**
473
+ * 内联 __start__.js
474
+ */
475
+ async inlineStart(html) {
476
+ const startPath = path.join(this.baseBuildDir, '__start__.js');
477
+ const startCode = await fs.readFile(startPath, 'utf-8');
478
+ // 替换 script 标签
479
+ const scriptPattern = /<script[^>]*src=["']__start__\.js["'][^>]*><\/script>/i;
480
+ if (scriptPattern.test(html)) {
481
+ html = html.replace(scriptPattern, `<script>${startCode}</script>`);
482
+ }
483
+ else {
484
+ // 如果 HTML 中没有找到 script 标签,在 </body> 之前插入
485
+ html = html.replace('</body>', `<script>${startCode}</script>\n</body>`);
486
+ }
487
+ return html;
488
+ }
489
+ /**
490
+ * 内联 __loading__.js
491
+ */
492
+ async inlineLoading(html) {
493
+ const loadingPath = path.join(this.baseBuildDir, '__loading__.js');
494
+ const scriptPattern = /<script[^>]*src=["']__loading__\.js["'][^>]*><\/script>/i;
495
+ try {
496
+ await fs.access(loadingPath);
497
+ // 文件存在,内联它
498
+ const loadingCode = await fs.readFile(loadingPath, 'utf-8');
499
+ if (scriptPattern.test(html)) {
500
+ html = html.replace(scriptPattern, `<script>${loadingCode}</script>`);
501
+ }
502
+ else {
503
+ // 如果 HTML 中没有找到 script 标签,在 </body> 之前添加
504
+ html = html.replace('</body>', `<script>${loadingCode}</script>\n</body>`);
505
+ }
506
+ }
507
+ catch (error) {
508
+ // __loading__.js 不存在,移除 script 标签
509
+ if (scriptPattern.test(html)) {
510
+ html = html.replace(scriptPattern, '');
511
+ }
512
+ }
513
+ return html;
514
+ }
515
+ /**
516
+ * 获取大小报告
517
+ */
518
+ getSizeReport() {
519
+ return this.sizeReport;
520
+ }
521
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,136 @@
1
+ import { defineConfig } from 'vite';
2
+ import path from 'path';
3
+ import { viteSingleFile } from 'vite-plugin-singlefile';
4
+ import viteImagemin from '@vheemstra/vite-plugin-imagemin';
5
+ import { PLATFORM_CONFIGS } from './platform-configs.js';
6
+ import { vitePlayCanvasPlugin } from './plugin-playcanvas.js';
7
+ import { vitePlatformPlugin } from './plugin-platform.js';
8
+ import { viteModelCompressionPlugin } from './plugin-model-compression.js';
9
+ import { createPlatformAdapter } from '../platforms/index.js';
10
+ export class ViteConfigBuilder {
11
+ baseBuildDir;
12
+ platform;
13
+ options;
14
+ constructor(baseBuildDir, platform, options) {
15
+ this.baseBuildDir = baseBuildDir;
16
+ this.platform = platform;
17
+ this.options = options;
18
+ }
19
+ create() {
20
+ const platformConfig = this.getPlatformConfig();
21
+ const outputDir = this.options.outputDir || './dist';
22
+ return defineConfig({
23
+ root: this.baseBuildDir,
24
+ base: './',
25
+ build: {
26
+ outDir: outputDir,
27
+ emptyOutDir: true,
28
+ // CSS 压缩
29
+ cssMinify: this.shouldMinifyCSS(platformConfig) ? 'lightningcss' : false,
30
+ // JS 压缩
31
+ minify: this.shouldMinifyJS(platformConfig) ? 'terser' : false,
32
+ terserOptions: {
33
+ compress: {
34
+ drop_console: true,
35
+ drop_debugger: true,
36
+ },
37
+ },
38
+ // 资源内联阈值(所有资源都内联)
39
+ assetsInlineLimit: Infinity,
40
+ // 不生成 sourcemap(Playable Ads 不需要)
41
+ sourcemap: platformConfig.includeSourcemap,
42
+ rollupOptions: {
43
+ input: {
44
+ main: path.join(this.baseBuildDir, 'index.html'),
45
+ },
46
+ },
47
+ },
48
+ plugins: this.createPlugins(platformConfig, outputDir),
49
+ });
50
+ }
51
+ getPlatformConfig() {
52
+ const baseConfig = PLATFORM_CONFIGS[this.platform];
53
+ // 根据选项覆盖配置
54
+ return {
55
+ ...baseConfig,
56
+ minifyCSS: this.options.cssMinify ?? baseConfig.minifyCSS,
57
+ minifyJS: this.options.jsMinify ?? baseConfig.minifyJS,
58
+ compressImages: this.options.compressImages ?? baseConfig.compressImages,
59
+ compressModels: this.options.compressModels ?? baseConfig.compressModels,
60
+ modelCompression: {
61
+ method: this.options.modelCompression ?? baseConfig.modelCompression?.method ?? 'draco',
62
+ quality: baseConfig.modelCompression?.quality ?? 0.8,
63
+ },
64
+ };
65
+ }
66
+ shouldMinifyCSS(config) {
67
+ return config.minifyCSS;
68
+ }
69
+ shouldMinifyJS(config) {
70
+ return config.minifyJS;
71
+ }
72
+ createPlugins(platformConfig, outputDir) {
73
+ const plugins = [];
74
+ // 1. PlayCanvas 资源转换插件(最先执行)
75
+ plugins.push(vitePlayCanvasPlugin({
76
+ baseBuildDir: this.baseBuildDir,
77
+ inlineScripts: true,
78
+ convertDataUrls: true,
79
+ }));
80
+ // 2. 平台特定插件
81
+ const platformAdapter = createPlatformAdapter(this.options);
82
+ plugins.push(vitePlatformPlugin({
83
+ platform: this.platform,
84
+ adapter: platformAdapter,
85
+ outputDir: outputDir,
86
+ }));
87
+ // 3. 图片压缩插件
88
+ if (platformConfig.compressImages) {
89
+ const imageQuality = platformConfig.imageQuality || {
90
+ jpg: 75,
91
+ png: [0.7, 0.8],
92
+ webp: 75,
93
+ };
94
+ const imageminPlugin = viteImagemin({
95
+ plugins: {
96
+ jpg: {
97
+ quality: this.options.imageQuality ?? imageQuality.jpg ?? 75,
98
+ },
99
+ png: {
100
+ quality: Array.isArray(imageQuality.png)
101
+ ? imageQuality.png
102
+ : [0.7, 0.8],
103
+ },
104
+ },
105
+ makeWebp: this.options.convertToWebP !== false ? {
106
+ plugins: {
107
+ jpg: {
108
+ quality: imageQuality.webp ?? 75,
109
+ },
110
+ png: {
111
+ quality: imageQuality.webp ?? 75,
112
+ },
113
+ },
114
+ } : undefined,
115
+ });
116
+ if (imageminPlugin) {
117
+ plugins.push(imageminPlugin);
118
+ }
119
+ }
120
+ // 4. 模型压缩插件
121
+ if (platformConfig.compressModels) {
122
+ plugins.push(viteModelCompressionPlugin({
123
+ quality: platformConfig.modelCompression?.quality ?? 0.8,
124
+ method: platformConfig.modelCompression?.method ?? 'draco',
125
+ enabled: true,
126
+ }));
127
+ }
128
+ // 5. 单文件输出插件(仅HTML格式)
129
+ if (platformConfig.outputFormat === 'html') {
130
+ plugins.push(viteSingleFile({
131
+ removeViteModuleLoader: true,
132
+ }));
133
+ }
134
+ return plugins.filter(Boolean);
135
+ }
136
+ }