@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.
- package/dist/analyzers/__tests__/optimization-analyzer.test.d.ts +1 -0
- package/dist/analyzers/__tests__/optimization-analyzer.test.js +169 -0
- package/dist/analyzers/playable-analyzer.js +3 -2
- package/dist/base-builder.js +9 -3
- package/dist/platforms/adikteev.js +4 -2
- package/dist/platforms/applovin.js +9 -3
- package/dist/platforms/inmobi.js +4 -2
- package/dist/platforms/ironsource.js +4 -1
- package/dist/platforms/liftoff.js +8 -3
- package/dist/platforms/snapchat.js +8 -2
- package/dist/platforms/unity.js +8 -2
- package/dist/playable-builder.js +3 -1
- package/dist/types.d.ts +1 -0
- package/dist/vite/config-builder.js +3 -0
- package/dist/vite/platform-configs.d.ts +1 -0
- package/dist/vite/platform-configs.js +1 -0
- package/dist/vite/plugin-build-state.d.ts +2 -0
- package/dist/vite/plugin-build-state.js +5 -3
- package/dist/vite/plugin-esm-html-generator.js +11 -2
- package/dist/vite/plugin-platform.js +3 -1
- package/dist/vite/plugin-playcanvas.d.ts +1 -0
- package/dist/vite/plugin-playcanvas.js +160 -20
- package/dist/vite/plugin-template-minifier.d.ts +20 -0
- package/dist/vite/plugin-template-minifier.js +392 -0
- package/package.json +1 -1
- package/templates/patches/one-page-mraid-resize-canvas.js +18 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 =
|
|
34
|
+
const stateManager = new BuildStateManager(this.outputDir, this.outputDir);
|
|
35
|
+
const stateLoaded = await stateManager.loadState();
|
|
35
36
|
let assets;
|
|
36
|
-
if (
|
|
37
|
+
if (stateLoaded) {
|
|
37
38
|
console.log('[PlayableAnalyzer] 使用 build-state.json 的数据');
|
|
38
39
|
assets = this.extractAssetsFromState(stateManager, htmlSize);
|
|
39
40
|
}
|
package/dist/base-builder.js
CHANGED
|
@@ -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
|
-
|
|
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 `
|
package/dist/platforms/inmobi.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 `
|
package/dist/platforms/unity.js
CHANGED
|
@@ -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
|
-
|
|
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 `
|
package/dist/playable-builder.js
CHANGED
|
@@ -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': "
|
|
252
|
+
'powerPreference': "${powerPreference}"
|
|
251
253
|
};
|
|
252
254
|
window.SCRIPTS = [${scripts.join(', ')}];
|
|
253
255
|
window.CONFIG_FILENAME = "${configDataUrl}";
|
package/dist/types.d.ts
CHANGED
|
@@ -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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
-
|
|
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': "
|
|
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
|
-
|
|
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"
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
//
|
|
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("${
|
|
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:
|
|
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({
|
|
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
|
-
|
|
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
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
var windowWidth = window.innerWidth;
|
|
10
10
|
var windowHeight = window.innerHeight;
|
|
11
11
|
|
|
12
|
-
if
|
|
12
|
+
// Get MRAID max size if available
|
|
13
|
+
if (window.mraid && typeof mraid.getMaxSize === 'function') {
|
|
13
14
|
var mraidSize = mraid.getMaxSize();
|
|
14
|
-
|
|
15
|
-
|
|
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';
|