@playcraft/build 0.0.4 → 0.0.8

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 (64) hide show
  1. package/dist/analyzers/scene-asset-collector.js +210 -1
  2. package/dist/base-builder.d.ts +15 -0
  3. package/dist/base-builder.js +192 -16
  4. package/dist/generators/config-generator.js +29 -3
  5. package/dist/loaders/playcanvas-loader.d.ts +7 -0
  6. package/dist/loaders/playcanvas-loader.js +53 -3
  7. package/dist/platforms/adikteev.d.ts +10 -0
  8. package/dist/platforms/adikteev.js +72 -0
  9. package/dist/platforms/base.d.ts +12 -0
  10. package/dist/platforms/base.js +208 -0
  11. package/dist/platforms/facebook.js +5 -2
  12. package/dist/platforms/index.d.ts +4 -0
  13. package/dist/platforms/index.js +16 -0
  14. package/dist/platforms/inmobi.d.ts +10 -0
  15. package/dist/platforms/inmobi.js +68 -0
  16. package/dist/platforms/ironsource.js +5 -2
  17. package/dist/platforms/moloco.js +5 -2
  18. package/dist/platforms/playcraft.d.ts +33 -0
  19. package/dist/platforms/playcraft.js +44 -0
  20. package/dist/platforms/remerge.d.ts +10 -0
  21. package/dist/platforms/remerge.js +56 -0
  22. package/dist/templates/__loading__.js +100 -0
  23. package/dist/templates/__modules__.js +47 -0
  24. package/dist/templates/__settings__.template.js +20 -0
  25. package/dist/templates/__start__.js +332 -0
  26. package/dist/templates/index.html +18 -0
  27. package/dist/templates/logo.png +0 -0
  28. package/dist/templates/manifest.json +1 -0
  29. package/dist/templates/patches/cannon.min.js +28 -0
  30. package/dist/templates/patches/lz4.js +10 -0
  31. package/dist/templates/patches/one-page-http-get.js +20 -0
  32. package/dist/templates/patches/one-page-inline-game-scripts.js +52 -0
  33. package/dist/templates/patches/one-page-mraid-resize-canvas.js +46 -0
  34. package/dist/templates/patches/p2.min.js +27 -0
  35. package/dist/templates/patches/playcraft-no-xhr.js +76 -0
  36. package/dist/templates/playcanvas-stable.min.js +16363 -0
  37. package/dist/templates/styles.css +43 -0
  38. package/dist/types.d.ts +14 -1
  39. package/dist/utils/build-mode-detector.d.ts +9 -0
  40. package/dist/utils/build-mode-detector.js +42 -0
  41. package/dist/vite/config-builder.d.ts +29 -1
  42. package/dist/vite/config-builder.js +169 -25
  43. package/dist/vite/platform-configs.d.ts +4 -0
  44. package/dist/vite/platform-configs.js +97 -13
  45. package/dist/vite/plugin-esm-html-generator.d.ts +22 -0
  46. package/dist/vite/plugin-esm-html-generator.js +1061 -0
  47. package/dist/vite/plugin-platform.js +56 -17
  48. package/dist/vite/plugin-playcanvas.d.ts +2 -0
  49. package/dist/vite/plugin-playcanvas.js +497 -40
  50. package/dist/vite/plugin-source-builder.d.ts +3 -0
  51. package/dist/vite/plugin-source-builder.js +886 -19
  52. package/dist/vite-builder.d.ts +19 -2
  53. package/dist/vite-builder.js +162 -12
  54. package/package.json +2 -1
  55. package/physics/cannon-es-bundle.js +13092 -0
  56. package/physics/cannon-rigidbody-adapter.js +375 -0
  57. package/physics/connon-integration.js +411 -0
  58. package/templates/__start__.js +8 -3
  59. package/templates/index.esm.html +20 -0
  60. package/templates/index.esm.mjs +502 -0
  61. package/templates/patches/one-page-inline-game-scripts.js +25 -1
  62. package/templates/patches/playcraft-cta-adapter.js +297 -0
  63. package/templates/patches/playcraft-no-xhr.js +25 -1
  64. package/templates/playcanvas-esm-wrapper.mjs +827 -0
@@ -16,31 +16,65 @@ export function vitePlayCanvasPlugin(options) {
16
16
  // 保存插件的 this 上下文
17
17
  pluginContext = this;
18
18
  },
19
- async transformIndexHtml(html) {
20
- if (options.ammoReplacement) {
21
- html = await injectPhysicsLibrary(html, options);
22
- }
23
- if (options.outputFormat === 'html') {
24
- // 1. 内联 PlayCanvas Engine + 引擎补丁
25
- html = await inlineEngineScript(html, options.baseBuildDir, options);
26
- // 2. 内联 __game-scripts.js(用户脚本)
27
- if (options.inlineGameScripts) {
28
- html = await inlineGameScripts(html, options.baseBuildDir);
19
+ // 使用 transformIndexHtml 的对象形式,指定在 pre 阶段执行
20
+ // 这确保在 Vite 处理资源引用之前移除不需要的引用
21
+ transformIndexHtml: {
22
+ order: 'pre',
23
+ async handler(html) {
24
+ if (options.ammoReplacement) {
25
+ html = await injectPhysicsLibrary(html, options);
29
26
  }
30
- // 3. 内联并转换 __settings__.js(传递插件上下文)
31
- html = await inlineAndConvertSettings(html, options.baseBuildDir, options, pluginContext);
32
- // 4. 内联 __modules__.js
33
- html = await inlineModulesScript(html, options.baseBuildDir);
34
- // 5. 内联 __start__.js
35
- html = await inlineStartScript(html, options.baseBuildDir, options);
36
- // 6. 内联 __loading__.js
37
- html = await inlineLoadingScript(html, options.baseBuildDir);
38
- // 7. 内联 CSS
39
- html = await inlineCSS(html, options.baseBuildDir, options);
40
- // 8. 处理 manifest.json
41
- html = await inlineManifest(html, options.baseBuildDir, options);
42
- }
43
- return html;
27
+ // ⚠️ 在最早期移除 manifest 引用,避免 Vite 将其作为资源处理
28
+ if (options.outputFormat === 'html' && options.convertDataUrls) {
29
+ html = html.replace(/<link[^>]*rel=["']manifest["'][^>]*>/gi, '');
30
+ }
31
+ if (options.outputFormat === 'html') {
32
+ // 1. 内联 PlayCanvas Engine + 引擎补丁
33
+ html = await inlineEngineScript(html, options.baseBuildDir, options);
34
+ // 2. 根据模式处理用户脚本
35
+ if (options.buildMode === 'esm' && options.isESMBundle) {
36
+ // ESM IIFE:脚本已被 viteESMBundlePlugin 打包并注入到 HTML
37
+ // ESM 模式的 index.esm.mjs 已包含完整的初始化逻辑,不需要 __start__.js 等
38
+ console.log('[PlayCanvasPlugin] ESM Bundle 模式:脚本由 ESMBundle 插件处理');
39
+ // ⚠️ 重要:ESM Bundle 模式也需要将 config.json 和场景资源内联到打包后的代码中
40
+ // 因为打包后的 IIFE 代码中 CONFIG_FILENAME = "config.json" 是硬编码的
41
+ html = await inlineESMBundleAssets(html, options.baseBuildDir, options, pluginContext);
42
+ // 处理 CSS 和 manifest
43
+ html = await inlineCSS(html, options.baseBuildDir, options);
44
+ html = await inlineManifest(html, options.baseBuildDir, options);
45
+ }
46
+ else {
47
+ // Classic 模式或 ESM 原生模式
48
+ if (options.buildMode === 'classic' && options.inlineGameScripts) {
49
+ // Classic 模式:内联 __game-scripts.js
50
+ html = await inlineGameScripts(html, options.baseBuildDir);
51
+ }
52
+ // ESM 原生模式:保持 <script type="module"> 和 import map
53
+ // 3. 内联并转换 __settings__.js(传递插件上下文)
54
+ html = await inlineAndConvertSettings(html, options.baseBuildDir, options, pluginContext);
55
+ // 4. 内联 __modules__.js
56
+ html = await inlineModulesScript(html, options.baseBuildDir);
57
+ // 5. 内联 __start__.js
58
+ html = await inlineStartScript(html, options.baseBuildDir, options);
59
+ // 6. 内联 __loading__.js
60
+ html = await inlineLoadingScript(html, options.baseBuildDir);
61
+ // 7. 内联 CSS
62
+ html = await inlineCSS(html, options.baseBuildDir, options);
63
+ // 8. 处理 manifest.json
64
+ html = await inlineManifest(html, options.baseBuildDir, options);
65
+ }
66
+ }
67
+ else if (options.outputFormat === 'zip') {
68
+ // ZIP 格式:不内联引擎,但需要在 IIFE 之前添加引擎脚本标签
69
+ if (options.buildMode === 'esm' && options.isESMBundle) {
70
+ // ESM → IIFE 模式:IIFE 依赖全局 pc 变量,需要先加载引擎
71
+ console.log('[PlayCanvasPlugin] ZIP + ESM Bundle 模式:添加引擎脚本标签');
72
+ html = addEngineScriptTag(html);
73
+ }
74
+ // 对于 Classic 模式或 ESM 原生模式,保持原有的多文件结构
75
+ }
76
+ return html;
77
+ },
44
78
  },
45
79
  async transform(code, id) {
46
80
  // 转换 __settings__.js 中的资源路径为 data URLs(传递插件上下文)
@@ -55,11 +89,20 @@ export function vitePlayCanvasPlugin(options) {
55
89
  * 内联 PlayCanvas Engine
56
90
  */
57
91
  async function inlineEngineScript(html, baseBuildDir, options) {
58
- const engineNames = [
92
+ // Classic 模式的引擎文件名
93
+ const classicEngineNames = [
59
94
  'playcanvas-stable.min.js',
60
95
  'playcanvas.min.js',
61
96
  '__lib__.js',
62
97
  ];
98
+ // ESM 模式的引擎文件位置
99
+ const esmEngineNames = [
100
+ 'js/playcanvas-engine-umd.js',
101
+ ];
102
+ // 根据构建模式选择引擎文件列表
103
+ const engineNames = options.buildMode === 'esm' && options.isESMBundle
104
+ ? [...esmEngineNames, ...classicEngineNames] // ESM 优先,然后 Classic
105
+ : classicEngineNames;
63
106
  for (const engineName of engineNames) {
64
107
  const enginePath = path.join(baseBuildDir, engineName);
65
108
  try {
@@ -69,7 +112,15 @@ async function inlineEngineScript(html, baseBuildDir, options) {
69
112
  const engineScript = options.compressEngine
70
113
  ? `${await getLz4InlineScript()}${await buildCompressedEngineScript(engineCode)}`
71
114
  : `<script>${engineCode}</script>`;
72
- // 替换 script 标签
115
+ // 对于 ESM 模式,引擎需要在 </head> 之前插入(确保在用户脚本之前加载)
116
+ if (options.buildMode === 'esm' && options.isESMBundle) {
117
+ // ESM 模式:在 </head> 之前插入引擎脚本
118
+ // 这确保 window.pc 在 IIFE 用户脚本执行前已设置
119
+ html = html.replace('</head>', `${engineScript}${patchScripts}\n</head>`);
120
+ console.log(`[PlayCanvasPlugin] ESM 模式:已内联引擎 ${engineName}`);
121
+ return html;
122
+ }
123
+ // Classic 模式:替换原有的 script 标签
73
124
  const scriptPattern = new RegExp(`<script[^>]*src=["']${engineName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*></script>`, 'i');
74
125
  html = html.replace(scriptPattern, `${engineScript}${patchScripts}`);
75
126
  return html;
@@ -80,6 +131,18 @@ async function inlineEngineScript(html, baseBuildDir, options) {
80
131
  }
81
132
  return html;
82
133
  }
134
+ /**
135
+ * 为 ZIP 格式添加引擎脚本标签(不内联)
136
+ * 用于 ESM → IIFE 模式,IIFE 依赖全局 pc 变量
137
+ */
138
+ function addEngineScriptTag(html) {
139
+ // 在 </head> 之前添加引擎脚本标签
140
+ // UMD 版本会设置 window.pc 全局变量
141
+ const engineScriptTag = `<script src="./js/playcanvas-engine-umd.js"></script>`;
142
+ html = html.replace('</head>', `${engineScriptTag}\n</head>`);
143
+ console.log('[PlayCanvasPlugin] ZIP 模式:已添加引擎脚本标签');
144
+ return html;
145
+ }
83
146
  /**
84
147
  * 内联并转换 __settings__.js
85
148
  */
@@ -165,6 +228,231 @@ window.PRELOAD_MODULES = ${JSON.stringify(preloadModules)};
165
228
  html = html.replace('</head>', `<script>${settingsCode}</script>\n</head>`);
166
229
  return html;
167
230
  }
231
+ /**
232
+ * ESM Bundle 模式专用:将 config.json 和场景资源内联到打包后的 IIFE 代码中
233
+ *
234
+ * 在 ESM Bundle 模式下,index.esm.mjs 已经被打包为 IIFE 并注入到 HTML 的 <script> 标签中
235
+ * 该 IIFE 代码中 CONFIG_FILENAME = "config.json" 是硬编码的字符串
236
+ * 此函数需要将其替换为内联的 data URL,使得单文件 HTML 可以正常工作
237
+ */
238
+ async function inlineESMBundleAssets(html, baseBuildDir, options, pluginContext) {
239
+ console.log('[PlayCanvasPlugin] ESM Bundle 模式:开始内联 config.json 和场景资源...');
240
+ // 1. 读取并处理 config.json
241
+ const configPath = path.join(baseBuildDir, 'config.json');
242
+ let configJson;
243
+ try {
244
+ const configContent = await fs.readFile(configPath, 'utf-8');
245
+ configJson = JSON.parse(configContent);
246
+ }
247
+ catch (error) {
248
+ console.warn(`[PlayCanvasPlugin] ESM Bundle: 无法读取 config.json: ${error}`);
249
+ return html;
250
+ }
251
+ // 2. 内联 config.json 中的所有资源 URL
252
+ if (options.convertDataUrls) {
253
+ configJson = await inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext);
254
+ }
255
+ // 3. 处理 Ammo 物理引擎替换
256
+ if (options.ammoReplacement) {
257
+ configJson = stripAmmoAssets(configJson);
258
+ }
259
+ // 4. 应用 MRAID 配置
260
+ if (options.mraidSupport) {
261
+ configJson = applyMraidConfig(configJson);
262
+ }
263
+ // 5. 生成 config data URL
264
+ const configDataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(configJson)).toString('base64')}`;
265
+ // 6. 处理场景文件 - 获取第一个场景的 data URL
266
+ let sceneDataUrl = '';
267
+ if (configJson.scenes && configJson.scenes.length > 0) {
268
+ const sceneUrl = configJson.scenes[0].url;
269
+ if (sceneUrl && !sceneUrl.startsWith('data:')) {
270
+ const scenePath = path.join(baseBuildDir, sceneUrl);
271
+ try {
272
+ const sceneContent = await fs.readFile(scenePath, 'utf-8');
273
+ sceneDataUrl = `data:application/json;base64,${Buffer.from(sceneContent).toString('base64')}`;
274
+ console.log(`[PlayCanvasPlugin] ESM Bundle: 场景已内联: ${sceneUrl}`);
275
+ }
276
+ catch (error) {
277
+ console.warn(`[PlayCanvasPlugin] ESM Bundle: 场景文件不存在: ${sceneUrl}`);
278
+ }
279
+ }
280
+ else {
281
+ sceneDataUrl = sceneUrl || '';
282
+ }
283
+ }
284
+ // 7. 内联 logo.png
285
+ // index.esm.mjs 中使用 `${ASSET_PREFIX}logo.png` 引用 logo
286
+ // 需要将 ASSET_PREFIX 替换为 data URL 或直接替换 logo 引用
287
+ if (options.convertDataUrls) {
288
+ const logoPath = path.join(baseBuildDir, 'logo.png');
289
+ try {
290
+ const logoBuffer = await fs.readFile(logoPath);
291
+ const logoDataUrl = `data:image/png;base64,${logoBuffer.toString('base64')}`;
292
+ // 替换 `${ASSET_PREFIX}logo.png` 模式
293
+ // 由于代码被打包为 IIFE,模板字符串可能被转换为字符串拼接
294
+ // 格式1: `${ASSET_PREFIX}logo.png`
295
+ // 格式2: ASSET_PREFIX + "logo.png"
296
+ // 格式3: "".concat(ASSET_PREFIX, "logo.png") (Vite 打包后)
297
+ const escapedLogoDataUrl = logoDataUrl.replace(/\$/g, '$$$$');
298
+ // 替换模板字符串格式(原始格式)
299
+ html = html.replace(/\$\{ASSET_PREFIX\}logo\.png/g, escapedLogoDataUrl);
300
+ // 替换字符串拼接格式
301
+ html = html.replace(/ASSET_PREFIX\s*\+\s*["']logo\.png["']/g, `"${escapedLogoDataUrl}"`);
302
+ // 替换 concat 格式 (Vite/Rollup 打包后常见格式)
303
+ html = html.replace(/["']?["']?\.concat\(ASSET_PREFIX,\s*["']logo\.png["']\)/g, `"${escapedLogoDataUrl}"`);
304
+ // 替换反引号模板字符串在 IIFE 中的编译结果
305
+ // `${ASSET_PREFIX}logo.png` 可能被编译为 ASSET_PREFIX+"logo.png"
306
+ html = html.replace(/`\$\{ASSET_PREFIX\}logo\.png`/g, `"${escapedLogoDataUrl}"`);
307
+ // ⚠️ 重要:Vite 压缩后,直接查找 "logo.png" 字符串
308
+ // 格式: .src="logo.png" 或 src:"logo.png" 或 ="logo.png"
309
+ const logoReplaceCount = (html.match(/["']logo\.png["']/g) || []).length;
310
+ if (logoReplaceCount > 0) {
311
+ html = html.replace(/["']logo\.png["']/g, `"${escapedLogoDataUrl}"`);
312
+ console.log(`[PlayCanvasPlugin] ESM Bundle: 替换了 ${logoReplaceCount} 处 "logo.png" 字符串`);
313
+ }
314
+ console.log(`[PlayCanvasPlugin] ESM Bundle: logo.png 已内联`);
315
+ }
316
+ catch (error) {
317
+ console.warn(`[PlayCanvasPlugin] ESM Bundle: logo.png 不存在,跳过内联`);
318
+ }
319
+ }
320
+ // 8. 在 HTML 中查找并替换 ESM Bundle IIFE 代码中的配置值
321
+ // CONFIG_FILENAME = "config.json" → CONFIG_FILENAME = "data:application/json;base64,..."
322
+ // 注意:替换字符串中的 $ 是特殊字符,需要转义
323
+ const escapedConfigDataUrl = configDataUrl.replace(/\$/g, '$$$$');
324
+ // 替换 CONFIG_FILENAME(支持多种格式)
325
+ // 格式1: const CONFIG_FILENAME = "config.json"
326
+ // 格式2: CONFIG_FILENAME="config.json"
327
+ html = html.replace(/CONFIG_FILENAME\s*=\s*["']config\.json["']/g, `CONFIG_FILENAME = "${escapedConfigDataUrl}"`);
328
+ // ⚠️ 重要:Vite 压缩后,变量名会被混淆(如 CONFIG_FILENAME → vh)
329
+ // 需要直接替换 .configure("config.json" 调用中的字符串字面量
330
+ // 格式: .configure("config.json", ...) 或 .configure('config.json', ...)
331
+ const configureReplaceCount = (html.match(/\.configure\s*\(\s*["']config\.json["']/g) || []).length;
332
+ if (configureReplaceCount > 0) {
333
+ html = html.replace(/\.configure\s*\(\s*["']config\.json["']/g, `.configure("${escapedConfigDataUrl}"`);
334
+ console.log(`[PlayCanvasPlugin] ESM Bundle: 替换了 ${configureReplaceCount} 处 .configure("config.json") 调用`);
335
+ }
336
+ // 9. 替换 SCENE_PATH
337
+ if (sceneDataUrl) {
338
+ const escapedSceneDataUrl = sceneDataUrl.replace(/\$/g, '$$$$');
339
+ // 查找 SCENE_PATH 的各种格式并替换
340
+ // 格式: const SCENE_PATH = "xxx.json" 或 SCENE_PATH = "xxx"
341
+ html = html.replace(/SCENE_PATH\s*=\s*["'][^"']+\.json["']/g, `SCENE_PATH = "${escapedSceneDataUrl}"`);
342
+ // ⚠️ Vite 压缩后格式:loadScene("xxx.json" 或 loadScene(变量名,
343
+ // 需要同时替换 loadScene 调用中的场景路径字符串
344
+ // 格式1: .loadScene("2412781.json", ...)
345
+ // 格式2: .loadScene(变量名, ...) - 需要替换变量定义
346
+ const sceneUrl = configJson.scenes?.[0]?.url;
347
+ if (sceneUrl && !sceneUrl.startsWith('data:')) {
348
+ // 直接替换 loadScene 中的场景路径字符串
349
+ const scenePathRegex = new RegExp(`\\.loadScene\\s*\\(\\s*["']${sceneUrl.replace(/\./g, '\\.')}["']`, 'g');
350
+ const loadSceneReplaceCount = (html.match(scenePathRegex) || []).length;
351
+ if (loadSceneReplaceCount > 0) {
352
+ html = html.replace(scenePathRegex, `.loadScene("${escapedSceneDataUrl}"`);
353
+ console.log(`[PlayCanvasPlugin] ESM Bundle: 替换了 ${loadSceneReplaceCount} 处 .loadScene("${sceneUrl}") 调用`);
354
+ }
355
+ // 同时替换压缩后的变量定义:const vh="2412781.json" 或 变量名="2412781.json"
356
+ // 这会处理如 const vh="2412781.json" 这种情况
357
+ const varDefRegex = new RegExp(`(\\w{1,3})\\s*=\\s*["']${sceneUrl.replace(/\./g, '\\.')}["']`, 'g');
358
+ const varDefReplaceCount = (html.match(varDefRegex) || []).length;
359
+ if (varDefReplaceCount > 0) {
360
+ html = html.replace(varDefRegex, `$1="${escapedSceneDataUrl}"`);
361
+ console.log(`[PlayCanvasPlugin] ESM Bundle: 替换了 ${varDefReplaceCount} 处场景路径变量定义`);
362
+ }
363
+ }
364
+ }
365
+ // 10. 处理 PRELOAD_MODULES(WASM 模块)
366
+ // 在 ESM Bundle 模式下,需要将 PRELOAD_MODULES 中的 URL 也转换为 data URL
367
+ const modulesRegex = /PRELOAD_MODULES\s*=\s*(\[[\s\S]*?\]);/;
368
+ const modulesMatch = html.match(modulesRegex);
369
+ if (modulesMatch) {
370
+ try {
371
+ const modulesStr = modulesMatch[1];
372
+ let modules = new Function(`return ${modulesStr}`)();
373
+ // 过滤 Ammo 模块
374
+ if (options.ammoReplacement) {
375
+ modules = modules.filter((module) => !isAmmoModule(module));
376
+ }
377
+ // 转换模块 URL 为 data URL
378
+ for (const module of modules) {
379
+ if (module.fallbackUrl && !module.fallbackUrl.startsWith('data:')) {
380
+ const fallbackPath = path.join(baseBuildDir, module.fallbackUrl);
381
+ try {
382
+ const fallbackCode = await fs.readFile(fallbackPath, 'utf-8');
383
+ module.fallbackUrl = `data:text/javascript;base64,${Buffer.from(fallbackCode).toString('base64')}`;
384
+ // 清空 WASM URL,强制使用 fallback
385
+ module.glueUrl = '';
386
+ module.wasmUrl = '';
387
+ }
388
+ catch (error) {
389
+ console.warn(`[PlayCanvasPlugin] ESM Bundle: 无法读取 fallback: ${module.fallbackUrl}`);
390
+ }
391
+ }
392
+ }
393
+ // 替换 PRELOAD_MODULES
394
+ const newModulesStr = JSON.stringify(modules);
395
+ const escapedModulesStr = newModulesStr.replace(/\$/g, '$$$$');
396
+ html = html.replace(modulesRegex, `PRELOAD_MODULES = ${escapedModulesStr};`);
397
+ }
398
+ catch (error) {
399
+ console.warn(`[PlayCanvasPlugin] ESM Bundle: 无法处理 PRELOAD_MODULES: ${error}`);
400
+ }
401
+ }
402
+ // 11. 注入 ScriptHandler 补丁(参考 PlayCanvas 官方 one-page-inline-game-scripts.js)
403
+ // 这个补丁覆盖 pc.ScriptHandler.prototype._loadScript,使其能够:
404
+ // 1. 正确处理 data URL 格式的脚本
405
+ // 2. 对于空脚本(已打包到 IIFE 中的),直接跳过执行
406
+ const scriptHandlerPatch = `
407
+ <script>
408
+ // ScriptHandler patch for one-page build (empty scripts are already bundled in IIFE)
409
+ (function() {
410
+ if (typeof pc === 'undefined' || !pc.ScriptHandler) return;
411
+
412
+ var originalLoadScript = pc.ScriptHandler.prototype._loadScript;
413
+ pc.ScriptHandler.prototype._loadScript = function(url, callback) {
414
+ // Check if this is a data URL
415
+ if (url && url.startsWith('data:text/javascript;base64,')) {
416
+ var head = document.head;
417
+ var element = document.createElement('script');
418
+ this._cache[url] = element;
419
+ element.async = false;
420
+
421
+ // Decode base64 content
422
+ var index = url.indexOf(',');
423
+ var base64 = url.slice(index + 1);
424
+
425
+ // If empty content (scripts already bundled), skip execution
426
+ if (!base64 || base64.length === 0) {
427
+ // Still create the element for cache consistency
428
+ callback(null, url, element);
429
+ return;
430
+ }
431
+
432
+ try {
433
+ var data = window.atob(base64);
434
+ element.innerText = data;
435
+ head.appendChild(element);
436
+ } catch (e) {
437
+ console.warn('[ScriptHandler] Failed to decode script:', e);
438
+ }
439
+
440
+ callback(null, url, element);
441
+ return;
442
+ }
443
+
444
+ // Fall back to original implementation for non-data URLs
445
+ return originalLoadScript.call(this, url, callback);
446
+ };
447
+ })();
448
+ </script>`;
449
+ // 将补丁注入到 </head> 之前,确保在引擎之后、脚本加载之前执行
450
+ if (html.includes('</head>')) {
451
+ html = html.replace('</head>', `${scriptHandlerPatch}\n</head>`);
452
+ }
453
+ console.log('[PlayCanvasPlugin] ESM Bundle 模式:资源内联完成');
454
+ return html;
455
+ }
168
456
  /**
169
457
  * 转换 settings 代码中的资源URL为 data URLs
170
458
  */
@@ -382,6 +670,24 @@ async function inlineModulesScript(html, baseBuildDir) {
382
670
  * 注意:脚本需要在 pc.AppBase 创建后执行,所以插入到 </body> 之前
383
671
  * 同时包装成一个函数,在 DOMContentLoaded 后执行,确保 PlayCanvas 引擎已完全初始化
384
672
  */
673
+ /**
674
+ * 注入打包后的游戏脚本(ESM → IIFE 场景)
675
+ */
676
+ async function injectBundledGameScripts(html, baseBuildDir) {
677
+ const gameScriptsPath = path.join(baseBuildDir, '__game-scripts.js');
678
+ try {
679
+ await fs.access(gameScriptsPath);
680
+ const gameScriptsCode = await fs.readFile(gameScriptsPath, 'utf-8');
681
+ // 在 PlayCanvas Engine 之后、__start__.js 之前插入
682
+ if (html.includes('</head>')) {
683
+ html = html.replace('</head>', `<script>${gameScriptsCode}</script>\n</head>`);
684
+ }
685
+ }
686
+ catch (error) {
687
+ console.warn('[PlayCanvasPlugin] 未找到打包后的游戏脚本');
688
+ }
689
+ return html;
690
+ }
385
691
  async function inlineGameScripts(html, baseBuildDir) {
386
692
  const gameScriptsPath = path.join(baseBuildDir, '__game-scripts.js');
387
693
  try {
@@ -599,6 +905,7 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
599
905
  const skippedAssets = [];
600
906
  const optimizedAssets = [];
601
907
  const skippedScripts = [];
908
+ const missingAssets = []; // 新增:缺失资源列表
602
909
  const SIZE_LIMIT = 1 * 1024 * 1024; // 1MB - 跳过超过这个大小的文件
603
910
  for (const [assetId, asset] of Object.entries(assets)) {
604
911
  const file = asset?.file;
@@ -611,11 +918,32 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
611
918
  }
612
919
  const cleanUrl = url.split('?')[0];
613
920
  const fileName = cleanUrl.toLowerCase();
614
- // 跳过指向 __game-scripts.js 的脚本资源
615
- // 这些资源已经被打包到 __game-scripts.js 中,不需要单独内联
616
- // __game-scripts.js 会通过 transformIndexHtml 钩子内联到 HTML 中
617
- if (cleanUrl === '__game-scripts.js' || asset.type === 'script') {
921
+ // ⚠️ 脚本资源特殊处理(参考 PlayCanvas 官方 one-page.js 实现)
922
+ // ESM Bundle 模式下,脚本代码已被打包到 IIFE 中执行
923
+ // PlayCanvas 官方做法:将脚本内容设为空,配合引擎补丁跳过执行
924
+ if (cleanUrl === '__game-scripts.js') {
925
+ // __game-scripts.js 是 Classic 模式的打包产物
926
+ skippedScripts.push(asset.name || assetId);
927
+ // 使用空内容的 data URL(PlayCanvas 官方做法)
928
+ file.url = 'data:text/javascript;base64,';
929
+ file.hash = '';
930
+ asset.preload = false;
931
+ continue;
932
+ }
933
+ if (asset.type === 'script') {
618
934
  skippedScripts.push(asset.name || assetId);
935
+ // ⚠️ ESM 脚本特殊处理(参考 PlayCanvas 官方 one-page.js):
936
+ // 官方工具对于 loadingType !== 0 的脚本,会将内容设为空字符串
937
+ // 然后配合 one-page-inline-game-scripts.js 引擎补丁处理
938
+ //
939
+ // 我们的方案:
940
+ // 1. 将脚本 URL 设为空内容的 data URL
941
+ // 2. 清空 hash 避免追加查询参数
942
+ // 3. 脚本代码已经通过 IIFE 中的 pc.createScript() 注册
943
+ // 4. 需要配合引擎补丁让 ScriptHandler 跳过空脚本
944
+ file.url = 'data:text/javascript;base64,'; // 空 JavaScript
945
+ file.hash = ''; // 清空 hash
946
+ asset.preload = false; // 禁用预加载
619
947
  continue;
620
948
  }
621
949
  // 跳过物理引擎缓存文件和大型文本文件
@@ -626,8 +954,8 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
626
954
  skippedAssets.push(`${asset.name || assetId} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
627
955
  // 标记为不预加载,避免引擎尝试加载
628
956
  asset.preload = false;
629
- // 清空 URL,避免加载失败
630
- file.url = 'data:text/plain;base64,';
957
+ // 提供有效的占位符数据,避免解析错误
958
+ file.url = getPlaceholderDataUrl(asset.type, fileName);
631
959
  continue;
632
960
  }
633
961
  const fullPath = path.join(baseBuildDir, cleanUrl);
@@ -635,26 +963,76 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
635
963
  // ✅ 简化逻辑:直接读取文件并判断是否需要压缩
636
964
  const buffer = await fs.readFile(fullPath);
637
965
  const originalSize = buffer.length;
966
+ // 检查空文件,提供有效的占位符
967
+ if (originalSize === 0) {
968
+ console.warn(`⚠️ 警告: 文件为空,使用占位符: ${asset.name || cleanUrl}`);
969
+ file.url = getPlaceholderDataUrl(asset.type, fileName);
970
+ file.size = 0;
971
+ continue;
972
+ }
638
973
  // 检查实际文件大小,跳过超大文件
639
974
  if (originalSize > SIZE_LIMIT) {
640
975
  skippedAssets.push(`${asset.name || assetId} (${(originalSize / 1024 / 1024).toFixed(2)}MB)`);
641
976
  asset.preload = false;
642
- file.url = 'data:text/plain;base64,';
977
+ // 提供有效的占位符数据,避免解析错误
978
+ file.url = getPlaceholderDataUrl(asset.type, fileName);
643
979
  continue;
644
980
  }
645
981
  let dataUrl;
646
982
  let finalSize;
983
+ // 处理 JSON 文件:去除注释和尾随逗号,验证格式
984
+ if (asset.type === 'json' || fileName.endsWith('.json')) {
985
+ try {
986
+ let jsonText = buffer.toString('utf-8');
987
+ // 移除单行注释 // ...
988
+ jsonText = jsonText.replace(/\/\/.*$/gm, '');
989
+ // 移除多行注释 /* ... */
990
+ jsonText = jsonText.replace(/\/\*[\s\S]*?\*\//g, '');
991
+ // 移除尾随逗号
992
+ jsonText = jsonText.replace(/,(\s*[}\]])/g, '$1');
993
+ // 验证是否为有效 JSON
994
+ const parsed = JSON.parse(jsonText);
995
+ const cleanJson = JSON.stringify(parsed);
996
+ dataUrl = `data:application/json;base64,${Buffer.from(cleanJson).toString('base64')}`;
997
+ finalSize = Buffer.from(cleanJson).length;
998
+ }
999
+ catch (error) {
1000
+ console.warn(`⚠️ 警告: JSON 文件格式无效,使用空对象: ${asset.name || cleanUrl}`);
1001
+ const emptyJson = '{}';
1002
+ dataUrl = `data:application/json;base64,${Buffer.from(emptyJson).toString('base64')}`;
1003
+ finalSize = 2;
1004
+ }
1005
+ }
647
1006
  // ✅ 核心优化:如果是图片,使用 sharp 压缩
648
- if (isImageFile(cleanUrl)) {
1007
+ else if (isImageFile(cleanUrl)) {
649
1008
  const compressed = await compressImage(buffer, cleanUrl, {
650
1009
  convertToWebP: true,
651
1010
  quality: 75,
652
1011
  });
653
- dataUrl = `data:${compressed.mime};base64,${compressed.buffer.toString('base64')}`;
654
- finalSize = compressed.buffer.length;
655
- // 记录优化效果
656
- const savings = ((1 - finalSize / originalSize) * 100).toFixed(1);
657
- optimizedAssets.push(`${asset.name || assetId}: ${(originalSize / 1024).toFixed(1)}KB → ${(finalSize / 1024).toFixed(1)}KB (-${savings}%)`);
1012
+ // 防御性检查:如果压缩后的 buffer 为空,使用原始 buffer
1013
+ if (!compressed.buffer || compressed.buffer.length === 0) {
1014
+ console.warn(`⚠️ 警告: 压缩后的图片为空,使用原始文件: ${asset.name || cleanUrl}`);
1015
+ const mime = guessMimeType(cleanUrl);
1016
+ dataUrl = `data:${mime};base64,${buffer.toString('base64')}`;
1017
+ finalSize = originalSize;
1018
+ }
1019
+ else {
1020
+ const base64 = compressed.buffer.toString('base64');
1021
+ // 额外检查:确保 base64 不为空
1022
+ if (!base64 || base64.length === 0) {
1023
+ console.warn(`⚠️ 警告: base64 编码为空: ${asset.name || cleanUrl}`);
1024
+ const mime = guessMimeType(cleanUrl);
1025
+ dataUrl = `data:${mime};base64,${buffer.toString('base64')}`;
1026
+ finalSize = originalSize;
1027
+ }
1028
+ else {
1029
+ dataUrl = `data:${compressed.mime};base64,${base64}`;
1030
+ finalSize = compressed.buffer.length;
1031
+ }
1032
+ // 记录优化效果
1033
+ const savings = ((1 - finalSize / originalSize) * 100).toFixed(1);
1034
+ optimizedAssets.push(`${asset.name || assetId}: ${(originalSize / 1024).toFixed(1)}KB → ${(finalSize / 1024).toFixed(1)}KB (-${savings}%)`);
1035
+ }
658
1036
  }
659
1037
  else {
660
1038
  // 非图片文件,直接 base64 编码
@@ -674,7 +1052,13 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
674
1052
  }
675
1053
  }
676
1054
  catch (error) {
677
- console.warn(`警告: 资源文件不存在: ${url}`);
1055
+ // 记录缺失资源详情,不只是简单警告
1056
+ missingAssets.push({
1057
+ name: asset.name || assetId,
1058
+ url: cleanUrl,
1059
+ type: asset.type || 'unknown',
1060
+ size: file.size,
1061
+ });
678
1062
  }
679
1063
  }
680
1064
  // 输出统计信息
@@ -702,6 +1086,40 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
702
1086
  console.log(` ... 还有 ${skippedScripts.length - 5} 个脚本`);
703
1087
  }
704
1088
  }
1089
+ // ⚠️ 重要:显示缺失资源的详细警告
1090
+ if (missingAssets.length > 0) {
1091
+ console.log('\n');
1092
+ console.warn(`⚠️ ==================== 缺失资源警告 ====================`);
1093
+ console.warn(`❌ 发现 ${missingAssets.length} 个资源文件不存在!`);
1094
+ console.warn(` 这些资源将不会被打包,可能导致游戏功能缺失!\n`);
1095
+ // 按类型分组显示
1096
+ const groupedByType = {};
1097
+ for (const asset of missingAssets) {
1098
+ const type = asset.type || 'unknown';
1099
+ if (!groupedByType[type]) {
1100
+ groupedByType[type] = [];
1101
+ }
1102
+ groupedByType[type].push(asset);
1103
+ }
1104
+ for (const [type, assets] of Object.entries(groupedByType)) {
1105
+ console.warn(` [${type}] ${assets.length} 个:`);
1106
+ const displayCount = Math.min(assets.length, 5);
1107
+ for (let i = 0; i < displayCount; i++) {
1108
+ const asset = assets[i];
1109
+ const sizeInfo = asset.size ? ` (${(asset.size / 1024).toFixed(1)}KB)` : '';
1110
+ console.warn(` - ${asset.name}${sizeInfo}`);
1111
+ console.warn(` 路径: ${asset.url}`);
1112
+ }
1113
+ if (assets.length > 5) {
1114
+ console.warn(` ... 还有 ${assets.length - 5} 个 ${type} 资源`);
1115
+ }
1116
+ }
1117
+ console.warn(`\n 💡 建议:`);
1118
+ console.warn(` 1. 检查 Base Build 是否包含所有资源文件`);
1119
+ console.warn(` 2. 确认资源路径是否正确`);
1120
+ console.warn(` 3. 如果使用场景选择功能,确认所选场景包含所有依赖资源`);
1121
+ console.warn(`⚠️ ======================================================\n`);
1122
+ }
705
1123
  return configJson;
706
1124
  }
707
1125
  async function inlineLogoInStartScript(startCode, baseBuildDir) {
@@ -715,6 +1133,30 @@ async function inlineLogoInStartScript(startCode, baseBuildDir) {
715
1133
  return startCode;
716
1134
  }
717
1135
  }
1136
+ /**
1137
+ * 为跳过的资源生成有效的占位符 data URL
1138
+ */
1139
+ function getPlaceholderDataUrl(assetType, fileName) {
1140
+ // 1x1 透明 PNG 的 base64 编码(最小有效图片)
1141
+ const transparentPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
1142
+ // JSON 类型:返回空 JSON 对象
1143
+ if (assetType === 'json' || fileName.endsWith('.json')) {
1144
+ return `data:application/json;base64,${Buffer.from('{}').toString('base64')}`;
1145
+ }
1146
+ // 图片/纹理类型:返回 1x1 透明 PNG
1147
+ // 优先检查 assetType,因为文件名可能没有扩展名
1148
+ if (assetType === 'texture' || assetType === 'textureatlas' || isImageFile(fileName)) {
1149
+ return `data:image/png;base64,${transparentPng}`;
1150
+ }
1151
+ // 文本类型:返回单个空格的 base64
1152
+ const mime = guessMimeType(fileName);
1153
+ if (mime.startsWith('text/') || assetType === 'text' || assetType === 'html' || assetType === 'css') {
1154
+ return `data:${mime};base64,${Buffer.from(' ').toString('base64')}`;
1155
+ }
1156
+ // 默认:对于所有其他类型,使用 1x1 透明 PNG 作为通用占位符
1157
+ // 这对大多数二进制格式都是安全的
1158
+ return `data:image/png;base64,${transparentPng}`;
1159
+ }
718
1160
  /**
719
1161
  * 判断是否为图片文件
720
1162
  */
@@ -743,6 +1185,11 @@ async function compressImage(buffer, filePath, options) {
743
1185
  const webpBuffer = await image
744
1186
  .webp({ quality, effort: 6 })
745
1187
  .toBuffer();
1188
+ // 防御性检查:如果压缩后为空,返回原始 buffer
1189
+ if (!webpBuffer || webpBuffer.length === 0) {
1190
+ console.warn(`警告: WebP 转换后为空,使用原始格式`);
1191
+ return { buffer, mime: guessMimeType(filePath) };
1192
+ }
746
1193
  return {
747
1194
  buffer: webpBuffer,
748
1195
  mime: 'image/webp',
@@ -754,12 +1201,22 @@ async function compressImage(buffer, filePath, options) {
754
1201
  const pngBuffer = await image
755
1202
  .png({ quality: 80, compressionLevel: 9 })
756
1203
  .toBuffer();
1204
+ // 防御性检查
1205
+ if (!pngBuffer || pngBuffer.length === 0) {
1206
+ console.warn(`警告: PNG 压缩后为空,使用原始文件`);
1207
+ return { buffer, mime: 'image/png' };
1208
+ }
757
1209
  return { buffer: pngBuffer, mime: 'image/png' };
758
1210
  case '.jpg':
759
1211
  case '.jpeg':
760
1212
  const jpgBuffer = await image
761
1213
  .jpeg({ quality: options?.quality ?? 80, mozjpeg: true })
762
1214
  .toBuffer();
1215
+ // 防御性检查
1216
+ if (!jpgBuffer || jpgBuffer.length === 0) {
1217
+ console.warn(`警告: JPEG 压缩后为空,使用原始文件`);
1218
+ return { buffer, mime: 'image/jpeg' };
1219
+ }
763
1220
  return { buffer: jpgBuffer, mime: 'image/jpeg' };
764
1221
  default:
765
1222
  // 其他格式不压缩