@playcraft/build 0.0.4 → 0.0.9

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
@@ -0,0 +1,1061 @@
1
+ import { build as viteBuild } from 'vite';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ /**
5
+ * 递归复制目录
6
+ */
7
+ async function copyDir(src, dest) {
8
+ await fs.mkdir(dest, { recursive: true });
9
+ const entries = await fs.readdir(src, { withFileTypes: true });
10
+ for (const entry of entries) {
11
+ const srcPath = path.join(src, entry.name);
12
+ const destPath = path.join(dest, entry.name);
13
+ if (entry.isDirectory()) {
14
+ await copyDir(srcPath, destPath);
15
+ }
16
+ else {
17
+ await fs.copyFile(srcPath, destPath);
18
+ }
19
+ }
20
+ }
21
+ /**
22
+ * 复制脚本文件到工作目录用于打包
23
+ * 只复制 js/ 目录、脚本目录(LiteCreator、Generated、Temp 等)和 planck.mjs 等脚本文件
24
+ * 不复制资源文件(files/、images/ 等)
25
+ */
26
+ async function copyScriptFilesForBundling(baseBuildDir, workDir) {
27
+ await fs.mkdir(workDir, { recursive: true });
28
+ // 读取入口文件获取脚本路径
29
+ const entryPath = path.join(baseBuildDir, 'js/index.mjs');
30
+ let scriptDirs = new Set();
31
+ try {
32
+ const entryContent = await fs.readFile(entryPath, 'utf-8');
33
+ const scriptPathsMatch = entryContent.match(/const SCRIPT_IMPORT_PATHS = (\[[\s\S]*?\]);/);
34
+ if (scriptPathsMatch) {
35
+ const scriptPaths = JSON.parse(scriptPathsMatch[1]);
36
+ // 提取脚本所在的顶级目录
37
+ for (const p of scriptPaths) {
38
+ // 例如 '../LiteCreator/Gameplay/...' -> 'LiteCreator'
39
+ // 注意:排除直接的文件(如 '../planck.mjs'),只提取目录
40
+ const match = p.match(/^\.\.\/([^\/]+)\//);
41
+ if (match) {
42
+ scriptDirs.add(match[1]);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ catch (e) {
48
+ console.log('[ESMBundle] 无法解析脚本路径,将复制所有 .mjs 和 .js 文件');
49
+ }
50
+ // 始终复制 js/ 目录
51
+ const jsSrcDir = path.join(baseBuildDir, 'js');
52
+ const jsDestDir = path.join(workDir, 'js');
53
+ try {
54
+ await copyDir(jsSrcDir, jsDestDir);
55
+ console.log('[ESMBundle] 已复制 js/ 目录到工作目录');
56
+ }
57
+ catch (e) {
58
+ console.error('[ESMBundle] 复制 js/ 目录失败:', e);
59
+ }
60
+ // 复制识别到的脚本目录
61
+ for (const dir of scriptDirs) {
62
+ const srcDir = path.join(baseBuildDir, dir);
63
+ const destDir = path.join(workDir, dir);
64
+ try {
65
+ await fs.access(srcDir);
66
+ await copyDir(srcDir, destDir);
67
+ console.log(`[ESMBundle] 已复制 ${dir}/ 目录到工作目录`);
68
+ }
69
+ catch (e) {
70
+ // 目录不存在,跳过
71
+ }
72
+ }
73
+ // 复制根目录下的 .mjs 文件(如 planck.mjs)
74
+ try {
75
+ const rootFiles = await fs.readdir(baseBuildDir, { withFileTypes: true });
76
+ for (const file of rootFiles) {
77
+ if (file.isFile() && (file.name.endsWith('.mjs') || file.name.endsWith('.js'))) {
78
+ const srcPath = path.join(baseBuildDir, file.name);
79
+ const destPath = path.join(workDir, file.name);
80
+ try {
81
+ // 检查目标路径是否存在
82
+ try {
83
+ const destStat = await fs.stat(destPath);
84
+ if (destStat.isDirectory()) {
85
+ // 如果目标是目录,递归删除它
86
+ await fs.rm(destPath, { recursive: true, force: true });
87
+ console.log(`[ESMBundle] 删除了同名目录: ${file.name}`);
88
+ }
89
+ else {
90
+ // 如果是文件,直接删除
91
+ await fs.rm(destPath, { force: true });
92
+ }
93
+ }
94
+ catch {
95
+ // 目标不存在,无需删除
96
+ }
97
+ await fs.copyFile(srcPath, destPath);
98
+ console.log(`[ESMBundle] 已复制 ${file.name} 到工作目录`);
99
+ }
100
+ catch (copyErr) {
101
+ // 如果 copyFile 失败,尝试读取内容并写入(绕过权限问题)
102
+ try {
103
+ const content = await fs.readFile(srcPath, 'utf-8');
104
+ await fs.writeFile(destPath, content, 'utf-8');
105
+ console.log(`[ESMBundle] 已写入 ${file.name} 到工作目录(使用备用方法)`);
106
+ }
107
+ catch (writeErr) {
108
+ console.warn(`[ESMBundle] 无法复制 ${file.name}:`, copyErr);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ catch (e) {
115
+ console.error('[ESMBundle] 复制根目录脚本文件失败:', e);
116
+ }
117
+ }
118
+ /**
119
+ * 从 index.html 中提取 Import Map
120
+ * 用于解析裸模块说明符(如 'gameRule')
121
+ */
122
+ async function extractImportMap(baseBuildDir) {
123
+ try {
124
+ const htmlPath = path.join(baseBuildDir, 'index.html');
125
+ const html = await fs.readFile(htmlPath, 'utf-8');
126
+ // 提取 import map 内容
127
+ const importMapMatch = html.match(/<script\s+type=["']importmap["'][^>]*>([\s\S]*?)<\/script>/i);
128
+ if (!importMapMatch) {
129
+ return {};
130
+ }
131
+ const importMapJson = JSON.parse(importMapMatch[1]);
132
+ return importMapJson.imports || {};
133
+ }
134
+ catch (e) {
135
+ console.log('[ESMBundle] 未能提取 Import Map:', e);
136
+ return {};
137
+ }
138
+ }
139
+ /**
140
+ * 生成统一的 createScript 延迟执行包装代码
141
+ * 这段代码只需要在入口文件中添加一次,所有用户脚本都可以使用
142
+ */
143
+ function generateCreateScriptDeferWrapper() {
144
+ return `
145
+ // ========== createScript Deferred Execution Wrapper ==========
146
+ // This wrapper ensures createScript calls work even before app.scripts is initialized.
147
+ // It intercepts pc.createScript and queues calls if app.scripts is not ready yet.
148
+ (function() {
149
+ if (typeof pc === 'undefined') return;
150
+
151
+ const _originalCreateScript = pc.createScript;
152
+ if (!_originalCreateScript) return;
153
+
154
+ const _pendingCreates = [];
155
+ let _appReady = false;
156
+
157
+ // Check if app is ready
158
+ function checkAppReady() {
159
+ return typeof pc !== 'undefined' && pc.app && pc.app.scripts;
160
+ }
161
+
162
+ // Process pending createScript calls
163
+ function processPending() {
164
+ if (!checkAppReady()) return;
165
+ _appReady = true;
166
+ pc.createScript = _originalCreateScript;
167
+ _pendingCreates.forEach(function(args) {
168
+ try {
169
+ args.callback(_originalCreateScript.apply(pc, args.params));
170
+ } catch (e) {
171
+ console.error('[createScript] Failed to register script:', args.params[0], e);
172
+ }
173
+ });
174
+ _pendingCreates.length = 0;
175
+ }
176
+
177
+ // Override createScript to queue calls if app not ready
178
+ if (!checkAppReady()) {
179
+ pc.createScript = function(name, app) {
180
+ if (checkAppReady() || _appReady) {
181
+ return _originalCreateScript.call(pc, name, app);
182
+ }
183
+ // Return a placeholder that will be populated later
184
+ const placeholder = function() {};
185
+ placeholder.__deferred = true;
186
+ placeholder.attributes = { add: function() {} };
187
+ placeholder.prototype = {};
188
+ _pendingCreates.push({
189
+ params: [name, app],
190
+ callback: function(real) {
191
+ Object.assign(placeholder, real);
192
+ Object.setPrototypeOf(placeholder, Object.getPrototypeOf(real));
193
+ }
194
+ });
195
+
196
+ // Register handler to process when ready
197
+ if (_pendingCreates.length === 1) {
198
+ window.__pendingScriptRegistrations = window.__pendingScriptRegistrations || [];
199
+ window.__pendingScriptRegistrations.push(processPending);
200
+ }
201
+
202
+ return placeholder;
203
+ };
204
+ }
205
+ })();
206
+ // ========== End createScript Deferred Execution Wrapper ==========
207
+
208
+ `;
209
+ }
210
+ /**
211
+ * 扫描并修改 ESM 脚本文件:
212
+ * 1. 提取继承 Script 的类信息(包括间接继承)
213
+ * 2. 在每个类定义后添加 pc.registerScript() 调用
214
+ *
215
+ * PlayCanvas Engine 2.x ESM 脚本需要通过 pc.registerScript() 显式注册
216
+ *
217
+ * 检测方式:
218
+ * 1. 直接继承: export class XXX extends Script
219
+ * 2. 间接继承: export class XXX extends YYY,且 YYY 是已知的 Script 子类
220
+ *
221
+ * 重要:仅凭 static scriptName 不能判断是否是 PlayCanvas Script!
222
+ * 用户可能在非 Script 类中使用 scriptName 作为普通标识符。
223
+ * 必须通过继承链确认是否继承自 Script/pc.Script。
224
+ */
225
+ /**
226
+ * 处理 ESM Script 类:添加注册代码和 attributes 定义
227
+ *
228
+ * @param baseBuildDir - Base Build 产物目录(脚本文件所在目录)
229
+ * @param scriptPaths - 脚本路径列表(相对于 js/index.mjs)
230
+ * @param preparedScriptsDir - 可选:预处理脚本的输出目录。如果提供,修改后的脚本会写入此目录而不是覆盖原文件
231
+ * @param configDir - 可选:config.json 所在目录。如果不提供,默认使用 baseBuildDir
232
+ */
233
+ async function processESMScriptClasses(baseBuildDir, scriptPaths, preparedScriptsDir, configDir) {
234
+ const scriptClasses = [];
235
+ const jsDir = path.join(baseBuildDir, 'js');
236
+ // config.json 所在目录,默认为 baseBuildDir
237
+ const configBaseDir = configDir || baseBuildDir;
238
+ // 用于缓存从 config.json 读取的脚本 schema
239
+ let scriptSchemas = null;
240
+ // 第一遍:收集所有已知的 Script 基类
241
+ // 仅 PlayCanvas 内置的 Script 基类
242
+ const knownScriptBases = new Set([
243
+ 'Script', 'pc.Script',
244
+ // 从代码中发现的继承 Script 的自定义基类会被动态添加
245
+ ]);
246
+ // 存储每个文件的内容和检测结果,避免重复读取
247
+ const fileContents = new Map();
248
+ const allClassDefs = new Map();
249
+ // 第一遍扫描:收集所有类定义
250
+ for (const relativePath of scriptPaths) {
251
+ const scriptPath = path.resolve(jsDir, relativePath);
252
+ try {
253
+ // 检查路径是否存在且是文件(不是目录)
254
+ const stat = await fs.stat(scriptPath);
255
+ if (!stat.isFile()) {
256
+ console.log(`[ESMBundle] 跳过非文件路径: ${relativePath}`);
257
+ continue;
258
+ }
259
+ }
260
+ catch {
261
+ continue;
262
+ }
263
+ const content = await fs.readFile(scriptPath, 'utf-8');
264
+ fileContents.set(relativePath, { content, modified: false });
265
+ // 匹配所有 export class XXX extends YYY 模式
266
+ const classPattern = /export\s+class\s+(\w+)\s+extends\s+([\w.]+)\s*\{/g;
267
+ let match;
268
+ while ((match = classPattern.exec(content)) !== null) {
269
+ const className = match[1];
270
+ const baseClass = match[2];
271
+ // 查找 static scriptName = 'xxx' 定义
272
+ const scriptNamePattern = new RegExp(`class\\s+${className}[\\s\\S]*?static\\s+scriptName\\s*=\\s*["']([\\w]+)["']`, 'm');
273
+ const scriptNameMatch = content.match(scriptNamePattern);
274
+ const scriptName = scriptNameMatch ? scriptNameMatch[1] : null;
275
+ allClassDefs.set(className, {
276
+ className,
277
+ baseClass,
278
+ scriptName,
279
+ filePath: relativePath,
280
+ });
281
+ // 如果直接继承 Script,添加到已知基类
282
+ if (baseClass === 'Script' || baseClass === 'pc.Script') {
283
+ knownScriptBases.add(className);
284
+ }
285
+ }
286
+ }
287
+ // 迭代传播:找出所有间接继承 Script 的类
288
+ // 只有继承链追溯到 Script/pc.Script 的类才会被标记
289
+ let changed = true;
290
+ while (changed) {
291
+ changed = false;
292
+ for (const [className, info] of allClassDefs) {
293
+ if (!knownScriptBases.has(className) && knownScriptBases.has(info.baseClass)) {
294
+ knownScriptBases.add(className);
295
+ changed = true;
296
+ }
297
+ }
298
+ }
299
+ // 输出调试信息:已识别的 Script 基类
300
+ console.log(`[ESMBundle] 已识别的 Script 基类: ${Array.from(knownScriptBases).filter(n => n !== 'Script' && n !== 'pc.Script').join(', ')}`);
301
+ // 第二遍:处理所有 Script 类
302
+ for (const relativePath of scriptPaths) {
303
+ const fileData = fileContents.get(relativePath);
304
+ if (!fileData)
305
+ continue;
306
+ let { content, modified } = fileData;
307
+ const classesInFile = [];
308
+ // 查找该文件中的所有 Script 类
309
+ for (const [className, info] of allClassDefs) {
310
+ if (info.filePath !== relativePath)
311
+ continue;
312
+ // 判断是否是 PlayCanvas Script 类
313
+ // 必须直接或间接继承 Script/pc.Script
314
+ // 注意:仅有 static scriptName 不足以判断,因为非 Script 类也可能使用这个属性
315
+ const isScript = knownScriptBases.has(className);
316
+ if (!isScript) {
317
+ // 如果有 scriptName 但不继承 Script,输出警告
318
+ if (info.scriptName) {
319
+ console.log(`[ESMBundle] 跳过 ${className}:有 scriptName='${info.scriptName}' 但不继承 Script (继承自 ${info.baseClass})`);
320
+ }
321
+ continue;
322
+ }
323
+ // 确定 scriptName
324
+ let scriptName;
325
+ if (info.scriptName) {
326
+ scriptName = info.scriptName;
327
+ }
328
+ else {
329
+ // 使用类名转换(首字母小写)
330
+ scriptName = className.charAt(0).toLowerCase() + className.slice(1);
331
+ console.log(`[ESMBundle] 警告: ${className} 没有定义 scriptName,使用默认值: ${scriptName}`);
332
+ }
333
+ // 检查是否有 static attributes 定义
334
+ const hasStaticAttributes = /static\s+attributes\s*=/.test(content.substring(content.indexOf(`class ${className}`), content.indexOf(`class ${className}`) + 3000));
335
+ const hasExternalAttributes = content.includes(`${className}.attributes`);
336
+ const hasAttributes = hasStaticAttributes || hasExternalAttributes;
337
+ scriptClasses.push({
338
+ className,
339
+ scriptName,
340
+ filePath: relativePath,
341
+ });
342
+ classesInFile.push({ className, scriptName, hasAttributes });
343
+ }
344
+ // 如果文件中有 Script 类,处理 attributes 和注册代码
345
+ if (classesInFile.length > 0) {
346
+ // 1. 从 config.json 读取脚本 schema(一次读取,缓存使用)
347
+ // config.json 结构:assets[assetId].data.scripts[scriptName].attributes
348
+ // 注意:使用 configBaseDir 而不是 baseBuildDir,因为 baseBuildDir 可能是工作目录(没有 config.json)
349
+ if (!scriptSchemas) {
350
+ try {
351
+ const configPath = path.join(configBaseDir, 'config.json');
352
+ console.log(`[ESMBundle] 读取 config.json: ${configPath}`);
353
+ const configContent = await fs.readFile(configPath, 'utf-8');
354
+ const configJson = JSON.parse(configContent);
355
+ scriptSchemas = {};
356
+ // 从 assets 中提取所有脚本的 schema
357
+ if (configJson.assets) {
358
+ for (const assetId of Object.keys(configJson.assets)) {
359
+ const asset = configJson.assets[assetId];
360
+ if (asset.type === 'script' && asset.data && asset.data.scripts) {
361
+ for (const [scriptName, scriptDef] of Object.entries(asset.data.scripts)) {
362
+ if (scriptDef && scriptDef.attributes) {
363
+ scriptSchemas[scriptName] = {
364
+ attributes: scriptDef.attributes,
365
+ attributesOrder: scriptDef.attributesOrder
366
+ };
367
+ }
368
+ }
369
+ }
370
+ }
371
+ }
372
+ console.log(`[ESMBundle] 从 config.json 提取了 ${Object.keys(scriptSchemas).length} 个脚本 schema`);
373
+ // 打印一些示例 schema 用于调试
374
+ const sampleSchemas = Object.entries(scriptSchemas).slice(0, 5);
375
+ for (const [name, schema] of sampleSchemas) {
376
+ const attrNames = Object.keys(schema.attributes || {});
377
+ console.log(` [ESMBundle] - ${name}: ${attrNames.length} attributes`);
378
+ }
379
+ }
380
+ catch (e) {
381
+ console.warn('[ESMBundle] 无法读取 config.json 中的脚本 schema:', e);
382
+ scriptSchemas = {};
383
+ }
384
+ }
385
+ // 2. 检查是否已经有注册代码
386
+ const hasRegistration = content.includes('pc.registerScript(') ||
387
+ content.includes('window.pc.registerScript(') ||
388
+ content.includes('// ESM Script Auto-Registration');
389
+ if (!hasRegistration) {
390
+ // 生成每个类的注册和 schema 添加调用
391
+ // 从 config.json 获取 schema,因为 ESM 类可能没有静态 attributes
392
+ // 关键:registerScript 不会自动创建 ScriptAttributes,需要手动创建
393
+ const schemas = scriptSchemas;
394
+ const registrationParts = classesInFile.map(({ className, scriptName }) => {
395
+ // 获取该脚本的 schema(如果存在于 config.json)
396
+ const schema = schemas[scriptName];
397
+ let attributesCode = '';
398
+ // 调试:打印 schema 查找结果
399
+ if (scriptName === 'stick3D' || scriptName === 'followCamera') {
400
+ console.log(`[ESMBundle] DEBUG: ${scriptName} schema lookup:`, schema ? `found ${Object.keys(schema.attributes || {}).length} attrs` : 'NOT FOUND');
401
+ }
402
+ if (schema && schema.attributes) {
403
+ // 为每个属性生成 add 调用
404
+ const attrEntries = Object.entries(schema.attributes);
405
+ console.log(`[ESMBundle] 为 ${scriptName} 添加 ${attrEntries.length} 个属性定义`);
406
+ // 生成 schema 对象(用于 addSchema)
407
+ const schemaJson = JSON.stringify({
408
+ attributes: schema.attributes,
409
+ attributesOrder: schema.attributesOrder || Object.keys(schema.attributes)
410
+ });
411
+ // 旧代码:给类添加 attributes 属性(不再需要,因为 Script 基类没有 getter)
412
+ // 新代码:只存储 schema 数据,在延迟执行时通过 addSchema 注册
413
+ attributesCode = `
414
+ // Store schema data for ${scriptName} (will be registered via addSchema later)
415
+ window.__esmScriptSchemas = window.__esmScriptSchemas || {};
416
+ window.__esmScriptSchemas['${scriptName}'] = ${schemaJson};`;
417
+ }
418
+ else {
419
+ // 没有 schema 时
420
+ console.log(`[ESMBundle] 警告: ${scriptName} 没有找到 schema`);
421
+ attributesCode = `
422
+ // No schema for ${scriptName}`;
423
+ }
424
+ // 返回两部分代码:
425
+ // 1. 立即执行:存储类引用和 schema 数据到全局对象(不需要 pc.app)
426
+ // 2. 延迟执行:注册脚本 + 添加 schema(需要 pc.app.scripts)
427
+ return {
428
+ scriptName,
429
+ className,
430
+ attributesCode,
431
+ // 立即执行的代码 - 存储类引用和 schema 数据
432
+ immediateCode: `(function() {
433
+ if (typeof ${className} !== 'undefined') {
434
+ window.__esmScriptClasses = window.__esmScriptClasses || {};
435
+ window.__esmScriptClasses['${scriptName}'] = ${className};${attributesCode}
436
+ }
437
+ })()`,
438
+ // 延迟执行的代码 - 注册脚本 + 添加 schema
439
+ deferredCode: `(function() {
440
+ var cls = window.__esmScriptClasses && window.__esmScriptClasses['${scriptName}'];
441
+ var schema = window.__esmScriptSchemas && window.__esmScriptSchemas['${scriptName}'];
442
+ if (cls) {
443
+ window.pc.registerScript(cls, '${scriptName}');
444
+ // Add schema to ScriptRegistry for initializeAttributes to work
445
+ if (schema && window.pc.app && window.pc.app.scripts && window.pc.app.scripts.addSchema) {
446
+ window.pc.app.scripts.addSchema('${scriptName}', schema);
447
+ }
448
+ }
449
+ })()`
450
+ };
451
+ });
452
+ // 分别生成立即执行和延迟执行的代码
453
+ const immediateCode = registrationParts.map(p => p.immediateCode).join(';\n');
454
+ const deferredCode = registrationParts.map(p => p.deferredCode).join(';\n');
455
+ const registrationCode = `
456
+ // ========== ESM Script Auto-Registration ==========
457
+ // Auto-generated: register ESM Script classes with PlayCanvas
458
+ // Strategy:
459
+ // 1. Immediately store class references and schema data to global objects (no pc.app needed)
460
+ // 2. Defer registerScript() + addSchema() calls until after app.init() when pc.app.scripts is available
461
+ //
462
+ // Key insight: ESM scripts extend pc.Script (gG), not pc.ScriptType (gX).
463
+ // For pc.Script subclasses, initializeAttributes uses getSchema() to get attribute definitions,
464
+ // so we must call addSchema() to register the schema with ScriptRegistry.
465
+
466
+ // Step 1: Store class references and schema data (immediate)
467
+ ${immediateCode};
468
+
469
+ // Step 2: Register scripts and schemas (may be deferred if pc.app.scripts not ready)
470
+ (function() {
471
+ var _pc = typeof window !== 'undefined' && window.pc;
472
+ var canRegister = _pc &&
473
+ typeof _pc.registerScript === 'function' &&
474
+ _pc.app && _pc.app.scripts;
475
+
476
+ if (!canRegister) {
477
+ // Defer registration until after app.init()
478
+ window.__deferredESMScripts = window.__deferredESMScripts || [];
479
+ window.__deferredESMScripts.push(function() {
480
+ ${deferredCode.split('\n').map(c => ' ' + c).join('\n')}
481
+ });
482
+ return;
483
+ }
484
+ // If can register immediately
485
+ ${deferredCode}
486
+ })();
487
+ // ========== End ESM Script Auto-Registration ==========
488
+ `;
489
+ content = content + registrationCode;
490
+ modified = true;
491
+ console.log(`[ESMBundle] 为 ${relativePath} 添加了 ${classesInFile.length} 个脚本的注册代码`);
492
+ }
493
+ }
494
+ // 写回文件
495
+ if (modified) {
496
+ // 如果提供了 preparedScriptsDir,写入临时目录而不是覆盖原文件
497
+ // 这样可以保持 baseBuildDir 中的原始脚本不变
498
+ const targetDir = preparedScriptsDir ? path.join(preparedScriptsDir, 'js') : jsDir;
499
+ const targetPath = path.resolve(targetDir, relativePath);
500
+ // 确保目标目录存在
501
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
502
+ await fs.writeFile(targetPath, content);
503
+ if (preparedScriptsDir) {
504
+ console.log(`[ESMBundle] 写入预处理脚本到临时目录: ${relativePath}`);
505
+ }
506
+ }
507
+ }
508
+ console.log(`[ESMBundle] 共处理 ${scriptClasses.length} 个 ESM Script 类`);
509
+ return scriptClasses;
510
+ }
511
+ /**
512
+ * 预处理用户脚本文件
513
+ * 对于没有导出的脚本,将代码包装为延迟执行
514
+ * 对于有导出的脚本,不再单独添加包装代码(包装代码统一在入口文件中)
515
+ */
516
+ async function preprocessUserScripts(baseBuildDir, scriptPaths, configDir) {
517
+ if (scriptPaths.length === 0) {
518
+ return [];
519
+ }
520
+ console.log(`[ESMBundle] 预处理 ${scriptPaths.length} 个用户脚本...`);
521
+ // 首先处理所有 ESM Script 类,添加注册代码
522
+ // 注意:configDir 用于读取 config.json(可能与 baseBuildDir 不同,例如工作目录场景)
523
+ const esmScriptClasses = await processESMScriptClasses(baseBuildDir, scriptPaths, undefined, configDir);
524
+ // js/index.mjs 的目录
525
+ const jsDir = path.join(baseBuildDir, 'js');
526
+ for (const relativePath of scriptPaths) {
527
+ // 脚本路径相对于 js/index.mjs
528
+ const scriptPath = path.resolve(jsDir, relativePath);
529
+ try {
530
+ // 检查路径是否存在且是文件(不是目录)
531
+ const stat = await fs.stat(scriptPath);
532
+ if (!stat.isFile()) {
533
+ console.log(`[ESMBundle] 跳过非文件路径: ${relativePath}`);
534
+ continue;
535
+ }
536
+ }
537
+ catch {
538
+ console.log(`[ESMBundle] 脚本不存在,跳过: ${relativePath}`);
539
+ continue;
540
+ }
541
+ let content = await fs.readFile(scriptPath, 'utf-8');
542
+ // 检查是否包含 createScript 调用(Legacy Script 语法)
543
+ if (!content.includes('createScript')) {
544
+ continue;
545
+ }
546
+ // 保留所有 import 语句,但把其他代码包装起来
547
+ const lines = content.split('\n');
548
+ const importLines = [];
549
+ const codeLines = [];
550
+ let inImportSection = true;
551
+ for (const line of lines) {
552
+ const trimmed = line.trim();
553
+ // 检查是否是 import 语句(支持多行 import)
554
+ if (inImportSection && (trimmed.startsWith('import ') || trimmed.startsWith('export ') && trimmed.includes(' from '))) {
555
+ importLines.push(line);
556
+ }
557
+ else if (inImportSection && trimmed === '') {
558
+ // 空行也保留在 import 区域
559
+ importLines.push(line);
560
+ }
561
+ else {
562
+ inImportSection = false;
563
+ codeLines.push(line);
564
+ }
565
+ }
566
+ // 只有实际有代码的脚本才需要包装
567
+ const codeContent = codeLines.join('\n').trim();
568
+ if (!codeContent) {
569
+ continue;
570
+ }
571
+ // 检查是否有导出语句(如 export class)
572
+ const hasExport = codeContent.includes('export ');
573
+ if (hasExport) {
574
+ // 对于有导出的脚本,不需要单独添加包装代码
575
+ // 因为统一的 createScript 包装代码已经在入口文件中添加
576
+ // 这些脚本可以直接使用,createScript 会被自动队列化
577
+ console.log(`[ESMBundle] 脚本有导出,依赖入口包装器: ${relativePath}`);
578
+ }
579
+ else {
580
+ // 没有导出的脚本,可以整体包装
581
+ const wrappedCode = `${importLines.join('\n')}
582
+
583
+ // Defer script registration until app.scripts is available
584
+ (function() {
585
+ const registerScript = function() {
586
+ ${codeLines.map(l => ' ' + l).join('\n')}
587
+ };
588
+
589
+ // Check if app.scripts is already available
590
+ if (typeof pc !== 'undefined' && pc.app && pc.app.scripts) {
591
+ registerScript();
592
+ } else {
593
+ // Queue for later execution
594
+ window.__pendingScriptRegistrations = window.__pendingScriptRegistrations || [];
595
+ window.__pendingScriptRegistrations.push(registerScript);
596
+ }
597
+ })();
598
+ `;
599
+ await fs.writeFile(scriptPath, wrappedCode);
600
+ console.log(`[ESMBundle] 已包装脚本: ${relativePath}`);
601
+ }
602
+ }
603
+ return esmScriptClasses;
604
+ }
605
+ /**
606
+ * 预先打包 ESM 脚本为 IIFE
607
+ * 这个函数在主 Vite 构建之前调用
608
+ *
609
+ * 重要:此函数会在临时目录中创建 baseBuildDir 的工作副本,
610
+ * 所有修改都在工作副本中进行,不会影响原始的 baseBuildDir。
611
+ */
612
+ export async function bundleESMToIIFE(options) {
613
+ console.log('[ESMBundle] 开始打包 ESM 脚本...');
614
+ const esmEntryPath = path.join(options.baseBuildDir, 'js/index.mjs');
615
+ try {
616
+ await fs.access(esmEntryPath);
617
+ }
618
+ catch {
619
+ console.log('[ESMBundle] 未找到 ESM 入口,跳过打包');
620
+ return '';
621
+ }
622
+ try {
623
+ // 读取并预处理入口文件
624
+ let entryContent = await fs.readFile(esmEntryPath, 'utf-8');
625
+ // 检测是否是预打包格式(由其他工具如 Rollup/Vite 打包的 ESM)
626
+ const isPreBundled = entryContent.includes('__esmScriptDynamicImportRuntime__') ||
627
+ entryContent.includes('function __esmScriptDynamicImportRuntime__');
628
+ if (isPreBundled) {
629
+ console.log('[ESMBundle] 检测到预打包的 ESM 格式,使用特殊处理流程...');
630
+ return await bundlePreBundledESM(options, entryContent);
631
+ }
632
+ // 创建工作副本目录(在输出目录下创建,避免修改原始 baseBuildDir)
633
+ const workDir = path.join(options.outputDir, '__esm-work__');
634
+ console.log('[ESMBundle] 创建工作副本目录:', workDir);
635
+ // 清理之前的工作目录(如果存在)
636
+ await fs.rm(workDir, { recursive: true, force: true });
637
+ // 复制必要的文件到工作目录(只复制脚本相关文件)
638
+ await copyScriptFilesForBundling(options.baseBuildDir, workDir);
639
+ // 在工作目录中进行所有修改
640
+ entryContent = entryContent.replace(/^\/\/ Import PlayCanvas Engine\s*\nimport \* as pc from ['"]\.\/playcanvas-engine\.mjs['"];\s*\n/m, '// PlayCanvas Engine is already loaded globally\nconst pc = window.pc;\n');
641
+ // 移除 window.pc = { ...pc } 这行,因为 pc 已经是 window.pc
642
+ entryContent = entryContent.replace(/^\/\/ Export for global access.*\nwindow\.pc = \{ \.\.\.pc \};\s*\n/m, '');
643
+ // 提取 SCRIPT_IMPORT_PATHS 数组
644
+ const scriptPathsMatch = entryContent.match(/const SCRIPT_IMPORT_PATHS = (\[[\s\S]*?\]);/);
645
+ let scriptPaths = [];
646
+ if (scriptPathsMatch) {
647
+ try {
648
+ scriptPaths = JSON.parse(scriptPathsMatch[1]);
649
+ console.log(`[ESMBundle] 找到 ${scriptPaths.length} 个脚本导入路径`);
650
+ }
651
+ catch (e) {
652
+ console.log('[ESMBundle] 解析脚本路径失败:', e);
653
+ }
654
+ }
655
+ // 不再直接添加静态导入语句到入口文件
656
+ // 因为这会导致脚本在 app.init() 之前执行,此时 pc.app.scripts 不存在
657
+ // 用户脚本将在后处理阶段被包装并延迟执行
658
+ // 预处理用户脚本文件,将 createScript 调用包装为延迟执行
659
+ // 同时扫描并返回 ESM Script 类信息,用于生成注册代码
660
+ // 注意:使用工作目录 workDir 进行脚本修改,但使用原始 baseBuildDir 读取 config.json
661
+ const esmScriptClasses = await preprocessUserScripts(workDir, scriptPaths, options.baseBuildDir);
662
+ // 生成统一的 createScript 包装代码(只添加一次)
663
+ const deferWrapper = generateCreateScriptDeferWrapper();
664
+ // 现在可以安全地添加静态导入,因为包装代码会拦截 createScript 调用
665
+ if (scriptPaths.length > 0) {
666
+ const staticImports = scriptPaths.map((p) => `import '${p}';`).join('\n');
667
+ // 在文件开头(引擎导入之后)插入包装代码和静态导入
668
+ entryContent = entryContent.replace('// PlayCanvas Engine is already loaded globally\nconst pc = window.pc;\n', `// PlayCanvas Engine is already loaded globally
669
+ const pc = window.pc;
670
+
671
+ ${deferWrapper}
672
+ // Static imports for bundling (scripts use the unified createScript wrapper above)
673
+ ${staticImports}
674
+ `);
675
+ console.log('[ESMBundle] 已添加统一的 createScript 包装器和静态导入语句');
676
+ }
677
+ else {
678
+ // 即使没有用户脚本,也添加包装代码以防万一
679
+ entryContent = entryContent.replace('// PlayCanvas Engine is already loaded globally\nconst pc = window.pc;\n', `// PlayCanvas Engine is already loaded globally
680
+ const pc = window.pc;
681
+
682
+ ${deferWrapper}`);
683
+ }
684
+ // 修改 loadUserScripts 函数,使其在 IIFE 模式下不尝试动态加载
685
+ // 因为脚本已经被静态打包了
686
+ entryContent = entryContent.replace(/async function loadUserScripts\(scriptPaths\) \{[\s\S]*?console\.log\('\[ESM\] Scripts loaded\. GameRule will be initialized after scene loads\.'\);\s*\}/, `async function loadUserScripts(scriptPaths) {
687
+ // In IIFE bundle mode, all scripts are already statically imported and bundled
688
+ // Execute deferred script registration now that app.scripts is available
689
+ if (window.__pendingScriptRegistrations) {
690
+ console.log('[ESM-IIFE] Executing ' + window.__pendingScriptRegistrations.length + ' deferred script registrations...');
691
+ window.__pendingScriptRegistrations.forEach(function(fn) {
692
+ try {
693
+ fn();
694
+ } catch (e) {
695
+ console.error('[ESM-IIFE] Script registration error:', e);
696
+ }
697
+ });
698
+ window.__pendingScriptRegistrations = null;
699
+ }
700
+ console.log('[ESM-IIFE] Scripts registration complete');
701
+ }`);
702
+ // 写入修改后的入口文件到工作目录(不是 baseBuildDir)
703
+ const modifiedEntryPath = path.join(workDir, 'js/index-bundled.mjs');
704
+ await fs.mkdir(path.dirname(modifiedEntryPath), { recursive: true });
705
+ await fs.writeFile(modifiedEntryPath, entryContent);
706
+ console.log('[ESMBundle] 已创建修改后的入口文件到工作目录');
707
+ // 创建临时输出目录
708
+ const tempOutputDir = path.join(options.outputDir, '__esm-temp__');
709
+ // 提取 Import Map 以解析裸模块说明符
710
+ const importMap = await extractImportMap(options.baseBuildDir);
711
+ console.log('[ESMBundle] 提取到 Import Map:', Object.keys(importMap));
712
+ // 使用 Vite 打包 ESM 为 IIFE
713
+ // 重要:使用工作目录 workDir 作为根目录,而不是 baseBuildDir
714
+ await viteBuild({
715
+ root: workDir,
716
+ logLevel: 'warn',
717
+ configFile: false, // 不使用配置文件,避免循环
718
+ build: {
719
+ outDir: tempOutputDir,
720
+ emptyOutDir: true,
721
+ lib: {
722
+ entry: modifiedEntryPath, // 使用修改后的入口文件
723
+ name: 'PlayCanvasESMBundle',
724
+ fileName: 'esm-bundle',
725
+ formats: ['iife'],
726
+ },
727
+ // 暂时禁用压缩来调试语法错误
728
+ minify: false,
729
+ rollupOptions: {
730
+ // 将 playcanvas 和引擎文件设为外部依赖
731
+ // 引擎已经在 HTML 中单独内联,不需要再次打包
732
+ external: (id) => {
733
+ // 裸模块 'playcanvas'
734
+ if (id === 'playcanvas')
735
+ return true;
736
+ // 相对路径的引擎文件
737
+ if (id.includes('playcanvas-engine'))
738
+ return true;
739
+ return false;
740
+ },
741
+ output: {
742
+ // 定义外部依赖的全局变量名
743
+ globals: {
744
+ 'playcanvas': 'pc',
745
+ },
746
+ },
747
+ plugins: [
748
+ // 自定义插件:处理模块解析
749
+ {
750
+ name: 'resolve-import-map',
751
+ resolveId(source) {
752
+ // playcanvas 及引擎文件使用全局 pc 对象
753
+ if (source === 'playcanvas' || source.includes('playcanvas-engine')) {
754
+ return { id: 'playcanvas', external: true };
755
+ }
756
+ // 如果是 Import Map 中定义的裸模块
757
+ if (importMap[source]) {
758
+ // 跳过 playcanvas 相关的映射
759
+ if (source === 'playcanvas' || importMap[source].includes('playcanvas')) {
760
+ return { id: 'playcanvas', external: true };
761
+ }
762
+ // 使用工作目录 workDir 解析路径
763
+ const resolved = path.resolve(workDir, importMap[source]);
764
+ console.log(`[ESMBundle] 解析 Import Map: ${source} -> ${resolved}`);
765
+ return resolved;
766
+ }
767
+ return null;
768
+ }
769
+ }
770
+ ],
771
+ onwarn: (warning, warn) => {
772
+ // 忽略某些警告
773
+ if (warning.code === 'THIS_IS_UNDEFINED')
774
+ return;
775
+ if (warning.code === 'CIRCULAR_DEPENDENCY')
776
+ return;
777
+ warn(warning);
778
+ },
779
+ },
780
+ },
781
+ });
782
+ // 读取打包后的文件
783
+ const bundledFilePath = path.join(tempOutputDir, 'esm-bundle.iife.js');
784
+ const bundledScriptContent = await fs.readFile(bundledFilePath, 'utf-8');
785
+ // 注册代码已经在 processESMScriptClasses 中添加到各个脚本文件
786
+ // 并随 IIFE 打包进来,不需要额外追加
787
+ if (esmScriptClasses.length > 0) {
788
+ console.log(`[ESMBundle] ${esmScriptClasses.length} 个 ESM Script 类的注册代码已包含在 IIFE 中`);
789
+ }
790
+ // DEBUG: 保留临时文件用于调试
791
+ // await fs.rm(tempOutputDir, { recursive: true, force: true });
792
+ // await fs.rm(workDir, { recursive: true, force: true });
793
+ console.log('[ESMBundle] DEBUG: 保留临时目录用于调试:', tempOutputDir, workDir);
794
+ console.log(`[ESMBundle] ESM 脚本打包完成 (${(bundledScriptContent.length / 1024).toFixed(2)} KB)`);
795
+ return bundledScriptContent;
796
+ }
797
+ catch (error) {
798
+ console.error('[ESMBundle] ESM 脚本打包失败:', error);
799
+ return '';
800
+ }
801
+ }
802
+ /**
803
+ * 处理预打包的 ESM 格式(由其他工具如 Rollup/Vite 打包)
804
+ * 这种格式的特点:
805
+ * 1. index.mjs 包含 __esmScriptDynamicImportRuntime__ 函数
806
+ * 2. 用户脚本已经被打包到 esm-*.js 文件中
807
+ * 3. 脚本类没有 registerScript 调用
808
+ */
809
+ async function bundlePreBundledESM(options, entryContent) {
810
+ const jsDir = path.join(options.baseBuildDir, 'js');
811
+ // 1. 查找 esm-*.js 文件(打包后的脚本文件)
812
+ const jsFiles = await fs.readdir(jsDir);
813
+ const esmBundleFiles = jsFiles.filter(f => f.startsWith('esm-') && f.endsWith('.js'));
814
+ if (esmBundleFiles.length === 0) {
815
+ console.log('[ESMBundle-PreBundled] 未找到 esm-*.js 文件');
816
+ return '';
817
+ }
818
+ console.log(`[ESMBundle-PreBundled] 找到 ${esmBundleFiles.length} 个打包文件: ${esmBundleFiles.join(', ')}`);
819
+ // 2. 扫描每个文件,提取继承 Script 的类
820
+ const scriptClasses = [];
821
+ // 已知的 Script 基类(包括引擎内置和用户自定义基类)
822
+ const knownScriptBases = new Set(['Script', 'pc.Script']);
823
+ const allClassDefs = new Map();
824
+ for (const file of esmBundleFiles) {
825
+ const filePath = path.join(jsDir, file);
826
+ const content = await fs.readFile(filePath, 'utf-8');
827
+ // 匹配所有 class XXX extends YYY 模式
828
+ const classPattern = /class\s+(\w+)\s+extends\s+([\w.]+)\s*\{/g;
829
+ let match;
830
+ while ((match = classPattern.exec(content)) !== null) {
831
+ const className = match[1];
832
+ const baseClass = match[2];
833
+ // 查找 static scriptName = 'xxx' 定义
834
+ // 注意:在打包后的代码中,scriptName 可能在类定义之后
835
+ const scriptNamePattern = new RegExp(`_define_property[^(]*\\(${className},"scriptName","([^"]+)"\\)|` +
836
+ `${className}\\.scriptName\\s*=\\s*["']([\\w]+)["']|` +
837
+ `static\\s+scriptName\\s*=\\s*["']([\\w]+)["']`, 'g');
838
+ // 在类定义后的一段范围内搜索 scriptName
839
+ const classDefEnd = match.index + match[0].length;
840
+ const searchRange = content.slice(classDefEnd, classDefEnd + 2000);
841
+ const scriptNameMatch = searchRange.match(scriptNamePattern) || content.match(scriptNamePattern);
842
+ let scriptName = null;
843
+ if (scriptNameMatch) {
844
+ // 提取 scriptName(可能在不同位置)
845
+ for (const m of scriptNameMatch) {
846
+ const extracted = m.match(/["'](\w+)["']/);
847
+ if (extracted) {
848
+ scriptName = extracted[1];
849
+ break;
850
+ }
851
+ }
852
+ }
853
+ allClassDefs.set(className, { className, baseClass, scriptName });
854
+ // 如果直接继承 Script,添加到已知基类
855
+ if (baseClass === 'Script' || baseClass === 'pc.Script') {
856
+ knownScriptBases.add(className);
857
+ }
858
+ }
859
+ }
860
+ // 迭代传播:找出所有间接继承 Script 的类
861
+ let changed = true;
862
+ while (changed) {
863
+ changed = false;
864
+ for (const [className, info] of allClassDefs) {
865
+ if (!knownScriptBases.has(className) && knownScriptBases.has(info.baseClass)) {
866
+ knownScriptBases.add(className);
867
+ changed = true;
868
+ }
869
+ }
870
+ }
871
+ // 收集所有 Script 类
872
+ for (const [className, info] of allClassDefs) {
873
+ if (knownScriptBases.has(className) && className !== 'Script' && className !== 'pc.Script') {
874
+ // 确定 scriptName
875
+ let scriptName = info.scriptName;
876
+ if (!scriptName) {
877
+ // 使用类名转换(首字母小写)
878
+ scriptName = className.charAt(0).toLowerCase() + className.slice(1);
879
+ }
880
+ scriptClasses.push({ className, scriptName });
881
+ }
882
+ }
883
+ console.log(`[ESMBundle-PreBundled] 识别出 ${scriptClasses.length} 个 Script 类`);
884
+ // 3. 从 config.json 读取脚本 schema
885
+ // config.json 结构:
886
+ // - application_properties.scripts: 脚本资产 ID 数组
887
+ // - assets[assetId].data.scripts[scriptName].attributes: 实际的属性定义
888
+ let scriptSchemas = {};
889
+ try {
890
+ const configPath = path.join(options.baseBuildDir, 'config.json');
891
+ const configContent = await fs.readFile(configPath, 'utf-8');
892
+ const configJson = JSON.parse(configContent);
893
+ // 从 assets 中提取所有脚本的 schema
894
+ if (configJson.assets) {
895
+ for (const assetId of Object.keys(configJson.assets)) {
896
+ const asset = configJson.assets[assetId];
897
+ // 检查是否是脚本资产
898
+ if (asset.type === 'script' && asset.data && asset.data.scripts) {
899
+ // 遍历该脚本资产中定义的所有脚本类型
900
+ for (const [scriptName, scriptDef] of Object.entries(asset.data.scripts)) {
901
+ if (scriptDef && scriptDef.attributes) {
902
+ scriptSchemas[scriptName] = {
903
+ attributes: scriptDef.attributes,
904
+ attributesOrder: scriptDef.attributesOrder
905
+ };
906
+ }
907
+ }
908
+ }
909
+ }
910
+ console.log(`[ESMBundle-PreBundled] 从 config.json 提取了 ${Object.keys(scriptSchemas).length} 个脚本 schema`);
911
+ // 打印一些示例 schema 用于调试
912
+ const sampleSchemas = Object.entries(scriptSchemas).slice(0, 3);
913
+ for (const [name, schema] of sampleSchemas) {
914
+ const attrNames = Object.keys(schema.attributes || {});
915
+ console.log(` - ${name}: ${attrNames.length} attributes [${attrNames.slice(0, 3).join(', ')}${attrNames.length > 3 ? '...' : ''}]`);
916
+ }
917
+ }
918
+ }
919
+ catch (e) {
920
+ console.warn('[ESMBundle-PreBundled] 无法读取 config.json 中的脚本 schema:', e);
921
+ }
922
+ // 4. 生成注册代码(包含从 config.json 获取的 schema)
923
+ // 关键:ESM scripts extend pc.Script (gG), not pc.ScriptType (gX)
924
+ // 对于 pc.Script 子类,initializeAttributes 使用 getSchema() 获取属性定义
925
+ // 所以必须调用 addSchema() 将 schema 注册到 ScriptRegistry
926
+ const registrationParts = scriptClasses.map(({ className, scriptName }) => {
927
+ // 获取该脚本的 schema(如果存在)
928
+ const schema = scriptSchemas[scriptName];
929
+ let attributesCode = '';
930
+ if (schema && schema.attributes) {
931
+ // 生成 schema 对象(用于 addSchema)
932
+ const schemaJson = JSON.stringify({
933
+ attributes: schema.attributes,
934
+ attributesOrder: schema.attributesOrder || Object.keys(schema.attributes)
935
+ });
936
+ attributesCode = `
937
+ // Store schema data for ${scriptName}
938
+ window.__esmScriptSchemas = window.__esmScriptSchemas || {};
939
+ window.__esmScriptSchemas['${scriptName}'] = ${schemaJson};`;
940
+ }
941
+ else {
942
+ // 没有 schema
943
+ attributesCode = `
944
+ // No schema for ${scriptName}`;
945
+ }
946
+ return {
947
+ // 立即执行的代码 - 存储类引用和 schema 数据
948
+ immediateCode: `(function() {
949
+ if (typeof ${className} !== 'undefined') {
950
+ window.__esmScriptClasses = window.__esmScriptClasses || {};
951
+ window.__esmScriptClasses['${scriptName}'] = ${className};${attributesCode}
952
+ }
953
+ })()`,
954
+ // 延迟执行的代码 - 注册脚本 + 添加 schema
955
+ deferredCode: `(function() {
956
+ var cls = window.__esmScriptClasses && window.__esmScriptClasses['${scriptName}'];
957
+ var schema = window.__esmScriptSchemas && window.__esmScriptSchemas['${scriptName}'];
958
+ if (cls) {
959
+ window.pc.registerScript(cls, '${scriptName}');
960
+ if (schema && window.pc.app && window.pc.app.scripts && window.pc.app.scripts.addSchema) {
961
+ window.pc.app.scripts.addSchema('${scriptName}', schema);
962
+ }
963
+ }
964
+ })()`
965
+ };
966
+ });
967
+ // 分别生成立即执行和延迟执行的代码
968
+ const immediateCode = registrationParts.map(p => p.immediateCode).join(';\n');
969
+ const deferredCode = registrationParts.map(p => p.deferredCode).join(';\n');
970
+ const registrationCode = `
971
+ // ========== ESM Script Auto-Registration (Pre-Bundled) ==========
972
+ // Key insight: ESM scripts extend pc.Script (gG), not pc.ScriptType (gX).
973
+ // For pc.Script subclasses, initializeAttributes uses getSchema() to get attribute definitions,
974
+ // so we must call addSchema() to register the schema with ScriptRegistry.
975
+
976
+ // Step 1: Store class references and schema data (immediate)
977
+ ${immediateCode};
978
+
979
+ // Step 2: Register scripts and schemas (deferred until pc.app.scripts is available)
980
+ (function() {
981
+ window.__deferredESMScripts = window.__deferredESMScripts || [];
982
+ window.__deferredESMScripts.push(function() {
983
+ ${deferredCode.split('\n').map(c => ' ' + c).join('\n')}
984
+ });
985
+ })();
986
+ // ========== End ESM Script Auto-Registration ==========
987
+ `;
988
+ // 4. 读取所有 ESM bundle 文件内容
989
+ let combinedScriptContent = '';
990
+ for (const file of esmBundleFiles) {
991
+ const filePath = path.join(jsDir, file);
992
+ const content = await fs.readFile(filePath, 'utf-8');
993
+ combinedScriptContent += content + '\n';
994
+ }
995
+ // 5. 处理入口文件
996
+ // 移除动态导入运行时相关代码
997
+ entryContent = entryContent.replace(/function\s+__esmScriptDynamicImportRuntime__[\s\S]*?switch\s*\(specifier\)\s*\{[\s\S]*?\}\s*\}/g, '// Dynamic import runtime removed for IIFE bundle');
998
+ // 移除对引擎的 ESM 导入
999
+ entryContent = entryContent.replace(/import\s+\*\s+as\s+(\w+)\s+from\s+['"][^'"]*playcanvas[^'"]*['"];?\s*/g, 'const $1 = window.pc;\n');
1000
+ // 移除其他 ESM 导入
1001
+ entryContent = entryContent.replace(/import\s*\{[^}]*\}\s*from\s*['"][^'"]+['"];?\s*/g, '');
1002
+ entryContent = entryContent.replace(/import\s+['"][^'"]+['"];?\s*/g, '');
1003
+ // 移除 export 语句
1004
+ entryContent = entryContent.replace(/export\s*\{[^}]*\};?\s*/g, '');
1005
+ // 6. 组合最终内容
1006
+ const finalContent = `
1007
+ (function(global) {
1008
+ // ========== Pre-Bundled ESM to IIFE Conversion ==========
1009
+ var pc = global.pc;
1010
+
1011
+ // Script Registration Code
1012
+ ${registrationCode}
1013
+
1014
+ // Combined Script Content
1015
+ ${combinedScriptContent}
1016
+
1017
+ // Entry Point Content
1018
+ ${entryContent}
1019
+
1020
+ // ========== End Pre-Bundled ESM to IIFE Conversion ==========
1021
+ })(typeof window !== 'undefined' ? window : this);
1022
+ `;
1023
+ console.log(`[ESMBundle-PreBundled] 打包完成 (${(finalContent.length / 1024).toFixed(2)} KB)`);
1024
+ return finalContent;
1025
+ }
1026
+ /**
1027
+ * ESM Bundle 插件
1028
+ *
1029
+ * 在 ESM 模式下,将 ESM 用户脚本打包为 IIFE 格式,以便在不支持 ESM 的广告平台上运行。
1030
+ *
1031
+ * 注意:ESM 打包必须在主构建之前完成,通过 bundleESMToIIFE() 函数预先调用
1032
+ */
1033
+ export function viteESMBundlePlugin(bundledScriptContent) {
1034
+ return {
1035
+ name: 'vite-plugin-esm-bundle',
1036
+ enforce: 'pre', // 在其他插件之前执行
1037
+ transformIndexHtml: {
1038
+ order: 'pre',
1039
+ handler(html) {
1040
+ if (!bundledScriptContent) {
1041
+ return html;
1042
+ }
1043
+ console.log('[ESMBundle] 注入打包后的 ESM 脚本到 HTML');
1044
+ // 1. 移除 Import Map
1045
+ html = html.replace(/<script\s+type=["']importmap["'][^>]*>[\s\S]*?<\/script>/gi, '');
1046
+ // 2. 移除 ESM 脚本引用,替换为打包后的 IIFE
1047
+ // 注意:在 String.replace() 中,替换字符串中的 $ 是特殊字符
1048
+ // $& 会插入匹配的子串,$' 会插入匹配子串右边的内容等
1049
+ // 因此需要将 bundledScriptContent 中的 $ 转义为 $$
1050
+ const escapedContent = bundledScriptContent.replace(/\$/g, '$$$$');
1051
+ html = html.replace(/<script\s+type=["']module["'][^>]*src=["'][^"']*index\.mjs["'][^>]*><\/script>/gi, `<script>
1052
+ /* Bundled ESM Scripts (IIFE) */
1053
+ ${escapedContent}
1054
+ </script>`);
1055
+ // 3. 移除其他 type="module" 脚本
1056
+ html = html.replace(/<script\s+type=["']module["'][^>]*>[\s\S]*?<\/script>/gi, '');
1057
+ return html;
1058
+ },
1059
+ },
1060
+ };
1061
+ }