thrunium 1.2.0-rc.3 → 1.2.0-rc.4
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/README.md +10 -2
- package/dist/cli.mjs +238 -69
- package/dist/thrunium-global.mjs +1 -0
- package/dist/thrunium.js +1320 -67
- package/dist/thrunium.min.js +3 -2
- package/dist/thrunium.min.js.map +4 -4
- package/dist/types/button.d.ts +60 -0
- package/dist/types/collider.d.ts +4 -4
- package/dist/types/debug.d.ts +80 -0
- package/dist/types/font-string.d.ts +12 -0
- package/dist/types/fonts.d.ts +35 -0
- package/dist/types/game.d.ts +336 -15
- package/dist/types/input.d.ts +127 -0
- package/dist/types/internals.d.ts +14 -0
- package/dist/types/namespace.d.ts +2 -0
- package/dist/types/reference.d.ts +7 -1
- package/dist/types/sprite.d.ts +22 -0
- package/dist/types/timers.d.ts +18 -0
- package/package.json +1 -1
- package/templates/js/src/game.js +35 -0
- package/templates/ts/src/game.ts +35 -0
- package/templates/js/game.js +0 -17
- package/templates/ts/game.ts +0 -17
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ with a `/*! THRUNIUM v<version> | (c) Infobits ApS | MIT License */` banner and
|
|
|
34
34
|
The engine is also on npm as `thrunium` (Node 24 or newer):
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
npx thrunium init my-game # new TypeScript project; add --js for JavaScript
|
|
37
|
+
npx thrunium init my-game # new TypeScript project in src/game.ts; add --js for JavaScript
|
|
38
38
|
cd my-game && npm run dev # init installs for you; use pnpm dev, yarn dev or bun run dev
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -57,7 +57,7 @@ Commands (`thrunium --help`, `--version`):
|
|
|
57
57
|
|---|---|
|
|
58
58
|
| `thrunium dev` | `--port <n>` (default 3000, tries the next 10 ports if busy), `--host <name>` (default `localhost`), `--no-open` |
|
|
59
59
|
| `thrunium build` | `--out <dir>` (default `dist`), `--engine cdn\|bundle` |
|
|
60
|
-
| `thrunium init [dir]` | `--js` (TypeScript by default), `--pm npm\|pnpm\|yarn\|bun` (default: the one that ran it, else npm), `--no-install`; the folder must be empty. Installs by default; if that fails the project is kept and the command is printed |
|
|
60
|
+
| `thrunium init [dir]` | `--js` (TypeScript by default), `--no-src` (game file in the root instead of `src/`), `--pm npm\|pnpm\|yarn\|bun` (default: the one that ran it, else npm), `--no-install`; the folder must be empty. Installs by default; if that fails the project is kept and the command is printed |
|
|
61
61
|
|
|
62
62
|
A project needs no `index.html`: without one, `dev` and `build` generate the page
|
|
63
63
|
from `game.json` (`entry`, else the first of `game.ts`, `game.js`, `src/game.ts`,
|
|
@@ -69,6 +69,14 @@ favicon, link-preview image, address and language from `page` in `game.json`, an
|
|
|
69
69
|
the contents of an optional `head.html` and `body.html` (see the "Dev server and
|
|
70
70
|
builds" guide). None of these mix with a hand-written `index.html`.
|
|
71
71
|
|
|
72
|
+
Games get keys and mouse state (`game.keys`, `game.mouse`), asset getters
|
|
73
|
+
(`game.image`, `game.sound`, `game.sprite`), declared fonts (`game.fonts`),
|
|
74
|
+
scenes with `game.paused`, timers (`game.after`, `game.every`), buttons
|
|
75
|
+
(`game.button`) and a debug menu (`game.debug`). `thrunium build` downloads
|
|
76
|
+
Google Fonts into the build, so players make no request to Google. See the
|
|
77
|
+
"Making games easier" guide. The debug menu is on under `thrunium dev` and off
|
|
78
|
+
in builds.
|
|
79
|
+
|
|
72
80
|
`dev` never uses the CDN. For a project with a module entry
|
|
73
81
|
(a generated page, or `<script type="module" src=...>` importing `thrunium`), `build` has two engine
|
|
74
82
|
modes. `cdn` (the default) adds a script tag for
|
package/dist/cli.mjs
CHANGED
|
@@ -117,6 +117,112 @@ var init_engine = __esm({
|
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
// cli/fonts.ts
|
|
121
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
122
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
|
|
123
|
+
import { join as join2 } from "node:path";
|
|
124
|
+
function findGoogleFontUrls(js) {
|
|
125
|
+
const urls = /* @__PURE__ */ new Set();
|
|
126
|
+
for (const m of js.matchAll(/(["'`])(https:\/\/fonts\.googleapis\.com\/css2?\?[^"'`\\\s]*)\1(?=\s*(\+)?)/g)) {
|
|
127
|
+
const url = m[2];
|
|
128
|
+
const concatenated = m[3] === "+";
|
|
129
|
+
const unfinished = /[=@&?]$/.test(url) || url.includes("${");
|
|
130
|
+
if (!concatenated && !unfinished) urls.add(url);
|
|
131
|
+
}
|
|
132
|
+
return [...urls];
|
|
133
|
+
}
|
|
134
|
+
function parseFontFaces(css) {
|
|
135
|
+
const faces = [];
|
|
136
|
+
for (const [, body] of css.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
|
|
137
|
+
const prop = (name) => new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`, "i").exec(body)?.[1].trim();
|
|
138
|
+
const family = prop("font-family");
|
|
139
|
+
const src = /url\(\s*([^)]+?)\s*\)/.exec(prop("src") ?? "")?.[1];
|
|
140
|
+
if (!family || !src) continue;
|
|
141
|
+
const face = { family: unquote(family), src: unquote(src) };
|
|
142
|
+
const weight = prop("font-weight");
|
|
143
|
+
const style = prop("font-style");
|
|
144
|
+
const unicodeRange = prop("unicode-range");
|
|
145
|
+
if (weight) face.weight = weight;
|
|
146
|
+
if (style) face.style = style;
|
|
147
|
+
if (unicodeRange) face.unicodeRange = unicodeRange;
|
|
148
|
+
faces.push(face);
|
|
149
|
+
}
|
|
150
|
+
return faces;
|
|
151
|
+
}
|
|
152
|
+
function fontsCacheDir(root) {
|
|
153
|
+
return join2(root, "node_modules/.cache/thrunium/fonts");
|
|
154
|
+
}
|
|
155
|
+
function manifestScript(manifest) {
|
|
156
|
+
return `<script>window.THRUNIUM_FONTS = ${JSON.stringify(manifest).replace(/</g, "\\u003c")};</script>`;
|
|
157
|
+
}
|
|
158
|
+
function writeAtomic(file, data) {
|
|
159
|
+
writeFileSync(file + ".tmp", data);
|
|
160
|
+
renameSync(file + ".tmp", file);
|
|
161
|
+
}
|
|
162
|
+
async function download(url, fetchImpl) {
|
|
163
|
+
const res = await fetchImpl(url, { headers: { "user-agent": USER_AGENT }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
164
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
165
|
+
return res;
|
|
166
|
+
}
|
|
167
|
+
function readCache(dir) {
|
|
168
|
+
try {
|
|
169
|
+
const stored = JSON.parse(readFileSync2(join2(dir, INDEX_FILE), "utf8"));
|
|
170
|
+
const fonts = stored.map((f) => ({ ...f, file: join2(dir, f.file) }));
|
|
171
|
+
return fonts.every((f) => existsSync2(f.file)) ? fonts : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function fetchOne(url, dir, fetchImpl) {
|
|
177
|
+
const cached = readCache(dir);
|
|
178
|
+
if (cached) return cached;
|
|
179
|
+
try {
|
|
180
|
+
const faces = parseFontFaces(await (await download(url, fetchImpl)).text());
|
|
181
|
+
if (faces.length === 0) throw new Error("the stylesheet has no @font-face blocks");
|
|
182
|
+
const names = /* @__PURE__ */ new Map();
|
|
183
|
+
for (const face of faces) {
|
|
184
|
+
const abs = new URL(face.src, url).href;
|
|
185
|
+
if (names.has(abs)) continue;
|
|
186
|
+
let name = (new URL(abs).pathname.split("/").pop() || "font").replace(/[^\w.-]/g, "_").replace(/^\./, "_");
|
|
187
|
+
if ([...names.values()].includes(name)) name = `${names.size}-${name}`;
|
|
188
|
+
names.set(abs, name);
|
|
189
|
+
}
|
|
190
|
+
mkdirSync(dir, { recursive: true });
|
|
191
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
192
|
+
await Promise.all(
|
|
193
|
+
[...names].map(async ([abs, name]) => {
|
|
194
|
+
const bytes = new Uint8Array(await (await download(abs, fetchImpl)).arrayBuffer());
|
|
195
|
+
writeAtomic(join2(dir, name), bytes);
|
|
196
|
+
hashes.set(name, sha256(bytes));
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
const stored = faces.map((face) => {
|
|
200
|
+
const name = names.get(new URL(face.src, url).href);
|
|
201
|
+
return { ...face, file: name, hash: hashes.get(name) };
|
|
202
|
+
});
|
|
203
|
+
writeAtomic(join2(dir, INDEX_FILE), JSON.stringify(stored));
|
|
204
|
+
return stored.map((f) => ({ ...f, file: join2(dir, f.file) }));
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new UserError(`Could not download the Google Font ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function fetchFonts(urls, cacheDir, fetchImpl) {
|
|
210
|
+
const results = await Promise.all(urls.map((url) => fetchOne(url, join2(cacheDir, sha256(url).slice(0, 16)), fetchImpl)));
|
|
211
|
+
return new Map(urls.map((url, i) => [url, results[i]]));
|
|
212
|
+
}
|
|
213
|
+
var USER_AGENT, TIMEOUT_MS, INDEX_FILE, unquote, sha256;
|
|
214
|
+
var init_fonts = __esm({
|
|
215
|
+
"cli/fonts.ts"() {
|
|
216
|
+
"use strict";
|
|
217
|
+
init_errors();
|
|
218
|
+
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
|
219
|
+
TIMEOUT_MS = 3e4;
|
|
220
|
+
INDEX_FILE = "faces.json";
|
|
221
|
+
unquote = (s) => s.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
222
|
+
sha256 = (data) => createHash2("sha256").update(data).digest("hex");
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
120
226
|
// cli/html.ts
|
|
121
227
|
function parseAttributes(tag) {
|
|
122
228
|
const attrs = /* @__PURE__ */ new Map();
|
|
@@ -219,16 +325,16 @@ var init_html = __esm({
|
|
|
219
325
|
});
|
|
220
326
|
|
|
221
327
|
// cli/page.ts
|
|
222
|
-
import { existsSync as
|
|
223
|
-
import { isAbsolute, join as
|
|
328
|
+
import { existsSync as existsSync3, statSync } from "node:fs";
|
|
329
|
+
import { isAbsolute, join as join3, normalize } from "node:path";
|
|
224
330
|
function checkAsset(root, field, value) {
|
|
225
331
|
if (HTTP_URL.test(value)) return value;
|
|
226
332
|
const problem = (why) => new UserError(`"page.${field}" in game.json must be an http(s) URL or a relative path to a file in the project (${why}): ${value}`);
|
|
227
333
|
if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//")) throw problem("unsupported address");
|
|
228
334
|
if (/[?#]/.test(value)) throw problem("no ? or # suffix");
|
|
229
335
|
if (isAbsolute(value) || normalize(value).split(/[\\/]/).includes("..")) throw problem("outside the project");
|
|
230
|
-
const path =
|
|
231
|
-
if (!
|
|
336
|
+
const path = join3(root, value);
|
|
337
|
+
if (!existsSync3(path) || !statSync(path).isFile()) throw problem("file not found");
|
|
232
338
|
return value.replace(/^(\.\/)+/, "");
|
|
233
339
|
}
|
|
234
340
|
function readPageSettings(root, value) {
|
|
@@ -292,15 +398,15 @@ var init_page = __esm({
|
|
|
292
398
|
});
|
|
293
399
|
|
|
294
400
|
// cli/project.ts
|
|
295
|
-
import { existsSync as
|
|
296
|
-
import { basename, isAbsolute as isAbsolute2, join as
|
|
401
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
|
|
402
|
+
import { basename, isAbsolute as isAbsolute2, join as join4, normalize as normalize2, resolve } from "node:path";
|
|
297
403
|
function resolveEngineMode(root, flag) {
|
|
298
404
|
if (flag) return flag;
|
|
299
|
-
const manifest =
|
|
300
|
-
if (!
|
|
405
|
+
const manifest = join4(root, "package.json");
|
|
406
|
+
if (!existsSync4(manifest)) return "cdn";
|
|
301
407
|
let pkg;
|
|
302
408
|
try {
|
|
303
|
-
pkg = JSON.parse(
|
|
409
|
+
pkg = JSON.parse(readFileSync3(manifest, "utf8"));
|
|
304
410
|
} catch {
|
|
305
411
|
throw new UserError("package.json is not valid JSON.");
|
|
306
412
|
}
|
|
@@ -310,14 +416,14 @@ function resolveEngineMode(root, flag) {
|
|
|
310
416
|
throw new UserError(`Invalid "thrunium.engine" in package.json: ${JSON.stringify(value)}. Use "cdn" or "bundle".`);
|
|
311
417
|
}
|
|
312
418
|
function isFile(path) {
|
|
313
|
-
return
|
|
419
|
+
return existsSync4(path) && statSync2(path).isFile();
|
|
314
420
|
}
|
|
315
421
|
function readGameJson(root) {
|
|
316
|
-
const file =
|
|
317
|
-
if (!
|
|
422
|
+
const file = join4(root, "game.json");
|
|
423
|
+
if (!existsSync4(file)) return null;
|
|
318
424
|
let json;
|
|
319
425
|
try {
|
|
320
|
-
json = JSON.parse(
|
|
426
|
+
json = JSON.parse(readFileSync3(file, "utf8"));
|
|
321
427
|
} catch {
|
|
322
428
|
throw new UserError("game.json is not valid JSON.");
|
|
323
429
|
}
|
|
@@ -335,24 +441,24 @@ function validateEntry(root, entry) {
|
|
|
335
441
|
if (!ENTRY_EXTENSIONS.test(entry)) {
|
|
336
442
|
throw new UserError(`"entry" in game.json must end in .ts, .js, .mts or .mjs: ${entry}`);
|
|
337
443
|
}
|
|
338
|
-
if (!isFile(
|
|
444
|
+
if (!isFile(join4(root, entry))) throw new UserError(`"entry" in game.json points to ${entry}, which does not exist.`);
|
|
339
445
|
}
|
|
340
446
|
function hasKey(file, key) {
|
|
341
447
|
try {
|
|
342
|
-
const json = JSON.parse(
|
|
448
|
+
const json = JSON.parse(readFileSync3(file, "utf8"));
|
|
343
449
|
return typeof json === "object" && json !== null && !Array.isArray(json) && key in json;
|
|
344
450
|
} catch {
|
|
345
451
|
return false;
|
|
346
452
|
}
|
|
347
453
|
}
|
|
348
454
|
function readExtra(root, file) {
|
|
349
|
-
const path =
|
|
350
|
-
return isFile(path) ?
|
|
455
|
+
const path = join4(root, file);
|
|
456
|
+
return isFile(path) ? readFileSync3(path, "utf8").replace(/^\uFEFF/, "").trimEnd() : "";
|
|
351
457
|
}
|
|
352
458
|
function resolvePage(root) {
|
|
353
|
-
const indexPath =
|
|
354
|
-
if (
|
|
355
|
-
const gameJson =
|
|
459
|
+
const indexPath = join4(root, "index.html");
|
|
460
|
+
if (existsSync4(indexPath)) {
|
|
461
|
+
const gameJson = join4(root, "game.json");
|
|
356
462
|
if (hasKey(gameJson, "entry")) {
|
|
357
463
|
throw new UserError('game.json has "entry", but index.html also exists and is used instead. Remove one of them.');
|
|
358
464
|
}
|
|
@@ -362,18 +468,18 @@ function resolvePage(root) {
|
|
|
362
468
|
);
|
|
363
469
|
}
|
|
364
470
|
for (const file of ["head.html", "body.html"]) {
|
|
365
|
-
if (isFile(
|
|
471
|
+
if (isFile(join4(root, file))) {
|
|
366
472
|
throw new UserError(
|
|
367
473
|
`${file} exists, but index.html also exists and is used instead. Move its contents into index.html or delete it.`
|
|
368
474
|
);
|
|
369
475
|
}
|
|
370
476
|
}
|
|
371
|
-
return { html:
|
|
477
|
+
return { html: readFileSync3(indexPath, "utf8"), htmlSource: "file", extras: { head: "", body: "" } };
|
|
372
478
|
}
|
|
373
479
|
const game = readGameJson(root);
|
|
374
480
|
let entry = game?.entry;
|
|
375
481
|
if (entry !== void 0) validateEntry(root, entry);
|
|
376
|
-
else entry = DEFAULT_ENTRIES.find((candidate) => isFile(
|
|
482
|
+
else entry = DEFAULT_ENTRIES.find((candidate) => isFile(join4(root, candidate)));
|
|
377
483
|
if (entry === void 0) {
|
|
378
484
|
throw new UserError(
|
|
379
485
|
`No game found: add game.ts or game.js (or set "entry" in game.json), or an index.html. Looked for: ${DEFAULT_ENTRIES.join(", ")}.`
|
|
@@ -394,7 +500,7 @@ function loadProject(root, engineFlag) {
|
|
|
394
500
|
const { html, htmlSource, extras } = resolvePage(root);
|
|
395
501
|
const entries = findModuleEntries(html);
|
|
396
502
|
for (const entry of entries) {
|
|
397
|
-
if (!
|
|
503
|
+
if (!existsSync4(join4(root, entry))) {
|
|
398
504
|
throw new UserError(`Entry file ${entry} (from ${htmlSource === "file" ? "index.html" : "game.json"}) does not exist.`);
|
|
399
505
|
}
|
|
400
506
|
}
|
|
@@ -456,6 +562,7 @@ var init_reload_client = __esm({
|
|
|
456
562
|
"cli/reload-client.ts"() {
|
|
457
563
|
"use strict";
|
|
458
564
|
RELOAD_CLIENT = `<script>
|
|
565
|
+
window.THRUNIUM_DEV = true;
|
|
459
566
|
(function () {
|
|
460
567
|
var overlay = null;
|
|
461
568
|
function clearOverlay() {
|
|
@@ -491,11 +598,11 @@ __export(dev_exports, {
|
|
|
491
598
|
startDev: () => startDev
|
|
492
599
|
});
|
|
493
600
|
import { spawn } from "node:child_process";
|
|
494
|
-
import { existsSync as
|
|
601
|
+
import { existsSync as existsSync5, readdirSync, realpathSync, statSync as statSync3, watch } from "node:fs";
|
|
495
602
|
import { readFile } from "node:fs/promises";
|
|
496
603
|
import { createServer } from "node:http";
|
|
497
604
|
import { isIP } from "node:net";
|
|
498
|
-
import { basename as basename2, extname, isAbsolute as isAbsolute3, join as
|
|
605
|
+
import { basename as basename2, dirname as dirname2, extname, isAbsolute as isAbsolute3, join as join5, normalize as normalize3, relative, resolve as resolve2, sep } from "node:path";
|
|
499
606
|
import { parseArgs } from "node:util";
|
|
500
607
|
import { context as esbuildContext, formatMessages } from "esbuild";
|
|
501
608
|
function openBrowser(url) {
|
|
@@ -533,10 +640,10 @@ async function listen(server, port, host) {
|
|
|
533
640
|
async function startDev(opts) {
|
|
534
641
|
const root = resolve2(opts.root);
|
|
535
642
|
const host = opts.host ?? "localhost";
|
|
536
|
-
const outDir =
|
|
643
|
+
const outDir = join5(root, "dist");
|
|
537
644
|
const realRoot = realpathSync(root);
|
|
538
645
|
const outTmp = outDir + ".tmp";
|
|
539
|
-
const virtualOut =
|
|
646
|
+
const virtualOut = join5(root, ".thrunium-dev");
|
|
540
647
|
loadProject(root);
|
|
541
648
|
const clients = /* @__PURE__ */ new Set();
|
|
542
649
|
let lastError = null;
|
|
@@ -557,6 +664,25 @@ data: ${JSON.stringify(data)}
|
|
|
557
664
|
let owned = /* @__PURE__ */ new Set();
|
|
558
665
|
let entries = [];
|
|
559
666
|
let ctx = null;
|
|
667
|
+
let fonts = /* @__PURE__ */ new Map();
|
|
668
|
+
const failedFonts = /* @__PURE__ */ new Set();
|
|
669
|
+
const fontUrl = (font) => `${FONTS_PREFIX}${basename2(dirname2(font.file))}/${basename2(font.file)}`;
|
|
670
|
+
async function refreshFonts(outputs) {
|
|
671
|
+
const urls = [...new Set([...outputs].flatMap(([name, contents]) => name.endsWith(".js") ? findGoogleFontUrls(Buffer.from(contents).toString("utf8")) : []))];
|
|
672
|
+
const next = /* @__PURE__ */ new Map();
|
|
673
|
+
await Promise.all(
|
|
674
|
+
urls.map(async (url2) => {
|
|
675
|
+
try {
|
|
676
|
+
next.set(url2, (await fetchFonts([url2], fontsCacheDir(root), opts.fetchImpl ?? fetch)).get(url2));
|
|
677
|
+
failedFonts.delete(url2);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
if (!failedFonts.has(url2)) console.warn(`thrunium: ${err instanceof Error ? err.message : String(err)}. It loads from Google instead.`);
|
|
680
|
+
failedFonts.add(url2);
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
);
|
|
684
|
+
fonts = new Map(urls.flatMap((url2) => next.has(url2) ? [[url2, next.get(url2)]] : []));
|
|
685
|
+
}
|
|
560
686
|
const formatErrors = async (errors) => (await formatMessages(errors, { kind: "error", color: false })).join("\n").trim();
|
|
561
687
|
async function startBundler(newEntries) {
|
|
562
688
|
await ctx?.dispose();
|
|
@@ -564,6 +690,7 @@ data: ${JSON.stringify(data)}
|
|
|
564
690
|
entries = newEntries;
|
|
565
691
|
buildError = null;
|
|
566
692
|
bundles.clear();
|
|
693
|
+
fonts = /* @__PURE__ */ new Map();
|
|
567
694
|
owned = new Set(entries.map((e) => resolve2(root, e)));
|
|
568
695
|
if (entries.length === 0) return;
|
|
569
696
|
let first = true;
|
|
@@ -596,6 +723,7 @@ data: ${JSON.stringify(data)}
|
|
|
596
723
|
const changed = next.size !== bundles.size || [...next].some(([k, v]) => !Buffer.from(v).equals(bundles.get(k) ?? new Uint8Array()));
|
|
597
724
|
bundles.clear();
|
|
598
725
|
for (const [k, v] of next) bundles.set(k, v);
|
|
726
|
+
await refreshFonts(next);
|
|
599
727
|
owned = new Set(Object.keys(result.metafile?.inputs ?? {}).map((p) => resolve2(root, p)));
|
|
600
728
|
for (const e of entries) owned.add(resolve2(root, e));
|
|
601
729
|
if (!wasFirst && (changed || lastError !== null)) broadcastReload();
|
|
@@ -651,9 +779,17 @@ data: ${JSON.stringify(data)}
|
|
|
651
779
|
const project = loadProject(root);
|
|
652
780
|
const entryMap = new Map(project.entries.map((e, i) => [e, `${BUNDLE_PREFIX}${i}.js`]));
|
|
653
781
|
const styles = project.entries.flatMap((_, i) => bundles.has(`${i}.css`) ? [`${BUNDLE_PREFIX}${i}.css`] : []);
|
|
782
|
+
const manifest = {};
|
|
783
|
+
for (const [googleUrl, files] of fonts) {
|
|
784
|
+
manifest[googleUrl] = files.map((font) => {
|
|
785
|
+
const { file: _file, hash: _hash, ...face } = font;
|
|
786
|
+
return { ...face, src: fontUrl(font) };
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
const engineTag = (fonts.size > 0 ? manifestScript(manifest) : "") + ENGINE_TAG;
|
|
654
790
|
const html = fillExtras(
|
|
655
791
|
transformHtml(project.html, {
|
|
656
|
-
engineTag: project.usesModules ?
|
|
792
|
+
engineTag: project.usesModules ? engineTag : null,
|
|
657
793
|
entryMap,
|
|
658
794
|
inject: RELOAD_CLIENT,
|
|
659
795
|
styles
|
|
@@ -678,10 +814,20 @@ data: ${JSON.stringify(data)}
|
|
|
678
814
|
res.writeHead(200, { ...noCache, "content-type": type }).end(contents);
|
|
679
815
|
return;
|
|
680
816
|
}
|
|
681
|
-
|
|
817
|
+
if (pathname.startsWith(FONTS_PREFIX)) {
|
|
818
|
+
const font = [...fonts.values()].flat().find((f) => fontUrl(f) === pathname);
|
|
819
|
+
if (!font) {
|
|
820
|
+
res.writeHead(404).end();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const type = MIME[extname(font.file).toLowerCase()] ?? "application/octet-stream";
|
|
824
|
+
res.writeHead(200, { ...noCache, "content-type": type }).end(await readFile(font.file));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const file = normalize3(join5(root, pathname));
|
|
682
828
|
const rel = relative(root, file);
|
|
683
829
|
if (/[\\\0]/.test(pathname) || // backslashes act as separators on Windows; NUL is never valid
|
|
684
|
-
rel.split(/[\\/]/).some((part) => part.startsWith(".")) || rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute3(rel) || !
|
|
830
|
+
rel.split(/[\\/]/).some((part) => part.startsWith(".")) || rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute3(rel) || !existsSync5(file) || !statSync3(file).isFile()) {
|
|
685
831
|
res.writeHead(404).end();
|
|
686
832
|
return;
|
|
687
833
|
}
|
|
@@ -728,20 +874,20 @@ data: ${JSON.stringify(data)}
|
|
|
728
874
|
};
|
|
729
875
|
const watchers = /* @__PURE__ */ new Map();
|
|
730
876
|
const skippedTop = (name) => {
|
|
731
|
-
const abs =
|
|
877
|
+
const abs = join5(root, name);
|
|
732
878
|
return name === "node_modules" || name.startsWith(".") || abs === outDir || abs === outTmp;
|
|
733
879
|
};
|
|
734
880
|
const onWatchError = (path) => (err) => {
|
|
735
881
|
watchers.get(path)?.close();
|
|
736
882
|
watchers.delete(path);
|
|
737
|
-
if (
|
|
883
|
+
if (existsSync5(path)) console.error(`thrunium: file watching stopped for ${path}: ${err.message}`);
|
|
738
884
|
};
|
|
739
885
|
const rescan = () => {
|
|
740
886
|
if (closed) return;
|
|
741
887
|
const dirs = /* @__PURE__ */ new Set();
|
|
742
888
|
try {
|
|
743
889
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
744
|
-
if (entry.isDirectory() && !skippedTop(entry.name)) dirs.add(
|
|
890
|
+
if (entry.isDirectory() && !skippedTop(entry.name)) dirs.add(join5(root, entry.name));
|
|
745
891
|
}
|
|
746
892
|
} catch {
|
|
747
893
|
return;
|
|
@@ -758,7 +904,7 @@ data: ${JSON.stringify(data)}
|
|
|
758
904
|
const w = watch(dir, { recursive: true }, (_event, filename) => {
|
|
759
905
|
if (!filename) return schedule();
|
|
760
906
|
const name = filename.toString();
|
|
761
|
-
if (name === basename2(dir) && !
|
|
907
|
+
if (name === basename2(dir) && !existsSync5(join5(dir, name))) return;
|
|
762
908
|
if (!ignored(resolve2(dir, name))) schedule();
|
|
763
909
|
});
|
|
764
910
|
w.on("error", onWatchError(dir));
|
|
@@ -770,7 +916,7 @@ data: ${JSON.stringify(data)}
|
|
|
770
916
|
const rootWatcher = watch(root, (event, filename) => {
|
|
771
917
|
if (event === "rename") rescan();
|
|
772
918
|
const abs = filename ? resolve2(root, filename.toString()) : root;
|
|
773
|
-
const pageInput = !filename || PAGE_INPUTS.some((file) => abs ===
|
|
919
|
+
const pageInput = !filename || PAGE_INPUTS.some((file) => abs === join5(root, file));
|
|
774
920
|
if (!pageInput && ignored(abs)) return;
|
|
775
921
|
schedule();
|
|
776
922
|
});
|
|
@@ -848,12 +994,13 @@ ${USAGE}`);
|
|
|
848
994
|
});
|
|
849
995
|
await server.close();
|
|
850
996
|
}
|
|
851
|
-
var USAGE, MIME, ENGINE_TAG, BUNDLE_PREFIX, PAGE_INPUTS, WATCH_DEBOUNCE_MS, PORT_ATTEMPTS;
|
|
997
|
+
var USAGE, MIME, ENGINE_TAG, BUNDLE_PREFIX, FONTS_PREFIX, PAGE_INPUTS, WATCH_DEBOUNCE_MS, PORT_ATTEMPTS;
|
|
852
998
|
var init_dev = __esm({
|
|
853
999
|
"cli/dev.ts"() {
|
|
854
1000
|
"use strict";
|
|
855
1001
|
init_engine();
|
|
856
1002
|
init_errors();
|
|
1003
|
+
init_fonts();
|
|
857
1004
|
init_html();
|
|
858
1005
|
init_project();
|
|
859
1006
|
init_reload_client();
|
|
@@ -888,6 +1035,7 @@ Options:
|
|
|
888
1035
|
};
|
|
889
1036
|
ENGINE_TAG = '<script src="/__thrunium/engine/thrunium.js"></script>';
|
|
890
1037
|
BUNDLE_PREFIX = "/__thrunium/bundle/";
|
|
1038
|
+
FONTS_PREFIX = "/__thrunium/fonts/";
|
|
891
1039
|
PAGE_INPUTS = ["index.html", "game.json", "head.html", "body.html"];
|
|
892
1040
|
WATCH_DEBOUNCE_MS = 50;
|
|
893
1041
|
PORT_ATTEMPTS = 11;
|
|
@@ -900,14 +1048,14 @@ __export(build_exports, {
|
|
|
900
1048
|
build: () => build,
|
|
901
1049
|
runBuild: () => runBuild
|
|
902
1050
|
});
|
|
903
|
-
import { existsSync as
|
|
904
|
-
import { dirname as
|
|
1051
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync4, writeFileSync as writeFileSync2, copyFileSync } from "node:fs";
|
|
1052
|
+
import { basename as basename3, dirname as dirname3, extname as extname2, isAbsolute as isAbsolute4, join as join6, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
|
|
905
1053
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
906
1054
|
import { build as esbuild, formatMessages as formatMessages2 } from "esbuild";
|
|
907
1055
|
function listFiles(root, dir, skipDirs) {
|
|
908
1056
|
const files = [];
|
|
909
1057
|
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
910
|
-
const abs =
|
|
1058
|
+
const abs = join6(dir, entry.name);
|
|
911
1059
|
if (entry.name.startsWith(".") || entry.name === "node_modules" || skipDirs.has(abs)) continue;
|
|
912
1060
|
if (entry.isDirectory()) files.push(...listFiles(root, abs, skipDirs));
|
|
913
1061
|
else if (entry.isFile() && !SKIPPED_NAMES.test(entry.name)) files.push(relative2(root, abs));
|
|
@@ -935,7 +1083,7 @@ async function runBuild(opts) {
|
|
|
935
1083
|
const entryMap = /* @__PURE__ */ new Map();
|
|
936
1084
|
const styles = [];
|
|
937
1085
|
if (project.usesModules) {
|
|
938
|
-
const outAbs =
|
|
1086
|
+
const outAbs = join6(root, ".thrunium-out");
|
|
939
1087
|
const result = await esbuild({
|
|
940
1088
|
...baseBuildOptions(root),
|
|
941
1089
|
entryPoints: project.entries,
|
|
@@ -970,12 +1118,25 @@ async function runBuild(opts) {
|
|
|
970
1118
|
if (project.usesModules) {
|
|
971
1119
|
if (bundled) {
|
|
972
1120
|
engineTag = '<script src="thrunium.min.js"></script>';
|
|
973
|
-
written.set("thrunium.min.js",
|
|
1121
|
+
written.set("thrunium.min.js", readFileSync4(engineFile("thrunium.min.js")));
|
|
974
1122
|
const map = engineFile("thrunium.min.js") + ".map";
|
|
975
|
-
if (
|
|
1123
|
+
if (existsSync6(map)) written.set("thrunium.min.js.map", readFileSync4(map));
|
|
976
1124
|
} else {
|
|
977
1125
|
engineTag = cdnScriptTag(engineVersion(), integrity);
|
|
978
1126
|
}
|
|
1127
|
+
const urls = [...new Set([...written].flatMap(([file, contents]) => file.endsWith(".js") ? findGoogleFontUrls(Buffer.from(contents).toString("utf8")) : []))];
|
|
1128
|
+
if (urls.length > 0) {
|
|
1129
|
+
const manifest = {};
|
|
1130
|
+
for (const [url, fonts] of await fetchFonts(urls, fontsCacheDir(root), opts.fetchImpl ?? fetch)) {
|
|
1131
|
+
manifest[url] = fonts.map(({ file, hash, ...face }) => {
|
|
1132
|
+
const ext = extname2(file);
|
|
1133
|
+
const src = `fonts/${basename3(file, ext)}-${hash.slice(0, 8)}${ext}`;
|
|
1134
|
+
written.set(src, readFileSync4(file));
|
|
1135
|
+
return { ...face, src };
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
engineTag = manifestScript(manifest) + engineTag;
|
|
1139
|
+
}
|
|
979
1140
|
}
|
|
980
1141
|
const tmp = outDir + ".tmp";
|
|
981
1142
|
written.set("index.html", fillExtras(transformHtml(project.html, { engineTag, entryMap, inject: null, styles }), project.extras));
|
|
@@ -986,28 +1147,28 @@ async function runBuild(opts) {
|
|
|
986
1147
|
const key = file.split(sep2).join("/");
|
|
987
1148
|
if (written.has(key) || consumed.has(resolve3(root, file))) continue;
|
|
988
1149
|
if (project.htmlSource === "generated" && PAGE_PARTS.has(key)) continue;
|
|
989
|
-
const dest =
|
|
990
|
-
|
|
991
|
-
copyFileSync(
|
|
1150
|
+
const dest = join6(tmp, file);
|
|
1151
|
+
mkdirSync2(dirname3(dest), { recursive: true });
|
|
1152
|
+
copyFileSync(join6(root, file), dest);
|
|
992
1153
|
}
|
|
993
1154
|
for (const [file, contents] of written) {
|
|
994
|
-
const dest =
|
|
995
|
-
|
|
996
|
-
|
|
1155
|
+
const dest = join6(tmp, file);
|
|
1156
|
+
mkdirSync2(dirname3(dest), { recursive: true });
|
|
1157
|
+
writeFileSync2(dest, contents);
|
|
997
1158
|
}
|
|
998
1159
|
rmSync(outDir, { recursive: true, force: true });
|
|
999
|
-
|
|
1160
|
+
renameSync2(tmp, outDir);
|
|
1000
1161
|
} catch (err) {
|
|
1001
1162
|
rmSync(tmp, { recursive: true, force: true });
|
|
1002
1163
|
throw err;
|
|
1003
1164
|
}
|
|
1004
1165
|
const files = listAll(outDir, outDir).sort();
|
|
1005
|
-
for (const f of files) console.log(` ${f} ${formatSize(statSync4(
|
|
1166
|
+
for (const f of files) console.log(` ${f} ${formatSize(statSync4(join6(outDir, f)).size)}`);
|
|
1006
1167
|
return { outDir, files };
|
|
1007
1168
|
}
|
|
1008
1169
|
function listAll(base, dir) {
|
|
1009
1170
|
return readdirSync2(dir, { withFileTypes: true }).flatMap(
|
|
1010
|
-
(e) => e.isDirectory() ? listAll(base,
|
|
1171
|
+
(e) => e.isDirectory() ? listAll(base, join6(dir, e.name)) : [relative2(base, join6(dir, e.name)).split(sep2).join("/")]
|
|
1011
1172
|
);
|
|
1012
1173
|
}
|
|
1013
1174
|
function formatSize(bytes) {
|
|
@@ -1045,6 +1206,7 @@ var init_build = __esm({
|
|
|
1045
1206
|
"use strict";
|
|
1046
1207
|
init_engine();
|
|
1047
1208
|
init_errors();
|
|
1209
|
+
init_fonts();
|
|
1048
1210
|
init_html();
|
|
1049
1211
|
init_project();
|
|
1050
1212
|
USAGE2 = `Usage: thrunium build [options]
|
|
@@ -1070,8 +1232,8 @@ __export(init_exports, {
|
|
|
1070
1232
|
spawnInstall: () => spawnInstall
|
|
1071
1233
|
});
|
|
1072
1234
|
import { spawn as spawn2 } from "node:child_process";
|
|
1073
|
-
import { cpSync, existsSync as
|
|
1074
|
-
import { basename as
|
|
1235
|
+
import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync5, renameSync as renameSync3, rmdirSync, statSync as statSync5, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1236
|
+
import { basename as basename4, join as join7, resolve as resolve4 } from "node:path";
|
|
1075
1237
|
import { parseArgs as parseArgs3 } from "node:util";
|
|
1076
1238
|
function detectPackageManager(userAgent) {
|
|
1077
1239
|
const [name, version] = (userAgent?.split(" ")[0] ?? "").split("/");
|
|
@@ -1093,31 +1255,36 @@ function spawnInstall(cmd, args, cwd) {
|
|
|
1093
1255
|
}
|
|
1094
1256
|
async function runInit(opts) {
|
|
1095
1257
|
const dir = resolve4(opts.dir);
|
|
1096
|
-
if (
|
|
1258
|
+
if (existsSync7(dir)) {
|
|
1097
1259
|
if (!statSync5(dir).isDirectory() || readdirSync3(dir).length > 0) {
|
|
1098
1260
|
throw new UserError(`${dir} already exists and is not an empty folder.`);
|
|
1099
1261
|
}
|
|
1100
1262
|
} else {
|
|
1101
|
-
|
|
1263
|
+
mkdirSync3(dir, { recursive: true });
|
|
1102
1264
|
}
|
|
1103
|
-
cpSync(
|
|
1104
|
-
if (opts.pm.name === "pnpm") cpSync(
|
|
1105
|
-
|
|
1106
|
-
const name =
|
|
1265
|
+
cpSync(join7(packageRoot(), "templates", opts.template), dir, { recursive: true });
|
|
1266
|
+
if (opts.pm.name === "pnpm") cpSync(join7(packageRoot(), "templates", "pnpm"), dir, { recursive: true });
|
|
1267
|
+
renameSync3(join7(dir, "gitignore"), join7(dir, ".gitignore"));
|
|
1268
|
+
const name = basename4(dir).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1107
1269
|
for (const file of TEXT_FILES) {
|
|
1108
|
-
const path =
|
|
1109
|
-
const text =
|
|
1110
|
-
|
|
1270
|
+
const path = join7(dir, file);
|
|
1271
|
+
const text = readFileSync5(path, "utf8").replace(/\{\{name\}\}/g, name).replace(/\{\{version\}\}/g, engineVersion());
|
|
1272
|
+
writeFileSync3(path, text);
|
|
1111
1273
|
}
|
|
1112
1274
|
const pinPnpm = opts.pm.name === "pnpm" && opts.pm.version;
|
|
1113
1275
|
if (pinPnpm || opts.pm.name === "npm") {
|
|
1114
|
-
const path =
|
|
1115
|
-
const pkg = JSON.parse(
|
|
1276
|
+
const path = join7(dir, "package.json");
|
|
1277
|
+
const pkg = JSON.parse(readFileSync5(path, "utf8"));
|
|
1116
1278
|
if (pinPnpm) pkg.packageManager = `pnpm@${opts.pm.version}`;
|
|
1117
1279
|
if (opts.pm.name === "npm") pkg.allowScripts = { esbuild: true };
|
|
1118
|
-
|
|
1280
|
+
writeFileSync3(path, `${JSON.stringify(pkg, null, 2)}
|
|
1119
1281
|
`);
|
|
1120
1282
|
}
|
|
1283
|
+
if (opts.src === false) {
|
|
1284
|
+
const file = opts.template === "ts" ? "game.ts" : "game.js";
|
|
1285
|
+
renameSync3(join7(dir, "src", file), join7(dir, file));
|
|
1286
|
+
rmdirSync(join7(dir, "src"));
|
|
1287
|
+
}
|
|
1121
1288
|
if (!opts.install) return;
|
|
1122
1289
|
const { cmd, args } = installCommand(opts.pm.name);
|
|
1123
1290
|
const ok = await (opts.runInstall ?? spawnInstall)(cmd, args, dir);
|
|
@@ -1134,6 +1301,7 @@ async function init(args) {
|
|
|
1134
1301
|
js: { type: "boolean" },
|
|
1135
1302
|
pm: { type: "string" },
|
|
1136
1303
|
install: { type: "boolean", default: true },
|
|
1304
|
+
src: { type: "boolean", default: true },
|
|
1137
1305
|
help: { type: "boolean", short: "h" }
|
|
1138
1306
|
},
|
|
1139
1307
|
allowPositionals: true,
|
|
@@ -1162,7 +1330,7 @@ ${USAGE3}`);
|
|
|
1162
1330
|
}
|
|
1163
1331
|
pm = values.pm === pm.name ? pm : { name: values.pm, version: null };
|
|
1164
1332
|
}
|
|
1165
|
-
await runInit({ dir, template: values.js ? "js" : "ts", pm, install: values.install });
|
|
1333
|
+
await runInit({ dir, template: values.js ? "js" : "ts", pm, install: values.install, src: values.src });
|
|
1166
1334
|
console.log(`Created ${dir === "." ? "the project" : dir}. Next:
|
|
1167
1335
|
|
|
1168
1336
|
${dir === "." ? "" : ` cd ${dir}
|
|
@@ -1184,6 +1352,7 @@ Options:
|
|
|
1184
1352
|
--pm <name> Package manager: npm, pnpm, yarn or bun
|
|
1185
1353
|
(default: the one that ran this command, else npm)
|
|
1186
1354
|
--no-install Do not install dependencies
|
|
1355
|
+
--no-src Put game.ts (or game.js) in the project root instead of src/
|
|
1187
1356
|
--help, -h Show this help
|
|
1188
1357
|
`;
|
|
1189
1358
|
PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
@@ -1193,7 +1362,7 @@ Options:
|
|
|
1193
1362
|
|
|
1194
1363
|
// cli/main.ts
|
|
1195
1364
|
init_errors();
|
|
1196
|
-
import { readFileSync as
|
|
1365
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
1197
1366
|
import { parseArgs as parseArgs4 } from "node:util";
|
|
1198
1367
|
var USAGE4 = `Usage: thrunium <command>
|
|
1199
1368
|
|
|
@@ -1208,7 +1377,7 @@ Options:
|
|
|
1208
1377
|
`;
|
|
1209
1378
|
function packageVersion() {
|
|
1210
1379
|
const url = new URL("../package.json", import.meta.url);
|
|
1211
|
-
return JSON.parse(
|
|
1380
|
+
return JSON.parse(readFileSync6(url, "utf8")).version;
|
|
1212
1381
|
}
|
|
1213
1382
|
async function main(argv) {
|
|
1214
1383
|
try {
|
package/dist/thrunium-global.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export const Game = T.Game;
|
|
|
14
14
|
export const Vector = T.Vector;
|
|
15
15
|
export const Collider = T.Collider;
|
|
16
16
|
export const Sprite = T.Sprite;
|
|
17
|
+
export const Button = T.Button;
|
|
17
18
|
export const Elem = T.Elem;
|
|
18
19
|
export const ajax = T.ajax;
|
|
19
20
|
export const Storage = T.Storage;
|