@playcraft/build 0.0.3 → 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 +17 -0
  3. package/dist/base-builder.js +211 -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 -39
  43. package/dist/vite/platform-configs.d.ts +4 -0
  44. package/dist/vite/platform-configs.js +98 -14
  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 +579 -49
  50. package/dist/vite/plugin-source-builder.d.ts +3 -0
  51. package/dist/vite/plugin-source-builder.js +920 -23
  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 +33 -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
@@ -74,8 +74,13 @@ export function viteSourceBuilderPlugin(options) {
74
74
  // 让 Vite 自然地处理这些文件及其依赖关系
75
75
  const imports = userScripts.map(script => {
76
76
  const scriptPath = path.join(options.projectDir, script.path);
77
- return `import '${scriptPath}';`;
77
+ // 将 Windows 路径中的反斜杠转换为正斜杠,避免在 import 语句中被解释为转义字符
78
+ // 使用 split + join 方式更可靠地处理路径分隔符
79
+ const normalizedPath = scriptPath.split(path.sep).join('/');
80
+ console.log(`[SourceBuilder] 脚本路径: ${scriptPath} -> ${normalizedPath}`);
81
+ return `import '${normalizedPath}';`;
78
82
  }).join('\n');
83
+ console.log(`[SourceBuilder] 虚拟模块内容:\n${imports}`);
79
84
  return imports || '// No user scripts';
80
85
  }
81
86
  return null;
@@ -90,22 +95,36 @@ export function viteSourceBuilderPlugin(options) {
90
95
  const config = await generateConfig(projectConfig, {
91
96
  selectedScenes: options.selectedScenes,
92
97
  });
93
- // 为脚本资产补齐 __game-scripts.js 信息
94
- await patchScriptAssets(config, options.outputDir);
98
+ // 为脚本资产补齐 __game-scripts.js 信息(仅在 Classic 模式下)
99
+ if (options.buildMode !== 'esm') {
100
+ await patchScriptAssets(config, options.outputDir);
101
+ }
95
102
  await fs.writeFile(path.join(options.outputDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8');
96
103
  console.log('[SourceBuilder] 生成 config.json');
97
104
  // 生成 __settings__.js
98
- const settings = generateSettings(projectConfig);
105
+ // 如果 config.scenes 中有场景,使用第一个场景的 URL
106
+ const firstScenePath = config.scenes && config.scenes.length > 0
107
+ ? config.scenes[0].url
108
+ : undefined;
109
+ const settings = generateSettings(projectConfig, firstScenePath);
99
110
  await fs.writeFile(path.join(options.outputDir, '__settings__.js'), settings, 'utf-8');
100
111
  console.log('[SourceBuilder] 生成 __settings__.js');
101
- // 复制模板文件
102
- await copyTemplates(options.outputDir);
103
- console.log('[SourceBuilder] 复制模板文件');
104
- // 复制资源文件
105
- await copyAssets(projectConfig, options.projectDir, options.outputDir, config.assets);
112
+ // 复制模板文件和生成 HTML
113
+ const buildMode = options.buildMode || 'classic';
114
+ if (buildMode === 'esm') {
115
+ await generateESMTemplate(projectConfig, options, config);
116
+ console.log('[SourceBuilder] 生成 ESM 模板');
117
+ }
118
+ else {
119
+ await copyTemplates(options.outputDir);
120
+ console.log('[SourceBuilder] 复制传统模板文件');
121
+ }
122
+ // 复制资源文件(ESM 模式下跳过脚本,因为脚本由 generateESMTemplate 中的 copyUserScriptsForESM 处理)
123
+ const skipScripts = buildMode === 'esm';
124
+ await copyAssets(projectConfig, options.projectDir, options.outputDir, config.assets, skipScripts);
106
125
  console.log('[SourceBuilder] 复制资源文件');
107
- // 生成场景文件
108
- await generateSceneFiles(projectConfig, options.projectDir, options.outputDir);
126
+ // 生成场景文件(只生成 config 中包含的场景)
127
+ await generateSceneFiles(projectConfig, options.projectDir, options.outputDir, config.scenes);
109
128
  console.log('[SourceBuilder] 生成场景文件');
110
129
  },
111
130
  };
@@ -190,6 +209,11 @@ async function collectUserScripts(projectConfig, projectDir) {
190
209
  const asset = pcProject.assets[String(scriptId)];
191
210
  if (asset && asset.type === 'script') {
192
211
  const assetData = asset;
212
+ // 检查 preload 属性(默认为 true)
213
+ const preload = assetData.preload !== false;
214
+ if (!preload) {
215
+ continue; // 跳过 non-preload 脚本
216
+ }
193
217
  if (assetData.file?.url && !assetData.file.url.startsWith('data:')) {
194
218
  const scriptPath = path.join(projectDir, assetData.file.url);
195
219
  try {
@@ -216,6 +240,11 @@ async function collectUserScripts(projectConfig, projectDir) {
216
240
  const asset = pcProject.assets[String(scriptId)];
217
241
  if (asset && asset.type === 'script') {
218
242
  const assetData = asset;
243
+ // 检查 preload 属性(默认为 true)
244
+ const preload = assetData.preload !== false;
245
+ if (!preload) {
246
+ continue; // 跳过 non-preload 脚本
247
+ }
219
248
  if (assetData.file?.url && !assetData.file.url.startsWith('data:')) {
220
249
  const scriptPath = path.join(projectDir, assetData.file.url);
221
250
  try {
@@ -269,18 +298,31 @@ async function copyTemplates(outputDir) {
269
298
  /**
270
299
  * 复制资源文件
271
300
  */
272
- async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride) {
301
+ async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride, skipScripts // ESM 模式下跳过脚本(脚本由 copyUserScriptsForESM 处理)
302
+ ) {
273
303
  const assetsOutputDir = path.join(outputDir, 'files');
274
304
  await fs.mkdir(assetsOutputDir, { recursive: true });
275
305
  const assets = assetsOverride || (projectConfig.format === 'playcanvas'
276
306
  ? projectConfig.assets
277
307
  : projectConfig.assets);
308
+ // 使用 Set 去重,避免重复复制同一个文件
309
+ const copiedUrls = new Set();
310
+ let copiedCount = 0;
311
+ let failedCount = 0;
278
312
  // 遍历 assets
279
- for (const [assetId, asset] of Object.entries(assets)) {
313
+ for (const [, asset] of Object.entries(assets)) {
280
314
  const assetData = asset;
281
315
  if (!assetData.file?.url || assetData.file.url.startsWith('data:')) {
282
316
  continue;
283
317
  }
318
+ // ESM 模式下跳过脚本类型的资源(脚本由 copyUserScriptsForESM 单独处理)
319
+ if (skipScripts && assetData.type === 'script') {
320
+ continue;
321
+ }
322
+ // 跳过已经复制过的 URL
323
+ if (copiedUrls.has(assetData.file.url)) {
324
+ continue;
325
+ }
284
326
  const sourcePath = path.join(projectDir, assetData.file.url);
285
327
  const targetPath = path.join(outputDir, assetData.file.url);
286
328
  try {
@@ -291,54 +333,215 @@ async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride)
291
333
  await fs.mkdir(targetDir, { recursive: true });
292
334
  // 复制文件
293
335
  await fs.copyFile(sourcePath, targetPath);
336
+ // 标记为已复制
337
+ copiedUrls.add(assetData.file.url);
338
+ copiedCount++;
294
339
  }
295
340
  catch (error) {
296
341
  // 资源文件可能不存在(可能是内联的)
297
- // console.warn(`警告: 无法复制资源文件 ${assetData.file.url}:`, error);
342
+ failedCount++;
343
+ }
344
+ }
345
+ console.log(`[SourceBuilder] 复制资源文件: ${copiedCount} 个成功${failedCount > 0 ? `, ${failedCount} 个失败` : ''}`);
346
+ }
347
+ /**
348
+ * 收集项目中所有已注册的脚本名称
349
+ * 从脚本资产中提取 scriptName(ESM 脚本通过 static scriptName 定义)
350
+ */
351
+ function collectRegisteredScriptNames(assets) {
352
+ const scriptNames = new Set();
353
+ for (const [, asset] of Object.entries(assets)) {
354
+ if (asset?.type !== 'script')
355
+ continue;
356
+ // 脚本名称可以从多个来源获取
357
+ // 1. ESM 脚本:从 data.scriptName 或文件名推导
358
+ // 2. Classic 脚本:从 name 字段或 file.filename 推导
359
+ const name = asset.name || '';
360
+ const filename = asset.file?.filename || '';
361
+ // 从文件名推导脚本名(移除扩展名,首字母小写)
362
+ let scriptName = '';
363
+ if (name) {
364
+ // 移除 .mjs/.js 扩展名
365
+ const baseName = name.replace(/\.(mjs|js)$/i, '');
366
+ scriptName = baseName.charAt(0).toLowerCase() + baseName.slice(1);
367
+ }
368
+ else if (filename) {
369
+ const baseName = filename.replace(/\.(mjs|js)$/i, '');
370
+ scriptName = baseName.charAt(0).toLowerCase() + baseName.slice(1);
371
+ }
372
+ if (scriptName) {
373
+ scriptNames.add(scriptName);
374
+ }
375
+ // 如果脚本资产有 data.scripts 数组(Classic 格式),也收集这些名称
376
+ if (asset.data?.scripts && Array.isArray(asset.data.scripts)) {
377
+ for (const s of asset.data.scripts) {
378
+ if (s?.name)
379
+ scriptNames.add(s.name);
380
+ }
381
+ }
382
+ }
383
+ return scriptNames;
384
+ }
385
+ /**
386
+ * 验证并清理场景中的无效脚本引用
387
+ * 移除引用了不存在脚本的组件
388
+ */
389
+ function validateAndCleanSceneScripts(sceneData, registeredScripts, _sceneName // 保留参数用于将来的日志输出
390
+ ) {
391
+ const removedScripts = [];
392
+ if (!sceneData?.entities) {
393
+ return { cleaned: sceneData, removedScripts };
394
+ }
395
+ // 深拷贝场景数据以避免修改原始数据
396
+ const cleaned = JSON.parse(JSON.stringify(sceneData));
397
+ for (const [entityId, entity] of Object.entries(cleaned.entities)) {
398
+ const scriptComp = entity?.components?.script;
399
+ if (!scriptComp)
400
+ continue;
401
+ // 处理 ESM 格式:scripts 是对象 { scriptName: {...}, ... }
402
+ if (scriptComp.scripts && typeof scriptComp.scripts === 'object' && !Array.isArray(scriptComp.scripts)) {
403
+ const validScripts = {};
404
+ const validOrder = [];
405
+ for (const [scriptName, scriptData] of Object.entries(scriptComp.scripts)) {
406
+ if (registeredScripts.has(scriptName)) {
407
+ validScripts[scriptName] = scriptData;
408
+ if (scriptComp.order?.includes(scriptName)) {
409
+ validOrder.push(scriptName);
410
+ }
411
+ }
412
+ else {
413
+ removedScripts.push(`${entity.name || entityId}/${scriptName}`);
414
+ }
415
+ }
416
+ scriptComp.scripts = validScripts;
417
+ scriptComp.order = validOrder;
418
+ // 如果所有脚本都被移除,删除整个 script 组件
419
+ if (Object.keys(validScripts).length === 0) {
420
+ delete entity.components.script;
421
+ }
422
+ }
423
+ // 处理 Classic 格式:scripts 是数组 [{ name: 'xxx' }, ...]
424
+ if (scriptComp.scripts && Array.isArray(scriptComp.scripts)) {
425
+ const validScripts = scriptComp.scripts.filter((s) => {
426
+ if (!s?.name)
427
+ return false;
428
+ if (registeredScripts.has(s.name))
429
+ return true;
430
+ removedScripts.push(`${entity.name || entityId}/${s.name}`);
431
+ return false;
432
+ });
433
+ scriptComp.scripts = validScripts;
434
+ if (validScripts.length === 0) {
435
+ delete entity.components.script;
436
+ }
298
437
  }
299
438
  }
439
+ return { cleaned, removedScripts };
300
440
  }
301
441
  /**
302
442
  * 生成场景文件
303
443
  */
304
- async function generateSceneFiles(projectConfig, projectDir, outputDir) {
444
+ async function generateSceneFiles(projectConfig, _projectDir, // 保留参数以保持 API 兼容性
445
+ outputDir, selectedScenes) {
446
+ // 收集已注册的脚本名称
447
+ const assets = projectConfig.format === 'playcanvas'
448
+ ? projectConfig.assets || {}
449
+ : projectConfig.assets || {};
450
+ const registeredScripts = collectRegisteredScriptNames(assets);
451
+ console.log(`[SceneGenerator] 已注册脚本数量: ${registeredScripts.size}`);
305
452
  if (projectConfig.format === 'playcanvas') {
306
453
  const pcProject = projectConfig;
307
454
  // PlayCanvas 格式:scenes.json 中已包含场景数据
308
455
  if (pcProject.scenes) {
309
- const scenes = Array.isArray(pcProject.scenes)
456
+ let scenes = Array.isArray(pcProject.scenes)
310
457
  ? pcProject.scenes
311
- : Object.entries(pcProject.scenes).map(([id, scene]) => ({ id, ...scene }));
458
+ : Object.entries(pcProject.scenes).map(([keyId, scene]) => {
459
+ // keyId 是场景文件名使用的 ID(如 "2388047")
460
+ return { ...scene, sceneFileId: keyId };
461
+ });
462
+ // 如果提供了 selectedScenes,只生成这些场景
463
+ if (selectedScenes && selectedScenes.length > 0) {
464
+ const selectedUrls = new Set(selectedScenes.map(s => s.url));
465
+ scenes = scenes.filter((scene) => {
466
+ // 优先使用 sceneFileId(来自 scenes.json 的 key)
467
+ const sceneId = scene.sceneFileId || scene.id || scene.name || 'scene';
468
+ const sceneUrl = `${sceneId}.json`;
469
+ return selectedUrls.has(sceneUrl);
470
+ });
471
+ }
472
+ let totalRemoved = 0;
312
473
  for (const scene of scenes) {
313
474
  const sceneData = scene;
314
- const sceneId = sceneData.id || sceneData.name || 'scene';
475
+ // 优先使用 sceneFileId(来自 scenes.json key)
476
+ const sceneId = sceneData.sceneFileId || sceneData.id || sceneData.name || 'scene';
315
477
  const scenePath = path.join(outputDir, `${sceneId}.json`);
316
- // 写入场景 JSON
317
- await fs.writeFile(scenePath, JSON.stringify(sceneData, null, 2), 'utf-8');
478
+ // 验证并清理无效脚本引用
479
+ const { cleaned, removedScripts } = validateAndCleanSceneScripts(sceneData, registeredScripts, sceneData.name || sceneId);
480
+ if (removedScripts.length > 0) {
481
+ totalRemoved += removedScripts.length;
482
+ console.warn(`\n⚠️ 场景 "${sceneData.name || sceneId}" 中发现 ${removedScripts.length} 个无效脚本引用,已移除:`);
483
+ removedScripts.slice(0, 10).forEach(s => console.warn(` - ${s}`));
484
+ if (removedScripts.length > 10) {
485
+ console.warn(` ... 还有 ${removedScripts.length - 10} 个`);
486
+ }
487
+ }
488
+ // 写入清理后的场景 JSON
489
+ await fs.writeFile(scenePath, JSON.stringify(cleaned, null, 2), 'utf-8');
490
+ }
491
+ if (totalRemoved > 0) {
492
+ console.log(`\n📋 共移除 ${totalRemoved} 个无效脚本引用`);
318
493
  }
319
494
  }
320
495
  }
321
496
  else {
322
497
  // PlayCraft 格式:从 scenes/ 目录读取
323
498
  const pcProject = projectConfig;
324
- for (const scene of pcProject.scenes) {
499
+ let scenes = pcProject.scenes;
500
+ // 如果提供了 selectedScenes,只生成这些场景
501
+ if (selectedScenes && selectedScenes.length > 0) {
502
+ const selectedNames = new Set(selectedScenes.map(s => s.name));
503
+ scenes = scenes.filter((scene) => {
504
+ const sceneName = scene.name || scene.id || 'scene';
505
+ return selectedNames.has(sceneName);
506
+ });
507
+ }
508
+ let totalRemoved = 0;
509
+ for (const scene of scenes) {
325
510
  const sceneData = scene;
326
511
  const sceneId = sceneData.id || sceneData.name || 'scene';
327
512
  const scenePath = path.join(outputDir, `${sceneId}.json`);
328
- // 写入场景 JSON
329
- await fs.writeFile(scenePath, JSON.stringify(sceneData, null, 2), 'utf-8');
513
+ // 验证并清理无效脚本引用
514
+ const { cleaned, removedScripts } = validateAndCleanSceneScripts(sceneData, registeredScripts, sceneData.name || sceneId);
515
+ if (removedScripts.length > 0) {
516
+ totalRemoved += removedScripts.length;
517
+ console.warn(`\n⚠️ 场景 "${sceneData.name || sceneId}" 中发现 ${removedScripts.length} 个无效脚本引用,已移除:`);
518
+ removedScripts.slice(0, 10).forEach(s => console.warn(` - ${s}`));
519
+ if (removedScripts.length > 10) {
520
+ console.warn(` ... 还有 ${removedScripts.length - 10} 个`);
521
+ }
522
+ }
523
+ // 写入清理后的场景 JSON
524
+ await fs.writeFile(scenePath, JSON.stringify(cleaned, null, 2), 'utf-8');
525
+ }
526
+ if (totalRemoved > 0) {
527
+ console.log(`\n📋 共移除 ${totalRemoved} 个无效脚本引用`);
330
528
  }
331
529
  }
332
530
  }
333
531
  /**
334
532
  * 规范化资源 URL(补齐 file.url)
533
+ * 源码导出中的资源只有 file.filename,没有 file.url
534
+ * 需要根据 assetId 和 revision 构建完整的文件路径
335
535
  */
336
536
  async function normalizeAssetUrls(projectConfig, projectDir) {
337
537
  const assets = projectConfig.format === 'playcanvas'
338
538
  ? projectConfig.assets
339
539
  : projectConfig.assets;
540
+ let normalizedCount = 0;
541
+ let notFoundCount = 0;
340
542
  for (const [assetId, asset] of Object.entries(assets)) {
341
543
  const assetData = asset;
544
+ // 跳过已有 url 或没有 file 的资源
342
545
  if (!assetData.file || assetData.file.url) {
343
546
  continue;
344
547
  }
@@ -353,18 +556,25 @@ async function normalizeAssetUrls(projectConfig, projectDir) {
353
556
  path.join('files', 'assets', String(assetId), filename),
354
557
  path.join('files', filename),
355
558
  ];
559
+ let found = false;
356
560
  for (const rel of candidates) {
357
561
  const abs = path.join(projectDir, rel);
358
562
  try {
359
563
  await fs.access(abs);
360
564
  assetData.file.url = rel;
565
+ normalizedCount++;
566
+ found = true;
361
567
  break;
362
568
  }
363
569
  catch (error) {
364
570
  // 继续尝试下一个
365
571
  }
366
572
  }
573
+ if (!found) {
574
+ notFoundCount++;
575
+ }
367
576
  }
577
+ console.log(`[SourceBuilder] 规范化资源 URL: ${normalizedCount} 个成功${notFoundCount > 0 ? `, ${notFoundCount} 个未找到` : ''}`);
368
578
  }
369
579
  /**
370
580
  * 为脚本资产补齐 __game-scripts.js 信息
@@ -406,3 +616,690 @@ async function patchScriptAssets(config, outputDir) {
406
616
  }
407
617
  }
408
618
  }
619
+ /**
620
+ * 生成 ESM 模板文件
621
+ */
622
+ async function generateESMTemplate(projectConfig, options, config) {
623
+ const templatesDir = path.resolve(__dirname, '../../templates');
624
+ const outputDir = options.outputDir;
625
+ // 1. 生成 index.html (修复 Import Map 路径)
626
+ const htmlTemplate = await fs.readFile(path.join(templatesDir, 'index.esm.html'), 'utf-8');
627
+ const projectName = projectConfig.format === 'playcanvas'
628
+ ? projectConfig.project.name || 'PlayCanvas Project'
629
+ : projectConfig.manifest.name || 'PlayCanvas Project';
630
+ // 修复 Import Map 中的路径(将 ./Lib/ 改为 ./)
631
+ const fixedImportMap = fixImportMapPaths(options.importMap?.content || { imports: {} });
632
+ const importMapJson = JSON.stringify(fixedImportMap, null, 2);
633
+ const html = htmlTemplate
634
+ .replace(/\{\{PROJECT_NAME\}\}/g, projectName)
635
+ .replace(/\{\{IMPORT_MAP\}\}/g, importMapJson);
636
+ await fs.writeFile(path.join(outputDir, 'index.html'), html, 'utf-8');
637
+ // 2. 复制 PlayCanvas Engine(UMD 格式)和 ESM 包装器
638
+ const jsDir = path.join(outputDir, 'js');
639
+ await fs.mkdir(jsDir, { recursive: true });
640
+ // 复制 UMD 引擎
641
+ const engineSourcePath = path.join(templatesDir, 'playcanvas-stable.min.js');
642
+ const engineTargetPath = path.join(jsDir, 'playcanvas-engine-umd.js');
643
+ try {
644
+ await fs.copyFile(engineSourcePath, engineTargetPath);
645
+ console.log('[SourceBuilder] PlayCanvas Engine (UMD) 已复制');
646
+ }
647
+ catch (error) {
648
+ console.warn('[SourceBuilder] 警告: 无法复制 PlayCanvas Engine:', error);
649
+ }
650
+ // 复制 ESM 包装器
651
+ const wrapperSourcePath = path.join(templatesDir, 'playcanvas-esm-wrapper.mjs');
652
+ const wrapperTargetPath = path.join(jsDir, 'playcanvas-engine.mjs');
653
+ try {
654
+ await fs.copyFile(wrapperSourcePath, wrapperTargetPath);
655
+ console.log('[SourceBuilder] PlayCanvas ESM 包装器已复制');
656
+ }
657
+ catch (error) {
658
+ console.warn('[SourceBuilder] 警告: 无法复制 ESM 包装器:', error);
659
+ }
660
+ // 3. 收集用户脚本的导入路径
661
+ const scriptImportPaths = await collectESMScriptImports(projectConfig, options);
662
+ // 4. 从 Import Map 中提取 gameRule 路径
663
+ let gameRulePath = '';
664
+ if (options.importMap?.content?.imports) {
665
+ const imports = options.importMap.content.imports;
666
+ if (imports['gameRule']) {
667
+ // 将 "./Generated/..." 格式转换为相对于 js/ 目录的路径 "../Generated/..."
668
+ gameRulePath = imports['gameRule'];
669
+ if (gameRulePath.startsWith('./')) {
670
+ gameRulePath = '..' + gameRulePath.slice(1); // "./" -> "../"
671
+ }
672
+ console.log(`[SourceBuilder] GameRule 路径: ${gameRulePath}`);
673
+ }
674
+ }
675
+ // 5. 生成 js/index.mjs(包含脚本导入),使用过滤后的 config.scenes
676
+ await generateESMEntry(projectConfig, outputDir, scriptImportPaths, config.scenes, gameRulePath);
677
+ // 5. 在 ESM 模式下,复制用户脚本为独立文件,并更新 config.assets 中的 URL
678
+ if (options.importMap) {
679
+ const scriptUrlMap = await copyUserScriptsForESM(projectConfig, options.projectDir, outputDir, options.importMap);
680
+ console.log('[SourceBuilder] 用户脚本已复制为独立 ESM 模块');
681
+ // 更新 config.assets 中脚本的 URL
682
+ if (scriptUrlMap.size > 0 && config.assets) {
683
+ for (const [scriptId, newUrl] of scriptUrlMap) {
684
+ if (config.assets[scriptId] && config.assets[scriptId].file) {
685
+ config.assets[scriptId].file.url = newUrl;
686
+ }
687
+ }
688
+ // 重新写入 config.json
689
+ await fs.writeFile(path.join(outputDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8');
690
+ console.log(`[SourceBuilder] 更新了 ${scriptUrlMap.size} 个脚本的 URL`);
691
+ }
692
+ // 6. 复制 Import Map 中引用的第三方库文件
693
+ await copyImportMapLibraries(projectConfig, options.projectDir, outputDir, options.importMap);
694
+ }
695
+ // 7. 复制其他必要的模板文件
696
+ const templateFiles = [
697
+ '__start__.js', // 可能不需要了,但先保留
698
+ '__modules__.js',
699
+ '__loading__.js',
700
+ 'styles.css',
701
+ 'logo.png',
702
+ 'manifest.json',
703
+ ];
704
+ for (const file of templateFiles) {
705
+ const sourcePath = path.join(templatesDir, file);
706
+ const targetPath = path.join(outputDir, file);
707
+ try {
708
+ await fs.copyFile(sourcePath, targetPath);
709
+ }
710
+ catch (error) {
711
+ // 某些文件可能不存在,忽略
712
+ }
713
+ }
714
+ }
715
+ /**
716
+ * 修复 Import Map 中的路径
717
+ * 将 ./Lib/xxx.mjs 等错误路径修复为正确的相对路径
718
+ * 并添加必要的模块映射(如 playcanvas)
719
+ */
720
+ function fixImportMapPaths(importMap) {
721
+ const fixedImports = {};
722
+ // 添加 playcanvas 引擎映射(脚本中使用 import from 'playcanvas')
723
+ fixedImports['playcanvas'] = './js/playcanvas-engine.mjs';
724
+ for (const [key, value] of Object.entries(importMap.imports || {})) {
725
+ // 修复 ./Lib/ 路径(通常第三方库文件会被复制到根目录)
726
+ let fixedValue = value;
727
+ if (value.startsWith('./Lib/')) {
728
+ // 将 ./Lib/xxx.mjs 改为 ./xxx.mjs
729
+ fixedValue = './' + value.slice(6);
730
+ }
731
+ fixedImports[key] = fixedValue;
732
+ }
733
+ return { imports: fixedImports };
734
+ }
735
+ /**
736
+ * 收集 ESM 脚本的导入路径
737
+ */
738
+ async function collectESMScriptImports(projectConfig, options) {
739
+ if (projectConfig.format !== 'playcanvas') {
740
+ return [];
741
+ }
742
+ const pcProject = projectConfig;
743
+ const scriptIds = pcProject.project.settings?.scripts || [];
744
+ const assets = pcProject.assets;
745
+ const importPaths = [];
746
+ // 构建文件夹 ID 到名称的映射
747
+ const folderMap = new Map();
748
+ for (const [assetId, asset] of Object.entries(assets)) {
749
+ const assetData = asset;
750
+ if (assetData.type === 'folder') {
751
+ folderMap.set(assetId, assetData.name);
752
+ }
753
+ }
754
+ // 遍历每个脚本,构建导入路径
755
+ for (const scriptId of scriptIds) {
756
+ const asset = assets[String(scriptId)];
757
+ if (!asset || asset.type !== 'script')
758
+ continue;
759
+ const assetData = asset;
760
+ // 检查 preload 属性
761
+ const preload = assetData.preload !== false;
762
+ if (!preload)
763
+ continue;
764
+ // 获取正确的文件名(带扩展名)
765
+ const filename = assetData.file?.filename || assetData.name + '.mjs';
766
+ // 获取脚本的文件夹路径
767
+ const pathIds = assetData.path || [];
768
+ const folderPath = buildScriptFolderPath(pathIds, folderMap);
769
+ // 根据 import map 确定目标路径
770
+ if (options.importMap) {
771
+ const targetPath = resolveTargetPathFromImportMap(folderPath, filename, options.importMap);
772
+ console.log(`[ESM Imports] ${assetData.name}: folderPath=${folderPath}, filename=${filename}, targetPath=${targetPath}`);
773
+ if (targetPath) {
774
+ // 生成相对于 js/ 目录的导入路径
775
+ // 入口文件在 js/index.mjs,脚本在根目录,所以需要 ../
776
+ importPaths.push('../' + targetPath);
777
+ }
778
+ }
779
+ }
780
+ return importPaths;
781
+ }
782
+ /**
783
+ * 生成 ESM 入口文件 js/index.mjs
784
+ */
785
+ async function generateESMEntry(projectConfig, outputDir, scriptImportPaths = [], configScenes, gameRulePath = '') {
786
+ const templatesDir = path.resolve(__dirname, '../../templates');
787
+ const esmTemplate = await fs.readFile(path.join(templatesDir, 'index.esm.mjs'), 'utf-8');
788
+ // 获取项目设置
789
+ let settings;
790
+ let scripts = [];
791
+ if (projectConfig.format === 'playcanvas') {
792
+ const pcProject = projectConfig;
793
+ settings = pcProject.project.settings || {};
794
+ scripts = settings.scripts || [];
795
+ }
796
+ else {
797
+ const pcProject = projectConfig;
798
+ settings = pcProject.manifest.settings || {};
799
+ scripts = settings.scripts || [];
800
+ }
801
+ // 确定场景路径(优先使用传入的 configScenes,否则从原始 projectConfig 获取)
802
+ let scenePath = '';
803
+ if (configScenes && configScenes.length > 0) {
804
+ // 使用过滤后的配置场景
805
+ scenePath = configScenes[0].url;
806
+ }
807
+ else if (projectConfig.format === 'playcanvas') {
808
+ const pcProject = projectConfig;
809
+ if (pcProject.scenes) {
810
+ const scenes = Array.isArray(pcProject.scenes)
811
+ ? pcProject.scenes
812
+ : Object.values(pcProject.scenes);
813
+ if (scenes.length > 0) {
814
+ const firstScene = scenes[0];
815
+ scenePath = firstScene.url || firstScene.file || `${firstScene.id || 'scene'}.json`;
816
+ }
817
+ }
818
+ }
819
+ else {
820
+ const pcProject = projectConfig;
821
+ if (pcProject.scenes && pcProject.scenes.length > 0) {
822
+ const firstScene = pcProject.scenes[0];
823
+ scenePath = firstScene.url || `${firstScene.id || 'scene'}.json`;
824
+ }
825
+ }
826
+ // 构建 CONTEXT_OPTIONS
827
+ const contextOptions = {
828
+ antialias: settings.antiAlias ?? true,
829
+ alpha: false,
830
+ preserveDrawingBuffer: settings.preserveDrawingBuffer ?? false,
831
+ deviceTypes: ['webgl2', 'webgl1'],
832
+ powerPreference: settings.powerPreference || 'high-performance'
833
+ };
834
+ // 构建 INPUT_SETTINGS
835
+ const inputSettings = {
836
+ useKeyboard: settings.useKeyboard ?? true,
837
+ useMouse: settings.useMouse ?? true,
838
+ useGamepads: settings.useGamepads ?? false,
839
+ useTouch: settings.useTouch ?? true
840
+ };
841
+ // 提取预加载模块(WASM 等)
842
+ const preloadModules = [];
843
+ // TODO: 从 assets 中提取 WASM 模块
844
+ // 生成脚本导入路径数组(延迟加载)
845
+ console.log(`[SourceBuilder] 生成 ${scriptImportPaths.length} 个脚本导入路径`);
846
+ // 替换模板占位符
847
+ const entry = esmTemplate
848
+ .replace(/\{\{SCRIPT_IMPORT_PATHS\}\}/g, JSON.stringify(scriptImportPaths))
849
+ .replace(/\{\{GAME_RULE_PATH\}\}/g, gameRulePath)
850
+ .replace(/\{\{ASSET_PREFIX\}\}/g, '')
851
+ .replace(/\{\{SCRIPT_PREFIX\}\}/g, '')
852
+ .replace(/\{\{SCENE_PATH\}\}/g, scenePath)
853
+ .replace(/\{\{CONTEXT_OPTIONS\}\}/g, JSON.stringify(contextOptions))
854
+ .replace(/\{\{SCRIPTS\}\}/g, JSON.stringify(scripts))
855
+ .replace(/\{\{INPUT_SETTINGS\}\}/g, JSON.stringify(inputSettings))
856
+ .replace(/\{\{PRELOAD_MODULES\}\}/g, JSON.stringify(preloadModules))
857
+ .replace(/\{\{LEGACY_SCRIPTS\}\}/g, String(settings.useLegacyScripts ?? false));
858
+ // 写入 js/index.mjs
859
+ const jsDir = path.join(outputDir, 'js');
860
+ await fs.mkdir(jsDir, { recursive: true });
861
+ await fs.writeFile(path.join(jsDir, 'index.mjs'), entry, 'utf-8');
862
+ }
863
+ /**
864
+ * 在 ESM 模式下复制用户脚本为独立文件
865
+ * @returns 脚本ID到新URL的映射
866
+ */
867
+ async function copyUserScriptsForESM(projectConfig, projectDir, outputDir, importMap) {
868
+ const scriptUrlMap = new Map();
869
+ console.log(`[copyUserScriptsForESM] 项目格式: ${projectConfig.format}`);
870
+ if (projectConfig.format !== 'playcanvas') {
871
+ // PlayCraft 格式暂不支持
872
+ console.log('[copyUserScriptsForESM] 跳过:非 PlayCanvas 格式');
873
+ return scriptUrlMap;
874
+ }
875
+ const pcProject = projectConfig;
876
+ const scriptIds = pcProject.project.settings?.scripts || [];
877
+ const assets = pcProject.assets;
878
+ console.log(`[copyUserScriptsForESM] 脚本数量: ${scriptIds.length}`);
879
+ // 构建文件夹 ID 到名称的映射
880
+ const folderMap = new Map();
881
+ for (const [assetId, asset] of Object.entries(assets)) {
882
+ const assetData = asset;
883
+ if (assetData.type === 'folder') {
884
+ folderMap.set(assetId, assetData.name);
885
+ }
886
+ }
887
+ // 遍历每个脚本
888
+ for (const scriptId of scriptIds) {
889
+ const asset = assets[String(scriptId)];
890
+ if (!asset || asset.type !== 'script')
891
+ continue;
892
+ const assetData = asset;
893
+ // 检查 preload 属性
894
+ const preload = assetData.preload !== false;
895
+ if (!preload) {
896
+ continue; // 跳过 non-preload 脚本
897
+ }
898
+ // 获取脚本源文件路径
899
+ let sourcePath = null;
900
+ const scriptIdStr = String(scriptId);
901
+ if (assetData.file?.url) {
902
+ sourcePath = path.join(projectDir, assetData.file.url);
903
+ }
904
+ else if (assetData.file?.filename) {
905
+ // 如果没有 file.url,根据 filename 和 revision 构建路径
906
+ const filename = assetData.file.filename;
907
+ const revision = assetData.revision ?? 1;
908
+ const candidates = [
909
+ path.join(projectDir, 'files', 'assets', scriptIdStr, String(revision), filename),
910
+ path.join(projectDir, 'files', 'assets', scriptIdStr, '1', filename),
911
+ path.join(projectDir, 'files', 'assets', scriptIdStr, filename),
912
+ ];
913
+ for (const candidate of candidates) {
914
+ try {
915
+ await fs.access(candidate);
916
+ sourcePath = candidate;
917
+ break;
918
+ }
919
+ catch {
920
+ // 继续尝试下一个
921
+ }
922
+ }
923
+ }
924
+ if (!sourcePath) {
925
+ console.warn(`[ESM] 无法找到脚本 ${assetData.name} 的源文件,跳过`);
926
+ continue;
927
+ }
928
+ // 获取脚本的文件夹路径
929
+ const pathIds = assetData.path || [];
930
+ const folderPath = buildScriptFolderPath(pathIds, folderMap);
931
+ // 获取正确的文件名(带扩展名)
932
+ const scriptFilename = assetData.file?.filename || assetData.name + '.mjs';
933
+ // 根据 import map 确定目标路径
934
+ const targetPath = resolveTargetPathFromImportMap(folderPath, scriptFilename, importMap);
935
+ if (!targetPath) {
936
+ console.warn(`[ESM] 无法为脚本 ${assetData.name} 确定目标路径,跳过`);
937
+ continue;
938
+ }
939
+ console.log(`[ESM] 脚本 ${assetData.name}: folderPath=${folderPath}, targetPath=${targetPath}`);
940
+ // 复制文件(并重写 import 语句中的 Import Map 别名)
941
+ const outputPath = path.join(outputDir, targetPath);
942
+ try {
943
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
944
+ // 读取源文件
945
+ let content = await fs.readFile(sourcePath, 'utf-8');
946
+ // 修复已知的代码问题(如未定义变量的错误日志)
947
+ console.log(`[copyUserScriptsForESM] 处理脚本: ${assetData.name}`);
948
+ content = fixKnownCodeIssues(content, assetData.name);
949
+ // 重写 import 语句,将 Import Map 别名转换为相对路径
950
+ content = rewriteImportsWithImportMap(content, targetPath, importMap);
951
+ // 重写 pc.app 访问,确保在 ESM 模式下能正确获取 app 实例
952
+ content = rewritePcAppAccess(content);
953
+ // 写入目标文件
954
+ await fs.writeFile(outputPath, content, 'utf-8');
955
+ // 记录脚本ID到新URL的映射
956
+ scriptUrlMap.set(String(scriptId), targetPath);
957
+ }
958
+ catch (error) {
959
+ console.warn(`[ESM] 无法复制脚本 ${assetData.name}:`, error);
960
+ }
961
+ }
962
+ return scriptUrlMap;
963
+ }
964
+ /**
965
+ * 重写脚本内容中的 import 语句
966
+ * 将 Import Map 别名转换为相对于脚本位置的相对路径
967
+ */
968
+ function rewriteImportsWithImportMap(content, scriptTargetPath, importMap) {
969
+ const imports = importMap.content.imports || {};
970
+ // 获取脚本所在目录(相对于输出根目录)
971
+ const scriptDir = path.dirname(scriptTargetPath);
972
+ // 正则匹配 import 语句中的模块说明符
973
+ // 匹配: import ... from 'xxx' 或 import ... from "xxx" 或 import('xxx') 等
974
+ const importRegex = /(from\s+['"])([^'"]+)(['"])|(\bimport\s*\(\s*['"])([^'"]+)(['"]\s*\))/g;
975
+ return content.replace(importRegex, (match, p1, p2, p3, p4, p5, p6) => {
976
+ const prefix = p1 || p4;
977
+ const modulePath = p2 || p5;
978
+ const suffix = p3 || p6;
979
+ // 如果已经是相对路径(./ 或 ../)或绝对路径(/ 或 http),不处理
980
+ if (modulePath.startsWith('./') || modulePath.startsWith('../') ||
981
+ modulePath.startsWith('/') || modulePath.startsWith('http://') ||
982
+ modulePath.startsWith('https://')) {
983
+ return match;
984
+ }
985
+ // 尝试通过 Import Map 解析模块路径
986
+ const resolvedPath = resolveModulePathViaImportMap(modulePath, imports);
987
+ if (resolvedPath) {
988
+ // 计算从脚本位置到解析后路径的相对路径
989
+ const relativePath = calculateRelativePath(scriptDir, resolvedPath);
990
+ return prefix + relativePath + suffix;
991
+ }
992
+ // 如果无法解析,保持原样
993
+ return match;
994
+ });
995
+ }
996
+ /**
997
+ * 通过 Import Map 解析模块路径
998
+ * 注意:需要应用与 fixImportMapPaths 相同的路径修复逻辑
999
+ */
1000
+ function resolveModulePathViaImportMap(modulePath, imports) {
1001
+ // 1. 精确匹配
1002
+ if (imports[modulePath]) {
1003
+ let resolved = imports[modulePath];
1004
+ // 去掉开头的 ./
1005
+ if (resolved.startsWith('./')) {
1006
+ resolved = resolved.slice(2);
1007
+ }
1008
+ // 修复 Lib/ 路径(与 fixImportMapPaths 保持一致)
1009
+ // 将 Lib/xxx.mjs 改为 xxx.mjs(因为这些文件会被复制到根目录)
1010
+ if (resolved.startsWith('Lib/')) {
1011
+ resolved = resolved.slice(4);
1012
+ }
1013
+ return resolved;
1014
+ }
1015
+ // 2. 前缀匹配(目录别名)
1016
+ for (const [prefix, target] of Object.entries(imports)) {
1017
+ if (prefix.endsWith('/') && modulePath.startsWith(prefix)) {
1018
+ const remainder = modulePath.slice(prefix.length);
1019
+ let targetBase = target;
1020
+ // 去掉开头的 ./
1021
+ if (targetBase.startsWith('./')) {
1022
+ targetBase = targetBase.slice(2);
1023
+ }
1024
+ // 修复 Lib/ 路径
1025
+ if (targetBase.startsWith('Lib/')) {
1026
+ targetBase = targetBase.slice(4);
1027
+ }
1028
+ // 去掉结尾的 /
1029
+ if (targetBase.endsWith('/')) {
1030
+ targetBase = targetBase.slice(0, -1);
1031
+ }
1032
+ return targetBase + '/' + remainder;
1033
+ }
1034
+ }
1035
+ return null;
1036
+ }
1037
+ /**
1038
+ * 计算从源目录到目标路径的相对路径
1039
+ */
1040
+ function calculateRelativePath(fromDir, toPath) {
1041
+ // 将路径统一使用正斜杠
1042
+ fromDir = fromDir.replace(/\\/g, '/');
1043
+ toPath = toPath.replace(/\\/g, '/');
1044
+ // 分割路径
1045
+ const fromParts = fromDir ? fromDir.split('/').filter(p => p) : [];
1046
+ const toParts = toPath.split('/').filter(p => p);
1047
+ // 找到共同前缀
1048
+ let commonLength = 0;
1049
+ for (let i = 0; i < Math.min(fromParts.length, toParts.length); i++) {
1050
+ if (fromParts[i] === toParts[i]) {
1051
+ commonLength++;
1052
+ }
1053
+ else {
1054
+ break;
1055
+ }
1056
+ }
1057
+ // 计算需要向上走多少级
1058
+ const upCount = fromParts.length - commonLength;
1059
+ // 构建相对路径
1060
+ const upPath = '../'.repeat(upCount);
1061
+ const downPath = toParts.slice(commonLength).join('/');
1062
+ const result = upPath + downPath;
1063
+ // 如果结果为空或不以 ./ 或 ../ 开头,添加 ./
1064
+ if (!result || (!result.startsWith('./') && !result.startsWith('../'))) {
1065
+ return './' + result;
1066
+ }
1067
+ return result;
1068
+ }
1069
+ /**
1070
+ * 修复已知的代码问题
1071
+ * 这些是项目源代码中的 bug,需要在打包时自动修复
1072
+ */
1073
+ function fixKnownCodeIssues(content, scriptName) {
1074
+ let modified = content;
1075
+ let fixCount = 0;
1076
+ // 修复 1: GameplaySystem.mjs 中 ruleName 未定义的问题
1077
+ // 原代码: console.error(`[LC] Load game rule '${ruleName}' failed`);
1078
+ // 问题: ruleName 变量在上面被注释掉了,但错误日志仍然引用它
1079
+ if (content.includes("console.error(`[LC] Load game rule '${ruleName}' failed`)")) {
1080
+ modified = modified.replace("console.error(`[LC] Load game rule '${ruleName}' failed`)", "console.error(`[LC] Load game rule failed - GameplaySystem.gameRule is null`)");
1081
+ fixCount++;
1082
+ console.log(`[SourceBuilder] 修复 ${scriptName}: ruleName 未定义问题`);
1083
+ }
1084
+ // 修复 2: AimAdjustmentBar.mjs 中 sprite 可能为 null 的问题
1085
+ // 原代码: const frameKey = this.wheel.element.sprite.frameKeys[frameNum];
1086
+ // 问题: sprite 资源可能还没加载完成或引用丢失
1087
+ if (content.includes('this.wheel.element.sprite.frameKeys[frameNum]')) {
1088
+ // 直接替换整行
1089
+ const oldLine = 'const frameKey = this.wheel.element.sprite.frameKeys[frameNum];';
1090
+ const newLines = `const sprite = this.wheel.element.sprite;
1091
+ if (!sprite || !sprite.frameKeys || !sprite.atlas) {
1092
+ console.warn('[AimAdjustmentBar] Sprite not ready, skipping wheel initialization');
1093
+ return;
1094
+ }
1095
+ const frameKey = sprite.frameKeys[frameNum];`;
1096
+ if (content.includes(oldLine)) {
1097
+ modified = modified.replace(oldLine, newLines);
1098
+ // 同时替换 atlas 的访问
1099
+ const oldAtlas = 'const atlas = this.wheel.element.sprite.atlas;';
1100
+ const newAtlas = 'const atlas = sprite.atlas;';
1101
+ modified = modified.replace(oldAtlas, newAtlas);
1102
+ // 添加对 atlas.frames 的空值检查
1103
+ const oldRect = 'const rect = atlas.frames[frameKey].rect;';
1104
+ const newRect = `if (!atlas.frames || !atlas.frames[frameKey]) {
1105
+ console.warn('[AimAdjustmentBar] Atlas frame not found:', frameKey);
1106
+ return;
1107
+ }
1108
+ const rect = atlas.frames[frameKey].rect;`;
1109
+ modified = modified.replace(oldRect, newRect);
1110
+ fixCount++;
1111
+ console.log(`[SourceBuilder] 修复 ${scriptName}: sprite 空值检查`);
1112
+ }
1113
+ }
1114
+ // 可以在这里添加更多已知问题的修复...
1115
+ if (fixCount > 0) {
1116
+ console.log(`[SourceBuilder] 共修复 ${scriptName} 中的 ${fixCount} 个已知问题`);
1117
+ }
1118
+ return modified;
1119
+ }
1120
+ /**
1121
+ * 重写 pc.app 访问为 window.pc.app
1122
+ *
1123
+ * 问题背景:
1124
+ * 在 ESM 模式下,用户脚本通过 `import * as pc from 'playcanvas'` 导入。
1125
+ * ESM 命名空间对象是静态的、冻结的,无法在运行时添加 `app` 属性。
1126
+ * 但 PlayCanvas 脚本习惯使用 `pc.app.root`、`pc.app.assets` 等方式访问应用实例。
1127
+ *
1128
+ * 解决方案:
1129
+ * 将代码中的 `pc.app` 重写为 `window.pc.app`,因为 `window.pc` 是可变的,
1130
+ * 在 index.esm.mjs 中会设置 `window.pc.app = app`。
1131
+ */
1132
+ function rewritePcAppAccess(content) {
1133
+ // 匹配 pc.app 的访问模式
1134
+ // 不匹配已经是 window.pc.app 的情况
1135
+ // 使用负向后瞻确保前面不是 window. 或其他字母
1136
+ // 使用两个步骤:
1137
+ // 1. 先统计需要替换的数量
1138
+ // 2. 再执行替换
1139
+ const pcAppRegex = /(?<!window\.)(?<!\w)pc\.app\b/g;
1140
+ // 统计匹配数量(在替换前)
1141
+ const matches = content.match(pcAppRegex);
1142
+ const matchCount = matches ? matches.length : 0;
1143
+ // 如果没有需要替换的内容,直接返回
1144
+ if (matchCount === 0) {
1145
+ return content;
1146
+ }
1147
+ // 执行替换
1148
+ const modified = content.replace(pcAppRegex, 'window.pc.app');
1149
+ console.log(`[SourceBuilder] 重写了 ${matchCount} 处 pc.app -> window.pc.app`);
1150
+ return modified;
1151
+ }
1152
+ /**
1153
+ * 构建脚本的文件夹路径(从 asset.path 数组)
1154
+ */
1155
+ function buildScriptFolderPath(pathIds, folderMap) {
1156
+ const pathNames = [];
1157
+ // 跳过根文件夹(第一个元素),构建路径
1158
+ for (let i = 1; i < pathIds.length; i++) {
1159
+ const folderId = String(pathIds[i]);
1160
+ const folderName = folderMap.get(folderId);
1161
+ if (folderName) {
1162
+ pathNames.push(folderName);
1163
+ }
1164
+ }
1165
+ return pathNames.join('/');
1166
+ }
1167
+ /**
1168
+ * 根据 import map 确定目标路径
1169
+ */
1170
+ function resolveTargetPathFromImportMap(folderPath, filename, importMap) {
1171
+ const imports = importMap.content.imports || {};
1172
+ // 尝试匹配 import map 中的前缀
1173
+ for (const [prefix, target] of Object.entries(imports)) {
1174
+ // prefix 可能是 "Gameplay/" 这样的目录前缀
1175
+ if (prefix.endsWith('/')) {
1176
+ const prefixName = prefix.slice(0, -1); // 移除末尾的 '/'
1177
+ // 检查文件夹路径是否以该前缀开始
1178
+ if (folderPath === prefixName || folderPath.startsWith(prefixName + '/')) {
1179
+ // 计算相对于前缀的剩余路径
1180
+ const relativePath = folderPath === prefixName
1181
+ ? ''
1182
+ : folderPath.slice(prefixName.length + 1);
1183
+ // 构建目标路径(使用正斜杠,因为 ESM import 需要 URL 风格路径)
1184
+ const targetBase = target.startsWith('./') ? target.slice(2) : target;
1185
+ const resultPath = relativePath
1186
+ ? path.join(targetBase, relativePath, filename)
1187
+ : path.join(targetBase, filename);
1188
+ // 确保使用正斜杠(Windows 兼容)
1189
+ return resultPath.replace(/\\/g, '/');
1190
+ }
1191
+ }
1192
+ }
1193
+ // 如果没有匹配的前缀,使用原始路径
1194
+ const defaultPath = folderPath ? path.join(folderPath, filename) : filename;
1195
+ // 确保使用正斜杠(Windows 兼容)
1196
+ return defaultPath.replace(/\\/g, '/');
1197
+ }
1198
+ /**
1199
+ * 复制 Import Map 中引用的第三方库文件
1200
+ * 例如 planck.mjs 等
1201
+ */
1202
+ async function copyImportMapLibraries(projectConfig, projectDir, outputDir, importMap) {
1203
+ const imports = importMap.content.imports || {};
1204
+ const assets = projectConfig.format === 'playcanvas'
1205
+ ? projectConfig.assets
1206
+ : projectConfig.assets;
1207
+ for (const [key, value] of Object.entries(imports)) {
1208
+ // 跳过目录前缀(以 / 结尾的)
1209
+ if (key.endsWith('/'))
1210
+ continue;
1211
+ // 跳过 playcanvas 引擎(由模板处理)
1212
+ if (key === 'playcanvas')
1213
+ continue;
1214
+ // 处理单个文件的导入(如 "planck": "./Lib/planck.mjs")
1215
+ let targetPath = value;
1216
+ // 修复路径(./Lib/ -> ./)
1217
+ if (targetPath.startsWith('./Lib/')) {
1218
+ targetPath = './' + targetPath.slice(6);
1219
+ }
1220
+ // 移除 ./ 前缀
1221
+ const outputRelPath = targetPath.startsWith('./') ? targetPath.slice(2) : targetPath;
1222
+ const outputPath = path.join(outputDir, outputRelPath);
1223
+ // 检查目标文件是否已存在(可能已被 copyUserScriptsForESM 复制)
1224
+ try {
1225
+ await fs.access(outputPath);
1226
+ console.log(`[ESM] 第三方库已存在,跳过: ${key} -> ${outputRelPath}`);
1227
+ continue;
1228
+ }
1229
+ catch {
1230
+ // 文件不存在,需要复制
1231
+ }
1232
+ // 查找源文件
1233
+ // 1. 首先检查是否是 asset 引用(通过 name 查找)
1234
+ let sourceFound = false;
1235
+ const filename = path.basename(outputRelPath);
1236
+ for (const [assetId, asset] of Object.entries(assets)) {
1237
+ const assetData = asset;
1238
+ if (assetData.type !== 'script')
1239
+ continue;
1240
+ if (assetData.name !== key && assetData.file?.filename !== filename)
1241
+ continue;
1242
+ // 找到匹配的资源
1243
+ let sourcePath = null;
1244
+ if (assetData.file?.url) {
1245
+ sourcePath = path.join(projectDir, assetData.file.url);
1246
+ }
1247
+ else if (assetData.file?.filename) {
1248
+ const revision = assetData.revision ?? 1;
1249
+ const candidates = [
1250
+ path.join(projectDir, 'files', 'assets', assetId, String(revision), assetData.file.filename),
1251
+ path.join(projectDir, 'files', 'assets', assetId, '1', assetData.file.filename),
1252
+ path.join(projectDir, 'files', 'assets', assetId, assetData.file.filename),
1253
+ ];
1254
+ for (const candidate of candidates) {
1255
+ try {
1256
+ await fs.access(candidate);
1257
+ sourcePath = candidate;
1258
+ break;
1259
+ }
1260
+ catch {
1261
+ // 继续尝试
1262
+ }
1263
+ }
1264
+ }
1265
+ if (sourcePath) {
1266
+ try {
1267
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
1268
+ await fs.copyFile(sourcePath, outputPath);
1269
+ console.log(`[ESM] 复制第三方库: ${key} -> ${outputRelPath}`);
1270
+ sourceFound = true;
1271
+ }
1272
+ catch (error) {
1273
+ console.warn(`[ESM] 无法复制第三方库 ${key}:`, error);
1274
+ }
1275
+ break;
1276
+ }
1277
+ }
1278
+ // 2. 如果没有在 assets 中找到,尝试在项目目录中直接查找
1279
+ if (!sourceFound) {
1280
+ // 尝试原始路径(例如 ./Lib/planck.mjs)
1281
+ const originalPath = value.startsWith('./') ? value.slice(2) : value;
1282
+ const candidates = [
1283
+ path.join(projectDir, originalPath),
1284
+ path.join(projectDir, filename),
1285
+ path.join(projectDir, 'Lib', filename),
1286
+ ];
1287
+ for (const candidate of candidates) {
1288
+ try {
1289
+ await fs.access(candidate);
1290
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
1291
+ await fs.copyFile(candidate, outputPath);
1292
+ console.log(`[ESM] 复制第三方库(从目录): ${key} -> ${outputRelPath}`);
1293
+ sourceFound = true;
1294
+ break;
1295
+ }
1296
+ catch {
1297
+ // 继续尝试
1298
+ }
1299
+ }
1300
+ }
1301
+ if (!sourceFound) {
1302
+ console.warn(`[ESM] 未找到第三方库 ${key} 的源文件`);
1303
+ }
1304
+ }
1305
+ }