@playcraft/build 0.0.3 → 0.0.4

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.
@@ -1,6 +1,8 @@
1
1
  export interface BaseBuildOptions {
2
2
  outputDir: string;
3
3
  selectedScenes?: string[];
4
+ analyze?: boolean;
5
+ analyzeReportPath?: string;
4
6
  }
5
7
  export interface BaseBuildOutput {
6
8
  outputDir: string;
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { build as viteBuild } from 'vite';
5
+ import { visualizer } from 'rollup-plugin-visualizer';
5
6
  import { viteSourceBuilderPlugin } from './vite/plugin-source-builder.js';
6
7
  const __filename = fileURLToPath(import.meta.url);
7
8
  const __dirname = path.dirname(__filename);
@@ -304,6 +305,11 @@ export class BaseBuilder {
304
305
  entryFileNames: '__[name].js',
305
306
  chunkFileNames: '__[name]-[hash].js',
306
307
  assetFileNames: 'files/assets/[name].[ext]',
308
+ format: 'iife', // 使用 IIFE 格式,避免 ES 模块的 import 语句
309
+ globals: {
310
+ 'playcanvas': 'pc',
311
+ 'pc': 'pc',
312
+ },
307
313
  },
308
314
  // 外部化 PlayCanvas Engine(不打包)
309
315
  external: ['pc', 'playcanvas'],
@@ -318,6 +324,19 @@ export class BaseBuilder {
318
324
  outputDir: this.options.outputDir,
319
325
  selectedScenes: this.options.selectedScenes,
320
326
  }),
327
+ // 打包分析报告(如果启用)
328
+ ...(this.options.analyze ? [
329
+ visualizer({
330
+ filename: this.options.analyzeReportPath
331
+ ? path.join(this.options.outputDir, this.options.analyzeReportPath)
332
+ : path.join(this.options.outputDir, 'base-bundle-report.html'),
333
+ template: 'treemap',
334
+ gzipSize: true,
335
+ brotliSize: true,
336
+ open: false,
337
+ sourcemap: false,
338
+ }),
339
+ ] : []),
321
340
  ],
322
341
  };
323
342
  // 2. 执行 Vite 构建
@@ -6,7 +6,6 @@ import viteImagemin from '@vheemstra/vite-plugin-imagemin';
6
6
  import imageminMozjpeg from 'imagemin-mozjpeg';
7
7
  import imageminPngquant from 'imagemin-pngquant';
8
8
  import imageminWebp from 'imagemin-webp';
9
- import { visualizer } from 'rollup-plugin-visualizer';
10
9
  import { PLATFORM_CONFIGS } from './platform-configs.js';
11
10
  import { vitePlayCanvasPlugin } from './plugin-playcanvas.js';
12
11
  import { vitePlatformPlugin } from './plugin-platform.js';
@@ -163,19 +162,6 @@ export class ViteConfigBuilder {
163
162
  removeViteModuleLoader: true,
164
163
  }));
165
164
  }
166
- // 6. 打包分析报告
167
- if (this.options.analyze) {
168
- const reportPath = this.options.analyzeReportPath
169
- ? this.options.analyzeReportPath
170
- : path.join(outputDir, 'bundle-report.html');
171
- plugins.push(visualizer({
172
- filename: reportPath,
173
- template: 'treemap',
174
- gzipSize: true,
175
- brotliSize: true,
176
- open: false,
177
- }));
178
- }
179
165
  return plugins.filter(Boolean);
180
166
  }
181
167
  getPlayableOptions(config) {
@@ -24,7 +24,7 @@ export const PLATFORM_CONFIGS = {
24
24
  playable: {
25
25
  patchXhrOut: true,
26
26
  inlineGameScripts: true,
27
- compressEngine: true,
27
+ compressEngine: false, // 关闭引擎压缩,便于调试
28
28
  configJsonInline: true,
29
29
  },
30
30
  },
@@ -23,17 +23,21 @@ export function vitePlayCanvasPlugin(options) {
23
23
  if (options.outputFormat === 'html') {
24
24
  // 1. 内联 PlayCanvas Engine + 引擎补丁
25
25
  html = await inlineEngineScript(html, options.baseBuildDir, options);
26
- // 2. 内联并转换 __settings__.js(传递插件上下文)
26
+ // 2. 内联 __game-scripts.js(用户脚本)
27
+ if (options.inlineGameScripts) {
28
+ html = await inlineGameScripts(html, options.baseBuildDir);
29
+ }
30
+ // 3. 内联并转换 __settings__.js(传递插件上下文)
27
31
  html = await inlineAndConvertSettings(html, options.baseBuildDir, options, pluginContext);
28
- // 3. 内联 __modules__.js
32
+ // 4. 内联 __modules__.js
29
33
  html = await inlineModulesScript(html, options.baseBuildDir);
30
- // 4. 内联 __start__.js
34
+ // 5. 内联 __start__.js
31
35
  html = await inlineStartScript(html, options.baseBuildDir, options);
32
- // 5. 内联 __loading__.js
36
+ // 6. 内联 __loading__.js
33
37
  html = await inlineLoadingScript(html, options.baseBuildDir);
34
- // 6. 内联 CSS
38
+ // 7. 内联 CSS
35
39
  html = await inlineCSS(html, options.baseBuildDir, options);
36
- // 7. 处理 manifest.json
40
+ // 8. 处理 manifest.json
37
41
  html = await inlineManifest(html, options.baseBuildDir, options);
38
42
  }
39
43
  return html;
@@ -375,6 +379,8 @@ async function inlineModulesScript(html, baseBuildDir) {
375
379
  }
376
380
  /**
377
381
  * 内联 __game-scripts.js
382
+ * 注意:脚本需要在 pc.AppBase 创建后执行,所以插入到 </body> 之前
383
+ * 同时包装成一个函数,在 DOMContentLoaded 后执行,确保 PlayCanvas 引擎已完全初始化
378
384
  */
379
385
  async function inlineGameScripts(html, baseBuildDir) {
380
386
  const gameScriptsPath = path.join(baseBuildDir, '__game-scripts.js');
@@ -386,13 +392,36 @@ async function inlineGameScripts(html, baseBuildDir) {
386
392
  return html;
387
393
  }
388
394
  const gameScriptsCode = await fs.readFile(gameScriptsPath, 'utf-8');
389
- // </head> 之前或第一个 <script> 标签之后插入游戏脚本
390
- if (html.includes('</head>')) {
391
- html = html.replace('</head>', `<script>${gameScriptsCode}</script>\n</head>`);
395
+ // 将脚本包装,确保在 pc.script 初始化后执行
396
+ // pc.createScript 需要 pc.script._scripts 已初始化
397
+ const wrappedCode = `
398
+ <script>
399
+ (function() {
400
+ // 等待 pc.script 准备好
401
+ function initGameScripts() {
402
+ if (typeof pc === 'undefined' || !pc.script) {
403
+ setTimeout(initGameScripts, 10);
404
+ return;
405
+ }
406
+ ${gameScriptsCode}
407
+ }
408
+ // 延迟执行,确保引擎初始化完成
409
+ if (document.readyState === 'loading') {
410
+ document.addEventListener('DOMContentLoaded', initGameScripts);
411
+ } else {
412
+ setTimeout(initGameScripts, 0);
413
+ }
414
+ })();
415
+ </script>`;
416
+ // 在 </body> 之前插入,确保在其他脚本之后执行
417
+ if (html.includes('</body>')) {
418
+ html = html.replace('</body>', `${wrappedCode}\n</body>`);
419
+ }
420
+ else if (html.includes('</html>')) {
421
+ html = html.replace('</html>', `${wrappedCode}\n</html>`);
392
422
  }
393
423
  else {
394
- // 如果没有 </head>,在第一个 <body> 标签之后插入
395
- html = html.replace('<body>', `<body>\n<script>${gameScriptsCode}</script>\n`);
424
+ html += wrappedCode;
396
425
  }
397
426
  return html;
398
427
  }
@@ -513,16 +542,44 @@ async function injectPhysicsLibrary(html, options) {
513
542
  if (!library) {
514
543
  return html;
515
544
  }
516
- const filename = library === 'p2' ? 'p2.min.js' : 'cannon.min.js';
517
- const code = await readPatchFile(filename);
518
- if (!code) {
545
+ let scriptsToInject = [];
546
+ if (library === 'p2') {
547
+ // Inject p2.js (2D physics)
548
+ const p2Code = await readPatchFile('p2.min.js');
549
+ if (p2Code) {
550
+ scriptsToInject.push(`<script>${p2Code}</script>`);
551
+ }
552
+ }
553
+ else if (library === 'cannon') {
554
+ // Inject Cannon.js (3D physics) + adapter
555
+ // Get __dirname equivalent in ES modules
556
+ const __filename = fileURLToPath(import.meta.url);
557
+ const __dirname = path.dirname(__filename);
558
+ const physicsDir = path.join(__dirname, '../../physics');
559
+ try {
560
+ // 1. Cannon.js engine
561
+ const cannonPath = path.join(physicsDir, 'cannon-es-bundle.js');
562
+ const cannonCode = await fs.readFile(cannonPath, 'utf-8');
563
+ scriptsToInject.push(`<script>${cannonCode}</script>`);
564
+ // 2. Rigidbody adapter (compatibility layer)
565
+ const adapterPath = path.join(physicsDir, 'cannon-rigidbody-adapter.js');
566
+ const adapterCode = await fs.readFile(adapterPath, 'utf-8');
567
+ scriptsToInject.push(`<script>${adapterCode}</script>`);
568
+ console.log('[PlayCanvas Plugin] Injected Cannon.js physics engine with rigidbody adapter');
569
+ }
570
+ catch (error) {
571
+ console.warn('[PlayCanvas Plugin] Failed to load Cannon.js files:', error);
572
+ return html;
573
+ }
574
+ }
575
+ if (scriptsToInject.length === 0) {
519
576
  return html;
520
577
  }
521
- const tag = `<script>${code}</script>`;
578
+ const injectedScripts = scriptsToInject.join('\n');
522
579
  if (html.includes('</head>')) {
523
- return html.replace('</head>', `${tag}\n</head>`);
580
+ return html.replace('</head>', `${injectedScripts}\n</head>`);
524
581
  }
525
- return `${tag}\n${html}`;
582
+ return `${injectedScripts}\n${html}`;
526
583
  }
527
584
  function isAmmoModule(module) {
528
585
  const moduleName = module.moduleName?.toLowerCase() ?? '';
@@ -541,6 +598,7 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
541
598
  const assets = configJson.assets;
542
599
  const skippedAssets = [];
543
600
  const optimizedAssets = [];
601
+ const skippedScripts = [];
544
602
  const SIZE_LIMIT = 1 * 1024 * 1024; // 1MB - 跳过超过这个大小的文件
545
603
  for (const [assetId, asset] of Object.entries(assets)) {
546
604
  const file = asset?.file;
@@ -553,6 +611,13 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
553
611
  }
554
612
  const cleanUrl = url.split('?')[0];
555
613
  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') {
618
+ skippedScripts.push(asset.name || assetId);
619
+ continue;
620
+ }
556
621
  // 跳过物理引擎缓存文件和大型文本文件
557
622
  if (fileName.endsWith('.pma.txt') ||
558
623
  fileName.includes('browsermetrics') ||
@@ -627,6 +692,16 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
627
692
  console.log(` ... 还有 ${skippedAssets.length - 10} 个文件`);
628
693
  }
629
694
  }
695
+ if (skippedScripts.length > 0) {
696
+ console.log(`\n📦 跳过了 ${skippedScripts.length} 个脚本资源(已打包到 __game-scripts.js)`);
697
+ if (skippedScripts.length <= 10) {
698
+ skippedScripts.forEach(name => console.log(` - ${name}`));
699
+ }
700
+ else {
701
+ skippedScripts.slice(0, 5).forEach(name => console.log(` - ${name}`));
702
+ console.log(` ... 还有 ${skippedScripts.length - 5} 个脚本`);
703
+ }
704
+ }
630
705
  return configJson;
631
706
  }
632
707
  async function inlineLogoInStartScript(startCode, baseBuildDir) {
@@ -830,11 +905,9 @@ async function buildCompressedEngineScript(engineCode) {
830
905
  const base64 = Buffer.from(compressed).toString('base64');
831
906
  // 遵循 PlayCanvas 官方 one-page.js 的实现方式
832
907
  // 参考: https://github.com/playcanvas/playcanvas-rest-api-tools/blob/main/one-page.js
833
- // 1. 使用 new Buffer(base64, "base64") 解码 base64
834
- // 2. 使用 Buffer.from(lz4.decompress(e)).toString() 解压并转字符串
835
- // 3. 使用 innerText 设置脚本内容
836
- // 4. 插入到当前脚本之前(保持执行顺序)
837
- const wrapper = `!function(){var e=new Buffer("${base64}","base64"),n=Buffer.from(lz4.decompress(e)).toString(),r=document.createElement("script");r.async=!1,r.innerText=n,document.currentScript.parentNode.insertBefore(r,document.currentScript)}();`;
908
+ // 官方使用 new Buffer() 因为 lz4.js 提供了 Buffer polyfill
909
+ // 使用 textContent 而不是 innerText 以避免浏览器对内容的处理
910
+ const wrapper = `!function(){var e=new Buffer("${base64}","base64"),n=Buffer.from(lz4.decompress(e)).toString(),r=document.createElement("script");r.async=!1,r.textContent=n,document.currentScript.parentNode.insertBefore(r,document.currentScript)}();`;
838
911
  return `<script>${wrapper}</script>`;
839
912
  }
840
913
  async function loadLz4Module() {
@@ -95,7 +95,11 @@ export function viteSourceBuilderPlugin(options) {
95
95
  await fs.writeFile(path.join(options.outputDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8');
96
96
  console.log('[SourceBuilder] 生成 config.json');
97
97
  // 生成 __settings__.js
98
- const settings = generateSettings(projectConfig);
98
+ // 如果 config.scenes 中有场景,使用第一个场景的 URL
99
+ const firstScenePath = config.scenes && config.scenes.length > 0
100
+ ? config.scenes[0].url
101
+ : undefined;
102
+ const settings = generateSettings(projectConfig, firstScenePath);
99
103
  await fs.writeFile(path.join(options.outputDir, '__settings__.js'), settings, 'utf-8');
100
104
  console.log('[SourceBuilder] 生成 __settings__.js');
101
105
  // 复制模板文件
@@ -104,8 +108,8 @@ export function viteSourceBuilderPlugin(options) {
104
108
  // 复制资源文件
105
109
  await copyAssets(projectConfig, options.projectDir, options.outputDir, config.assets);
106
110
  console.log('[SourceBuilder] 复制资源文件');
107
- // 生成场景文件
108
- await generateSceneFiles(projectConfig, options.projectDir, options.outputDir);
111
+ // 生成场景文件(只生成 config 中包含的场景)
112
+ await generateSceneFiles(projectConfig, options.projectDir, options.outputDir, config.scenes);
109
113
  console.log('[SourceBuilder] 生成场景文件');
110
114
  },
111
115
  };
@@ -275,12 +279,18 @@ async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride)
275
279
  const assets = assetsOverride || (projectConfig.format === 'playcanvas'
276
280
  ? projectConfig.assets
277
281
  : projectConfig.assets);
282
+ // 使用 Set 去重,避免重复复制同一个文件
283
+ const copiedUrls = new Set();
278
284
  // 遍历 assets
279
285
  for (const [assetId, asset] of Object.entries(assets)) {
280
286
  const assetData = asset;
281
287
  if (!assetData.file?.url || assetData.file.url.startsWith('data:')) {
282
288
  continue;
283
289
  }
290
+ // 跳过已经复制过的 URL
291
+ if (copiedUrls.has(assetData.file.url)) {
292
+ continue;
293
+ }
284
294
  const sourcePath = path.join(projectDir, assetData.file.url);
285
295
  const targetPath = path.join(outputDir, assetData.file.url);
286
296
  try {
@@ -291,6 +301,8 @@ async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride)
291
301
  await fs.mkdir(targetDir, { recursive: true });
292
302
  // 复制文件
293
303
  await fs.copyFile(sourcePath, targetPath);
304
+ // 标记为已复制
305
+ copiedUrls.add(assetData.file.url);
294
306
  }
295
307
  catch (error) {
296
308
  // 资源文件可能不存在(可能是内联的)
@@ -301,14 +313,23 @@ async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride)
301
313
  /**
302
314
  * 生成场景文件
303
315
  */
304
- async function generateSceneFiles(projectConfig, projectDir, outputDir) {
316
+ async function generateSceneFiles(projectConfig, projectDir, outputDir, selectedScenes) {
305
317
  if (projectConfig.format === 'playcanvas') {
306
318
  const pcProject = projectConfig;
307
319
  // PlayCanvas 格式:scenes.json 中已包含场景数据
308
320
  if (pcProject.scenes) {
309
- const scenes = Array.isArray(pcProject.scenes)
321
+ let scenes = Array.isArray(pcProject.scenes)
310
322
  ? pcProject.scenes
311
323
  : Object.entries(pcProject.scenes).map(([id, scene]) => ({ id, ...scene }));
324
+ // 如果提供了 selectedScenes,只生成这些场景
325
+ if (selectedScenes && selectedScenes.length > 0) {
326
+ const selectedUrls = new Set(selectedScenes.map(s => s.url));
327
+ scenes = scenes.filter((scene) => {
328
+ const sceneId = scene.id || scene.name || 'scene';
329
+ const sceneUrl = `${sceneId}.json`;
330
+ return selectedUrls.has(sceneUrl);
331
+ });
332
+ }
312
333
  for (const scene of scenes) {
313
334
  const sceneData = scene;
314
335
  const sceneId = sceneData.id || sceneData.name || 'scene';
@@ -321,7 +342,16 @@ async function generateSceneFiles(projectConfig, projectDir, outputDir) {
321
342
  else {
322
343
  // PlayCraft 格式:从 scenes/ 目录读取
323
344
  const pcProject = projectConfig;
324
- for (const scene of pcProject.scenes) {
345
+ let scenes = pcProject.scenes;
346
+ // 如果提供了 selectedScenes,只生成这些场景
347
+ if (selectedScenes && selectedScenes.length > 0) {
348
+ const selectedNames = new Set(selectedScenes.map(s => s.name));
349
+ scenes = scenes.filter((scene) => {
350
+ const sceneName = scene.name || scene.id || 'scene';
351
+ return selectedNames.has(sceneName);
352
+ });
353
+ }
354
+ for (const scene of scenes) {
325
355
  const sceneData = scene;
326
356
  const sceneId = sceneData.id || sceneData.name || 'scene';
327
357
  const scenePath = path.join(outputDir, `${sceneId}.json`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/build",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,5 +1,13 @@
1
1
  (function () {
2
2
  pc.ScriptHandler.prototype._loadScript = function (url, callback) {
3
+ // 如果 URL 不是 data URL(如 __game-scripts.js),则跳过加载
4
+ // 因为脚本已经在 HTML 中作为 <script> 标签执行过了
5
+ if (!url.startsWith('data:')) {
6
+ // 直接调用回调,不创建新的脚本元素
7
+ callback(null, url, null);
8
+ return;
9
+ }
10
+
3
11
  var head = document.head;
4
12
  var element = document.createElement('script');
5
13
  this._cache[url] = element;