@datamoc/mw_games 0.10.0 → 0.12.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 +83 -0
- 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/audio/Playable.d.ts +2 -0
- package/dist/audio/Playable.d.ts.map +1 -1
- package/dist/audio/Playable.js.map +1 -1
- package/dist/audio/Sound.d.ts +7 -1
- package/dist/audio/Sound.d.ts.map +1 -1
- package/dist/audio/Sound.js +8 -1
- package/dist/audio/Sound.js.map +1 -1
- package/dist/mw_games.global.js +56 -56
- package/dist/rpg/EventRunner.d.ts +4 -0
- package/dist/rpg/EventRunner.d.ts.map +1 -1
- package/dist/rpg/EventRunner.js +4 -4
- package/dist/rpg/EventRunner.js.map +1 -1
- package/dist/rpg/GridMover.d.ts +6 -0
- package/dist/rpg/GridMover.d.ts.map +1 -1
- package/dist/rpg/GridMover.js +17 -0
- package/dist/rpg/GridMover.js.map +1 -1
- package/dist/rpg/MoveRoute.d.ts +87 -0
- package/dist/rpg/MoveRoute.d.ts.map +1 -0
- package/dist/rpg/MoveRoute.js +110 -0
- package/dist/rpg/MoveRoute.js.map +1 -0
- package/dist/rpg/index.d.ts +2 -0
- package/dist/rpg/index.d.ts.map +1 -1
- package/dist/rpg/index.js +1 -0
- package/dist/rpg/index.js.map +1 -1
- package/dist/two-d/ui/EventDialogue.d.ts.map +1 -1
- package/dist/two-d/ui/EventDialogue.js +7 -1
- package/dist/two-d/ui/EventDialogue.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -2
- package/tools/compile-resources.d.mts +29 -0
- package/tools/compile-resources.mjs +61 -7
- package/tools/single-file.d.mts +1 -0
- package/tools/single-file.mjs +72 -28
- package/tools/webp-convert.d.mts +8 -0
- package/tools/webp-convert.mjs +90 -0
package/tools/single-file.d.mts
CHANGED
package/tools/single-file.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join, basename, resolve } from 'node:path';
|
|
3
|
-
import { gzipSync } from 'node:zlib';
|
|
3
|
+
import { gzipSync, brotliCompressSync, constants } from 'node:zlib';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -14,16 +14,27 @@ import { fileURLToPath } from 'node:url';
|
|
|
14
14
|
* loads directly into the page, in the same order, so the folder can be reduced to one file
|
|
15
15
|
* before it is shared or archived.
|
|
16
16
|
*
|
|
17
|
-
* Compression (`compress: true`)
|
|
18
|
-
* unpacks it at load with the browser's own `DecompressionStream
|
|
19
|
-
* Streams API, not a shipped decoder. That beats a pure-JS decompressor such as
|
|
20
|
-
* roadmap item's other candidate) on every axis that matters here: it adds
|
|
21
|
-
* page, needs no license or maintenance, and
|
|
22
|
-
* already
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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.
|
|
27
38
|
*
|
|
28
39
|
* Decoding is async even when uncompressed (kept uniform, and `atob` on a very large payload
|
|
29
40
|
* still benefits from not blocking the parser), so a splash screen (`splash: true`, the
|
|
@@ -33,15 +44,28 @@ import { fileURLToPath } from 'node:url';
|
|
|
33
44
|
*
|
|
34
45
|
* @param {object} options
|
|
35
46
|
* @param {string} options.dist path to the folder `emit-page.mjs` already wrote
|
|
36
|
-
* @param {boolean} [options.compress]
|
|
37
|
-
* @param {
|
|
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)
|
|
38
52
|
* @param {boolean} [options.splash] show a splash screen while scripts decode (default true)
|
|
39
53
|
* @param {string} [options.output] output file name, written inside `dist` (default
|
|
40
54
|
* `standalone.html`) - additive, so the existing multi-file `index.html` is untouched
|
|
41
55
|
* @returns {Promise<{path: string, scripts: number, rawBytes: number, embeddedBytes: number}>}
|
|
42
56
|
*/
|
|
43
|
-
export async function buildSingleFile({
|
|
57
|
+
export async function buildSingleFile({
|
|
58
|
+
dist,
|
|
59
|
+
compress = false,
|
|
60
|
+
algorithm = 'gzip',
|
|
61
|
+
level,
|
|
62
|
+
splash = true,
|
|
63
|
+
output = 'standalone.html',
|
|
64
|
+
} = {}) {
|
|
44
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
|
+
}
|
|
45
69
|
|
|
46
70
|
const htmlPath = join(dist, 'index.html');
|
|
47
71
|
const html = await readFile(htmlPath, 'utf8');
|
|
@@ -58,6 +82,11 @@ export async function buildSingleFile({ dist, compress = false, level = 9, splas
|
|
|
58
82
|
throw new Error(`${htmlPath}: no <script src="..."> tags found - is this an emit-page.mjs output folder?`);
|
|
59
83
|
}
|
|
60
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
|
+
|
|
61
90
|
let rawBytes = 0;
|
|
62
91
|
let embeddedBytes = 0;
|
|
63
92
|
const payloads = [];
|
|
@@ -65,14 +94,14 @@ export async function buildSingleFile({ dist, compress = false, level = 9, splas
|
|
|
65
94
|
const filePath = join(dist, src.replace(/^\.\//, ''));
|
|
66
95
|
const code = await readFile(filePath, 'utf8');
|
|
67
96
|
rawBytes += Buffer.byteLength(code, 'utf8');
|
|
68
|
-
const bytes = compress ?
|
|
97
|
+
const bytes = compress ? compressBytes(Buffer.from(code, 'utf8')) : Buffer.from(code, 'utf8');
|
|
69
98
|
const b64 = bytes.toString('base64');
|
|
70
99
|
embeddedBytes += b64.length;
|
|
71
100
|
payloads.push({ name: basename(src), b64 });
|
|
72
101
|
}
|
|
73
102
|
|
|
74
103
|
const withoutScripts = html.replace(scriptTag, '').replace(/\n{3,}/g, '\n\n');
|
|
75
|
-
const bootstrap = renderBootstrap(payloads, compress);
|
|
104
|
+
const bootstrap = renderBootstrap(payloads, compress ? algorithm : 'none');
|
|
76
105
|
const splashCss = splash ? renderSplashStyle() : '';
|
|
77
106
|
const splashHtml = splash ? renderSplashMarkup() : '';
|
|
78
107
|
|
|
@@ -103,8 +132,10 @@ function renderSplashMarkup() {
|
|
|
103
132
|
return '\t<div id="mwg-splash"><div class="mwg-spinner"></div><div>Loading...</div></div>\n';
|
|
104
133
|
}
|
|
105
134
|
|
|
106
|
-
function renderBootstrap(payloads,
|
|
135
|
+
function renderBootstrap(payloads, algorithm) {
|
|
107
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' : '';
|
|
108
139
|
return (
|
|
109
140
|
'<script>\n' +
|
|
110
141
|
'(function(){\n' +
|
|
@@ -120,16 +151,25 @@ function renderBootstrap(payloads, compress) {
|
|
|
120
151
|
// game bundle that reads them executes.
|
|
121
152
|
'\twindow.__MWG_ASSETS__=window.__MWG_ASSETS__||{};\n' +
|
|
122
153
|
`\tvar PAYLOADS=${json};\n` +
|
|
123
|
-
`\tvar
|
|
154
|
+
`\tvar FORMAT=${JSON.stringify(streamFormat)};\n` +
|
|
124
155
|
'\tfunction b64ToBytes(b64){var bin=atob(b64);var bytes=new Uint8Array(bin.length);' +
|
|
125
156
|
'for(var i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);return bytes;}\n' +
|
|
126
157
|
'\tfunction decodeText(bytes){\n' +
|
|
127
|
-
'\t\tif(!
|
|
128
|
-
|
|
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(' +
|
|
129
166
|
'"mwg standalone build: this browser has no DecompressionStream, needed to unpack the compressed bundle. ' +
|
|
130
|
-
'Use the multi-file build instead.")
|
|
131
|
-
'\t\tvar stream=new Blob([bytes]).stream().pipeThrough(new DecompressionStream(
|
|
132
|
-
'\t\treturn new Response(stream).arrayBuffer().then(function(buf){return new TextDecoder().decode(buf);});\n' +
|
|
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' +
|
|
133
173
|
'\t}\n' +
|
|
134
174
|
'\tfunction removeSplash(){var el=document.getElementById("mwg-splash");if(el&&el.parentNode)el.parentNode.removeChild(el);}\n' +
|
|
135
175
|
'\tfunction runNext(i){\n' +
|
|
@@ -158,23 +198,27 @@ if (isMain) {
|
|
|
158
198
|
const dist = args[0];
|
|
159
199
|
if (!dist || dist.startsWith('--')) {
|
|
160
200
|
console.error(
|
|
161
|
-
'usage: node tools/single-file.mjs <dist folder> [--compress[=level]] [--
|
|
201
|
+
'usage: node tools/single-file.mjs <dist folder> [--compress[=level]] [--brotli[=quality]] ' +
|
|
202
|
+
'[--no-splash] [--output name.html]',
|
|
162
203
|
);
|
|
163
204
|
process.exit(1);
|
|
164
205
|
}
|
|
206
|
+
const brotliFlag = args.find((a) => a === '--brotli' || a.startsWith('--brotli='));
|
|
165
207
|
const compressFlag = args.find((a) => a === '--compress' || a.startsWith('--compress='));
|
|
166
|
-
const compress = Boolean(compressFlag);
|
|
167
|
-
const
|
|
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;
|
|
168
212
|
const splash = !args.includes('--no-splash');
|
|
169
213
|
const outputIndex = args.indexOf('--output');
|
|
170
214
|
const output = outputIndex !== -1 ? args[outputIndex + 1] : 'standalone.html';
|
|
171
215
|
|
|
172
|
-
const result = await buildSingleFile({ dist: resolve(dist), compress, level, splash, output });
|
|
216
|
+
const result = await buildSingleFile({ dist: resolve(dist), compress, algorithm, level, splash, output });
|
|
173
217
|
const kb = (n) => (n / 1024).toFixed(1) + ' KB';
|
|
174
218
|
console.log(`\n ${result.path}`);
|
|
175
219
|
console.log(
|
|
176
220
|
` ${result.scripts} script(s) inlined, ${kb(result.rawBytes)} raw -> ${kb(result.embeddedBytes)} embedded` +
|
|
177
|
-
(compress ? ` (
|
|
221
|
+
(compress ? ` (${algorithm} level ${level ?? (algorithm === 'brotli' ? 11 : 9)})` : ' (uncompressed)'),
|
|
178
222
|
);
|
|
179
223
|
console.log(' one file, no server, no sibling script - open it directly');
|
|
180
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
|
+
}
|