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 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 existsSync2, statSync } from "node:fs";
223
- import { isAbsolute, join as join2, normalize } from "node:path";
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 = join2(root, value);
231
- if (!existsSync2(path) || !statSync(path).isFile()) throw problem("file not found");
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 existsSync3, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
296
- import { basename, isAbsolute as isAbsolute2, join as join3, normalize as normalize2, resolve } from "node:path";
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 = join3(root, "package.json");
300
- if (!existsSync3(manifest)) return "cdn";
405
+ const manifest = join4(root, "package.json");
406
+ if (!existsSync4(manifest)) return "cdn";
301
407
  let pkg;
302
408
  try {
303
- pkg = JSON.parse(readFileSync2(manifest, "utf8"));
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 existsSync3(path) && statSync2(path).isFile();
419
+ return existsSync4(path) && statSync2(path).isFile();
314
420
  }
315
421
  function readGameJson(root) {
316
- const file = join3(root, "game.json");
317
- if (!existsSync3(file)) return null;
422
+ const file = join4(root, "game.json");
423
+ if (!existsSync4(file)) return null;
318
424
  let json;
319
425
  try {
320
- json = JSON.parse(readFileSync2(file, "utf8"));
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(join3(root, entry))) throw new UserError(`"entry" in game.json points to ${entry}, which does not exist.`);
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(readFileSync2(file, "utf8"));
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 = join3(root, file);
350
- return isFile(path) ? readFileSync2(path, "utf8").replace(/^\uFEFF/, "").trimEnd() : "";
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 = join3(root, "index.html");
354
- if (existsSync3(indexPath)) {
355
- const gameJson = join3(root, "game.json");
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(join3(root, file))) {
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: readFileSync2(indexPath, "utf8"), htmlSource: "file", extras: { head: "", body: "" } };
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(join3(root, candidate)));
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 (!existsSync3(join3(root, entry))) {
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 existsSync4, readdirSync, realpathSync, statSync as statSync3, watch } from "node:fs";
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 join4, normalize as normalize3, relative, resolve as resolve2, sep } from "node:path";
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 = join4(root, "dist");
643
+ const outDir = join5(root, "dist");
537
644
  const realRoot = realpathSync(root);
538
645
  const outTmp = outDir + ".tmp";
539
- const virtualOut = join4(root, ".thrunium-dev");
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 ? ENGINE_TAG : null,
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
- const file = normalize3(join4(root, pathname));
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) || !existsSync4(file) || !statSync3(file).isFile()) {
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 = join4(root, name);
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 (existsSync4(path)) console.error(`thrunium: file watching stopped for ${path}: ${err.message}`);
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(join4(root, entry.name));
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) && !existsSync4(join4(dir, name))) return;
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 === join4(root, file));
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 existsSync5, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync3, renameSync, rmSync, statSync as statSync4, writeFileSync, copyFileSync } from "node:fs";
904
- import { dirname as dirname2, isAbsolute as isAbsolute4, join as join5, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
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 = join5(dir, entry.name);
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 = join5(root, ".thrunium-out");
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", readFileSync3(engineFile("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 (existsSync5(map)) written.set("thrunium.min.js.map", readFileSync3(map));
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 = join5(tmp, file);
990
- mkdirSync(dirname2(dest), { recursive: true });
991
- copyFileSync(join5(root, file), dest);
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 = join5(tmp, file);
995
- mkdirSync(dirname2(dest), { recursive: true });
996
- writeFileSync(dest, contents);
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
- renameSync(tmp, outDir);
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(join5(outDir, f)).size)}`);
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, join5(dir, e.name)) : [relative2(base, join5(dir, e.name)).split(sep2).join("/")]
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 existsSync6, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, statSync as statSync5, writeFileSync as writeFileSync2 } from "node:fs";
1074
- import { basename as basename3, join as join6, resolve as resolve4 } from "node:path";
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 (existsSync6(dir)) {
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
- mkdirSync2(dir, { recursive: true });
1263
+ mkdirSync3(dir, { recursive: true });
1102
1264
  }
1103
- cpSync(join6(packageRoot(), "templates", opts.template), dir, { recursive: true });
1104
- if (opts.pm.name === "pnpm") cpSync(join6(packageRoot(), "templates", "pnpm"), dir, { recursive: true });
1105
- renameSync2(join6(dir, "gitignore"), join6(dir, ".gitignore"));
1106
- const name = basename3(dir).toLowerCase().replace(/[^a-z0-9-]/g, "-");
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 = join6(dir, file);
1109
- const text = readFileSync4(path, "utf8").replace(/\{\{name\}\}/g, name).replace(/\{\{version\}\}/g, engineVersion());
1110
- writeFileSync2(path, text);
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 = join6(dir, "package.json");
1115
- const pkg = JSON.parse(readFileSync4(path, "utf8"));
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
- writeFileSync2(path, `${JSON.stringify(pkg, null, 2)}
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 readFileSync5 } from "node:fs";
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(readFileSync5(url, "utf8")).version;
1380
+ return JSON.parse(readFileSync6(url, "utf8")).version;
1212
1381
  }
1213
1382
  async function main(argv) {
1214
1383
  try {
@@ -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;