@playcraft/build 0.0.11 → 0.0.13

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,169 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { OptimizationAnalyzer } from '../optimization-analyzer.js';
3
+ describe('OptimizationAnalyzer', () => {
4
+ it('should detect large uncompressed files', () => {
5
+ const state = {
6
+ version: '1.0.0',
7
+ buildTime: Date.now(),
8
+ assets: {
9
+ 'large-file': {
10
+ id: 'large-file',
11
+ originalName: 'large-file.js',
12
+ originalPath: '/path/to/large-file.js',
13
+ originalSize: 200 * 1024, // 200KB
14
+ finalSize: 195 * 1024, // 只压缩了 5KB
15
+ totalCompressionRatio: 0.025, // 2.5% 压缩率
16
+ type: 'script',
17
+ processingHistory: [
18
+ {
19
+ stage: 'base-build',
20
+ name: 'large-file.js',
21
+ size: 195 * 1024,
22
+ optimizations: [],
23
+ },
24
+ ],
25
+ },
26
+ },
27
+ stages: [],
28
+ };
29
+ const analyzer = new OptimizationAnalyzer(state);
30
+ const result = analyzer.analyze();
31
+ expect(result.totalSuggestions).toBeGreaterThan(0);
32
+ expect(result.suggestions.some(s => s.type === 'large-uncompressed')).toBe(true);
33
+ });
34
+ it('should detect PNG images that can be converted to WebP', () => {
35
+ const state = {
36
+ version: '1.0.0',
37
+ buildTime: Date.now(),
38
+ assets: {
39
+ 'image': {
40
+ id: 'image',
41
+ originalName: 'image.png',
42
+ originalPath: '/path/to/image.png',
43
+ originalSize: 100 * 1024, // 100KB
44
+ finalSize: 100 * 1024,
45
+ totalCompressionRatio: 0,
46
+ type: 'texture',
47
+ processingHistory: [
48
+ {
49
+ stage: 'base-build',
50
+ name: 'image.png',
51
+ size: 100 * 1024,
52
+ optimizations: [],
53
+ },
54
+ ],
55
+ },
56
+ },
57
+ stages: [],
58
+ };
59
+ const analyzer = new OptimizationAnalyzer(state);
60
+ const result = analyzer.analyze();
61
+ expect(result.suggestions.some(s => s.type === 'format-conversion')).toBe(true);
62
+ });
63
+ it('should detect missing minification', () => {
64
+ const state = {
65
+ version: '1.0.0',
66
+ buildTime: Date.now(),
67
+ assets: {
68
+ 'script': {
69
+ id: 'script',
70
+ originalName: 'script.js',
71
+ originalPath: '/path/to/script.js',
72
+ originalSize: 50 * 1024, // 50KB
73
+ finalSize: 50 * 1024,
74
+ totalCompressionRatio: 0,
75
+ type: 'script',
76
+ processingHistory: [
77
+ {
78
+ stage: 'base-build',
79
+ name: 'script.js',
80
+ size: 50 * 1024,
81
+ optimizations: [], // 没有 minify
82
+ },
83
+ ],
84
+ },
85
+ },
86
+ stages: [],
87
+ };
88
+ const analyzer = new OptimizationAnalyzer(state);
89
+ const result = analyzer.analyze();
90
+ expect(result.suggestions.some(s => s.type === 'enable-minify')).toBe(true);
91
+ });
92
+ it('should group suggestions by severity', () => {
93
+ const state = {
94
+ version: '1.0.0',
95
+ buildTime: Date.now(),
96
+ assets: {
97
+ 'large-uncompressed': {
98
+ id: 'large-uncompressed',
99
+ originalName: 'large.js',
100
+ originalPath: '/path/to/large.js',
101
+ originalSize: 200 * 1024,
102
+ finalSize: 195 * 1024,
103
+ totalCompressionRatio: 0.025,
104
+ type: 'script',
105
+ processingHistory: [
106
+ {
107
+ stage: 'base-build',
108
+ name: 'large.js',
109
+ size: 195 * 1024,
110
+ optimizations: [],
111
+ },
112
+ ],
113
+ },
114
+ 'png-image': {
115
+ id: 'png-image',
116
+ originalName: 'image.png',
117
+ originalPath: '/path/to/image.png',
118
+ originalSize: 100 * 1024,
119
+ finalSize: 100 * 1024,
120
+ totalCompressionRatio: 0,
121
+ type: 'texture',
122
+ processingHistory: [
123
+ {
124
+ stage: 'base-build',
125
+ name: 'image.png',
126
+ size: 100 * 1024,
127
+ optimizations: [],
128
+ },
129
+ ],
130
+ },
131
+ },
132
+ stages: [],
133
+ };
134
+ const analyzer = new OptimizationAnalyzer(state);
135
+ const result = analyzer.analyze();
136
+ expect(result.bySeverity.high.length).toBeGreaterThan(0);
137
+ expect(result.bySeverity.medium.length).toBeGreaterThan(0);
138
+ });
139
+ it('should calculate total estimated savings', () => {
140
+ const state = {
141
+ version: '1.0.0',
142
+ buildTime: Date.now(),
143
+ assets: {
144
+ 'large-file': {
145
+ id: 'large-file',
146
+ originalName: 'large-file.js',
147
+ originalPath: '/path/to/large-file.js',
148
+ originalSize: 200 * 1024,
149
+ finalSize: 195 * 1024,
150
+ totalCompressionRatio: 0.025,
151
+ type: 'script',
152
+ processingHistory: [
153
+ {
154
+ stage: 'base-build',
155
+ name: 'large-file.js',
156
+ size: 195 * 1024,
157
+ optimizations: [],
158
+ },
159
+ ],
160
+ },
161
+ },
162
+ stages: [],
163
+ };
164
+ const analyzer = new OptimizationAnalyzer(state);
165
+ const result = analyzer.analyze();
166
+ expect(result.totalEstimatedSavings).toBeGreaterThan(0);
167
+ expect(result.estimatedOptimizationRate).toBeGreaterThan(0);
168
+ });
169
+ });
@@ -31,9 +31,10 @@ export class PlayableAnalyzer {
31
31
  const htmlContent = await fs.readFile(this.htmlPath, 'utf-8');
32
32
  const htmlSize = Buffer.from(htmlContent, 'utf-8').length;
33
33
  // 尝试从 build-state.json 读取资源信息
34
- const stateManager = await BuildStateManager.load(this.outputDir);
34
+ const stateManager = new BuildStateManager(this.outputDir, this.outputDir);
35
+ const stateLoaded = await stateManager.loadState();
35
36
  let assets;
36
- if (stateManager) {
37
+ if (stateLoaded) {
37
38
  console.log('[PlayableAnalyzer] 使用 build-state.json 的数据');
38
39
  assets = this.extractAssetsFromState(stateManager, htmlSize);
39
40
  }
@@ -52,11 +52,12 @@ export class BaseBuilder {
52
52
  await this.saveBuildMetadata(result.metadata);
53
53
  // 记录构建产物
54
54
  await this.recordBuildOutput(result);
55
- // 结束构建阶段并保存状态
55
+ // 结束构建阶段
56
56
  this.stateManager.endStage();
57
- await this.stateManager.saveState();
58
57
  // 生成分析报告(如果启用)
59
58
  if (this.options.analyze) {
59
+ // 只在分析模式下保存状态文件(分析报告需要用到)
60
+ await this.stateManager.saveState();
60
61
  await this.generateAnalysisReport();
61
62
  }
62
63
  return result;
@@ -532,11 +533,16 @@ export class BaseBuilder {
532
533
  gzipSize: true,
533
534
  brotliSize: true,
534
535
  open: false,
535
- sourcemap: false,
536
536
  }),
537
537
  ] : []),
538
538
  ],
539
539
  };
540
+ // DEBUG: 检查 analyze 配置
541
+ if (this.options.analyze) {
542
+ console.log('[BaseBuilder] analyze 已启用,报告路径:', this.options.analyzeReportPath
543
+ ? path.join(this.options.outputDir, this.options.analyzeReportPath)
544
+ : path.join(this.options.outputDir, 'base-bundle-report.html'));
545
+ }
540
546
  // 2. 执行 Vite 构建
541
547
  await viteBuild(viteConfig);
542
548
  // 3. 扫描生成的文件
@@ -31,7 +31,10 @@ export class AdikteevAdapter extends PlatformAdapter {
31
31
  removeEventListener: function(event, listener) {},
32
32
  getState: function() { return 'default'; },
33
33
  getPlacementType: function() { return 'interstitial'; },
34
- isViewable: function() { return true; }
34
+ isViewable: function() { return true; },
35
+ getMaxSize: function() { return { width: window.innerWidth, height: window.innerHeight }; },
36
+ getCurrentPosition: function() { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; },
37
+ getScreenSize: function() { return { width: screen.width, height: screen.height }; }
35
38
  };
36
39
  </script>
37
40
  <!--
@@ -42,7 +45,6 @@ export class AdikteevAdapter extends PlatformAdapter {
42
45
  - 需等待 viewableChange 事件后再启动游戏
43
46
  - 不允许自动重定向
44
47
  -->
45
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
46
48
  `;
47
49
  // 在 </head> 之前插入平台特定的 API
48
50
  html = html.replace('</head>', `${adikteevScript}</head>`);
@@ -33,7 +33,10 @@ export class AppLovinAdapter extends PlatformAdapter {
33
33
  removeEventListener: function(event, listener) {},
34
34
  getState: function() { return 'default'; },
35
35
  getPlacementType: function() { return 'interstitial'; },
36
- isViewable: function() { return true; }
36
+ isViewable: function() { return true; },
37
+ getMaxSize: function() { return { width: window.innerWidth, height: window.innerHeight }; },
38
+ getCurrentPosition: function() { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; },
39
+ getScreenSize: function() { return { width: screen.width, height: screen.height }; }
37
40
  };
38
41
  </script>
39
42
  <!--
@@ -43,8 +46,11 @@ export class AppLovinAdapter extends PlatformAdapter {
43
46
  - CTA 使用 mraid.open()
44
47
  -->
45
48
  `;
46
- // 在 </head> 之前插入
47
- return html.replace('</head>', `${appLovinScript}</head>`);
49
+ // 在 </head> 之前插入平台特定的 API
50
+ html = html.replace('</head>', `${appLovinScript}</head>`);
51
+ // 注入统一的 CTA 适配器
52
+ html = this.injectCTAAdapter(html);
53
+ return html;
48
54
  }
49
55
  getPlatformScript() {
50
56
  return `
@@ -30,7 +30,10 @@ export class InMobiAdapter extends PlatformAdapter {
30
30
  },
31
31
  removeEventListener: function(event, listener) {},
32
32
  getState: function() { return 'default'; },
33
- getPlacementType: function() { return 'interstitial'; }
33
+ getPlacementType: function() { return 'interstitial'; },
34
+ getMaxSize: function() { return { width: window.innerWidth, height: window.innerHeight }; },
35
+ getCurrentPosition: function() { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; },
36
+ getScreenSize: function() { return { width: screen.width, height: screen.height }; }
34
37
  };
35
38
  </script>
36
39
  <!--
@@ -40,7 +43,6 @@ export class InMobiAdapter extends PlatformAdapter {
40
43
  - 使用 mraid.open() 跳转
41
44
  - 不允许自动重定向
42
45
  -->
43
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
44
46
  `;
45
47
  // 在 </head> 之前插入平台特定的 API
46
48
  html = html.replace('</head>', `${inmobiScript}</head>`);
@@ -28,7 +28,10 @@ export class IronSourceAdapter extends PlatformAdapter {
28
28
  addEventListener: function(event, listener) {},
29
29
  removeEventListener: function(event, listener) {},
30
30
  getState: function() { return 'default'; },
31
- getPlacementType: function() { return 'interstitial'; }
31
+ getPlacementType: function() { return 'interstitial'; },
32
+ getMaxSize: function() { return { width: window.innerWidth, height: window.innerHeight }; },
33
+ getCurrentPosition: function() { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; },
34
+ getScreenSize: function() { return { width: screen.width, height: screen.height }; }
32
35
  };
33
36
 
34
37
  // dapi API (ironSource specific)
@@ -23,7 +23,10 @@ export class LiftoffAdapter extends PlatformAdapter {
23
23
  window.open(url, '_blank');
24
24
  },
25
25
  addEventListener: function(event, listener) {},
26
- removeEventListener: function(event, listener) {}
26
+ removeEventListener: function(event, listener) {},
27
+ getMaxSize: function() { return { width: window.innerWidth, height: window.innerHeight }; },
28
+ getCurrentPosition: function() { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; },
29
+ getScreenSize: function() { return { width: screen.width, height: screen.height }; }
27
30
  };
28
31
  </script>
29
32
  <!--
@@ -36,10 +39,12 @@ export class LiftoffAdapter extends PlatformAdapter {
36
39
  - 支持标准尺寸:320x480, 480x320, 768x1024, 1024x768, 320x50, 728x90, 300x250
37
40
  - 关闭按钮由平台处理,可能遮挡角落 50x50px
38
41
  -->
39
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
40
42
  `;
41
43
  // 在 </head> 之前插入
42
- return html.replace('</head>', `${liftoffScript}</head>`);
44
+ html = html.replace('</head>', `${liftoffScript}</head>`);
45
+ // 注入统一的 CTA 适配器
46
+ html = this.injectCTAAdapter(html);
47
+ return html;
43
48
  }
44
49
  getPlatformScript() {
45
50
  return `
@@ -23,7 +23,10 @@ export class SnapchatAdapter extends PlatformAdapter {
23
23
  addEventListener: function(event, listener) {},
24
24
  removeEventListener: function(event, listener) {},
25
25
  getState: function() { return 'ready'; },
26
- getPlacementType: function() { return 'interstitial'; }
26
+ getPlacementType: function() { return 'interstitial'; },
27
+ getMaxSize: function() { return { width: window.innerWidth, height: window.innerHeight }; },
28
+ getCurrentPosition: function() { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; },
29
+ getScreenSize: function() { return { width: screen.width, height: screen.height }; }
27
30
  };
28
31
 
29
32
  // Snapchat CTA function
@@ -34,7 +37,10 @@ export class SnapchatAdapter extends PlatformAdapter {
34
37
  };
35
38
  </script>
36
39
  `;
37
- return html.replace('</head>', `${mraidScript}</head>`);
40
+ html = html.replace('</head>', `${mraidScript}</head>`);
41
+ // 注入统一的 CTA 适配器
42
+ html = this.injectCTAAdapter(html);
43
+ return html;
38
44
  }
39
45
  getPlatformScript() {
40
46
  return `
@@ -31,7 +31,10 @@ export class UnityAdapter extends PlatformAdapter {
31
31
  removeEventListener: function(event, listener) {},
32
32
  getState: function() { return 'default'; },
33
33
  getPlacementType: function() { return 'interstitial'; },
34
- isViewable: function() { return true; }
34
+ isViewable: function() { return true; },
35
+ getMaxSize: function() { return { width: window.innerWidth, height: window.innerHeight }; },
36
+ getCurrentPosition: function() { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; },
37
+ getScreenSize: function() { return { width: screen.width, height: screen.height }; }
35
38
  };
36
39
  </script>
37
40
  <!--
@@ -44,7 +47,10 @@ export class UnityAdapter extends PlatformAdapter {
44
47
  -->
45
48
  `;
46
49
  // 在 </head> 之前插入
47
- return html.replace('</head>', `${unityScript}</head>`);
50
+ html = html.replace('</head>', `${unityScript}</head>`);
51
+ // 注入统一的 CTA 适配器
52
+ html = this.injectCTAAdapter(html);
53
+ return html;
48
54
  }
49
55
  getPlatformScript() {
50
56
  return `
@@ -238,6 +238,8 @@ export class PlayableBuilder {
238
238
  const appProps = configJson.application_properties || {};
239
239
  const scripts = appProps.scripts || [];
240
240
  const preloadModules = this.extractPreloadModules(configJson);
241
+ // 保留原始的 powerPreference 设置,默认为 high-performance
242
+ const powerPreference = appProps.powerPreference || 'high-performance';
241
243
  const settingsCode = `
242
244
  window.ASSET_PREFIX = "";
243
245
  window.SCRIPT_PREFIX = "";
@@ -247,7 +249,7 @@ window.CONTEXT_OPTIONS = {
247
249
  'alpha': ${appProps.transparentCanvas === true},
248
250
  'preserveDrawingBuffer': ${appProps.preserveDrawingBuffer === true},
249
251
  'deviceTypes': ['webgl2', 'webgl1'],
250
- 'powerPreference': "default"
252
+ 'powerPreference': "${powerPreference}"
251
253
  };
252
254
  window.SCRIPTS = [${scripts.join(', ')}];
253
255
  window.CONFIG_FILENAME = "${configDataUrl}";
package/dist/types.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface BuildOptions {
7
7
  format?: OutputFormat;
8
8
  outputDir?: string;
9
9
  compressEngine?: boolean;
10
+ compressConfigJson?: boolean;
10
11
  analyze?: boolean;
11
12
  analyzeReportPath?: string;
12
13
  patchXhrOut?: boolean;
@@ -246,6 +246,7 @@ export class ViteConfigBuilder {
246
246
  patchXhrOut: playableOptions.patchXhrOut,
247
247
  inlineGameScripts: playableOptions.inlineGameScripts,
248
248
  compressEngine: playableOptions.compressEngine,
249
+ compressConfigJson: playableOptions.compressConfigJson,
249
250
  configJsonInline: playableOptions.configJsonInline,
250
251
  mraidSupport: playableOptions.mraidSupport,
251
252
  ammoReplacement: this.options.ammoReplacement,
@@ -306,6 +307,7 @@ export class ViteConfigBuilder {
306
307
  baseBuildDir: this.baseBuildDir,
307
308
  outputDir: absoluteOutputDir,
308
309
  platform: this.platform,
310
+ analyze: this.options.analyze,
309
311
  }));
310
312
  // 6. 单文件输出插件(仅HTML格式)
311
313
  if (platformConfig.outputFormat === 'html') {
@@ -323,6 +325,7 @@ export class ViteConfigBuilder {
323
325
  patchXhrOut: this.options.patchXhrOut ?? playable.patchXhrOut ?? false,
324
326
  inlineGameScripts: this.options.inlineGameScripts ?? playable.inlineGameScripts ?? false,
325
327
  compressEngine: this.options.compressEngine ?? playable.compressEngine ?? false,
328
+ compressConfigJson: this.options.compressConfigJson ?? playable.compressConfigJson ?? false,
326
329
  configJsonInline: playable.configJsonInline ?? (config.outputFormat === 'html'),
327
330
  externFiles,
328
331
  mraidSupport: this.options.mraidSupport ?? playable.mraidSupport ?? false,
@@ -30,6 +30,7 @@ export interface PlatformViteConfig {
30
30
  patchXhrOut?: boolean;
31
31
  inlineGameScripts?: boolean;
32
32
  compressEngine?: boolean;
33
+ compressConfigJson?: boolean;
33
34
  configJsonInline?: boolean;
34
35
  externFiles?: boolean | ExternFilesConfig;
35
36
  mraidSupport?: boolean;
@@ -28,6 +28,7 @@ export const PLATFORM_CONFIGS = {
28
28
  patchXhrOut: false,
29
29
  inlineGameScripts: false,
30
30
  compressEngine: false,
31
+ compressConfigJson: false,
31
32
  configJsonInline: false,
32
33
  externFiles: true, // 允许外部文件
33
34
  mraidSupport: false,
@@ -3,6 +3,8 @@ export interface BuildStatePluginOptions {
3
3
  baseBuildDir: string;
4
4
  outputDir: string;
5
5
  platform: string;
6
+ /** 是否启用分析模式(只在分析模式下保存状态文件) */
7
+ analyze?: boolean;
6
8
  }
7
9
  /**
8
10
  * Vite 插件:记录 Playable Build 状态
@@ -128,11 +128,13 @@ export function viteBuildStatePlugin(options) {
128
128
  }
129
129
  },
130
130
  async closeBundle() {
131
- console.log('[BuildState] 保存构建状态...');
132
131
  // 结束 Playable 构建阶段
133
132
  stateManager.endStage();
134
- // 保存状态
135
- await stateManager.saveState();
133
+ // 只在分析模式下保存状态文件(分析报告需要用到)
134
+ if (options.analyze) {
135
+ console.log('[BuildState] 保存构建状态...');
136
+ await stateManager.saveState();
137
+ }
136
138
  // 输出统计信息
137
139
  const stats = stateManager.getStatistics();
138
140
  console.log(`[BuildState] 构建统计:`);
@@ -724,8 +724,17 @@ ${deferWrapper}`);
724
724
  fileName: 'esm-bundle',
725
725
  formats: ['iife'],
726
726
  },
727
- // 暂时禁用压缩来调试语法错误
728
- minify: false,
727
+ // 使用 terser 压缩 JS
728
+ minify: options.minify ? 'terser' : false,
729
+ terserOptions: options.minify ? {
730
+ compress: {
731
+ drop_console: true,
732
+ drop_debugger: true,
733
+ },
734
+ format: {
735
+ comments: false, // 移除所有注释
736
+ },
737
+ } : undefined,
729
738
  rollupOptions: {
730
739
  // 将 playcanvas 和引擎文件设为外部依赖
731
740
  // 引擎已经在 HTML 中单独内联,不需要再次打包
@@ -196,7 +196,9 @@ async function rewriteConfig(folderPath, assetPrefix) {
196
196
  }
197
197
  }
198
198
  async function applyMraidSupport(outDir) {
199
- await patchConfigFillMode(outDir);
199
+ // Note: We no longer patch fillMode to 'NONE' because it causes canvas scaling issues
200
+ // The MRAID resize patch handles the sizing correctly with any fillMode
201
+ // await patchConfigFillMode(outDir); // Removed: causes canvas scaling issues
200
202
  await patchStylesForMraid(outDir);
201
203
  }
202
204
  async function patchConfigFillMode(outDir) {
@@ -9,6 +9,7 @@ export interface PlayCanvasPluginOptions {
9
9
  patchXhrOut: boolean;
10
10
  inlineGameScripts: boolean;
11
11
  compressEngine: boolean;
12
+ compressConfigJson: boolean;
12
13
  configJsonInline: boolean;
13
14
  mraidSupport: boolean;
14
15
  ammoReplacement?: 'p2' | 'cannon';
@@ -73,6 +73,9 @@ export function vitePlayCanvasPlugin(options) {
73
73
  }
74
74
  // 对于 Classic 模式或 ESM 原生模式,保持原有的多文件结构
75
75
  }
76
+ // 注意:HTML 压缩不能在这里执行,因为 Vite 还需要解析 HTML
77
+ // HTML 模板已经在 templates/ 中被压缩为单行
78
+ // 最终的 HTML 压缩由 vite-plugin-singlefile 在最后阶段处理
76
79
  return html;
77
80
  },
78
81
  },
@@ -107,7 +110,7 @@ async function inlineEngineScript(html, baseBuildDir, options) {
107
110
  const enginePath = path.join(baseBuildDir, engineName);
108
111
  try {
109
112
  await fs.access(enginePath);
110
- const engineCode = await fs.readFile(enginePath, 'utf-8');
113
+ let engineCode = await fs.readFile(enginePath, 'utf-8');
111
114
  const patchScripts = await getEnginePatchScripts(options);
112
115
  const engineScript = options.compressEngine
113
116
  ? `${await getLz4InlineScript()}${await buildCompressedEngineScript(engineCode)}`
@@ -202,6 +205,8 @@ async function generateAndInlineSettings(html, baseBuildDir, options, pluginCont
202
205
  const appProps = configJson.application_properties || {};
203
206
  const scripts = appProps.scripts || [];
204
207
  const preloadModules = extractPreloadModules(configJson);
208
+ // 保留原始的 powerPreference 设置,默认为 high-performance
209
+ const powerPreference = appProps.powerPreference || 'high-performance';
205
210
  const settingsCode = `
206
211
  window.ASSET_PREFIX = "";
207
212
  window.SCRIPT_PREFIX = "";
@@ -211,7 +216,7 @@ window.CONTEXT_OPTIONS = {
211
216
  'alpha': ${appProps.transparentCanvas === true},
212
217
  'preserveDrawingBuffer': ${appProps.preserveDrawingBuffer === true},
213
218
  'deviceTypes': ['webgl2', 'webgl1'],
214
- 'powerPreference': "default"
219
+ 'powerPreference': "${powerPreference}"
215
220
  };
216
221
  window.SCRIPTS = [${scripts.join(', ')}];
217
222
  window.CONFIG_FILENAME = ${configValue};
@@ -260,8 +265,24 @@ async function inlineESMBundleAssets(html, baseBuildDir, options, pluginContext)
260
265
  if (options.mraidSupport) {
261
266
  configJson = applyMraidConfig(configJson);
262
267
  }
263
- // 5. 生成 config data URL
264
- const configDataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(configJson)).toString('base64')}`;
268
+ // 5. 生成 config.json data URL
269
+ // 如果启用 compressConfigJson,使用 LZ4 压缩后再 base64 编码
270
+ const configJsonString = JSON.stringify(configJson);
271
+ let configDataUrl;
272
+ if (options.compressConfigJson) {
273
+ const lz4 = await loadLz4Module();
274
+ const compressed = lz4.compress(Buffer.from(configJsonString));
275
+ const compressedBase64 = Buffer.from(compressed).toString('base64');
276
+ // 使用 lz4-json 前缀标识压缩格式,运行时需要解压
277
+ configDataUrl = `data:application/x-lz4-json;base64,${compressedBase64}`;
278
+ const originalSize = Buffer.from(configJsonString).length;
279
+ const compressedSize = compressed.length;
280
+ const ratio = ((1 - compressedSize / originalSize) * 100).toFixed(1);
281
+ console.log(`[PlayCanvasPlugin] ESM Bundle: config.json 已压缩 (${formatBytes(originalSize)} → ${formatBytes(compressedSize)}, 减少 ${ratio}%)`);
282
+ }
283
+ else {
284
+ configDataUrl = `data:application/json;base64,${Buffer.from(configJsonString).toString('base64')}`;
285
+ }
265
286
  // 6. 处理场景文件 - 获取第一个场景的 data URL
266
287
  let sceneDataUrl = '';
267
288
  if (configJson.scenes && configJson.scenes.length > 0) {
@@ -318,20 +339,21 @@ async function inlineESMBundleAssets(html, baseBuildDir, options, pluginContext)
318
339
  }
319
340
  }
320
341
  // 8. 在 HTML 中查找并替换 ESM Bundle IIFE 代码中的配置值
321
- // CONFIG_FILENAME = "config.json" CONFIG_FILENAME = "data:application/json;base64,..."
322
- // 注意:替换字符串中的 $ 是特殊字符,需要转义
323
- const escapedConfigDataUrl = configDataUrl.replace(/\$/g, '$$$$');
324
- // 替换 CONFIG_FILENAME(支持多种格式)
325
- // 格式1: const CONFIG_FILENAME = "config.json"
326
- // 格式2: CONFIG_FILENAME="config.json"
327
- html = html.replace(/CONFIG_FILENAME\s*=\s*["']config\.json["']/g, `CONFIG_FILENAME = "${escapedConfigDataUrl}"`);
328
- // ⚠️ 重要:Vite 压缩后,变量名会被混淆(如 CONFIG_FILENAME vh)
329
- // 需要直接替换 .configure("config.json" 调用中的字符串字面量
330
- // 格式: .configure("config.json", ...) 或 .configure('config.json', ...)
342
+ // 方案1+3:直接将 CONFIG_FILENAME = "config.json" 替换为 data URL
343
+ // 8.1 替换 CONFIG_FILENAME
344
+ // 格式1: CONFIG_FILENAME = "config.json"
345
+ // 格式2: CONFIG_FILENAME="config.json" (压缩后)
346
+ const configReplaceCount = (html.match(/CONFIG_FILENAME\s*=\s*["']config\.json["']/g) || []).length;
347
+ if (configReplaceCount > 0) {
348
+ html = html.replace(/CONFIG_FILENAME\s*=\s*["']config\.json["']/g, `CONFIG_FILENAME="${configDataUrl}"`);
349
+ console.log(`[PlayCanvasPlugin] ESM Bundle: 替换了 ${configReplaceCount} CONFIG_FILENAME`);
350
+ }
351
+ // 8.2 替换 configure("config.json") 调用
352
+ // Vite 压缩后可能是: .configure("config.json",
331
353
  const configureReplaceCount = (html.match(/\.configure\s*\(\s*["']config\.json["']/g) || []).length;
332
354
  if (configureReplaceCount > 0) {
333
- html = html.replace(/\.configure\s*\(\s*["']config\.json["']/g, `.configure("${escapedConfigDataUrl}"`);
334
- console.log(`[PlayCanvasPlugin] ESM Bundle: 替换了 ${configureReplaceCount} 处 .configure("config.json") 调用`);
355
+ html = html.replace(/\.configure\s*\(\s*["']config\.json["']/g, `.configure("${configDataUrl}"`);
356
+ console.log(`[PlayCanvasPlugin] ESM Bundle: 替换了 ${configureReplaceCount} 处 .configure("config.json")`);
335
357
  }
336
358
  // 9. 替换 SCENE_PATH
337
359
  if (sceneDataUrl) {
@@ -780,6 +802,33 @@ async function inlineLoadingScript(html, baseBuildDir) {
780
802
  }
781
803
  return html;
782
804
  }
805
+ /**
806
+ * 压缩 CSS 代码
807
+ * 移除注释、多余空白、合并为单行
808
+ */
809
+ function minifyCSS(css) {
810
+ return css
811
+ // 移除多行注释 /* ... */
812
+ .replace(/\/\*[\s\S]*?\*\//g, '')
813
+ // 移除单行注释 // ... (CSS 不标准但有些预处理器会保留)
814
+ .replace(/\/\/.*$/gm, '')
815
+ // 将多个空白字符(空格、换行、制表符)替换为单个空格
816
+ .replace(/\s+/g, ' ')
817
+ // 移除 { 前的空格
818
+ .replace(/\s*\{\s*/g, '{')
819
+ // 移除 } 后的空格
820
+ .replace(/\s*\}\s*/g, '}')
821
+ // 移除 : 前后的空格
822
+ .replace(/\s*:\s*/g, ':')
823
+ // 移除 ; 前后的空格
824
+ .replace(/\s*;\s*/g, ';')
825
+ // 移除 , 后的空格
826
+ .replace(/,\s*/g, ',')
827
+ // 移除最后一个 ; 在 } 前
828
+ .replace(/;}/g, '}')
829
+ // 去除首尾空白
830
+ .trim();
831
+ }
783
832
  /**
784
833
  * 内联 CSS 文件
785
834
  */
@@ -799,6 +848,8 @@ async function inlineCSS(html, baseBuildDir, options) {
799
848
  if (options.mraidSupport && !cssContent.includes('fill-mode-NONE')) {
800
849
  cssContent += '\n#application-canvas.fill-mode-NONE { margin: 0; width: 100%; height: 100%; }\n';
801
850
  }
851
+ // 压缩 CSS(移除注释、多余空白、合并为单行)
852
+ cssContent = minifyCSS(cssContent);
802
853
  // 替换 link 标签为 style 标签
803
854
  html = html.replace(match[0], `<style>${cssContent}</style>`);
804
855
  }
@@ -1004,10 +1055,14 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
1004
1055
  }
1005
1056
  }
1006
1057
  // ✅ 核心优化:如果是图片,使用 sharp 压缩
1058
+ // ⚠️ 重要:字体纹理(type === 'font')不能转换为 WebP!
1059
+ // MSDF/位图字体的 PNG 纹理包含精确的距离场/像素数据,
1060
+ // WebP 有损压缩会破坏这些数据导致字体渲染异常
1007
1061
  else if (isImageFile(cleanUrl)) {
1062
+ const isFontTexture = asset.type === 'font';
1008
1063
  const compressed = await compressImage(buffer, cleanUrl, {
1009
- convertToWebP: true,
1010
- quality: 75,
1064
+ convertToWebP: !isFontTexture, // 字体纹理不转换为 WebP
1065
+ quality: isFontTexture ? 100 : 75, // 字体纹理使用无损压缩
1011
1066
  });
1012
1067
  // 防御性检查:如果压缩后的 buffer 为空,使用原始 buffer
1013
1068
  if (!compressed.buffer || compressed.buffer.length === 0) {
@@ -1196,10 +1251,15 @@ async function compressImage(buffer, filePath, options) {
1196
1251
  };
1197
1252
  }
1198
1253
  // 按原格式压缩
1254
+ const quality = options?.quality ?? 80;
1199
1255
  switch (ext) {
1200
1256
  case '.png':
1257
+ // 如果 quality >= 100,使用无损压缩(适用于字体纹理)
1201
1258
  const pngBuffer = await image
1202
- .png({ quality: 80, compressionLevel: 9 })
1259
+ .png({
1260
+ compressionLevel: quality >= 100 ? 6 : 9, // 无损时使用中等压缩
1261
+ palette: quality < 100, // 无损时不使用调色板
1262
+ })
1203
1263
  .toBuffer();
1204
1264
  // 防御性检查
1205
1265
  if (!pngBuffer || pngBuffer.length === 0) {
@@ -1295,7 +1355,9 @@ function buildConfigValue(configJson) {
1295
1355
  function applyMraidConfig(configJson) {
1296
1356
  const next = { ...configJson };
1297
1357
  const props = { ...(next.application_properties || {}) };
1298
- props.fillMode = 'NONE';
1358
+ // Keep original fillMode (usually KEEP_ASPECT) for correct canvas scaling
1359
+ // The MRAID resize patch will handle the sizing
1360
+ // props.fillMode = 'NONE'; // Removed: causes canvas scaling issues
1299
1361
  next.application_properties = props;
1300
1362
  return next;
1301
1363
  }
@@ -1339,6 +1401,18 @@ async function getEnginePatchScripts(options) {
1339
1401
  const patchCode = await readPatchFile('one-page-mraid-resize-canvas.js');
1340
1402
  scripts.push(`<script>${patchCode}</script>`);
1341
1403
  }
1404
+ // 如果启用了 config.json 压缩,需要注入 LZ4 解压运行时代码
1405
+ // 注意:必须在 compressEngine 之前检查,因为 compressEngine 也会注入 lz4.js
1406
+ if (options.compressConfigJson && !options.compressEngine) {
1407
+ // 如果没有启用引擎压缩,需要单独注入 lz4.js
1408
+ const lz4Script = await getLz4InlineScript();
1409
+ scripts.push(lz4Script);
1410
+ }
1411
+ // 注入 config.json 解压 patch(拦截 fetch 请求并解压 LZ4 数据)
1412
+ if (options.compressConfigJson) {
1413
+ const configDecompressPatch = buildConfigJsonDecompressPatch();
1414
+ scripts.push(`<script>${configDecompressPatch}</script>`);
1415
+ }
1342
1416
  if (options.compressEngine) {
1343
1417
  // Focus patch: 确保 canvas 获得焦点以接收键盘事件
1344
1418
  const focusPatch = `!function(){var e=function(){var e=document.getElementById("application-canvas");if(!e)return!1;try{e.focus()}catch(t){}e.addEventListener("pointerdown",function(){e.focus()}),e.addEventListener("click",function(){e.focus()});return!0},t=0;if(!e()){var n=setInterval(function(){(e()||++t>50)&&clearInterval(n)},100)}}();`;
@@ -1379,3 +1453,69 @@ async function readPatchFile(name) {
1379
1453
  const patchPath = path.resolve(currentDir, '../../templates/patches', name);
1380
1454
  return await fs.readFile(patchPath, 'utf-8');
1381
1455
  }
1456
+ /**
1457
+ * 格式化字节数为人类可读格式
1458
+ */
1459
+ function formatBytes(bytes) {
1460
+ if (bytes === 0)
1461
+ return '0 B';
1462
+ const k = 1024;
1463
+ const sizes = ['B', 'KB', 'MB', 'GB'];
1464
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1465
+ return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
1466
+ }
1467
+ /**
1468
+ * 构建 config.json 解压 patch
1469
+ *
1470
+ * 这个 patch 拦截 PlayCanvas 的 pc.Http.prototype.get 请求,
1471
+ * 检测是否为 LZ4 压缩的 config.json,如果是则解压后返回。
1472
+ *
1473
+ * 工作原理:
1474
+ * 1. 保存原始 pc.Http.prototype.get 函数
1475
+ * 2. 重写 get,检测 URL 是否为 LZ4 压缩的 JSON data URL
1476
+ * 3. 如果是,解压并通过 callback 返回解析后的 JSON 对象
1477
+ *
1478
+ * 注意:
1479
+ * - PlayCanvas 使用 pc.Http.prototype.get 加载 config.json,不是 fetch
1480
+ * - 使用 lz4.js 提供的 Buffer polyfill 进行解压
1481
+ * - 必须在 pc 对象可用后才能生效,所以使用轮询等待
1482
+ */
1483
+ function buildConfigJsonDecompressPatch() {
1484
+ // 原始代码:
1485
+ // (function() {
1486
+ // function patch() {
1487
+ // if (!window.pc || !window.pc.Http) return false;
1488
+ // var oldGet = pc.Http.prototype.get;
1489
+ // pc.Http.prototype.get = function(url, options, callback) {
1490
+ // if (typeof options === 'function') {
1491
+ // callback = options;
1492
+ // options = {};
1493
+ // }
1494
+ // if (typeof url === 'string' && url.startsWith('data:application/x-lz4-json;base64,')) {
1495
+ // try {
1496
+ // var base64 = url.replace('data:application/x-lz4-json;base64,', '');
1497
+ // var compressed = new Buffer(base64, 'base64');
1498
+ // var decompressed = lz4.decompress(compressed);
1499
+ // var str = Buffer.from(decompressed).toString('utf8');
1500
+ // var json = JSON.parse(str);
1501
+ // console.log('[LZ4 Config] Decompressed config.json successfully, size:', str.length);
1502
+ // callback(null, json);
1503
+ // } catch(e) {
1504
+ // console.error('[LZ4 Config] Decompression error:', e);
1505
+ // callback(e);
1506
+ // }
1507
+ // return;
1508
+ // }
1509
+ // oldGet.call(this, url, options, callback);
1510
+ // };
1511
+ // return true;
1512
+ // }
1513
+ // if (!patch()) {
1514
+ // var c = 0;
1515
+ // var i = setInterval(function() {
1516
+ // if (patch() || ++c > 100) clearInterval(i);
1517
+ // }, 10);
1518
+ // }
1519
+ // })();
1520
+ return `!function(){function p(){if(!window.pc||!pc.Http)return!1;var o=pc.Http.prototype.get;return pc.Http.prototype.get=function(e,t,n){"function"==typeof t&&(n=t,t={});if("string"==typeof e&&e.startsWith("data:application/x-lz4-json;base64,")){try{var r=e.replace("data:application/x-lz4-json;base64,",""),a=new Buffer(r,"base64"),d=lz4.decompress(a),s=Buffer.from(d).toString("utf8"),j=JSON.parse(s);console.log("[LZ4 Config] Decompressed config.json successfully, size:",s.length);n(null,j)}catch(x){console.error("[LZ4 Config] Decompression error:",x);n(x)}return}o.call(this,e,t,n)},!0}if(!p()){var c=0,i=setInterval(function(){(p()||++c>100)&&clearInterval(i)},10)}}();`;
1521
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Vite 插件:模板字符串压缩
3
+ *
4
+ * 在构建完成后(closeBundle 阶段)对输出的 HTML 文件进行后处理,
5
+ * 压缩 JS 代码中的多行模板字符串,减小包体积。
6
+ *
7
+ * 这个插件必须在 vite-plugin-singlefile 之后运行,
8
+ * 因为它直接处理最终输出的文件,而不是通过 Vite 的 HTML 处理管道。
9
+ */
10
+ import type { Plugin } from 'vite';
11
+ export interface TemplateMinifierOptions {
12
+ /** 输出目录 */
13
+ outputDir: string;
14
+ /** 是否启用(默认 true) */
15
+ enabled?: boolean;
16
+ }
17
+ /**
18
+ * 创建模板字符串压缩插件
19
+ */
20
+ export declare function viteTemplateMinifierPlugin(options: TemplateMinifierOptions): Plugin;
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Vite 插件:模板字符串压缩
3
+ *
4
+ * 在构建完成后(closeBundle 阶段)对输出的 HTML 文件进行后处理,
5
+ * 压缩 JS 代码中的多行模板字符串,减小包体积。
6
+ *
7
+ * 这个插件必须在 vite-plugin-singlefile 之后运行,
8
+ * 因为它直接处理最终输出的文件,而不是通过 Vite 的 HTML 处理管道。
9
+ */
10
+ import fs from 'fs/promises';
11
+ import path from 'path';
12
+ /**
13
+ * 压缩 JS 代码中的模板字符串
14
+ *
15
+ * 处理策略:
16
+ * 1. 找到所有多行模板字符串(包含 \n 的反引号字符串)
17
+ * 2. 对于不包含 ${...} 的简单模板字符串,将换行和多余空白压缩
18
+ * 3. 对于包含 ${...} 的复杂模板字符串,保守处理
19
+ */
20
+ function minifyTemplateStrings(code) {
21
+ const result = [];
22
+ let i = 0;
23
+ let minifiedCount = 0;
24
+ let totalSaved = 0;
25
+ while (i < code.length) {
26
+ // 跳过字符串字面量(避免误处理)
27
+ if (code[i] === '"' || code[i] === "'") {
28
+ const quote = code[i];
29
+ result.push(code[i]);
30
+ i++;
31
+ while (i < code.length && code[i] !== quote) {
32
+ if (code[i] === '\\' && i + 1 < code.length) {
33
+ result.push(code[i], code[i + 1]);
34
+ i += 2;
35
+ }
36
+ else {
37
+ result.push(code[i]);
38
+ i++;
39
+ }
40
+ }
41
+ if (i < code.length) {
42
+ result.push(code[i]);
43
+ i++;
44
+ }
45
+ continue;
46
+ }
47
+ // 处理模板字符串
48
+ if (code[i] === '`') {
49
+ const startPos = i;
50
+ i++; // 跳过开始的反引号
51
+ // 收集模板字符串内容
52
+ let content = '';
53
+ let nestingLevel = 0; // 跟踪 ${...} 嵌套
54
+ while (i < code.length) {
55
+ if (code[i] === '\\' && i + 1 < code.length) {
56
+ // 转义字符
57
+ content += code[i] + code[i + 1];
58
+ i += 2;
59
+ }
60
+ else if (code[i] === '$' && code[i + 1] === '{') {
61
+ // 模板表达式开始
62
+ content += '${';
63
+ i += 2;
64
+ nestingLevel++;
65
+ // 找到匹配的 }
66
+ let braceCount = 1;
67
+ while (i < code.length && braceCount > 0) {
68
+ if (code[i] === '{') {
69
+ braceCount++;
70
+ content += code[i];
71
+ }
72
+ else if (code[i] === '}') {
73
+ braceCount--;
74
+ content += code[i];
75
+ }
76
+ else if (code[i] === '`') {
77
+ // 嵌套的模板字符串,递归处理
78
+ content += code[i];
79
+ i++;
80
+ while (i < code.length && code[i] !== '`') {
81
+ if (code[i] === '\\' && i + 1 < code.length) {
82
+ content += code[i] + code[i + 1];
83
+ i += 2;
84
+ }
85
+ else {
86
+ content += code[i];
87
+ i++;
88
+ }
89
+ }
90
+ if (i < code.length) {
91
+ content += code[i];
92
+ }
93
+ }
94
+ else if (code[i] === '"' || code[i] === "'") {
95
+ // 嵌套的字符串
96
+ const quote = code[i];
97
+ content += code[i];
98
+ i++;
99
+ while (i < code.length && code[i] !== quote) {
100
+ if (code[i] === '\\' && i + 1 < code.length) {
101
+ content += code[i] + code[i + 1];
102
+ i += 2;
103
+ }
104
+ else {
105
+ content += code[i];
106
+ i++;
107
+ }
108
+ }
109
+ if (i < code.length) {
110
+ content += code[i];
111
+ }
112
+ }
113
+ else {
114
+ content += code[i];
115
+ }
116
+ i++;
117
+ }
118
+ nestingLevel--;
119
+ }
120
+ else if (code[i] === '`') {
121
+ // 找到结束的反引号
122
+ break;
123
+ }
124
+ else {
125
+ content += code[i];
126
+ i++;
127
+ }
128
+ }
129
+ const originalLength = content.length;
130
+ // 决定是否压缩这个模板字符串
131
+ if (shouldMinifyTemplate(content)) {
132
+ const minified = minifyTemplateContent(content);
133
+ result.push('`' + minified + '`');
134
+ const saved = originalLength - minified.length;
135
+ if (saved > 0) {
136
+ minifiedCount++;
137
+ totalSaved += saved;
138
+ }
139
+ }
140
+ else {
141
+ result.push('`' + content + '`');
142
+ }
143
+ i++; // 跳过结束的反引号
144
+ }
145
+ else {
146
+ result.push(code[i]);
147
+ i++;
148
+ }
149
+ }
150
+ if (minifiedCount > 0) {
151
+ console.log(`[TemplateMinifier] 压缩了 ${minifiedCount} 个模板字符串,节省约 ${(totalSaved / 1024).toFixed(2)} KB`);
152
+ }
153
+ return result.join('');
154
+ }
155
+ /**
156
+ * 判断是否应该压缩这个模板字符串
157
+ */
158
+ function shouldMinifyTemplate(content) {
159
+ // 必须包含换行才需要压缩
160
+ if (!content.includes('\n')) {
161
+ return false;
162
+ }
163
+ // 长度太短不值得压缩
164
+ if (content.length < 50) {
165
+ return false;
166
+ }
167
+ // 着色器代码也需要压缩!不要跳过
168
+ return true;
169
+ }
170
+ /**
171
+ * 判断是否是着色器代码(GLSL 或 WGSL)
172
+ */
173
+ function isShaderCode(content) {
174
+ // GLSL 关键特征
175
+ const glslKeywords = [
176
+ /\buniform\s+\w+/,
177
+ /\bvarying\s+\w+/,
178
+ /\battribute\s+\w+/,
179
+ /\bvoid\s+main\s*\(/,
180
+ /\bgl_Position\b/,
181
+ /\bgl_FragColor\b/,
182
+ /\bgl_FragCoord\b/,
183
+ /\bprecision\s+(highp|mediump|lowp)/,
184
+ /\b(vec2|vec3|vec4|mat3|mat4|sampler2D)\b/,
185
+ /\btexture2D\s*\(/,
186
+ /\btexelFetch\s*\(/,
187
+ ];
188
+ // WGSL 关键特征
189
+ const wgslKeywords = [
190
+ /@(vertex|fragment|compute)\b/,
191
+ /@(group|binding)\s*\(/,
192
+ /@builtin\s*\(/,
193
+ /@workgroup_size\s*\(/,
194
+ /\bfn\s+\w+\s*\(/,
195
+ /\bvar<\w+>/,
196
+ /\b(vec2f|vec3f|vec4f|vec2i|vec3i|vec4i|vec2u|vec3u|vec4u)\b/,
197
+ /\bstruct\s+\w+\s*\{/,
198
+ /\btextureLoad\s*\(/,
199
+ /\btextureSample\s*\(/,
200
+ /\bworkgroupBarrier\s*\(/,
201
+ /\barray<\w+/,
202
+ ];
203
+ const allKeywords = [...glslKeywords, ...wgslKeywords];
204
+ let matchCount = 0;
205
+ for (const keyword of allKeywords) {
206
+ if (keyword.test(content)) {
207
+ matchCount++;
208
+ if (matchCount >= 2) {
209
+ return true;
210
+ }
211
+ }
212
+ }
213
+ return false;
214
+ }
215
+ /**
216
+ * 压缩模板字符串内容
217
+ */
218
+ function minifyTemplateContent(content) {
219
+ // 检查是否是着色器代码
220
+ if (isShaderCode(content)) {
221
+ return minifyShaderContent(content);
222
+ }
223
+ // 检查是否包含 ${...} 模板表达式
224
+ const hasTemplateExpr = /\$\{/.test(content);
225
+ if (hasTemplateExpr) {
226
+ // 包含模板表达式,保守处理
227
+ // 只压缩纯空白行和行首缩进,保留换行结构
228
+ return content
229
+ // 移除行首缩进(保留换行)
230
+ .replace(/\n[ \t]+/g, '\n')
231
+ // 移除多个连续空行
232
+ .replace(/\n{3,}/g, '\n\n')
233
+ // 移除开头的空白
234
+ .replace(/^[ \t\n]+/, '')
235
+ // 移除结尾的空白
236
+ .replace(/[ \t\n]+$/, '');
237
+ }
238
+ // 不包含模板表达式,可以更激进地压缩
239
+ // 但要注意保留字符串内容的语义
240
+ // 检查是否像是 JSON 或配置数据
241
+ const trimmed = content.trim();
242
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
243
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
244
+ // 可能是 JSON,尝试压缩
245
+ try {
246
+ // 尝试解析为 JSON,然后重新序列化为紧凑格式
247
+ const parsed = JSON.parse(trimmed);
248
+ return JSON.stringify(parsed);
249
+ }
250
+ catch {
251
+ // 不是有效 JSON,按普通文本处理
252
+ }
253
+ }
254
+ // 普通多行文本,压缩空白但保留必要的空格
255
+ return content
256
+ // 将换行和周围空白替换为单个空格
257
+ .replace(/\s*\n\s*/g, ' ')
258
+ // 合并多个空格
259
+ .replace(/ +/g, ' ')
260
+ .trim();
261
+ }
262
+ /**
263
+ * 压缩着色器代码(GLSL/WGSL)
264
+ *
265
+ * 着色器代码需要特殊处理:
266
+ * 1. 预处理器指令(#define, #ifdef 等)需要独占一行
267
+ * 2. 可以移除多余的空白和缩进
268
+ * 3. 保留关键字之间的空格
269
+ */
270
+ function minifyShaderContent(code) {
271
+ const lines = code.split('\n');
272
+ const processedLines = [];
273
+ for (let line of lines) {
274
+ // 移除行首尾空白
275
+ line = line.trim();
276
+ // 跳过空行
277
+ if (!line)
278
+ continue;
279
+ // 跳过单行注释
280
+ if (line.startsWith('//'))
281
+ continue;
282
+ // 预处理器指令需要独占一行(#define, #ifdef, #endif, #include 等)
283
+ if (line.startsWith('#')) {
284
+ processedLines.push('\n' + line);
285
+ }
286
+ else {
287
+ // 压缩行内多余空白,但保守处理
288
+ line = line
289
+ // 将多个空白替换为单个空格
290
+ .replace(/\s+/g, ' ')
291
+ // 移除分号后的空格(但不移除分号)
292
+ .replace(/;\s+/g, ';')
293
+ // 移除逗号后多余空格(保留一个)
294
+ .replace(/,\s+/g, ', ')
295
+ // 移除括号内侧的空格
296
+ .replace(/\(\s+/g, '(')
297
+ .replace(/\s+\)/g, ')')
298
+ // 移除花括号内侧的空格
299
+ .replace(/\{\s+/g, '{')
300
+ .replace(/\s+\}/g, '}')
301
+ // 移除方括号内侧的空格
302
+ .replace(/\[\s+/g, '[')
303
+ .replace(/\s+\]/g, ']')
304
+ // 移除冒号周围的空格(WGSL 类型声明)
305
+ .replace(/\s*:\s*/g, ':')
306
+ // 移除箭头周围的空格(WGSL 返回类型)
307
+ .replace(/\s*->\s*/g, '->')
308
+ .trim();
309
+ if (line) {
310
+ processedLines.push(line);
311
+ }
312
+ }
313
+ }
314
+ // 合并非预处理器行,预处理器指令保留换行
315
+ let result = '';
316
+ for (const line of processedLines) {
317
+ if (line.startsWith('\n')) {
318
+ // 预处理器指令,保留换行
319
+ result += line;
320
+ }
321
+ else {
322
+ // 普通代码,用换行连接(保守做法,避免语法错误)
323
+ if (result && !result.endsWith('\n')) {
324
+ result += '\n';
325
+ }
326
+ result += line;
327
+ }
328
+ }
329
+ return result.trim();
330
+ }
331
+ /**
332
+ * 创建模板字符串压缩插件
333
+ */
334
+ export function viteTemplateMinifierPlugin(options) {
335
+ const { outputDir, enabled = true } = options;
336
+ return {
337
+ name: 'vite-plugin-template-minifier',
338
+ enforce: 'post', // 在其他插件之后执行
339
+ async closeBundle() {
340
+ if (!enabled) {
341
+ return;
342
+ }
343
+ // 查找输出目录中的 HTML 文件
344
+ const htmlPath = path.join(outputDir, 'index.html');
345
+ try {
346
+ await fs.access(htmlPath);
347
+ }
348
+ catch {
349
+ // HTML 文件不存在,跳过
350
+ return;
351
+ }
352
+ console.log('[TemplateMinifier] 开始压缩模板字符串...');
353
+ try {
354
+ let html = await fs.readFile(htmlPath, 'utf-8');
355
+ const originalSize = html.length;
356
+ // 压缩 HTML 中的 JS 代码里的模板字符串
357
+ // 找到所有 <script> 标签并处理其内容
358
+ html = html.replace(/<script([^>]*)>([\s\S]*?)<\/script>/gi, (match, attrs, content) => {
359
+ // 跳过外部脚本
360
+ if (/\bsrc\s*=/.test(attrs)) {
361
+ return match;
362
+ }
363
+ // 跳过非 JS 脚本(如 JSON、importmap)
364
+ const typeMatch = attrs.match(/\btype\s*=\s*["']([^"']+)["']/i);
365
+ if (typeMatch) {
366
+ const type = typeMatch[1].toLowerCase();
367
+ if (type === 'application/json' ||
368
+ type === 'importmap' ||
369
+ type === 'application/ld+json') {
370
+ return match;
371
+ }
372
+ }
373
+ // 压缩 JS 中的模板字符串
374
+ const minified = minifyTemplateStrings(content);
375
+ return `<script${attrs}>${minified}</script>`;
376
+ });
377
+ const newSize = html.length;
378
+ const saved = originalSize - newSize;
379
+ if (saved > 0) {
380
+ await fs.writeFile(htmlPath, html, 'utf-8');
381
+ console.log(`[TemplateMinifier] 完成!节省 ${(saved / 1024).toFixed(2)} KB (${((saved / originalSize) * 100).toFixed(2)}%)`);
382
+ }
383
+ else {
384
+ console.log('[TemplateMinifier] 没有可压缩的内容');
385
+ }
386
+ }
387
+ catch (error) {
388
+ console.error('[TemplateMinifier] 压缩失败:', error);
389
+ }
390
+ },
391
+ };
392
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/build",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,10 +9,13 @@
9
9
  var windowWidth = window.innerWidth;
10
10
  var windowHeight = window.innerHeight;
11
11
 
12
- if (window.mraid) {
12
+ // Get MRAID max size if available
13
+ if (window.mraid && typeof mraid.getMaxSize === 'function') {
13
14
  var mraidSize = mraid.getMaxSize();
14
- windowWidth = mraidSize.width;
15
- windowHeight = mraidSize.height;
15
+ if (mraidSize && mraidSize.width && mraidSize.height) {
16
+ windowWidth = mraidSize.width;
17
+ windowHeight = mraidSize.height;
18
+ }
16
19
  }
17
20
 
18
21
  if (this._fillMode === pc.FILLMODE_KEEP_ASPECT) {
@@ -29,8 +32,19 @@
29
32
  } else if (this._fillMode === pc.FILLMODE_FILL_WINDOW) {
30
33
  width = windowWidth;
31
34
  height = windowHeight;
35
+ } else {
36
+ // FILLMODE_NONE: For MRAID, use KEEP_ASPECT behavior to ensure correct sizing
37
+ var r = this.graphicsDevice.canvas.width / this.graphicsDevice.canvas.height;
38
+ var winR = windowWidth / windowHeight;
39
+
40
+ if (r > winR) {
41
+ width = windowWidth;
42
+ height = width / r;
43
+ } else {
44
+ height = windowHeight;
45
+ width = height * r;
46
+ }
32
47
  }
33
- // OTHERWISE: FILLMODE_NONE use width and height that are provided
34
48
 
35
49
  this.graphicsDevice.canvas.style.width = width + 'px';
36
50
  this.graphicsDevice.canvas.style.height = height + 'px';