@playcraft/build 0.0.2 → 0.0.3

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,6 @@
1
1
  export interface BaseBuildOptions {
2
2
  outputDir: string;
3
+ selectedScenes?: string[];
3
4
  }
4
5
  export interface BaseBuildOutput {
5
6
  outputDir: string;
@@ -316,6 +316,7 @@ export class BaseBuilder {
316
316
  viteSourceBuilderPlugin({
317
317
  projectDir: this.projectDir,
318
318
  outputDir: this.options.outputDir,
319
+ selectedScenes: this.options.selectedScenes,
319
320
  }),
320
321
  ],
321
322
  };
@@ -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;
@@ -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,8 +23,8 @@ 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);
26
+ // 2. 内联并转换 __settings__.js(传递插件上下文)
27
+ html = await inlineAndConvertSettings(html, options.baseBuildDir, options, pluginContext);
22
28
  // 3. 内联 __modules__.js
23
29
  html = await inlineModulesScript(html, options.baseBuildDir);
24
30
  // 4. 内联 __start__.js
@@ -33,9 +39,9 @@ export function vitePlayCanvasPlugin(options) {
33
39
  return html;
34
40
  },
35
41
  async transform(code, id) {
36
- // 转换 __settings__.js 中的资源路径为 data URLs
42
+ // 转换 __settings__.js 中的资源路径为 data URLs(传递插件上下文)
37
43
  if (id.endsWith('__settings__.js')) {
38
- return await convertSettingsToDataUrls(code, options.baseBuildDir, options);
44
+ return await convertSettingsToDataUrls(code, options.baseBuildDir, options, pluginContext);
39
45
  }
40
46
  return code;
41
47
  },
@@ -73,18 +79,18 @@ async function inlineEngineScript(html, baseBuildDir, options) {
73
79
  /**
74
80
  * 内联并转换 __settings__.js
75
81
  */
76
- async function inlineAndConvertSettings(html, baseBuildDir, options) {
82
+ async function inlineAndConvertSettings(html, baseBuildDir, options, pluginContext) {
77
83
  const settingsPath = path.join(baseBuildDir, '__settings__.js');
78
84
  try {
79
85
  await fs.access(settingsPath);
80
86
  }
81
87
  catch (error) {
82
88
  // __settings__.js 不存在,尝试从 config.json 生成
83
- return await generateAndInlineSettings(html, baseBuildDir, options);
89
+ return await generateAndInlineSettings(html, baseBuildDir, options, pluginContext);
84
90
  }
85
91
  let settingsCode = await fs.readFile(settingsPath, 'utf-8');
86
- // 转换资源URL为data URLs
87
- settingsCode = await convertSettingsToDataUrls(settingsCode, baseBuildDir, options);
92
+ // 转换资源URL为data URLs(传递插件上下文)
93
+ settingsCode = await convertSettingsToDataUrls(settingsCode, baseBuildDir, options, pluginContext);
88
94
  // 替换 script 标签
89
95
  const scriptPattern = /<script[^>]*src=["']__settings__\.js["'][^>]*><\/script>/i;
90
96
  html = html.replace(scriptPattern, `<script>${settingsCode}</script>`);
@@ -93,12 +99,12 @@ async function inlineAndConvertSettings(html, baseBuildDir, options) {
93
99
  /**
94
100
  * 生成并内联 settings(如果 __settings__.js 不存在)
95
101
  */
96
- async function generateAndInlineSettings(html, baseBuildDir, options) {
102
+ async function generateAndInlineSettings(html, baseBuildDir, options, pluginContext) {
97
103
  const configPath = path.join(baseBuildDir, 'config.json');
98
104
  const configContent = await fs.readFile(configPath, 'utf-8');
99
105
  let configJson = JSON.parse(configContent);
100
106
  if (options.convertDataUrls) {
101
- configJson = await inlineConfigAssetUrls(configJson, baseBuildDir);
107
+ configJson = await inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext);
102
108
  }
103
109
  if (options.ammoReplacement) {
104
110
  configJson = stripAmmoAssets(configJson);
@@ -158,9 +164,9 @@ window.PRELOAD_MODULES = ${JSON.stringify(preloadModules)};
158
164
  /**
159
165
  * 转换 settings 代码中的资源URL为 data URLs
160
166
  */
161
- async function convertSettingsToDataUrls(settingsCode, baseBuildDir, options) {
162
- // 1. 转换 config.json
163
- settingsCode = await convertConfigUrl(settingsCode, baseBuildDir, options);
167
+ async function convertSettingsToDataUrls(settingsCode, baseBuildDir, options, pluginContext) {
168
+ // 1. 转换 config.json(传递插件上下文)
169
+ settingsCode = await convertConfigUrl(settingsCode, baseBuildDir, options, pluginContext);
164
170
  // 2. 转换场景文件
165
171
  settingsCode = await convertSceneUrl(settingsCode, baseBuildDir);
166
172
  // 3. 转换 PRELOAD_MODULES(优先使用 JS fallback)
@@ -170,7 +176,7 @@ async function convertSettingsToDataUrls(settingsCode, baseBuildDir, options) {
170
176
  /**
171
177
  * 转换 CONFIG_FILENAME 为 data URL
172
178
  */
173
- async function convertConfigUrl(settingsCode, baseBuildDir, options) {
179
+ async function convertConfigUrl(settingsCode, baseBuildDir, options, pluginContext) {
174
180
  const configMatch = settingsCode.match(/window\.CONFIG_FILENAME\s*=\s*"([^"]+)"/);
175
181
  if (!configMatch) {
176
182
  return settingsCode;
@@ -184,7 +190,7 @@ async function convertConfigUrl(settingsCode, baseBuildDir, options) {
184
190
  const configContent = await fs.readFile(fullConfigPath, 'utf-8');
185
191
  let configJson = JSON.parse(configContent);
186
192
  if (options.convertDataUrls) {
187
- configJson = await inlineConfigAssetUrls(configJson, baseBuildDir);
193
+ configJson = await inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext);
188
194
  }
189
195
  if (options.ammoReplacement) {
190
196
  configJson = stripAmmoAssets(configJson);
@@ -528,12 +534,15 @@ function isAmmoModule(module) {
528
534
  wasmUrl.includes('ammo') ||
529
535
  fallbackUrl.includes('ammo'));
530
536
  }
531
- async function inlineConfigAssetUrls(configJson, baseBuildDir) {
537
+ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
532
538
  if (!configJson?.assets) {
533
539
  return configJson;
534
540
  }
535
541
  const assets = configJson.assets;
536
- for (const asset of Object.values(assets)) {
542
+ const skippedAssets = [];
543
+ const optimizedAssets = [];
544
+ const SIZE_LIMIT = 1 * 1024 * 1024; // 1MB - 跳过超过这个大小的文件
545
+ for (const [assetId, asset] of Object.entries(assets)) {
537
546
  const file = asset?.file;
538
547
  if (!file?.url || typeof file.url !== 'string') {
539
548
  continue;
@@ -543,12 +552,54 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir) {
543
552
  continue;
544
553
  }
545
554
  const cleanUrl = url.split('?')[0];
555
+ const fileName = cleanUrl.toLowerCase();
556
+ // 跳过物理引擎缓存文件和大型文本文件
557
+ if (fileName.endsWith('.pma.txt') ||
558
+ fileName.includes('browsermetrics') ||
559
+ fileName.includes('deferredbrowsermetrics') ||
560
+ (fileName.endsWith('.txt') && file.size && file.size > SIZE_LIMIT)) {
561
+ skippedAssets.push(`${asset.name || assetId} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
562
+ // 标记为不预加载,避免引擎尝试加载
563
+ asset.preload = false;
564
+ // 清空 URL,避免加载失败
565
+ file.url = 'data:text/plain;base64,';
566
+ continue;
567
+ }
546
568
  const fullPath = path.join(baseBuildDir, cleanUrl);
547
569
  try {
570
+ // ✅ 简化逻辑:直接读取文件并判断是否需要压缩
548
571
  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;
572
+ const originalSize = buffer.length;
573
+ // 检查实际文件大小,跳过超大文件
574
+ if (originalSize > SIZE_LIMIT) {
575
+ skippedAssets.push(`${asset.name || assetId} (${(originalSize / 1024 / 1024).toFixed(2)}MB)`);
576
+ asset.preload = false;
577
+ file.url = 'data:text/plain;base64,';
578
+ continue;
579
+ }
580
+ let dataUrl;
581
+ let finalSize;
582
+ // ✅ 核心优化:如果是图片,使用 sharp 压缩
583
+ if (isImageFile(cleanUrl)) {
584
+ const compressed = await compressImage(buffer, cleanUrl, {
585
+ convertToWebP: true,
586
+ quality: 75,
587
+ });
588
+ dataUrl = `data:${compressed.mime};base64,${compressed.buffer.toString('base64')}`;
589
+ finalSize = compressed.buffer.length;
590
+ // 记录优化效果
591
+ const savings = ((1 - finalSize / originalSize) * 100).toFixed(1);
592
+ optimizedAssets.push(`${asset.name || assetId}: ${(originalSize / 1024).toFixed(1)}KB → ${(finalSize / 1024).toFixed(1)}KB (-${savings}%)`);
593
+ }
594
+ else {
595
+ // 非图片文件,直接 base64 编码
596
+ const mime = guessMimeType(cleanUrl);
597
+ dataUrl = `data:${mime};base64,${buffer.toString('base64')}`;
598
+ finalSize = originalSize;
599
+ }
600
+ // 更新 asset 配置
601
+ file.url = dataUrl;
602
+ file.size = finalSize;
552
603
  // data URL 不需要 hash/variants,避免引擎追加 ?t=hash 造成无效 URL
553
604
  if ('hash' in file) {
554
605
  delete file.hash;
@@ -561,6 +612,21 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir) {
561
612
  console.warn(`警告: 资源文件不存在: ${url}`);
562
613
  }
563
614
  }
615
+ // 输出统计信息
616
+ if (optimizedAssets.length > 0) {
617
+ console.log(`\n✅ 通过 Vite 优化了 ${optimizedAssets.length} 个资源:`);
618
+ optimizedAssets.slice(0, 5).forEach(info => console.log(` - ${info}`));
619
+ if (optimizedAssets.length > 5) {
620
+ console.log(` ... 还有 ${optimizedAssets.length - 5} 个资源`);
621
+ }
622
+ }
623
+ if (skippedAssets.length > 0) {
624
+ console.log(`\n⚠️ 已跳过 ${skippedAssets.length} 个大型资源文件 (>1MB):`);
625
+ skippedAssets.slice(0, 10).forEach(name => console.log(` - ${name}`));
626
+ if (skippedAssets.length > 10) {
627
+ console.log(` ... 还有 ${skippedAssets.length - 10} 个文件`);
628
+ }
629
+ }
564
630
  return configJson;
565
631
  }
566
632
  async function inlineLogoInStartScript(startCode, baseBuildDir) {
@@ -574,6 +640,80 @@ async function inlineLogoInStartScript(startCode, baseBuildDir) {
574
640
  return startCode;
575
641
  }
576
642
  }
643
+ /**
644
+ * 判断是否为图片文件
645
+ */
646
+ function isImageFile(filePath) {
647
+ const ext = path.extname(filePath).toLowerCase();
648
+ return ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext);
649
+ }
650
+ /**
651
+ * 使用 sharp 压缩图片
652
+ * @param buffer 原始图片 buffer
653
+ * @param filePath 文件路径(用于判断格式)
654
+ * @param options 压缩选项
655
+ */
656
+ async function compressImage(buffer, filePath, options) {
657
+ try {
658
+ // 动态导入 sharp
659
+ const sharp = (await import('sharp')).default;
660
+ const ext = path.extname(filePath).toLowerCase();
661
+ const convertToWebP = options?.convertToWebP !== false;
662
+ let image = sharp(buffer);
663
+ // 获取元数据
664
+ const metadata = await image.metadata();
665
+ if (convertToWebP && ['.png', '.jpg', '.jpeg'].includes(ext)) {
666
+ // 转换为 WebP(节省 60-70% 空间)
667
+ const quality = options?.quality ?? 75;
668
+ const webpBuffer = await image
669
+ .webp({ quality, effort: 6 })
670
+ .toBuffer();
671
+ return {
672
+ buffer: webpBuffer,
673
+ mime: 'image/webp',
674
+ };
675
+ }
676
+ // 按原格式压缩
677
+ switch (ext) {
678
+ case '.png':
679
+ const pngBuffer = await image
680
+ .png({ quality: 80, compressionLevel: 9 })
681
+ .toBuffer();
682
+ return { buffer: pngBuffer, mime: 'image/png' };
683
+ case '.jpg':
684
+ case '.jpeg':
685
+ const jpgBuffer = await image
686
+ .jpeg({ quality: options?.quality ?? 80, mozjpeg: true })
687
+ .toBuffer();
688
+ return { buffer: jpgBuffer, mime: 'image/jpeg' };
689
+ default:
690
+ // 其他格式不压缩
691
+ return { buffer, mime: guessMimeType(filePath) };
692
+ }
693
+ }
694
+ catch (error) {
695
+ // 如果 sharp 失败,返回原始 buffer
696
+ console.warn(`警告: sharp 压缩失败,使用原始文件:`, error);
697
+ return { buffer, mime: guessMimeType(filePath) };
698
+ }
699
+ }
700
+ /**
701
+ * 从 Vite 返回的代码中提取 data URL
702
+ */
703
+ function extractDataUrl(code) {
704
+ // 格式 1: export default "data:..."
705
+ let match = code.match(/export\s+default\s+["']([^"']*data:[^"']+)["']/);
706
+ if (match)
707
+ return match[1];
708
+ // 格式 2: "data:..."
709
+ match = code.match(/["']([^"']*data:[^"']+)["']/);
710
+ if (match)
711
+ return match[1];
712
+ // 格式 3: 整个代码就是 data URL
713
+ if (code.trim().startsWith('data:'))
714
+ return code.trim();
715
+ return null;
716
+ }
577
717
  function guessMimeType(filePath) {
578
718
  const ext = path.extname(filePath).toLowerCase();
579
719
  switch (ext) {
@@ -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,8 +86,10 @@ 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');
@@ -102,6 +129,54 @@ async function detectFormat(projectDir) {
102
129
  // 默认为 PlayCanvas
103
130
  return 'playcanvas';
104
131
  }
132
+ /**
133
+ * 构建脚本路径映射(基于文件夹层次结构)
134
+ */
135
+ function buildScriptPathMapping(projectConfig, userScripts, projectDir) {
136
+ const moduleToFile = new Map();
137
+ const fileToModule = new Map();
138
+ const assets = projectConfig.format === 'playcanvas'
139
+ ? projectConfig.assets
140
+ : projectConfig.assets;
141
+ // 构建文件夹 ID 到名称的映射
142
+ const folderMap = new Map();
143
+ for (const [assetId, asset] of Object.entries(assets)) {
144
+ const assetData = asset;
145
+ if (assetData.type === 'folder') {
146
+ folderMap.set(assetId, assetData.name);
147
+ }
148
+ }
149
+ // 为每个脚本构建模块路径
150
+ for (const script of userScripts) {
151
+ const asset = assets[script.id];
152
+ if (!asset)
153
+ continue;
154
+ const pathIds = asset.path || [];
155
+ const pathNames = [];
156
+ // 跳过根文件夹(第一个元素),构建路径
157
+ for (let i = 1; i < pathIds.length; i++) {
158
+ const folderId = String(pathIds[i]);
159
+ const folderName = folderMap.get(folderId);
160
+ if (folderName) {
161
+ pathNames.push(folderName);
162
+ }
163
+ }
164
+ // 添加文件名
165
+ const filename = asset.name || asset.file?.filename;
166
+ if (filename) {
167
+ pathNames.push(filename);
168
+ }
169
+ const modulePath = pathNames.join('/');
170
+ const fullPath = path.join(projectDir, script.path);
171
+ // 添加双向映射
172
+ moduleToFile.set(modulePath, fullPath);
173
+ fileToModule.set(fullPath, modulePath);
174
+ // 同时添加不带扩展名的映射
175
+ const withoutExt = modulePath.replace(/\.mjs$/, '');
176
+ moduleToFile.set(withoutExt, fullPath);
177
+ }
178
+ return { moduleToFile, fileToModule };
179
+ }
105
180
  /**
106
181
  * 收集用户脚本
107
182
  */
@@ -161,17 +236,6 @@ async function collectUserScripts(projectConfig, projectDir) {
161
236
  }
162
237
  return scripts;
163
238
  }
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
239
  /**
176
240
  * 复制模板文件
177
241
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/build",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
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"