@datamoc/mw_games 0.9.1 → 0.11.0
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/CHANGELOG.md +41 -0
- package/README.md +4 -2
- package/dist/assets/loader.d.ts.map +1 -1
- package/dist/assets/loader.js +22 -2
- package/dist/assets/loader.js.map +1 -1
- package/dist/mw_games.global.js +20 -20
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +8 -1
- package/tools/compile-resources.d.mts +29 -0
- package/tools/compile-resources.mjs +61 -7
- package/tools/single-file.d.mts +17 -0
- package/tools/single-file.mjs +224 -0
- package/tools/webp-convert.d.mts +8 -0
- package/tools/webp-convert.mjs +90 -0
package/dist/version.d.ts
CHANGED
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
* this from `package.json` automatically, chosen because this project's own build already
|
|
10
10
|
* has no step before `tsc`/`vite` that this would need to run ahead of.
|
|
11
11
|
*/
|
|
12
|
-
export declare const version = "0.
|
|
12
|
+
export declare const version = "0.11.0";
|
|
13
13
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,OAAO,
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,OAAO,WAAW,CAAC"}
|
package/dist/version.js
CHANGED
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
* this from `package.json` automatically, chosen because this project's own build already
|
|
10
10
|
* has no step before `tsc`/`vite` that this would need to run ahead of.
|
|
11
11
|
*/
|
|
12
|
-
export const version = '0.
|
|
12
|
+
export const version = '0.11.0';
|
|
13
13
|
//# sourceMappingURL=version.js.map
|
package/dist/version.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,
|
|
1
|
+
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,QAAQ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datamoc/mw_games",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "A local-file game framework: renderer-free game logic, drawn through PixiJS in 2D or Babylon.js in 3D, with mobile and desktop packaging.",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -39,12 +39,17 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"dist",
|
|
41
41
|
"tools/compile-resources.mjs",
|
|
42
|
+
"tools/compile-resources.d.mts",
|
|
43
|
+
"tools/webp-convert.mjs",
|
|
44
|
+
"tools/webp-convert.d.mts",
|
|
42
45
|
"tools/extract-html.mjs",
|
|
43
46
|
"tools/extract-html.d.mts",
|
|
44
47
|
"tools/compress-dist.mjs",
|
|
45
48
|
"tools/compress-dist.d.mts",
|
|
46
49
|
"tools/classic-html.mjs",
|
|
47
50
|
"tools/classic-html.d.mts",
|
|
51
|
+
"tools/single-file.mjs",
|
|
52
|
+
"tools/single-file.d.mts",
|
|
48
53
|
"tools/mwl.mjs",
|
|
49
54
|
"LICENSE",
|
|
50
55
|
"CHANGELOG.md",
|
|
@@ -131,6 +136,7 @@
|
|
|
131
136
|
},
|
|
132
137
|
"./tools/compile-resources": "./tools/compile-resources.mjs",
|
|
133
138
|
"./tools/compress-dist": "./tools/compress-dist.mjs",
|
|
139
|
+
"./tools/webp-convert": "./tools/webp-convert.mjs",
|
|
134
140
|
"./two-d": {
|
|
135
141
|
"types": "./dist/two-d/index.d.ts",
|
|
136
142
|
"import": "./dist/two-d/index.js"
|
|
@@ -290,6 +296,7 @@
|
|
|
290
296
|
"pixi.js": "^8.20.1",
|
|
291
297
|
"playwright-core": "^1.62.1",
|
|
292
298
|
"prettier": "^3.9.6",
|
|
299
|
+
"sharp": "^0.35.4",
|
|
293
300
|
"typescript": "^7.0.2",
|
|
294
301
|
"vite": "^8.2.2",
|
|
295
302
|
"ws": "^8.21.3"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const DEFAULT_MIME: Record<string, string>;
|
|
2
|
+
|
|
3
|
+
export function walk(dir: string): AsyncGenerator<string>;
|
|
4
|
+
|
|
5
|
+
export interface CompileResourcesOptions {
|
|
6
|
+
from: string;
|
|
7
|
+
to: string;
|
|
8
|
+
mime?: Record<string, string>;
|
|
9
|
+
groupBy?: (key: string) => string;
|
|
10
|
+
toWebp?: boolean;
|
|
11
|
+
webpLossless?: boolean;
|
|
12
|
+
webpQuality?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CompileResourcesGroup {
|
|
16
|
+
name: string;
|
|
17
|
+
count: number;
|
|
18
|
+
size: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CompileResourcesResult {
|
|
22
|
+
groups: CompileResourcesGroup[];
|
|
23
|
+
rawBytes: number;
|
|
24
|
+
embeddedBytes: number;
|
|
25
|
+
webpConverted: number;
|
|
26
|
+
skipped: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function compileResources(options: CompileResourcesOptions): Promise<CompileResourcesResult>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
2
2
|
import { join, extname, relative, sep, dirname, basename } from 'node:path';
|
|
3
|
+
import { WEBP_CONVERTIBLE_EXTENSIONS } from './webp-convert.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Turns a folder of assets into scripts a `file://` page can load.
|
|
@@ -56,9 +57,27 @@ export async function* walk(dir) {
|
|
|
56
57
|
* @param options.to folder to write the generated scripts into
|
|
57
58
|
* @param options.mime extension to MIME type, merged over the defaults
|
|
58
59
|
* @param options.groupBy how to name the script an asset lands in; defaults to its top folder
|
|
60
|
+
* @param options.toWebp convert `.png`/`.jpg`/`.jpeg` assets to WebP before embedding
|
|
61
|
+
* (default false - see `tools/webp-convert.mjs`). The asset's *key* never changes (a game
|
|
62
|
+
* still calls `load('tiles.png')` in both dev and built modes; dev mode serves the literal
|
|
63
|
+
* file from disk, unconverted), only the embedded `data:` URI's bytes and MIME type do, and
|
|
64
|
+
* only when the WebP result is actually smaller - a conversion that would not save
|
|
65
|
+
* anything is silently skipped rather than embedded anyway.
|
|
66
|
+
* @param options.webpLossless use WebP's lossless mode when converting (default true - a
|
|
67
|
+
* pixel-art tileset's exact colours survive; set false only with `webpQuality` to opt into
|
|
68
|
+
* lossy re-encoding for photographic source art)
|
|
69
|
+
* @param options.webpQuality 0-100, used only when `webpLossless` is false (default 90)
|
|
59
70
|
* @returns the group names, in the order the page should load them
|
|
60
71
|
*/
|
|
61
|
-
export async function compileResources({
|
|
72
|
+
export async function compileResources({
|
|
73
|
+
from,
|
|
74
|
+
to,
|
|
75
|
+
mime = {},
|
|
76
|
+
groupBy,
|
|
77
|
+
toWebp: convertToWebp = false,
|
|
78
|
+
webpLossless = true,
|
|
79
|
+
webpQuality = 90,
|
|
80
|
+
} = {}) {
|
|
62
81
|
if (!from || !to) throw new Error('compileResources needs both `from` and `to`');
|
|
63
82
|
|
|
64
83
|
const types = { ...DEFAULT_MIME, ...mime };
|
|
@@ -70,9 +89,16 @@ export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
|
|
|
70
89
|
const groups = new Map();
|
|
71
90
|
let skipped = 0;
|
|
72
91
|
let rawBytes = 0;
|
|
92
|
+
let embeddedBytes = 0;
|
|
93
|
+
let webpConverted = 0;
|
|
94
|
+
|
|
95
|
+
// only paid when toWebp is actually used, so a build that never asks for it never imports
|
|
96
|
+
// (or requires installing) sharp
|
|
97
|
+
const toWebp = convertToWebp ? (await import('./webp-convert.mjs')).toWebp : null;
|
|
73
98
|
|
|
74
99
|
for await (const file of walk(from)) {
|
|
75
|
-
const
|
|
100
|
+
const ext = extname(file).toLowerCase();
|
|
101
|
+
const type = types[ext];
|
|
76
102
|
if (!type) {
|
|
77
103
|
skipped++;
|
|
78
104
|
continue;
|
|
@@ -81,10 +107,22 @@ export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
|
|
|
81
107
|
const key = relative(from, file).split(sep).join('/');
|
|
82
108
|
const data = await readFile(file);
|
|
83
109
|
|
|
110
|
+
let mimeType = type;
|
|
111
|
+
let bytes = data;
|
|
112
|
+
if (toWebp && WEBP_CONVERTIBLE_EXTENSIONS.has(ext)) {
|
|
113
|
+
const webp = await toWebp(data, { lossless: webpLossless, quality: webpQuality });
|
|
114
|
+
if (webp.length < data.length) {
|
|
115
|
+
mimeType = 'image/webp';
|
|
116
|
+
bytes = webp;
|
|
117
|
+
webpConverted++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
const name = group(key);
|
|
85
122
|
if (!groups.has(name)) groups.set(name, {});
|
|
86
|
-
groups.get(name)[key] = `data:${
|
|
123
|
+
groups.get(name)[key] = `data:${mimeType};base64,${bytes.toString('base64')}`;
|
|
87
124
|
rawBytes += data.length;
|
|
125
|
+
embeddedBytes += bytes.length;
|
|
88
126
|
}
|
|
89
127
|
|
|
90
128
|
const written = [];
|
|
@@ -98,7 +136,7 @@ export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
|
|
|
98
136
|
written.push({ name, count: Object.keys(assets).length, size: js.length });
|
|
99
137
|
}
|
|
100
138
|
|
|
101
|
-
return { groups: written, rawBytes, skipped };
|
|
139
|
+
return { groups: written, rawBytes, embeddedBytes, webpConverted, skipped };
|
|
102
140
|
}
|
|
103
141
|
|
|
104
142
|
// ---------------------------------------------------------------- CLI
|
|
@@ -106,17 +144,27 @@ export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
|
|
|
106
144
|
const isMain = process.argv[1] && import.meta.url.endsWith(basename(process.argv[1]));
|
|
107
145
|
|
|
108
146
|
if (isMain) {
|
|
109
|
-
const
|
|
147
|
+
const args = process.argv.slice(2);
|
|
148
|
+
const [from, to] = args.filter((a) => !a.startsWith('--'));
|
|
110
149
|
if (!from || !to) {
|
|
111
|
-
console.error(
|
|
150
|
+
console.error(
|
|
151
|
+
'usage: node compile-resources.mjs <asset folder> <output folder> [--to-webp] [--webp-lossy[=quality]]',
|
|
152
|
+
);
|
|
112
153
|
process.exit(1);
|
|
113
154
|
}
|
|
155
|
+
const toWebp = args.includes('--to-webp');
|
|
156
|
+
const lossyFlag = args.find((a) => a === '--webp-lossy' || a.startsWith('--webp-lossy='));
|
|
157
|
+
const webpLossless = !lossyFlag;
|
|
158
|
+
const webpQuality = lossyFlag && lossyFlag.includes('=') ? Number(lossyFlag.split('=')[1]) : 90;
|
|
114
159
|
|
|
115
|
-
const { groups, rawBytes, skipped } = await compileResources({
|
|
160
|
+
const { groups, rawBytes, embeddedBytes, webpConverted, skipped } = await compileResources({
|
|
116
161
|
from,
|
|
117
162
|
to,
|
|
118
163
|
//a flat folder has no subdirectories to group by, so everything lands in one file
|
|
119
164
|
groupBy: (key) => (key.includes('/') ? key.slice(0, key.indexOf('/')) : 'assets'),
|
|
165
|
+
toWebp,
|
|
166
|
+
webpLossless,
|
|
167
|
+
webpQuality,
|
|
120
168
|
});
|
|
121
169
|
|
|
122
170
|
const kb = (n) => (n / 1024).toFixed(1).padStart(8) + ' KB';
|
|
@@ -124,6 +172,12 @@ if (isMain) {
|
|
|
124
172
|
console.log(` ${join(relative(process.cwd(), to), name + '.js').padEnd(40)}${kb(size)} (${count} files)`);
|
|
125
173
|
}
|
|
126
174
|
console.log(` ${groups.length} script(s), ${kb(rawBytes)} of assets` + (skipped ? `, ${skipped} skipped` : ''));
|
|
175
|
+
if (toWebp) {
|
|
176
|
+
console.log(
|
|
177
|
+
` ${webpConverted} image(s) converted to WebP (${webpLossless ? 'lossless' : `lossy, quality ${webpQuality}`}), ` +
|
|
178
|
+
`${kb(rawBytes)} -> ${kb(embeddedBytes)} embedded`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
127
181
|
}
|
|
128
182
|
|
|
129
183
|
export { dirname };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SingleFileOptions {
|
|
2
|
+
dist: string;
|
|
3
|
+
compress?: boolean;
|
|
4
|
+
algorithm?: 'gzip' | 'brotli';
|
|
5
|
+
level?: number;
|
|
6
|
+
splash?: boolean;
|
|
7
|
+
output?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SingleFileResult {
|
|
11
|
+
path: string;
|
|
12
|
+
scripts: number;
|
|
13
|
+
rawBytes: number;
|
|
14
|
+
embeddedBytes: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildSingleFile(options: SingleFileOptions): Promise<SingleFileResult>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, basename, resolve } from 'node:path';
|
|
3
|
+
import { gzipSync, brotliCompressSync, constants } from 'node:zlib';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Turns an `emit-page.mjs` output folder (`index.html` + sibling asset/bundle `.js` files)
|
|
8
|
+
* into one genuinely standalone HTML file with no sibling script at all - roadmap item 349,
|
|
9
|
+
* the "single-file" leg of the build-options matrix `emit-page.mjs --single-file` exposes.
|
|
10
|
+
*
|
|
11
|
+
* The existing multi-file output already opens by double-clicking (that's the whole point of
|
|
12
|
+
* `tools/compile-resources.mjs` and `tools/classic-html.mjs`), but it is still an `index.html`
|
|
13
|
+
* plus N `.js` files that have to travel together. This inlines every `<script src>` the page
|
|
14
|
+
* loads directly into the page, in the same order, so the folder can be reduced to one file
|
|
15
|
+
* before it is shared or archived.
|
|
16
|
+
*
|
|
17
|
+
* Compression (`compress: true`) compresses each script's source before it is
|
|
18
|
+
* base64-embedded, and unpacks it at load with the browser's own `DecompressionStream` - a
|
|
19
|
+
* real Web Streams API, not a shipped decoder. That beats a pure-JS decompressor such as
|
|
20
|
+
* `js-lzma` (the roadmap item's other candidate) on every axis that matters here: it adds
|
|
21
|
+
* zero bytes to the page, needs no license or maintenance, and both algorithms below are
|
|
22
|
+
* exactly what `tools/compress-dist.mjs` already writes for the server-negotiated path, so the
|
|
23
|
+
* project only carries one compression story either way.
|
|
24
|
+
*
|
|
25
|
+
* `algorithm: 'gzip'` (the default) is the safer choice for browser reach: `compress: false`
|
|
26
|
+
* has no requirement beyond base64, which every browser this project targets can decode, and
|
|
27
|
+
* gzip's `DecompressionStream('gzip')` has been available since May 2023. `algorithm:
|
|
28
|
+
* 'brotli'` compresses further for the same reason `compress-dist.mjs` prefers it over gzip
|
|
29
|
+
* when a server can negotiate it, and needs `DecompressionStream('br')` - an option this
|
|
30
|
+
* repo's roadmap (item 350) took up expecting broad support, but a real Chrome (153, tested
|
|
31
|
+
* directly in this repo's own verification pass) still throws
|
|
32
|
+
* `Unsupported compression format: 'br'` for both `'br'` and `'brotli'`, so treat brotli here
|
|
33
|
+
* as an advanced, narrower-reach option rather than a safe default until that is re-verified
|
|
34
|
+
* against current browsers - `algorithm: 'gzip'` stays the one every browser this project
|
|
35
|
+
* targets can actually decode today. Either way, the bootstrap throws a clear, actionable
|
|
36
|
+
* error rather than silently failing when the browser's `DecompressionStream` does not support
|
|
37
|
+
* the chosen format.
|
|
38
|
+
*
|
|
39
|
+
* Decoding is async even when uncompressed (kept uniform, and `atob` on a very large payload
|
|
40
|
+
* still benefits from not blocking the parser), so a splash screen (`splash: true`, the
|
|
41
|
+
* default) covers the page until every script has run. It is deliberately minimal - inline
|
|
42
|
+
* styles and a CSS spinner, no asset of its own - since this is a framework-level fallback a
|
|
43
|
+
* game is free to replace, not a themed loading screen.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} options
|
|
46
|
+
* @param {string} options.dist path to the folder `emit-page.mjs` already wrote
|
|
47
|
+
* @param {boolean} [options.compress] compress each inlined script (default false)
|
|
48
|
+
* @param {'gzip'|'brotli'} [options.algorithm] compression algorithm when `compress` is set
|
|
49
|
+
* (default 'gzip')
|
|
50
|
+
* @param {number} [options.level] gzip level 1-9, or brotli quality 0-11, when `compress` is
|
|
51
|
+
* set (default 9 for gzip, 11 for brotli)
|
|
52
|
+
* @param {boolean} [options.splash] show a splash screen while scripts decode (default true)
|
|
53
|
+
* @param {string} [options.output] output file name, written inside `dist` (default
|
|
54
|
+
* `standalone.html`) - additive, so the existing multi-file `index.html` is untouched
|
|
55
|
+
* @returns {Promise<{path: string, scripts: number, rawBytes: number, embeddedBytes: number}>}
|
|
56
|
+
*/
|
|
57
|
+
export async function buildSingleFile({
|
|
58
|
+
dist,
|
|
59
|
+
compress = false,
|
|
60
|
+
algorithm = 'gzip',
|
|
61
|
+
level,
|
|
62
|
+
splash = true,
|
|
63
|
+
output = 'standalone.html',
|
|
64
|
+
} = {}) {
|
|
65
|
+
if (!dist) throw new Error('buildSingleFile needs a `dist` folder');
|
|
66
|
+
if (algorithm !== 'gzip' && algorithm !== 'brotli') {
|
|
67
|
+
throw new Error(`buildSingleFile: algorithm must be 'gzip' or 'brotli', got ${JSON.stringify(algorithm)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const htmlPath = join(dist, 'index.html');
|
|
71
|
+
const html = await readFile(htmlPath, 'utf8');
|
|
72
|
+
|
|
73
|
+
const scriptTag = /[ \t]*<script\b[^>]*\bsrc="([^"]+)"[^>]*><\/script>\n?/g;
|
|
74
|
+
const sources = [];
|
|
75
|
+
let firstMatchIndex = -1;
|
|
76
|
+
let match;
|
|
77
|
+
while ((match = scriptTag.exec(html))) {
|
|
78
|
+
if (firstMatchIndex === -1) firstMatchIndex = match.index;
|
|
79
|
+
sources.push(match[1]);
|
|
80
|
+
}
|
|
81
|
+
if (sources.length === 0) {
|
|
82
|
+
throw new Error(`${htmlPath}: no <script src="..."> tags found - is this an emit-page.mjs output folder?`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const compressBytes = (buf) =>
|
|
86
|
+
algorithm === 'brotli'
|
|
87
|
+
? brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: level ?? 11 } })
|
|
88
|
+
: gzipSync(buf, { level: level ?? 9 });
|
|
89
|
+
|
|
90
|
+
let rawBytes = 0;
|
|
91
|
+
let embeddedBytes = 0;
|
|
92
|
+
const payloads = [];
|
|
93
|
+
for (const src of sources) {
|
|
94
|
+
const filePath = join(dist, src.replace(/^\.\//, ''));
|
|
95
|
+
const code = await readFile(filePath, 'utf8');
|
|
96
|
+
rawBytes += Buffer.byteLength(code, 'utf8');
|
|
97
|
+
const bytes = compress ? compressBytes(Buffer.from(code, 'utf8')) : Buffer.from(code, 'utf8');
|
|
98
|
+
const b64 = bytes.toString('base64');
|
|
99
|
+
embeddedBytes += b64.length;
|
|
100
|
+
payloads.push({ name: basename(src), b64 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const withoutScripts = html.replace(scriptTag, '').replace(/\n{3,}/g, '\n\n');
|
|
104
|
+
const bootstrap = renderBootstrap(payloads, compress ? algorithm : 'none');
|
|
105
|
+
const splashCss = splash ? renderSplashStyle() : '';
|
|
106
|
+
const splashHtml = splash ? renderSplashMarkup() : '';
|
|
107
|
+
|
|
108
|
+
let page = withoutScripts;
|
|
109
|
+
page = page.includes('</head>') ? page.replace('</head>', `${splashCss}</head>`) : splashCss + page;
|
|
110
|
+
page = page.replace(/(<body[^>]*>)/, `$1\n${splashHtml}`);
|
|
111
|
+
page = page.replace(/<\/body>/, `\t${bootstrap}\n</body>`);
|
|
112
|
+
|
|
113
|
+
const outPath = join(dist, output);
|
|
114
|
+
await writeFile(outPath, page, 'utf8');
|
|
115
|
+
|
|
116
|
+
return { path: outPath, scripts: payloads.length, rawBytes, embeddedBytes };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderSplashStyle() {
|
|
120
|
+
return (
|
|
121
|
+
'\t<style id="mwg-splash-style">\n' +
|
|
122
|
+
'\t\t#mwg-splash{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' +
|
|
123
|
+
'flex-direction:column;gap:12px;background:#14161c;color:#eee;font:14px sans-serif;z-index:9999}\n' +
|
|
124
|
+
'\t\t#mwg-splash .mwg-spinner{width:28px;height:28px;border-radius:50%;' +
|
|
125
|
+
'border:3px solid rgba(255,255,255,0.25);border-top-color:#fff;animation:mwg-spin 0.8s linear infinite}\n' +
|
|
126
|
+
'\t\t@keyframes mwg-spin{to{transform:rotate(360deg)}}\n' +
|
|
127
|
+
'\t</style>\n'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderSplashMarkup() {
|
|
132
|
+
return '\t<div id="mwg-splash"><div class="mwg-spinner"></div><div>Loading...</div></div>\n';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderBootstrap(payloads, algorithm) {
|
|
136
|
+
const json = JSON.stringify(payloads);
|
|
137
|
+
// 'none' | 'gzip' | 'brotli' -> the DecompressionStream format string, or '' for uncompressed
|
|
138
|
+
const streamFormat = algorithm === 'brotli' ? 'br' : algorithm === 'gzip' ? 'gzip' : '';
|
|
139
|
+
return (
|
|
140
|
+
'<script>\n' +
|
|
141
|
+
'(function(){\n' +
|
|
142
|
+
// Seeded synchronously, before any decoding starts: several examples' own dev/build
|
|
143
|
+
// detection scripts (see examples/*/index.html) check `window.__MWG_ASSETS__` at
|
|
144
|
+
// DOMContentLoaded to tell a built page from the unbuilt dev template, a check that only
|
|
145
|
+
// works because the multi-file build's asset script is a *deferred* <script src>, which
|
|
146
|
+
// finishes before that event. This bootstrap decodes asynchronously instead, so without
|
|
147
|
+
// this line those checks would fire too early and show a false "not built" notice. The
|
|
148
|
+
// compiled asset script itself creates this object with the same `||` guard once it
|
|
149
|
+
// actually runs (see tools/compile-resources.mjs) - this only moves that existence
|
|
150
|
+
// earlier, not the entries themselves, which are still filled in strictly before the
|
|
151
|
+
// game bundle that reads them executes.
|
|
152
|
+
'\twindow.__MWG_ASSETS__=window.__MWG_ASSETS__||{};\n' +
|
|
153
|
+
`\tvar PAYLOADS=${json};\n` +
|
|
154
|
+
`\tvar FORMAT=${JSON.stringify(streamFormat)};\n` +
|
|
155
|
+
'\tfunction b64ToBytes(b64){var bin=atob(b64);var bytes=new Uint8Array(bin.length);' +
|
|
156
|
+
'for(var i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);return bytes;}\n' +
|
|
157
|
+
'\tfunction decodeText(bytes){\n' +
|
|
158
|
+
'\t\tif(!FORMAT)return Promise.resolve(new TextDecoder().decode(bytes));\n' +
|
|
159
|
+
// `new DecompressionStream(FORMAT)` throws *synchronously* (a TypeError) when the browser
|
|
160
|
+
// does not support the requested format - it does not reject a promise. A brotli build on
|
|
161
|
+
// a browser without DecompressionStream("br") support hits exactly this: without the
|
|
162
|
+
// try/catch here that throw would escape decodeText's caller entirely, skip the
|
|
163
|
+
// .catch(...) below, and leave the splash screen stuck forever instead of failing visibly.
|
|
164
|
+
'\t\ttry{\n' +
|
|
165
|
+
'\t\t\tif(typeof DecompressionStream==="undefined")throw new Error(' +
|
|
166
|
+
'"mwg standalone build: this browser has no DecompressionStream, needed to unpack the compressed bundle. ' +
|
|
167
|
+
'Use the multi-file build instead.");\n' +
|
|
168
|
+
'\t\t\tvar stream=new Blob([bytes]).stream().pipeThrough(new DecompressionStream(FORMAT));\n' +
|
|
169
|
+
'\t\t\treturn new Response(stream).arrayBuffer().then(function(buf){return new TextDecoder().decode(buf);});\n' +
|
|
170
|
+
'\t\t}catch(err){\n' +
|
|
171
|
+
'\t\t\treturn Promise.reject(err);\n' +
|
|
172
|
+
'\t\t}\n' +
|
|
173
|
+
'\t}\n' +
|
|
174
|
+
'\tfunction removeSplash(){var el=document.getElementById("mwg-splash");if(el&&el.parentNode)el.parentNode.removeChild(el);}\n' +
|
|
175
|
+
'\tfunction runNext(i){\n' +
|
|
176
|
+
'\t\tif(i>=PAYLOADS.length){removeSplash();return;}\n' +
|
|
177
|
+
'\t\tvar p=PAYLOADS[i];\n' +
|
|
178
|
+
'\t\tdecodeText(b64ToBytes(p.b64)).then(function(code){\n' +
|
|
179
|
+
'\t\t\tvar s=document.createElement("script");s.text=code;document.body.appendChild(s);\n' +
|
|
180
|
+
'\t\t\trunNext(i+1);\n' +
|
|
181
|
+
'\t\t}).catch(function(err){\n' +
|
|
182
|
+
'\t\t\tremoveSplash();\n' +
|
|
183
|
+
'\t\t\tconsole.error("mwg standalone build failed to load "+p.name, err);\n' +
|
|
184
|
+
'\t\t});\n' +
|
|
185
|
+
'\t}\n' +
|
|
186
|
+
'\trunNext(0);\n' +
|
|
187
|
+
'})();\n' +
|
|
188
|
+
'</script>'
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------- CLI
|
|
193
|
+
|
|
194
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
195
|
+
|
|
196
|
+
if (isMain) {
|
|
197
|
+
const args = process.argv.slice(2);
|
|
198
|
+
const dist = args[0];
|
|
199
|
+
if (!dist || dist.startsWith('--')) {
|
|
200
|
+
console.error(
|
|
201
|
+
'usage: node tools/single-file.mjs <dist folder> [--compress[=level]] [--brotli[=quality]] ' +
|
|
202
|
+
'[--no-splash] [--output name.html]',
|
|
203
|
+
);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
const brotliFlag = args.find((a) => a === '--brotli' || a.startsWith('--brotli='));
|
|
207
|
+
const compressFlag = args.find((a) => a === '--compress' || a.startsWith('--compress='));
|
|
208
|
+
const compress = Boolean(brotliFlag || compressFlag);
|
|
209
|
+
const algorithm = brotliFlag ? 'brotli' : 'gzip';
|
|
210
|
+
const levelFlag = brotliFlag ?? compressFlag;
|
|
211
|
+
const level = levelFlag && levelFlag.includes('=') ? Number(levelFlag.split('=')[1]) : undefined;
|
|
212
|
+
const splash = !args.includes('--no-splash');
|
|
213
|
+
const outputIndex = args.indexOf('--output');
|
|
214
|
+
const output = outputIndex !== -1 ? args[outputIndex + 1] : 'standalone.html';
|
|
215
|
+
|
|
216
|
+
const result = await buildSingleFile({ dist: resolve(dist), compress, algorithm, level, splash, output });
|
|
217
|
+
const kb = (n) => (n / 1024).toFixed(1) + ' KB';
|
|
218
|
+
console.log(`\n ${result.path}`);
|
|
219
|
+
console.log(
|
|
220
|
+
` ${result.scripts} script(s) inlined, ${kb(result.rawBytes)} raw -> ${kb(result.embeddedBytes)} embedded` +
|
|
221
|
+
(compress ? ` (${algorithm} level ${level ?? (algorithm === 'brotli' ? 11 : 9)})` : ' (uncompressed)'),
|
|
222
|
+
);
|
|
223
|
+
console.log(' one file, no server, no sibling script - open it directly');
|
|
224
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFile, writeFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { extname, basename, resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Converts a raster image buffer to WebP - roadmap item 350's "convert pictures to WebP"
|
|
7
|
+
* half, kept as its own module so `tools/compile-resources.mjs` (a published, shipped tool -
|
|
8
|
+
* see `package.json`'s `files`/`exports`) never has to import an image codec itself. This file
|
|
9
|
+
* is *not* shipped: it dynamically imports `sharp`, a devDependency of this repo, so a game
|
|
10
|
+
* that never opts into WebP conversion carries no new dependency at all. A game that does want
|
|
11
|
+
* it installs `sharp` in its own project the same way this repo does.
|
|
12
|
+
*
|
|
13
|
+
* `lossless` (default true) uses WebP's real lossless mode (VP8L), which is what a pixel-art
|
|
14
|
+
* tileset's exact colours need - not "high quality lossy", which is still a DCT approximation
|
|
15
|
+
* at any quality setting. Lossy mode (`lossless: false`, with `quality`) is available for
|
|
16
|
+
* photographic source art where the extra size saving is worth it, but it is never the
|
|
17
|
+
* default: this project does not silently trade fidelity for size.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
let sharpModule;
|
|
21
|
+
async function loadSharp() {
|
|
22
|
+
if (sharpModule) return sharpModule;
|
|
23
|
+
try {
|
|
24
|
+
sharpModule = (await import('sharp')).default;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
'WebP conversion needs the optional `sharp` package, which is not installed. ' +
|
|
28
|
+
'Run `npm install --save-dev sharp` in your project to use toWebp, or pass ' +
|
|
29
|
+
'`toWebp: false` (the default) to skip WebP conversion entirely.',
|
|
30
|
+
{ cause: err },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return sharpModule;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {Buffer} buffer source image bytes (PNG or JPEG)
|
|
38
|
+
* @param {object} [options]
|
|
39
|
+
* @param {boolean} [options.lossless] use WebP's lossless mode (default true)
|
|
40
|
+
* @param {number} [options.quality] 0-100, used only when `lossless` is false (default 90)
|
|
41
|
+
* @returns {Promise<Buffer>} WebP-encoded image bytes
|
|
42
|
+
*/
|
|
43
|
+
export async function toWebp(buffer, { lossless = true, quality = 90 } = {}) {
|
|
44
|
+
const sharp = await loadSharp();
|
|
45
|
+
return sharp(buffer)
|
|
46
|
+
.webp(lossless ? { lossless: true } : { quality })
|
|
47
|
+
.toBuffer();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The extensions `compileResources`'s `toWebp` option will attempt to convert. `.gif` (frame
|
|
52
|
+
* animation, which WebP would need to be re-authored to preserve) and `.svg` (already vector,
|
|
53
|
+
* so there is nothing raster to convert) are deliberately left out, along with `.webp` itself.
|
|
54
|
+
*/
|
|
55
|
+
export const WEBP_CONVERTIBLE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------- CLI
|
|
58
|
+
|
|
59
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
60
|
+
|
|
61
|
+
if (isMain) {
|
|
62
|
+
const args = process.argv.slice(2);
|
|
63
|
+
const input = args.find((a) => !a.startsWith('--'));
|
|
64
|
+
if (!input) {
|
|
65
|
+
console.error('usage: node tools/webp-convert.mjs <file.png|file.jpg> [--lossy[=quality]]');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
const lossyFlag = args.find((a) => a === '--lossy' || a.startsWith('--lossy='));
|
|
69
|
+
const lossless = !lossyFlag;
|
|
70
|
+
const quality = lossyFlag && lossyFlag.includes('=') ? Number(lossyFlag.split('=')[1]) : 90;
|
|
71
|
+
|
|
72
|
+
const inPath = resolve(input);
|
|
73
|
+
if (!WEBP_CONVERTIBLE_EXTENSIONS.has(extname(inPath).toLowerCase())) {
|
|
74
|
+
console.error(`${input}: not a convertible image (expected .png, .jpg or .jpeg)`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const source = await readFile(inPath);
|
|
78
|
+
const converted = await toWebp(source, { lossless, quality });
|
|
79
|
+
const outPath = resolve(dirname(inPath), basename(inPath, extname(inPath)) + '.webp');
|
|
80
|
+
await writeFile(outPath, converted);
|
|
81
|
+
|
|
82
|
+
const { size: sourceSize } = await stat(inPath);
|
|
83
|
+
const kb = (n) => (n / 1024).toFixed(1) + ' KB';
|
|
84
|
+
const pct = Math.round((100 * (sourceSize - converted.length)) / sourceSize);
|
|
85
|
+
console.log(` ${outPath}`);
|
|
86
|
+
console.log(
|
|
87
|
+
` ${kb(sourceSize)} -> ${kb(converted.length)} (${pct >= 0 ? '-' : '+'}${Math.abs(pct)}%)` +
|
|
88
|
+
(lossless ? ' [lossless]' : ` [lossy, quality ${quality}]`),
|
|
89
|
+
);
|
|
90
|
+
}
|