@playcraft/build 0.0.2 → 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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Scene Asset Dependency Collector
3
+ *
4
+ * 分析 PlayCanvas 场景文件,收集所有依赖的资源 ID
5
+ */
6
+ export interface SceneDependencies {
7
+ sceneId: string;
8
+ sceneName: string;
9
+ directAssets: Set<string>;
10
+ indirectAssets: Set<string>;
11
+ scripts: Set<string>;
12
+ }
13
+ /**
14
+ * 分析场景的资源依赖
15
+ * @param sceneData 场景数据对象
16
+ * @param assets 所有资源的映射表
17
+ * @returns 场景的资源依赖信息
18
+ */
19
+ export declare function analyzeSceneDependencies(sceneData: any, assets: Record<string, any>): Promise<SceneDependencies>;
20
+ /**
21
+ * 分析多个场景的资源依赖并合并
22
+ * @param scenes 场景列表
23
+ * @param assets 所有资源的映射表
24
+ * @param globalScriptIds 全局脚本 ID 列表(来自 settings.scripts)
25
+ * @returns 合并后的资源 ID 集合
26
+ */
27
+ export declare function collectScenesAssets(scenes: any[], assets: Record<string, any>, globalScriptIds?: Set<string>): Promise<Set<string>>;
28
+ /**
29
+ * 打印场景依赖统计信息
30
+ */
31
+ export declare function printSceneDependencies(deps: SceneDependencies): void;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Scene Asset Dependency Collector
3
+ *
4
+ * 分析 PlayCanvas 场景文件,收集所有依赖的资源 ID
5
+ */
6
+ /**
7
+ * 分析场景的资源依赖
8
+ * @param sceneData 场景数据对象
9
+ * @param assets 所有资源的映射表
10
+ * @returns 场景的资源依赖信息
11
+ */
12
+ export async function analyzeSceneDependencies(sceneData, assets) {
13
+ const deps = {
14
+ sceneId: sceneData.id || sceneData.scene || 'unknown',
15
+ sceneName: sceneData.name || 'Unknown Scene',
16
+ directAssets: new Set(),
17
+ indirectAssets: new Set(),
18
+ scripts: new Set(),
19
+ };
20
+ // 1. 遍历所有实体
21
+ if (sceneData.entities) {
22
+ for (const [entityId, entity] of Object.entries(sceneData.entities)) {
23
+ collectEntityAssets(entity, deps, assets);
24
+ }
25
+ }
26
+ // 2. 递归收集间接依赖
27
+ collectIndirectDependencies(deps, assets);
28
+ return deps;
29
+ }
30
+ /**
31
+ * 收集实体的资源引用
32
+ */
33
+ function collectEntityAssets(entity, deps, assets) {
34
+ if (!entity || typeof entity !== 'object')
35
+ return;
36
+ // 扫描所有组件
37
+ if (entity.components) {
38
+ for (const [componentName, componentData] of Object.entries(entity.components)) {
39
+ if (componentData && typeof componentData === 'object') {
40
+ // 递归扫描组件数据,查找资源 ID
41
+ findAssetIds(componentData, deps, assets);
42
+ }
43
+ }
44
+ }
45
+ // 扫描其他可能包含资源引用的字段
46
+ const fieldsToScan = ['script', 'scripts', 'data', 'attributes'];
47
+ for (const field of fieldsToScan) {
48
+ if (entity[field]) {
49
+ findAssetIds(entity[field], deps, assets);
50
+ }
51
+ }
52
+ }
53
+ /**
54
+ * 递归查找 JSON 中的资源 ID
55
+ * PlayCanvas 中资源 ID 通常是数字或数字字符串
56
+ */
57
+ function findAssetIds(obj, deps, assets, depth = 0) {
58
+ // 防止无限递归
59
+ if (depth > 15)
60
+ return;
61
+ if (obj === null || obj === undefined)
62
+ return;
63
+ // 检查是否是资源 ID
64
+ if (typeof obj === 'number' || typeof obj === 'string') {
65
+ const assetId = String(obj);
66
+ // 验证是否是真实的资源 ID
67
+ if (assets[assetId]) {
68
+ const asset = assets[assetId];
69
+ deps.directAssets.add(assetId);
70
+ // 如果是脚本资源,单独记录
71
+ if (asset.type === 'script') {
72
+ deps.scripts.add(assetId);
73
+ }
74
+ }
75
+ return;
76
+ }
77
+ // 递归处理数组
78
+ if (Array.isArray(obj)) {
79
+ for (const item of obj) {
80
+ findAssetIds(item, deps, assets, depth + 1);
81
+ }
82
+ return;
83
+ }
84
+ // 递归处理对象
85
+ if (typeof obj === 'object') {
86
+ for (const [key, value] of Object.entries(obj)) {
87
+ // 跳过一些不包含资源引用的字段
88
+ if (key === 'position' || key === 'rotation' || key === 'scale' ||
89
+ key === 'enabled' || key === 'name' || key === 'parent' ||
90
+ key === 'children') {
91
+ continue;
92
+ }
93
+ findAssetIds(value, deps, assets, depth + 1);
94
+ }
95
+ }
96
+ }
97
+ /**
98
+ * 收集间接依赖(材质 → 纹理,模型 → 材质等)
99
+ */
100
+ function collectIndirectDependencies(deps, assets) {
101
+ const visited = new Set();
102
+ const queue = Array.from(deps.directAssets);
103
+ while (queue.length > 0) {
104
+ const assetId = queue.shift();
105
+ if (visited.has(assetId))
106
+ continue;
107
+ visited.add(assetId);
108
+ const asset = assets[assetId];
109
+ if (!asset)
110
+ continue;
111
+ // 检查不同类型资源的依赖
112
+ switch (asset.type) {
113
+ case 'material':
114
+ // 材质可能引用纹理
115
+ findAssetIdsInData(asset.data, deps, assets, queue, visited);
116
+ break;
117
+ case 'model':
118
+ // 模型可能引用材质
119
+ findAssetIdsInData(asset.data, deps, assets, queue, visited);
120
+ // 检查材质映射
121
+ if (asset.data?.mapping) {
122
+ for (const [meshName, materialId] of Object.entries(asset.data.mapping)) {
123
+ if (typeof materialId === 'number' || typeof materialId === 'string') {
124
+ const id = String(materialId);
125
+ if (assets[id] && !visited.has(id) && !deps.directAssets.has(id)) {
126
+ deps.indirectAssets.add(id);
127
+ queue.push(id);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ break;
133
+ case 'cubemap':
134
+ case 'texture':
135
+ // 纹理资源通常是叶子节点,但可能引用其他纹理(如合成纹理)
136
+ findAssetIdsInData(asset.data, deps, assets, queue, visited);
137
+ break;
138
+ case 'sprite':
139
+ // Sprite 可能引用纹理图集
140
+ if (asset.data?.textureAtlasAsset) {
141
+ const id = String(asset.data.textureAtlasAsset);
142
+ if (assets[id] && !visited.has(id) && !deps.directAssets.has(id)) {
143
+ deps.indirectAssets.add(id);
144
+ queue.push(id);
145
+ }
146
+ }
147
+ findAssetIdsInData(asset.data, deps, assets, queue, visited);
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * 在资源数据中查找资源 ID
154
+ */
155
+ function findAssetIdsInData(data, deps, assets, queue, visited) {
156
+ if (!data)
157
+ return;
158
+ // 递归扫描 data 对象
159
+ const scan = (obj, depth = 0) => {
160
+ if (depth > 10)
161
+ return;
162
+ if (obj === null || obj === undefined)
163
+ return;
164
+ if (typeof obj === 'number' || typeof obj === 'string') {
165
+ const id = String(obj);
166
+ if (assets[id] && !visited.has(id) && !deps.directAssets.has(id)) {
167
+ deps.indirectAssets.add(id);
168
+ queue.push(id);
169
+ }
170
+ }
171
+ else if (Array.isArray(obj)) {
172
+ for (const item of obj) {
173
+ scan(item, depth + 1);
174
+ }
175
+ }
176
+ else if (typeof obj === 'object') {
177
+ for (const [key, value] of Object.entries(obj)) {
178
+ // 跳过明显不是资源引用的字段
179
+ if (key === 'name' || key === 'enabled' || key === 'position' ||
180
+ key === 'rotation' || key === 'scale') {
181
+ continue;
182
+ }
183
+ scan(value, depth + 1);
184
+ }
185
+ }
186
+ };
187
+ scan(data);
188
+ }
189
+ /**
190
+ * 分析多个场景的资源依赖并合并
191
+ * @param scenes 场景列表
192
+ * @param assets 所有资源的映射表
193
+ * @param globalScriptIds 全局脚本 ID 列表(来自 settings.scripts)
194
+ * @returns 合并后的资源 ID 集合
195
+ */
196
+ export async function collectScenesAssets(scenes, assets, globalScriptIds) {
197
+ const allAssetIds = new Set();
198
+ for (const scene of scenes) {
199
+ const deps = await analyzeSceneDependencies(scene, assets);
200
+ // 合并直接依赖和间接依赖
201
+ for (const id of deps.directAssets)
202
+ allAssetIds.add(id);
203
+ for (const id of deps.indirectAssets)
204
+ allAssetIds.add(id);
205
+ for (const id of deps.scripts)
206
+ allAssetIds.add(id);
207
+ }
208
+ // 包含全局脚本(在 settings.scripts 中声明的)
209
+ if (globalScriptIds) {
210
+ for (const id of globalScriptIds) {
211
+ allAssetIds.add(id);
212
+ }
213
+ }
214
+ return allAssetIds;
215
+ }
216
+ /**
217
+ * 打印场景依赖统计信息
218
+ */
219
+ export function printSceneDependencies(deps) {
220
+ console.log(`\n📊 场景: ${deps.sceneName} (ID: ${deps.sceneId})`);
221
+ console.log(` - 直接引用资源: ${deps.directAssets.size} 个`);
222
+ console.log(` - 间接依赖资源: ${deps.indirectAssets.size} 个`);
223
+ console.log(` - 脚本资源: ${deps.scripts.size} 个`);
224
+ console.log(` - 总计: ${deps.directAssets.size + deps.indirectAssets.size} 个资源`);
225
+ }
@@ -1,5 +1,8 @@
1
1
  export interface BaseBuildOptions {
2
2
  outputDir: string;
3
+ selectedScenes?: string[];
4
+ analyze?: boolean;
5
+ analyzeReportPath?: string;
3
6
  }
4
7
  export interface BaseBuildOutput {
5
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'],
@@ -316,7 +322,21 @@ export class BaseBuilder {
316
322
  viteSourceBuilderPlugin({
317
323
  projectDir: this.projectDir,
318
324
  outputDir: this.options.outputDir,
325
+ selectedScenes: this.options.selectedScenes,
319
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
+ ] : []),
320
340
  ],
321
341
  };
322
342
  // 2. 执行 Vite 构建
@@ -1,7 +1,10 @@
1
1
  import { PlayCanvasProject } from '../loaders/playcanvas-loader.js';
2
2
  import { PlayCraftProject } from '../loaders/playcraft-loader.js';
3
3
  export type ProjectConfig = PlayCanvasProject | PlayCraftProject;
4
+ export interface GenerateConfigOptions {
5
+ selectedScenes?: string[];
6
+ }
4
7
  /**
5
8
  * 生成 PlayCanvas 构建产物格式的 config.json
6
9
  */
7
- export declare function generateConfig(projectConfig: ProjectConfig): any;
10
+ export declare function generateConfig(projectConfig: ProjectConfig, options?: GenerateConfigOptions): Promise<any>;
@@ -1,3 +1,4 @@
1
+ import { collectScenesAssets, printSceneDependencies, analyzeSceneDependencies } from '../analyzers/scene-asset-collector.js';
1
2
  function shouldIncludeAsset(asset, scriptIds, requiredScriptIds) {
2
3
  if (!asset) {
3
4
  return false;
@@ -41,7 +42,7 @@ function collectRequiredScriptIds(assets) {
41
42
  /**
42
43
  * 生成 PlayCanvas 构建产物格式的 config.json
43
44
  */
44
- export function generateConfig(projectConfig) {
45
+ export async function generateConfig(projectConfig, options) {
45
46
  if (projectConfig.format === 'playcanvas') {
46
47
  const pcProject = projectConfig;
47
48
  // 从 PlayCanvas 项目格式生成 config.json
@@ -56,30 +57,62 @@ export function generateConfig(projectConfig) {
56
57
  config.application_properties.batchGroups = [];
57
58
  }
58
59
  // 处理场景
60
+ let allScenes = [];
59
61
  if (pcProject.scenes) {
60
- if (Array.isArray(pcProject.scenes)) {
61
- config.scenes = pcProject.scenes.map((scene) => ({
62
- name: scene.name || scene.id,
63
- url: scene.url || scene.file || `${scene.id}.json`,
64
- }));
65
- }
66
- else {
67
- // scenes.json 是对象格式
68
- config.scenes = Object.entries(pcProject.scenes).map(([id, scene]) => ({
69
- name: scene.name || id,
70
- url: scene.url || scene.file || `${id}.json`,
71
- }));
72
- }
62
+ allScenes = Array.isArray(pcProject.scenes)
63
+ ? pcProject.scenes
64
+ : Object.entries(pcProject.scenes).map(([id, scene]) => ({ id, ...scene }));
65
+ }
66
+ // 场景过滤:如果指定了选中的场景,则只包含这些场景
67
+ let selectedScenes = allScenes;
68
+ if (options?.selectedScenes && options.selectedScenes.length > 0) {
69
+ selectedScenes = allScenes.filter(scene => {
70
+ const sceneId = String(scene.id || scene.scene);
71
+ const sceneName = scene.name || '';
72
+ return options.selectedScenes.some(selected => selected === sceneId || selected === sceneName);
73
+ });
74
+ console.log(`\n🎬 场景过滤:`);
75
+ console.log(` - 总场景数: ${allScenes.length}`);
76
+ console.log(` - 选中场景: ${selectedScenes.length}`);
77
+ selectedScenes.forEach(scene => {
78
+ console.log(` • ${scene.name || scene.id}`);
79
+ });
73
80
  }
74
- // 处理资产(过滤非运行时资产)
81
+ config.scenes = selectedScenes.map((scene) => ({
82
+ name: scene.name || scene.id,
83
+ url: scene.url || scene.file || `${scene.id}.json`,
84
+ }));
85
+ // 处理资产:基于场景过滤
75
86
  if (pcProject.assets) {
76
87
  const scriptIds = new Set((pcProject.project.settings?.scripts || []).map((id) => String(id)));
77
88
  const requiredScriptIds = collectRequiredScriptIds(pcProject.assets);
89
+ let allowedAssetIds = null;
90
+ // 如果启用了场景过滤,分析场景依赖
91
+ if (options?.selectedScenes && options.selectedScenes.length > 0 && selectedScenes.length > 0) {
92
+ console.log(`\n🔍 分析场景资源依赖...`);
93
+ allowedAssetIds = await collectScenesAssets(selectedScenes, pcProject.assets, scriptIds);
94
+ // 打印每个场景的依赖统计
95
+ for (const scene of selectedScenes) {
96
+ const deps = await analyzeSceneDependencies(scene, pcProject.assets);
97
+ printSceneDependencies(deps);
98
+ }
99
+ console.log(`\n📊 资源过滤统计:`);
100
+ console.log(` - 总资源数: ${Object.keys(pcProject.assets).length}`);
101
+ console.log(` - 全局脚本: ${scriptIds.size}`);
102
+ console.log(` - 场景依赖: ${allowedAssetIds.size}`);
103
+ console.log(` - 将节省: ${Object.keys(pcProject.assets).length - allowedAssetIds.size} 个资源 (${((1 - allowedAssetIds.size / Object.keys(pcProject.assets).length) * 100).toFixed(1)}%)`);
104
+ }
78
105
  const filtered = {};
79
106
  for (const [assetId, asset] of Object.entries(pcProject.assets)) {
80
- if (shouldIncludeAsset(asset, scriptIds, requiredScriptIds)) {
81
- filtered[assetId] = asset;
107
+ // 基本过滤(非运行时资产)
108
+ if (!shouldIncludeAsset(asset, scriptIds, requiredScriptIds)) {
109
+ continue;
82
110
  }
111
+ // 场景过滤:如果启用,只包含场景依赖的资源
112
+ if (allowedAssetIds && !allowedAssetIds.has(assetId)) {
113
+ continue;
114
+ }
115
+ filtered[assetId] = asset;
83
116
  }
84
117
  config.assets = filtered;
85
118
  }
@@ -99,21 +132,58 @@ export function generateConfig(projectConfig) {
99
132
  config.application_properties.batchGroups = [];
100
133
  }
101
134
  // 处理场景
102
- if (pcProject.scenes && pcProject.scenes.length > 0) {
103
- config.scenes = pcProject.scenes.map((scene) => ({
135
+ let allScenes = pcProject.scenes || [];
136
+ let selectedScenes = allScenes;
137
+ if (options?.selectedScenes && options.selectedScenes.length > 0 && allScenes.length > 0) {
138
+ selectedScenes = allScenes.filter(scene => {
139
+ const sceneId = String(scene.id || scene.name);
140
+ const sceneName = scene.name || '';
141
+ return options.selectedScenes.some(selected => selected === sceneId || selected === sceneName);
142
+ });
143
+ console.log(`\n🎬 场景过滤:`);
144
+ console.log(` - 总场景数: ${allScenes.length}`);
145
+ console.log(` - 选中场景: ${selectedScenes.length}`);
146
+ selectedScenes.forEach(scene => {
147
+ console.log(` • ${scene.name || scene.id}`);
148
+ });
149
+ }
150
+ if (selectedScenes.length > 0) {
151
+ config.scenes = selectedScenes.map((scene) => ({
104
152
  name: scene.name || scene.id,
105
153
  url: scene.url || `${scene.id || 'scene'}.json`,
106
154
  }));
107
155
  }
108
- // 处理资产(过滤非运行时资产)
156
+ // 处理资产:基于场景过滤
109
157
  if (pcProject.assets) {
110
158
  const scriptIds = new Set((pcProject.manifest.settings?.scripts || []).map((id) => String(id)));
111
159
  const requiredScriptIds = collectRequiredScriptIds(pcProject.assets);
160
+ let allowedAssetIds = null;
161
+ // 如果启用了场景过滤,分析场景依赖
162
+ if (options?.selectedScenes && options.selectedScenes.length > 0 && selectedScenes.length > 0) {
163
+ console.log(`\n🔍 分析场景资源依赖...`);
164
+ allowedAssetIds = await collectScenesAssets(selectedScenes, pcProject.assets, scriptIds);
165
+ // 打印每个场景的依赖统计
166
+ for (const scene of selectedScenes) {
167
+ const deps = await analyzeSceneDependencies(scene, pcProject.assets);
168
+ printSceneDependencies(deps);
169
+ }
170
+ console.log(`\n📊 资源过滤统计:`);
171
+ console.log(` - 总资源数: ${Object.keys(pcProject.assets).length}`);
172
+ console.log(` - 全局脚本: ${scriptIds.size}`);
173
+ console.log(` - 场景依赖: ${allowedAssetIds.size}`);
174
+ console.log(` - 将节省: ${Object.keys(pcProject.assets).length - allowedAssetIds.size} 个资源 (${((1 - allowedAssetIds.size / Object.keys(pcProject.assets).length) * 100).toFixed(1)}%)`);
175
+ }
112
176
  const filtered = {};
113
177
  for (const [assetId, asset] of Object.entries(pcProject.assets)) {
114
- if (shouldIncludeAsset(asset, scriptIds, requiredScriptIds)) {
115
- filtered[assetId] = asset;
178
+ // 基本过滤(非运行时资产)
179
+ if (!shouldIncludeAsset(asset, scriptIds, requiredScriptIds)) {
180
+ continue;
181
+ }
182
+ // 场景过滤:如果启用,只包含场景依赖的资源
183
+ if (allowedAssetIds && !allowedAssetIds.has(assetId)) {
184
+ continue;
116
185
  }
186
+ filtered[assetId] = asset;
117
187
  }
118
188
  config.assets = filtered;
119
189
  }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type Platform = 'facebook' | 'snapchat' | 'ironsource' | 'applovin' | 'google' | 'tiktok' | 'unity' | 'liftoff' | 'moloco' | 'bigo';
1
+ export type Platform = 'facebook' | 'snapchat' | 'ironsource' | 'applovin' | 'google' | 'tiktok' | 'unity' | 'liftoff' | 'moloco' | 'bigo' | 'inmobi' | 'adikteev' | 'remerge';
2
2
  export type OutputFormat = 'html' | 'zip';
3
3
  export interface BuildOptions {
4
4
  platform: Platform;
@@ -25,6 +25,7 @@ export interface BuildOptions {
25
25
  skipBuild?: boolean;
26
26
  buildOptions?: LocalBuildOptions;
27
27
  useVite?: boolean;
28
+ selectedScenes?: string[];
28
29
  cssMinify?: boolean;
29
30
  jsMinify?: boolean;
30
31
  compressImages?: boolean;
@@ -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
  },
@@ -254,4 +254,80 @@ export const PLATFORM_CONFIGS = {
254
254
  externFiles: true,
255
255
  },
256
256
  },
257
+ inmobi: {
258
+ sizeLimit: 5 * 1024 * 1024, // 5MB
259
+ outputFormat: 'zip',
260
+ minifyCSS: true,
261
+ minifyJS: true,
262
+ compressImages: true,
263
+ compressModels: true,
264
+ injectScripts: ['mraid'],
265
+ outputFileName: 'index.html',
266
+ includeSourcemap: false,
267
+ imageQuality: {
268
+ jpg: 75,
269
+ png: [0.7, 0.8],
270
+ webp: 75,
271
+ },
272
+ modelCompression: {
273
+ method: 'draco',
274
+ quality: 0.8,
275
+ },
276
+ playable: {
277
+ inlineGameScripts: true,
278
+ externFiles: true,
279
+ mraidSupport: true,
280
+ },
281
+ },
282
+ adikteev: {
283
+ sizeLimit: 5 * 1024 * 1024, // 5MB
284
+ outputFormat: 'html',
285
+ minifyCSS: true,
286
+ minifyJS: true,
287
+ compressImages: true,
288
+ compressModels: true,
289
+ injectScripts: ['mraid3'],
290
+ outputFileName: 'index.html',
291
+ includeSourcemap: false,
292
+ imageQuality: {
293
+ jpg: 75,
294
+ png: [0.7, 0.8],
295
+ webp: 75,
296
+ },
297
+ modelCompression: {
298
+ method: 'draco',
299
+ quality: 0.8,
300
+ },
301
+ playable: {
302
+ patchXhrOut: true,
303
+ inlineGameScripts: true,
304
+ configJsonInline: true,
305
+ mraidSupport: true,
306
+ },
307
+ },
308
+ remerge: {
309
+ sizeLimit: 5 * 1024 * 1024, // 5MB
310
+ outputFormat: 'html',
311
+ minifyCSS: true,
312
+ minifyJS: true,
313
+ compressImages: true,
314
+ compressModels: true,
315
+ injectScripts: ['fbPlayableAd'],
316
+ outputFileName: 'index.html',
317
+ includeSourcemap: false,
318
+ imageQuality: {
319
+ jpg: 75,
320
+ png: [0.7, 0.8],
321
+ webp: 75,
322
+ },
323
+ modelCompression: {
324
+ method: 'draco',
325
+ quality: 0.8,
326
+ },
327
+ playable: {
328
+ patchXhrOut: true,
329
+ inlineGameScripts: true,
330
+ configJsonInline: true,
331
+ },
332
+ },
257
333
  };
@@ -7,9 +7,15 @@ import { createRequire } from 'module';
7
7
  * 处理 PlayCanvas 特定的资源转换和内联
8
8
  */
9
9
  export function vitePlayCanvasPlugin(options) {
10
+ // 保存插件上下文,用于调用 Vite 的 load 方法
11
+ let pluginContext = null;
10
12
  return {
11
13
  name: 'vite-plugin-playcanvas',
12
14
  enforce: 'pre',
15
+ buildStart() {
16
+ // 保存插件的 this 上下文
17
+ pluginContext = this;
18
+ },
13
19
  async transformIndexHtml(html) {
14
20
  if (options.ammoReplacement) {
15
21
  html = await injectPhysicsLibrary(html, options);
@@ -17,25 +23,29 @@ export function vitePlayCanvasPlugin(options) {
17
23
  if (options.outputFormat === 'html') {
18
24
  // 1. 内联 PlayCanvas Engine + 引擎补丁
19
25
  html = await inlineEngineScript(html, options.baseBuildDir, options);
20
- // 2. 内联并转换 __settings__.js
21
- html = await inlineAndConvertSettings(html, options.baseBuildDir, options);
22
- // 3. 内联 __modules__.js
26
+ // 2. 内联 __game-scripts.js(用户脚本)
27
+ if (options.inlineGameScripts) {
28
+ html = await inlineGameScripts(html, options.baseBuildDir);
29
+ }
30
+ // 3. 内联并转换 __settings__.js(传递插件上下文)
31
+ html = await inlineAndConvertSettings(html, options.baseBuildDir, options, pluginContext);
32
+ // 4. 内联 __modules__.js
23
33
  html = await inlineModulesScript(html, options.baseBuildDir);
24
- // 4. 内联 __start__.js
34
+ // 5. 内联 __start__.js
25
35
  html = await inlineStartScript(html, options.baseBuildDir, options);
26
- // 5. 内联 __loading__.js
36
+ // 6. 内联 __loading__.js
27
37
  html = await inlineLoadingScript(html, options.baseBuildDir);
28
- // 6. 内联 CSS
38
+ // 7. 内联 CSS
29
39
  html = await inlineCSS(html, options.baseBuildDir, options);
30
- // 7. 处理 manifest.json
40
+ // 8. 处理 manifest.json
31
41
  html = await inlineManifest(html, options.baseBuildDir, options);
32
42
  }
33
43
  return html;
34
44
  },
35
45
  async transform(code, id) {
36
- // 转换 __settings__.js 中的资源路径为 data URLs
46
+ // 转换 __settings__.js 中的资源路径为 data URLs(传递插件上下文)
37
47
  if (id.endsWith('__settings__.js')) {
38
- return await convertSettingsToDataUrls(code, options.baseBuildDir, options);
48
+ return await convertSettingsToDataUrls(code, options.baseBuildDir, options, pluginContext);
39
49
  }
40
50
  return code;
41
51
  },
@@ -73,18 +83,18 @@ async function inlineEngineScript(html, baseBuildDir, options) {
73
83
  /**
74
84
  * 内联并转换 __settings__.js
75
85
  */
76
- async function inlineAndConvertSettings(html, baseBuildDir, options) {
86
+ async function inlineAndConvertSettings(html, baseBuildDir, options, pluginContext) {
77
87
  const settingsPath = path.join(baseBuildDir, '__settings__.js');
78
88
  try {
79
89
  await fs.access(settingsPath);
80
90
  }
81
91
  catch (error) {
82
92
  // __settings__.js 不存在,尝试从 config.json 生成
83
- return await generateAndInlineSettings(html, baseBuildDir, options);
93
+ return await generateAndInlineSettings(html, baseBuildDir, options, pluginContext);
84
94
  }
85
95
  let settingsCode = await fs.readFile(settingsPath, 'utf-8');
86
- // 转换资源URL为data URLs
87
- settingsCode = await convertSettingsToDataUrls(settingsCode, baseBuildDir, options);
96
+ // 转换资源URL为data URLs(传递插件上下文)
97
+ settingsCode = await convertSettingsToDataUrls(settingsCode, baseBuildDir, options, pluginContext);
88
98
  // 替换 script 标签
89
99
  const scriptPattern = /<script[^>]*src=["']__settings__\.js["'][^>]*><\/script>/i;
90
100
  html = html.replace(scriptPattern, `<script>${settingsCode}</script>`);
@@ -93,12 +103,12 @@ async function inlineAndConvertSettings(html, baseBuildDir, options) {
93
103
  /**
94
104
  * 生成并内联 settings(如果 __settings__.js 不存在)
95
105
  */
96
- async function generateAndInlineSettings(html, baseBuildDir, options) {
106
+ async function generateAndInlineSettings(html, baseBuildDir, options, pluginContext) {
97
107
  const configPath = path.join(baseBuildDir, 'config.json');
98
108
  const configContent = await fs.readFile(configPath, 'utf-8');
99
109
  let configJson = JSON.parse(configContent);
100
110
  if (options.convertDataUrls) {
101
- configJson = await inlineConfigAssetUrls(configJson, baseBuildDir);
111
+ configJson = await inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext);
102
112
  }
103
113
  if (options.ammoReplacement) {
104
114
  configJson = stripAmmoAssets(configJson);
@@ -158,9 +168,9 @@ window.PRELOAD_MODULES = ${JSON.stringify(preloadModules)};
158
168
  /**
159
169
  * 转换 settings 代码中的资源URL为 data URLs
160
170
  */
161
- async function convertSettingsToDataUrls(settingsCode, baseBuildDir, options) {
162
- // 1. 转换 config.json
163
- settingsCode = await convertConfigUrl(settingsCode, baseBuildDir, options);
171
+ async function convertSettingsToDataUrls(settingsCode, baseBuildDir, options, pluginContext) {
172
+ // 1. 转换 config.json(传递插件上下文)
173
+ settingsCode = await convertConfigUrl(settingsCode, baseBuildDir, options, pluginContext);
164
174
  // 2. 转换场景文件
165
175
  settingsCode = await convertSceneUrl(settingsCode, baseBuildDir);
166
176
  // 3. 转换 PRELOAD_MODULES(优先使用 JS fallback)
@@ -170,7 +180,7 @@ async function convertSettingsToDataUrls(settingsCode, baseBuildDir, options) {
170
180
  /**
171
181
  * 转换 CONFIG_FILENAME 为 data URL
172
182
  */
173
- async function convertConfigUrl(settingsCode, baseBuildDir, options) {
183
+ async function convertConfigUrl(settingsCode, baseBuildDir, options, pluginContext) {
174
184
  const configMatch = settingsCode.match(/window\.CONFIG_FILENAME\s*=\s*"([^"]+)"/);
175
185
  if (!configMatch) {
176
186
  return settingsCode;
@@ -184,7 +194,7 @@ async function convertConfigUrl(settingsCode, baseBuildDir, options) {
184
194
  const configContent = await fs.readFile(fullConfigPath, 'utf-8');
185
195
  let configJson = JSON.parse(configContent);
186
196
  if (options.convertDataUrls) {
187
- configJson = await inlineConfigAssetUrls(configJson, baseBuildDir);
197
+ configJson = await inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext);
188
198
  }
189
199
  if (options.ammoReplacement) {
190
200
  configJson = stripAmmoAssets(configJson);
@@ -369,6 +379,8 @@ async function inlineModulesScript(html, baseBuildDir) {
369
379
  }
370
380
  /**
371
381
  * 内联 __game-scripts.js
382
+ * 注意:脚本需要在 pc.AppBase 创建后执行,所以插入到 </body> 之前
383
+ * 同时包装成一个函数,在 DOMContentLoaded 后执行,确保 PlayCanvas 引擎已完全初始化
372
384
  */
373
385
  async function inlineGameScripts(html, baseBuildDir) {
374
386
  const gameScriptsPath = path.join(baseBuildDir, '__game-scripts.js');
@@ -380,13 +392,36 @@ async function inlineGameScripts(html, baseBuildDir) {
380
392
  return html;
381
393
  }
382
394
  const gameScriptsCode = await fs.readFile(gameScriptsPath, 'utf-8');
383
- // </head> 之前或第一个 <script> 标签之后插入游戏脚本
384
- if (html.includes('</head>')) {
385
- 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>`);
386
422
  }
387
423
  else {
388
- // 如果没有 </head>,在第一个 <body> 标签之后插入
389
- html = html.replace('<body>', `<body>\n<script>${gameScriptsCode}</script>\n`);
424
+ html += wrappedCode;
390
425
  }
391
426
  return html;
392
427
  }
@@ -507,16 +542,44 @@ async function injectPhysicsLibrary(html, options) {
507
542
  if (!library) {
508
543
  return html;
509
544
  }
510
- const filename = library === 'p2' ? 'p2.min.js' : 'cannon.min.js';
511
- const code = await readPatchFile(filename);
512
- 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) {
513
576
  return html;
514
577
  }
515
- const tag = `<script>${code}</script>`;
578
+ const injectedScripts = scriptsToInject.join('\n');
516
579
  if (html.includes('</head>')) {
517
- return html.replace('</head>', `${tag}\n</head>`);
580
+ return html.replace('</head>', `${injectedScripts}\n</head>`);
518
581
  }
519
- return `${tag}\n${html}`;
582
+ return `${injectedScripts}\n${html}`;
520
583
  }
521
584
  function isAmmoModule(module) {
522
585
  const moduleName = module.moduleName?.toLowerCase() ?? '';
@@ -528,12 +591,16 @@ function isAmmoModule(module) {
528
591
  wasmUrl.includes('ammo') ||
529
592
  fallbackUrl.includes('ammo'));
530
593
  }
531
- async function inlineConfigAssetUrls(configJson, baseBuildDir) {
594
+ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
532
595
  if (!configJson?.assets) {
533
596
  return configJson;
534
597
  }
535
598
  const assets = configJson.assets;
536
- for (const asset of Object.values(assets)) {
599
+ const skippedAssets = [];
600
+ const optimizedAssets = [];
601
+ const skippedScripts = [];
602
+ const SIZE_LIMIT = 1 * 1024 * 1024; // 1MB - 跳过超过这个大小的文件
603
+ for (const [assetId, asset] of Object.entries(assets)) {
537
604
  const file = asset?.file;
538
605
  if (!file?.url || typeof file.url !== 'string') {
539
606
  continue;
@@ -543,12 +610,61 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir) {
543
610
  continue;
544
611
  }
545
612
  const cleanUrl = url.split('?')[0];
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
+ }
621
+ // 跳过物理引擎缓存文件和大型文本文件
622
+ if (fileName.endsWith('.pma.txt') ||
623
+ fileName.includes('browsermetrics') ||
624
+ fileName.includes('deferredbrowsermetrics') ||
625
+ (fileName.endsWith('.txt') && file.size && file.size > SIZE_LIMIT)) {
626
+ skippedAssets.push(`${asset.name || assetId} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
627
+ // 标记为不预加载,避免引擎尝试加载
628
+ asset.preload = false;
629
+ // 清空 URL,避免加载失败
630
+ file.url = 'data:text/plain;base64,';
631
+ continue;
632
+ }
546
633
  const fullPath = path.join(baseBuildDir, cleanUrl);
547
634
  try {
635
+ // ✅ 简化逻辑:直接读取文件并判断是否需要压缩
548
636
  const buffer = await fs.readFile(fullPath);
549
- const mime = guessMimeType(cleanUrl);
550
- file.url = `data:${mime};base64,${buffer.toString('base64')}`;
551
- file.size = buffer.length;
637
+ const originalSize = buffer.length;
638
+ // 检查实际文件大小,跳过超大文件
639
+ if (originalSize > SIZE_LIMIT) {
640
+ skippedAssets.push(`${asset.name || assetId} (${(originalSize / 1024 / 1024).toFixed(2)}MB)`);
641
+ asset.preload = false;
642
+ file.url = 'data:text/plain;base64,';
643
+ continue;
644
+ }
645
+ let dataUrl;
646
+ let finalSize;
647
+ // ✅ 核心优化:如果是图片,使用 sharp 压缩
648
+ if (isImageFile(cleanUrl)) {
649
+ const compressed = await compressImage(buffer, cleanUrl, {
650
+ convertToWebP: true,
651
+ quality: 75,
652
+ });
653
+ dataUrl = `data:${compressed.mime};base64,${compressed.buffer.toString('base64')}`;
654
+ finalSize = compressed.buffer.length;
655
+ // 记录优化效果
656
+ const savings = ((1 - finalSize / originalSize) * 100).toFixed(1);
657
+ optimizedAssets.push(`${asset.name || assetId}: ${(originalSize / 1024).toFixed(1)}KB → ${(finalSize / 1024).toFixed(1)}KB (-${savings}%)`);
658
+ }
659
+ else {
660
+ // 非图片文件,直接 base64 编码
661
+ const mime = guessMimeType(cleanUrl);
662
+ dataUrl = `data:${mime};base64,${buffer.toString('base64')}`;
663
+ finalSize = originalSize;
664
+ }
665
+ // 更新 asset 配置
666
+ file.url = dataUrl;
667
+ file.size = finalSize;
552
668
  // data URL 不需要 hash/variants,避免引擎追加 ?t=hash 造成无效 URL
553
669
  if ('hash' in file) {
554
670
  delete file.hash;
@@ -561,6 +677,31 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir) {
561
677
  console.warn(`警告: 资源文件不存在: ${url}`);
562
678
  }
563
679
  }
680
+ // 输出统计信息
681
+ if (optimizedAssets.length > 0) {
682
+ console.log(`\n✅ 通过 Vite 优化了 ${optimizedAssets.length} 个资源:`);
683
+ optimizedAssets.slice(0, 5).forEach(info => console.log(` - ${info}`));
684
+ if (optimizedAssets.length > 5) {
685
+ console.log(` ... 还有 ${optimizedAssets.length - 5} 个资源`);
686
+ }
687
+ }
688
+ if (skippedAssets.length > 0) {
689
+ console.log(`\n⚠️ 已跳过 ${skippedAssets.length} 个大型资源文件 (>1MB):`);
690
+ skippedAssets.slice(0, 10).forEach(name => console.log(` - ${name}`));
691
+ if (skippedAssets.length > 10) {
692
+ console.log(` ... 还有 ${skippedAssets.length - 10} 个文件`);
693
+ }
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
+ }
564
705
  return configJson;
565
706
  }
566
707
  async function inlineLogoInStartScript(startCode, baseBuildDir) {
@@ -574,6 +715,80 @@ async function inlineLogoInStartScript(startCode, baseBuildDir) {
574
715
  return startCode;
575
716
  }
576
717
  }
718
+ /**
719
+ * 判断是否为图片文件
720
+ */
721
+ function isImageFile(filePath) {
722
+ const ext = path.extname(filePath).toLowerCase();
723
+ return ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext);
724
+ }
725
+ /**
726
+ * 使用 sharp 压缩图片
727
+ * @param buffer 原始图片 buffer
728
+ * @param filePath 文件路径(用于判断格式)
729
+ * @param options 压缩选项
730
+ */
731
+ async function compressImage(buffer, filePath, options) {
732
+ try {
733
+ // 动态导入 sharp
734
+ const sharp = (await import('sharp')).default;
735
+ const ext = path.extname(filePath).toLowerCase();
736
+ const convertToWebP = options?.convertToWebP !== false;
737
+ let image = sharp(buffer);
738
+ // 获取元数据
739
+ const metadata = await image.metadata();
740
+ if (convertToWebP && ['.png', '.jpg', '.jpeg'].includes(ext)) {
741
+ // 转换为 WebP(节省 60-70% 空间)
742
+ const quality = options?.quality ?? 75;
743
+ const webpBuffer = await image
744
+ .webp({ quality, effort: 6 })
745
+ .toBuffer();
746
+ return {
747
+ buffer: webpBuffer,
748
+ mime: 'image/webp',
749
+ };
750
+ }
751
+ // 按原格式压缩
752
+ switch (ext) {
753
+ case '.png':
754
+ const pngBuffer = await image
755
+ .png({ quality: 80, compressionLevel: 9 })
756
+ .toBuffer();
757
+ return { buffer: pngBuffer, mime: 'image/png' };
758
+ case '.jpg':
759
+ case '.jpeg':
760
+ const jpgBuffer = await image
761
+ .jpeg({ quality: options?.quality ?? 80, mozjpeg: true })
762
+ .toBuffer();
763
+ return { buffer: jpgBuffer, mime: 'image/jpeg' };
764
+ default:
765
+ // 其他格式不压缩
766
+ return { buffer, mime: guessMimeType(filePath) };
767
+ }
768
+ }
769
+ catch (error) {
770
+ // 如果 sharp 失败,返回原始 buffer
771
+ console.warn(`警告: sharp 压缩失败,使用原始文件:`, error);
772
+ return { buffer, mime: guessMimeType(filePath) };
773
+ }
774
+ }
775
+ /**
776
+ * 从 Vite 返回的代码中提取 data URL
777
+ */
778
+ function extractDataUrl(code) {
779
+ // 格式 1: export default "data:..."
780
+ let match = code.match(/export\s+default\s+["']([^"']*data:[^"']+)["']/);
781
+ if (match)
782
+ return match[1];
783
+ // 格式 2: "data:..."
784
+ match = code.match(/["']([^"']*data:[^"']+)["']/);
785
+ if (match)
786
+ return match[1];
787
+ // 格式 3: 整个代码就是 data URL
788
+ if (code.trim().startsWith('data:'))
789
+ return code.trim();
790
+ return null;
791
+ }
577
792
  function guessMimeType(filePath) {
578
793
  const ext = path.extname(filePath).toLowerCase();
579
794
  switch (ext) {
@@ -690,11 +905,9 @@ async function buildCompressedEngineScript(engineCode) {
690
905
  const base64 = Buffer.from(compressed).toString('base64');
691
906
  // 遵循 PlayCanvas 官方 one-page.js 的实现方式
692
907
  // 参考: https://github.com/playcanvas/playcanvas-rest-api-tools/blob/main/one-page.js
693
- // 1. 使用 new Buffer(base64, "base64") 解码 base64
694
- // 2. 使用 Buffer.from(lz4.decompress(e)).toString() 解压并转字符串
695
- // 3. 使用 innerText 设置脚本内容
696
- // 4. 插入到当前脚本之前(保持执行顺序)
697
- 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)}();`;
698
911
  return `<script>${wrapper}</script>`;
699
912
  }
700
913
  async function loadLz4Module() {
@@ -2,6 +2,7 @@ import type { Plugin } from 'vite';
2
2
  export interface SourceBuilderPluginOptions {
3
3
  projectDir: string;
4
4
  outputDir: string;
5
+ selectedScenes?: string[];
5
6
  }
6
7
  export interface UserScript {
7
8
  id: string;
@@ -15,6 +15,8 @@ export function viteSourceBuilderPlugin(options) {
15
15
  let projectFormat = null;
16
16
  let projectConfig = null;
17
17
  let userScripts = [];
18
+ let scriptPathMap = new Map(); // 模块路径 -> 文件系统路径
19
+ let fileToModuleMap = new Map(); // 文件系统路径 -> 模块路径
18
20
  return {
19
21
  name: 'vite-plugin-source-builder',
20
22
  enforce: 'pre',
@@ -35,23 +37,46 @@ export function viteSourceBuilderPlugin(options) {
35
37
  // 3. 收集用户脚本
36
38
  userScripts = await collectUserScripts(projectConfig, options.projectDir);
37
39
  console.log(`[SourceBuilder] 收集到 ${userScripts.length} 个用户脚本`);
40
+ // 4. 构建脚本路径映射(基于文件夹层次)
41
+ const mappings = buildScriptPathMapping(projectConfig, userScripts, options.projectDir);
42
+ scriptPathMap = mappings.moduleToFile;
43
+ fileToModuleMap = mappings.fileToModule;
44
+ console.log(`[SourceBuilder] 构建了 ${scriptPathMap.size} 个路径映射`);
38
45
  },
39
- // 处理虚拟模块(用户脚本)
40
- resolveId(id) {
46
+ // 处理虚拟入口模块和自定义导入路径
47
+ resolveId(id, importer) {
41
48
  if (id === 'virtual:game-scripts') {
42
49
  return '\0virtual:game-scripts';
43
50
  }
51
+ // 解析自定义导入路径(如 Gameplay/GameplaySystem.mjs)
52
+ if (scriptPathMap.has(id)) {
53
+ return scriptPathMap.get(id);
54
+ }
55
+ // 处理相对导入(../xxx 或 ./xxx)
56
+ if (importer && (id.startsWith('./') || id.startsWith('../'))) {
57
+ // 获取导入者的模块路径
58
+ const importerModulePath = fileToModuleMap.get(importer);
59
+ if (importerModulePath) {
60
+ // 基于模块路径解析相对导入
61
+ const importerDir = path.dirname(importerModulePath);
62
+ const resolvedModulePath = path.posix.join(importerDir, id);
63
+ // 从模块路径映射中查找实际文件
64
+ if (scriptPathMap.has(resolvedModulePath)) {
65
+ return scriptPathMap.get(resolvedModulePath);
66
+ }
67
+ }
68
+ }
44
69
  return null;
45
70
  },
46
71
  load(id) {
47
72
  if (id === '\0virtual:game-scripts') {
48
- // 合并所有用户脚本
49
- const scriptCode = userScripts.length > 0
50
- ? userScripts
51
- .map(s => wrapPlayCanvasScript(s.code, s.name, s))
52
- .join('\n\n')
53
- : '// No user scripts';
54
- return scriptCode;
73
+ // 生成入口文件,直接导入所有真实的脚本文件
74
+ // Vite 自然地处理这些文件及其依赖关系
75
+ const imports = userScripts.map(script => {
76
+ const scriptPath = path.join(options.projectDir, script.path);
77
+ return `import '${scriptPath}';`;
78
+ }).join('\n');
79
+ return imports || '// No user scripts';
55
80
  }
56
81
  return null;
57
82
  },
@@ -61,14 +86,20 @@ export function viteSourceBuilderPlugin(options) {
61
86
  }
62
87
  // 确保输出目录存在
63
88
  await fs.mkdir(options.outputDir, { recursive: true });
64
- // 生成 config.json
65
- const config = generateConfig(projectConfig);
89
+ // 生成 config.json(支持场景过滤)
90
+ const config = await generateConfig(projectConfig, {
91
+ selectedScenes: options.selectedScenes,
92
+ });
66
93
  // 为脚本资产补齐 __game-scripts.js 信息
67
94
  await patchScriptAssets(config, options.outputDir);
68
95
  await fs.writeFile(path.join(options.outputDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8');
69
96
  console.log('[SourceBuilder] 生成 config.json');
70
97
  // 生成 __settings__.js
71
- 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);
72
103
  await fs.writeFile(path.join(options.outputDir, '__settings__.js'), settings, 'utf-8');
73
104
  console.log('[SourceBuilder] 生成 __settings__.js');
74
105
  // 复制模板文件
@@ -77,8 +108,8 @@ export function viteSourceBuilderPlugin(options) {
77
108
  // 复制资源文件
78
109
  await copyAssets(projectConfig, options.projectDir, options.outputDir, config.assets);
79
110
  console.log('[SourceBuilder] 复制资源文件');
80
- // 生成场景文件
81
- await generateSceneFiles(projectConfig, options.projectDir, options.outputDir);
111
+ // 生成场景文件(只生成 config 中包含的场景)
112
+ await generateSceneFiles(projectConfig, options.projectDir, options.outputDir, config.scenes);
82
113
  console.log('[SourceBuilder] 生成场景文件');
83
114
  },
84
115
  };
@@ -102,6 +133,54 @@ async function detectFormat(projectDir) {
102
133
  // 默认为 PlayCanvas
103
134
  return 'playcanvas';
104
135
  }
136
+ /**
137
+ * 构建脚本路径映射(基于文件夹层次结构)
138
+ */
139
+ function buildScriptPathMapping(projectConfig, userScripts, projectDir) {
140
+ const moduleToFile = new Map();
141
+ const fileToModule = new Map();
142
+ const assets = projectConfig.format === 'playcanvas'
143
+ ? projectConfig.assets
144
+ : projectConfig.assets;
145
+ // 构建文件夹 ID 到名称的映射
146
+ const folderMap = new Map();
147
+ for (const [assetId, asset] of Object.entries(assets)) {
148
+ const assetData = asset;
149
+ if (assetData.type === 'folder') {
150
+ folderMap.set(assetId, assetData.name);
151
+ }
152
+ }
153
+ // 为每个脚本构建模块路径
154
+ for (const script of userScripts) {
155
+ const asset = assets[script.id];
156
+ if (!asset)
157
+ continue;
158
+ const pathIds = asset.path || [];
159
+ const pathNames = [];
160
+ // 跳过根文件夹(第一个元素),构建路径
161
+ for (let i = 1; i < pathIds.length; i++) {
162
+ const folderId = String(pathIds[i]);
163
+ const folderName = folderMap.get(folderId);
164
+ if (folderName) {
165
+ pathNames.push(folderName);
166
+ }
167
+ }
168
+ // 添加文件名
169
+ const filename = asset.name || asset.file?.filename;
170
+ if (filename) {
171
+ pathNames.push(filename);
172
+ }
173
+ const modulePath = pathNames.join('/');
174
+ const fullPath = path.join(projectDir, script.path);
175
+ // 添加双向映射
176
+ moduleToFile.set(modulePath, fullPath);
177
+ fileToModule.set(fullPath, modulePath);
178
+ // 同时添加不带扩展名的映射
179
+ const withoutExt = modulePath.replace(/\.mjs$/, '');
180
+ moduleToFile.set(withoutExt, fullPath);
181
+ }
182
+ return { moduleToFile, fileToModule };
183
+ }
105
184
  /**
106
185
  * 收集用户脚本
107
186
  */
@@ -161,17 +240,6 @@ async function collectUserScripts(projectConfig, projectDir) {
161
240
  }
162
241
  return scripts;
163
242
  }
164
- /**
165
- * 包装 PlayCanvas 脚本
166
- */
167
- function wrapPlayCanvasScript(code, scriptName, script) {
168
- // 清理脚本名称(移除特殊字符)
169
- const cleanName = scriptName.replace(/[^a-zA-Z0-9_]/g, '_');
170
- return `
171
- var ${cleanName} = pc.createScript('${cleanName}');
172
- ${code}
173
- `.trim();
174
- }
175
243
  /**
176
244
  * 复制模板文件
177
245
  */
@@ -211,12 +279,18 @@ async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride)
211
279
  const assets = assetsOverride || (projectConfig.format === 'playcanvas'
212
280
  ? projectConfig.assets
213
281
  : projectConfig.assets);
282
+ // 使用 Set 去重,避免重复复制同一个文件
283
+ const copiedUrls = new Set();
214
284
  // 遍历 assets
215
285
  for (const [assetId, asset] of Object.entries(assets)) {
216
286
  const assetData = asset;
217
287
  if (!assetData.file?.url || assetData.file.url.startsWith('data:')) {
218
288
  continue;
219
289
  }
290
+ // 跳过已经复制过的 URL
291
+ if (copiedUrls.has(assetData.file.url)) {
292
+ continue;
293
+ }
220
294
  const sourcePath = path.join(projectDir, assetData.file.url);
221
295
  const targetPath = path.join(outputDir, assetData.file.url);
222
296
  try {
@@ -227,6 +301,8 @@ async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride)
227
301
  await fs.mkdir(targetDir, { recursive: true });
228
302
  // 复制文件
229
303
  await fs.copyFile(sourcePath, targetPath);
304
+ // 标记为已复制
305
+ copiedUrls.add(assetData.file.url);
230
306
  }
231
307
  catch (error) {
232
308
  // 资源文件可能不存在(可能是内联的)
@@ -237,14 +313,23 @@ async function copyAssets(projectConfig, projectDir, outputDir, assetsOverride)
237
313
  /**
238
314
  * 生成场景文件
239
315
  */
240
- async function generateSceneFiles(projectConfig, projectDir, outputDir) {
316
+ async function generateSceneFiles(projectConfig, projectDir, outputDir, selectedScenes) {
241
317
  if (projectConfig.format === 'playcanvas') {
242
318
  const pcProject = projectConfig;
243
319
  // PlayCanvas 格式:scenes.json 中已包含场景数据
244
320
  if (pcProject.scenes) {
245
- const scenes = Array.isArray(pcProject.scenes)
321
+ let scenes = Array.isArray(pcProject.scenes)
246
322
  ? pcProject.scenes
247
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
+ }
248
333
  for (const scene of scenes) {
249
334
  const sceneData = scene;
250
335
  const sceneId = sceneData.id || sceneData.name || 'scene';
@@ -257,7 +342,16 @@ async function generateSceneFiles(projectConfig, projectDir, outputDir) {
257
342
  else {
258
343
  // PlayCraft 格式:从 scenes/ 目录读取
259
344
  const pcProject = projectConfig;
260
- 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) {
261
355
  const sceneData = scene;
262
356
  const sceneId = sceneData.id || sceneData.name || 'scene';
263
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.2",
3
+ "version": "0.0.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,6 +38,7 @@
38
38
  "lightningcss": "^1.27.0",
39
39
  "mime-types": "^2.1.35",
40
40
  "rollup-plugin-visualizer": "^6.0.5",
41
+ "sharp": "^0.33.0",
41
42
  "terser": "^5.30.0",
42
43
  "vite": "^6.0.0",
43
44
  "vite-plugin-singlefile": "^2.0.0"
@@ -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;