@origingame/engine 0.0.0-stage → 0.1.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.
Files changed (158) hide show
  1. package/bin/og.mjs +25 -0
  2. package/dist/web/origin-engine.d.ts +1233 -0
  3. package/dist/web/origin-engine.js +8628 -0
  4. package/dist/web/origin-engine_bg.wasm +0 -0
  5. package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
  6. package/dist/web-publish/origin-engine-lanes.js +8509 -0
  7. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  8. package/dist/web-publish/origin-engine.d.ts +1176 -0
  9. package/dist/web-publish/origin-engine.js +8313 -0
  10. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  11. package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
  12. package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  13. package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
  14. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  15. package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
  16. package/dist/web-publish-2d/origin-engine.js +8313 -0
  17. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  18. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
  19. package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  20. package/knowledge/README.md +31 -0
  21. package/knowledge/knowledge.json +13 -0
  22. package/knowledge/looks/catalogue.json +1871 -0
  23. package/knowledge/package.json +33 -0
  24. package/knowledge/rules/platform.json +329 -0
  25. package/knowledge/scaffold/README.md +27 -0
  26. package/knowledge/scaffold/distribution.json +48 -0
  27. package/knowledge/scaffold/origin-engine.json +19 -0
  28. package/knowledge/scaffold/template/.origin/setup +14 -0
  29. package/knowledge/scaffold/template/LICENSES.md +23 -0
  30. package/knowledge/scaffold/template/README.md +37 -0
  31. package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
  32. package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
  33. package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
  34. package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
  35. package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
  36. package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
  37. package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
  38. package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
  39. package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
  40. package/knowledge/scaffold/template/gitattributes +22 -0
  41. package/knowledge/scaffold/template/package.json +18 -0
  42. package/knowledge/scaffold/template/project.ogt +25 -0
  43. package/knowledge/scaffold/template/src/main.ts +246 -0
  44. package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
  45. package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
  46. package/knowledge/scaffold/template/tsconfig.json +17 -0
  47. package/knowledge/scripts/lib.ts +203 -0
  48. package/knowledge/scripts/mount.ts +210 -0
  49. package/knowledge/scripts/pack.ts +24 -0
  50. package/knowledge/scripts/scaffold.ts +72 -0
  51. package/knowledge/scripts/validate.ts +749 -0
  52. package/knowledge/skills/og-ai/SKILL.md +386 -0
  53. package/knowledge/skills/og-audio/SKILL.md +191 -0
  54. package/knowledge/skills/og-build/SKILL.md +330 -0
  55. package/knowledge/skills/og-core/SKILL.md +285 -0
  56. package/knowledge/skills/og-feel/SKILL.md +385 -0
  57. package/knowledge/skills/og-game/SKILL.md +1479 -0
  58. package/knowledge/skills/og-i18n/SKILL.md +220 -0
  59. package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
  60. package/knowledge/skills/og-taste/SKILL.md +537 -0
  61. package/knowledge/skills/og-ui/SKILL.md +444 -0
  62. package/knowledge/wiki/ai.md +627 -0
  63. package/knowledge/wiki/anim.md +1187 -0
  64. package/knowledge/wiki/atmosphere.md +636 -0
  65. package/knowledge/wiki/audio.md +732 -0
  66. package/knowledge/wiki/camera.md +711 -0
  67. package/knowledge/wiki/commands.md +357 -0
  68. package/knowledge/wiki/game.md +220 -0
  69. package/knowledge/wiki/i18n.md +213 -0
  70. package/knowledge/wiki/index.md +1027 -0
  71. package/knowledge/wiki/input.md +1714 -0
  72. package/knowledge/wiki/load.md +1280 -0
  73. package/knowledge/wiki/math.md +166 -0
  74. package/knowledge/wiki/nav.md +1154 -0
  75. package/knowledge/wiki/physics.md +3142 -0
  76. package/knowledge/wiki/platform.md +857 -0
  77. package/knowledge/wiki/probe.md +1692 -0
  78. package/knowledge/wiki/reflect.md +285 -0
  79. package/knowledge/wiki/render.md +3511 -0
  80. package/knowledge/wiki/rng.md +393 -0
  81. package/knowledge/wiki/stream.md +376 -0
  82. package/knowledge/wiki/system.md +197 -0
  83. package/knowledge/wiki/systems.md +43 -0
  84. package/knowledge/wiki/terrain.md +727 -0
  85. package/knowledge/wiki/tilemap.md +422 -0
  86. package/knowledge/wiki/time.md +150 -0
  87. package/knowledge/wiki/transform.md +230 -0
  88. package/knowledge/wiki/tween.md +580 -0
  89. package/knowledge/wiki/ui.md +8000 -0
  90. package/knowledge/wiki/world.md +909 -0
  91. package/package.json +68 -4
  92. package/schema/api.schema.json +29675 -0
  93. package/shell/README.md +22 -0
  94. package/shell/brand/README.md +23 -0
  95. package/shell/brand/icon-192.png +0 -0
  96. package/shell/brand/icon-512.png +0 -0
  97. package/shell/brand/mark-on-paper.svg +6 -0
  98. package/shell/brand/origin-mark.svg +5 -0
  99. package/shell/brand/origin-motion.css +251 -0
  100. package/shell/brand/tokens.css +20 -0
  101. package/shell/brand.ts +132 -0
  102. package/shell/page.ts +715 -0
  103. package/shell/platform.ts +203 -0
  104. package/shell/player.ts +238 -0
  105. package/shell/shell.css +185 -0
  106. package/shell/sw.ts +177 -0
  107. package/shell/transport.ts +515 -0
  108. package/shell/wire.ts +369 -0
  109. package/src/e6.ts +69 -0
  110. package/src/env.d.ts +100 -0
  111. package/src/generated/abi.ts +980 -0
  112. package/src/index.ts +193 -0
  113. package/src/node/audio.ts +86 -0
  114. package/src/node/build.ts +109 -0
  115. package/src/node/device.ts +981 -0
  116. package/src/node/doctor.ts +60 -0
  117. package/src/node/index.ts +102 -0
  118. package/src/node/platform.ts +24 -0
  119. package/src/node/playtest-run.ts +249 -0
  120. package/src/node/png.ts +130 -0
  121. package/src/node/polish.ts +976 -0
  122. package/src/node/run.ts +373 -0
  123. package/src/node/web-build.ts +808 -0
  124. package/src/p2.ts +35 -0
  125. package/src/platform-local.ts +211 -0
  126. package/src/playtest.ts +131 -0
  127. package/src/runtime.ts +1509 -0
  128. package/src/systems/cameras.ts +247 -0
  129. package/src/ui-kit/bind.ts +141 -0
  130. package/src/ui-kit/huds.ts +303 -0
  131. package/src/ui-kit/index.ts +36 -0
  132. package/src/ui-kit/menus.ts +187 -0
  133. package/src/ui-kit/settings.ts +131 -0
  134. package/src/ui-kit/store.ts +222 -0
  135. package/src/ui-kit/story.ts +89 -0
  136. package/src/ui-kit/util.ts +106 -0
  137. package/src/web/a11y.ts +429 -0
  138. package/src/web/audio-worklet.ts +193 -0
  139. package/src/web/audio.ts +307 -0
  140. package/src/web/basis.ts +239 -0
  141. package/src/web/gamepad.ts +123 -0
  142. package/src/web/host.ts +166 -0
  143. package/src/web/input.ts +237 -0
  144. package/src/web/lanes.ts +21 -0
  145. package/src/web/play.ts +20 -0
  146. package/src/web/rpc-ring.ts +110 -0
  147. package/src/web/settle.ts +122 -0
  148. package/src/web/shell.ts +643 -0
  149. package/src/web/text-input.ts +248 -0
  150. package/src/web/worker.ts +1002 -0
  151. package/types/bindings.json +13654 -0
  152. package/types/index.d.ts +20090 -0
  153. package/vendor/basis/LICENSE +201 -0
  154. package/vendor/basis/NOTICE +15 -0
  155. package/vendor/basis/README.md +18 -0
  156. package/vendor/basis/basis_transcoder.js +19 -0
  157. package/vendor/basis/basis_transcoder.wasm +0 -0
  158. package/README.md +0 -3
@@ -0,0 +1,643 @@
1
+ // The main-thread shell (docs/design.md §2.2): it owns the page, transfers
2
+ // the canvas to the engine Worker as an OffscreenCanvas, and pumps input into
3
+ // the SharedArrayBuffer ring when the page is cross-origin isolated (falling
4
+ // back to postMessage otherwise). It never runs game code.
5
+ //
6
+ // Input: keyboard (KeyboardEvent.code; the keys that would scroll or move
7
+ // focus are kept from the page), pointer events of every type (mouse, pen,
8
+ // touch), up to five at once: each pressed pointer gets the lowest free
9
+ // engine id (0, the primary pointer og.Input follows, while none is held;
10
+ // then 1 to 4), which og.Pointers reports with its position and press
11
+ // point, so a stick and a button can be held together. Gamepads are polled
12
+ // on the display frame (gamepad.ts) and delivered both as og.Gamepad states
13
+ // and, for games that only read keys, as the keys of their standard mapping.
14
+ //
15
+ // Accessibility: the UI's accessibility tree is mirrored into the page over
16
+ // the canvas (a11y.ts), and the page's `prefers-reduced-motion: reduce`,
17
+ // `prefers-contrast: more` and `prefers-reduced-transparency: reduce` reach
18
+ // the UI with the viewport (og-ui's theme takes them over the game's
19
+ // settings; glass, scrims and translucent fills become opaque), followed as
20
+ // they change. So does the platform's keyboard: on Apple platforms
21
+ // `macKeys` gives og-ui's text controls macOS's editing keys (Command for
22
+ // the shortcuts and the line's ends, Option by words, Control+A and E). A
23
+ // Mac's browser sends no keyup for a key released while Command is held,
24
+ // so the shell releases those keys itself when Command goes up.
25
+
26
+ import { createRing, writeRing, type InjectedInput } from "./input.ts";
27
+ import { TextEntry, type TextTarget } from "./text-input.ts";
28
+ import { A11yMirror, type A11yTree } from "./a11y.ts";
29
+ import { laneChoice } from "./lanes.ts";
30
+ import { createRpcRings, readFrames, waitForFrames, writeFrame, type RpcRings } from "./rpc-ring.ts";
31
+ import { localPlatform, storagePersistence, type PlatformCall, type PlatformHost } from "../platform-local.ts";
32
+ import type { BootPhases } from "./worker.ts";
33
+
34
+ export interface ShellOptions {
35
+ /** The play surface. Its pixel size is set from its CSS size and the device pixel ratio. */
36
+ readonly canvas: HTMLCanvasElement;
37
+ /** The game's engine Worker (a module Worker whose entry calls startWorker). */
38
+ readonly worker: Worker;
39
+ /** URLs of the wasm-bindgen glue (origin-engine.js) and module (origin-engine_bg.wasm), as the Worker resolves them. */
40
+ readonly glue: string;
41
+ readonly wasm: string;
42
+ /**
43
+ * The lanes variant's glue and module (origin-engine-lanes.js and
44
+ * origin-engine-lanes_bg.wasm) and its size in bytes, when the build ships
45
+ * one: the shell runs it when the page is cross-origin isolated and the
46
+ * device has the cores for it (laneChoice).
47
+ */
48
+ readonly lanes?: { readonly glue: string; readonly wasm: string; readonly bytes?: number };
49
+ /** Use the WebGL2 fallback instead of WebGPU. */
50
+ readonly webgl?: boolean;
51
+ /** A replay seed; omitted is GameDesc.seed. */
52
+ readonly seed?: number;
53
+ /** Tick and render with the display (default true); false leaves ticks to `call("advance", n)`. */
54
+ readonly autoplay?: boolean;
55
+ /** The URL base (as the Worker resolves it) whose `cooked/` bundle the Worker fetches before the engine starts. */
56
+ readonly root?: string;
57
+ /** The Basis transcoder's script and module URLs (as the Worker resolves them). */
58
+ readonly basis?: { readonly js: string; readonly wasm: string };
59
+ /** The project's physics profile (project.ogt `profile`); omitted is 3d. */
60
+ readonly profile?: "2d" | "3d";
61
+ /** project.ogt's text: its tick, seed, scenes and scene fill what GameDesc leaves unset (CreateOptions.project). */
62
+ readonly project?: string;
63
+ /** Boot progress, 0 to 1, with the stage (`wasm`, `bundle`, `fetch`, `transcode`, `ready`). */
64
+ readonly onProgress?: (value: number, stage: string) => void;
65
+ /** The highest device pixel ratio the canvas is drawn at (default 2). */
66
+ readonly dprCap?: number;
67
+ /** Bytes the boot fetches (the wasm, the cooked bundle), so progress is the bytes received. */
68
+ readonly expect?: { readonly wasm?: number; readonly cooked?: number };
69
+ /** Lines of the engine's log and the Worker's console. */
70
+ readonly onLog?: (lines: readonly string[]) => void;
71
+ /** The engine failed after it was ready (a trap, a Rust panic, a failing system, a hang): the line to show and what it was. */
72
+ readonly onCrash?: (message: string, error: HostError) => void;
73
+ /** The first frame was rendered. */
74
+ readonly onFirstFrame?: () => void;
75
+ /** Without autoplay, take one tick before the first frame (the first screen behind a play gate). */
76
+ readonly prime?: boolean;
77
+ /** The locale og.i18n is asked for; default: `navigator.language`. */
78
+ readonly locale?: string;
79
+ /** What answers og.platform's calls; default: the browser's own storage (`setPlatform` changes it, a Player bridge for instance). */
80
+ readonly platform?: PlatformHost;
81
+ }
82
+
83
+ /** The pixel size of a canvas laid out at `cssWidth`×`cssHeight` on this display, with the DPR cap. */
84
+ export function pixelSize(cssWidth: number, cssHeight: number, devicePixelRatio: number, dprCap = 2): { width: number; height: number; dpr: number } {
85
+ const dpr = Math.max(0.5, Math.min(devicePixelRatio || 1, dprCap > 0 ? dprCap : 2));
86
+ return { width: Math.max(1, Math.round(cssWidth * dpr)), height: Math.max(1, Math.round(cssHeight * dpr)), dpr };
87
+ }
88
+
89
+ /** The page's preferences og-ui follows, each with the viewport field it sets. */
90
+ export const PAGE_PREFERENCES = [
91
+ ["reducedMotion", "(prefers-reduced-motion: reduce)"],
92
+ ["highContrast", "(prefers-contrast: more)"],
93
+ ["reducedTransparency", "(prefers-reduced-transparency: reduce)"],
94
+ ] as const;
95
+
96
+ /** The page's motion, contrast and transparency preferences and the platform's editing keys, as the viewport carries them to og-ui. */
97
+ export function pagePreferences(): { reducedMotion: boolean; highContrast: boolean; reducedTransparency: boolean; macKeys: boolean } {
98
+ const query = (q: string) => globalThis.matchMedia?.(q).matches ?? false;
99
+ const [motion, contrast, transparency] = PAGE_PREFERENCES.map(([, q]) => query(q));
100
+ return { reducedMotion: motion!, highContrast: contrast!, reducedTransparency: transparency!, macKeys: macKeys() };
101
+ }
102
+
103
+ /** Whether the page runs on an Apple platform, whose text fields take Command and Option (og-ui's `Viewport.macKeys`). */
104
+ export function macKeys(nav: { platform?: string; userAgentData?: { platform?: string } } | undefined = globalThis.navigator as never): boolean {
105
+ const platform = nav?.userAgentData?.platform || nav?.platform || "";
106
+ return /^(mac|iphone|ipad|ipod)/i.test(platform);
107
+ }
108
+
109
+ /** The modifier keys' codes: held through other keys, released on their own keyup. */
110
+ const MODIFIER_KEYS = new Set(["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", "CapsLock"]);
111
+
112
+ /** Keys the page would act on itself (scroll, focus); the shell keeps them for the game. */
113
+ const KEPT_KEYS = new Set(["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab", "PageUp", "PageDown", "Home", "End", "Backspace"]);
114
+
115
+ export { LANES_MAX_THREADS, laneChoice } from "./lanes.ts";
116
+
117
+ /**
118
+ * Why the engine stopped, as the Worker or the shell's frame deadline
119
+ * reported it (docs/web-export.md §Errors): `panic`, a Rust panic on the
120
+ * engine's thread or inside a lane (`lane` names the lanes site; the wasm
121
+ * recorded it before it trapped); `error`, a failing system, a trap without
122
+ * a panic or an uncaught error; `hang`, a playing engine Worker that sent
123
+ * no frame for FRAME_DEADLINE_MS.
124
+ */
125
+ export interface HostError {
126
+ readonly kind: "panic" | "error" | "hang";
127
+ readonly message: string;
128
+ /** The tick the world was running, when known. */
129
+ readonly tick: number | null;
130
+ /** The lanes site a panic happened in (`anim`, `extract`, `cull`, `physics`); null on the engine's thread. */
131
+ readonly lane: string | null;
132
+ /** A panic's `file:line:column`. */
133
+ readonly location: string | null;
134
+ }
135
+
136
+ /**
137
+ * How long a playing, visible page may go without a frame from the engine
138
+ * Worker before the shell gives up on it (a Worker stuck in a loop, or in
139
+ * `Atomics.wait` for a lane that never ends): it terminates the Worker and
140
+ * reports a `hang`. A panicking lane is caught sooner, at the lane boundary.
141
+ */
142
+ export const FRAME_DEADLINE_MS = 15_000;
143
+
144
+ /** What the Worker reported when the engine was ready. */
145
+ export interface Ready {
146
+ /** The mode the engine got: lanes on with the pool's threads, or why it runs single-threaded. */
147
+ readonly lanes: { readonly on: boolean; readonly threads: number; readonly why: string };
148
+ readonly tier: string;
149
+ readonly transport: "ring" | "message";
150
+ readonly rpc: "ring" | "message";
151
+ /** The encodings the bundle's Basis textures reached the renderer in (`bc7+bc5`, `astc4x4`, `etc2+eacrg11`, `rgba8`), or null without a bundle. */
152
+ readonly textureFormat: string | null;
153
+ readonly files: number;
154
+ readonly textures: number;
155
+ }
156
+
157
+ const BUTTONS = ["left", "middle", "right"] as const;
158
+
159
+ /**
160
+ * How long the boot may go without a word from the Worker (progress, a log
161
+ * line) before it fails: generous, since a slow network moves the progress
162
+ * and only a hung Worker says nothing for this long.
163
+ */
164
+ export const BOOT_DEADLINE_MS = 120_000;
165
+
166
+ export class Shell {
167
+ readonly worker: Worker;
168
+ readonly tier: string;
169
+ /** `ring` when input crosses through the SharedArrayBuffer, `message` otherwise. */
170
+ readonly transport: "ring" | "message";
171
+ /** What the Worker reported at start. */
172
+ readonly ready: Ready;
173
+ /** Where the Worker's boot went (worker.ts BootPhases), once the first frame is drawn. */
174
+ boot: BootPhases | null = null;
175
+ readonly #pending = new Map<number, { resolve(v: unknown): void; reject(e: Error): void }>();
176
+ readonly #rpcPending = new Map<number, { resolve(v: unknown): void; reject(e: Error): void }>();
177
+ readonly #listeners: Array<() => void> = [];
178
+ readonly #rpc: RpcRings | undefined;
179
+ #onLog: ShellOptions["onLog"];
180
+ #onCrash: ShellOptions["onCrash"];
181
+ #onFirstFrame: ShellOptions["onFirstFrame"];
182
+ #next = 1;
183
+ #rpcNext = 1;
184
+ #pumping = false;
185
+ #ring: SharedArrayBuffer | undefined;
186
+ #dprCap = 2;
187
+ /** Engine pointer ids (0 to 4) of the page pointers held down, by PointerEvent.pointerId. */
188
+ #pointers = new Map<number, number>();
189
+ #playing = false;
190
+ /** Why the engine no longer answers (it crashed, or the shell was disposed); every call after it fails with it. */
191
+ #stopped: Error | null = null;
192
+ /** The hidden textarea that takes the page's text for the UI's focused field (text-input.ts). */
193
+ #text: TextEntry | undefined;
194
+ /** The DOM mirror of the UI's accessibility tree (a11y.ts). */
195
+ #a11y: A11yMirror | undefined;
196
+ /** The viewport last sent to the Worker (the page's preferences are re-read into it when they change). */
197
+ #viewport: Record<string, unknown> = {};
198
+ /** What stopped the engine, once something did. */
199
+ #error: HostError | null = null;
200
+ /** When the Worker last said it plays (`beat`), and the tick it reached. */
201
+ #beat = 0;
202
+ #beatTick: number | null = null;
203
+ /** When the deadline was last looked at: a late look means the page itself was blocked. */
204
+ #watched = 0;
205
+ #watchdog: ReturnType<typeof setInterval> | undefined;
206
+ #platform: PlatformHost = localPlatform(storagePersistence(`og.platform:${globalThis.location?.pathname ?? "/"}`));
207
+
208
+ private constructor(worker: Worker, ready: Ready, rpc: RpcRings | undefined) {
209
+ this.worker = worker;
210
+ this.tier = ready.tier;
211
+ this.transport = ready.transport;
212
+ this.ready = ready;
213
+ this.#rpc = rpc;
214
+ worker.addEventListener("message", (e: MessageEvent) => {
215
+ const m = e.data as { type: string; id?: number; value?: unknown; error?: string; frame?: string; lines?: string[]; message?: string };
216
+ if (m.type === "log" && m.lines) {
217
+ this.#onLog?.(m.lines);
218
+ return;
219
+ }
220
+ if (m.type === "crash") {
221
+ const message = m.message ?? "the engine stopped";
222
+ const error = (m as unknown as { error?: HostError }).error ?? { kind: "error", message, tick: this.#beatTick, lane: null, location: null };
223
+ this.#crashed(message, error);
224
+ return;
225
+ }
226
+ if (m.type === "beat") {
227
+ this.#beat = performance.now();
228
+ this.#beatTick = (m as { tick?: number | null }).tick ?? this.#beatTick;
229
+ return;
230
+ }
231
+ if (m.type === "frame") {
232
+ this.boot = (m as { boot?: BootPhases }).boot ?? null;
233
+ this.#onFirstFrame?.();
234
+ return;
235
+ }
236
+ if (m.type === "text") {
237
+ this.#text?.update((m as unknown as { target: TextTarget | null }).target);
238
+ return;
239
+ }
240
+ if (m.type === "copy") {
241
+ this.#text?.copied((m as unknown as { text: string }).text);
242
+ return;
243
+ }
244
+ if (m.type === "a11y") {
245
+ this.#a11y?.update((m as unknown as { tree: A11yTree }).tree);
246
+ this.#text?.relabel();
247
+ return;
248
+ }
249
+ if (m.type === "rpc" && m.frame !== undefined) {
250
+ this.#answer(m.frame);
251
+ return;
252
+ }
253
+ if (m.type === "platform") {
254
+ const calls = (m as unknown as { calls: PlatformCall[] }).calls ?? [];
255
+ this.#platform.handle(calls, (request, answer) => worker.postMessage({ type: "platform-answer", request, answer }));
256
+ return;
257
+ }
258
+ if (m.type !== "result" || m.id === undefined) return;
259
+ const p = this.#pending.get(m.id);
260
+ this.#pending.delete(m.id);
261
+ if (m.error !== undefined) p?.reject(new Error(m.error));
262
+ else p?.resolve(m.value);
263
+ });
264
+ }
265
+
266
+ /** Start the engine in the Worker and wire the page's input to it. */
267
+ static async start(options: ShellOptions): Promise<Shell> {
268
+ const { canvas, worker } = options;
269
+ const size = pixelSize(canvas.clientWidth || 1, canvas.clientHeight || 1, globalThis.devicePixelRatio || 1, options.dprCap);
270
+ const dpr = size.dpr;
271
+ canvas.width = size.width;
272
+ canvas.height = size.height;
273
+ // Logs and a crash during the boot reach the page as well as after it.
274
+ const early = (e: MessageEvent) => {
275
+ const m = e.data as { type: string; lines?: string[] };
276
+ if (m.type === "log" && m.lines) options.onLog?.(m.lines);
277
+ };
278
+ worker.addEventListener("message", early);
279
+ const offscreen = canvas.transferControlToOffscreen();
280
+ const ring = globalThis.crossOriginIsolated ? createRing() : undefined;
281
+ const rpc = globalThis.crossOriginIsolated ? createRpcRings() : undefined;
282
+ // The boot fails (and the Worker stops) when the Worker says so, when it
283
+ // does not load or throws while loading (game.js missing, a syntax error,
284
+ // a top-level throw: an `error` event and no message), when its message
285
+ // cannot be read, or when it says nothing for BOOT_DEADLINE_MS.
286
+ const ready = new Promise<Ready>((resolve, reject) => {
287
+ let deadline: ReturnType<typeof setTimeout> | undefined;
288
+ const settle = () => {
289
+ clearTimeout(deadline);
290
+ worker.removeEventListener("message", on);
291
+ worker.removeEventListener("error", onError);
292
+ worker.removeEventListener("messageerror", onMessageError);
293
+ };
294
+ const failed = (message: string, error?: HostError) => {
295
+ settle();
296
+ worker.terminate();
297
+ reject(Object.assign(new Error(message), { error: error ?? { kind: "error", message, tick: null, lane: null, location: null } }));
298
+ };
299
+ const arm = () => {
300
+ clearTimeout(deadline);
301
+ deadline = setTimeout(() => failed(`the engine Worker said nothing for ${BOOT_DEADLINE_MS / 1000} s while starting`), BOOT_DEADLINE_MS);
302
+ };
303
+ const onError = (e: Event) => {
304
+ const message = (e as ErrorEvent).message;
305
+ failed(message ? `the engine Worker failed: ${message}` : "the engine Worker did not load (game.js is missing, or it failed while loading)");
306
+ };
307
+ const onMessageError = () => failed("a message from the engine Worker could not be read");
308
+ const on = (e: MessageEvent) => {
309
+ arm();
310
+ const m = e.data as { type: string; value?: number; stage?: string; message?: string } & Partial<Ready>;
311
+ if (m.type === "progress") options.onProgress?.(m.value ?? 0, m.stage ?? "");
312
+ else if (m.type === "ready") {
313
+ settle();
314
+ resolve({
315
+ lanes: m.lanes ?? { on: false, threads: 1, why: "the Worker did not report lanes" },
316
+ tier: m.tier ?? "none",
317
+ transport: m.transport ?? "message",
318
+ rpc: m.rpc ?? "message",
319
+ textureFormat: m.textureFormat ?? null,
320
+ files: m.files ?? 0,
321
+ textures: m.textures ?? 0,
322
+ });
323
+ } else if (m.type === "error") failed(m.message ?? "the engine did not start", (m as { error?: HostError }).error);
324
+ };
325
+ worker.addEventListener("message", on);
326
+ worker.addEventListener("error", onError);
327
+ worker.addEventListener("messageerror", onMessageError);
328
+ arm();
329
+ });
330
+ const choice = laneChoice(!!options.lanes, !!globalThis.crossOriginIsolated, globalThis.navigator?.hardwareConcurrency ?? 0);
331
+ worker.postMessage(
332
+ {
333
+ type: "start",
334
+ glue: options.glue,
335
+ wasm: options.wasm,
336
+ ...("threads" in choice ? { lanes: { ...options.lanes!, threads: choice.threads } } : { lanesWhy: choice.why }),
337
+ canvas: offscreen,
338
+ webgl: options.webgl ?? false,
339
+ seed: options.seed,
340
+ ring,
341
+ rpc,
342
+ autoplay: options.autoplay ?? true,
343
+ root: options.root,
344
+ basis: options.basis,
345
+ profile: options.profile,
346
+ ...(options.project === undefined ? {} : { project: options.project }),
347
+ locale: options.locale ?? globalThis.navigator?.language,
348
+ ...(options.expect ? { expect: options.expect } : {}),
349
+ ...(options.prime ? { prime: true } : {}),
350
+ viewport: { width: canvas.clientWidth || canvas.width / dpr, height: canvas.clientHeight || canvas.height / dpr, dpr, coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false, ...pagePreferences() },
351
+ },
352
+ [offscreen],
353
+ );
354
+ let answered: Ready;
355
+ try {
356
+ answered = await ready;
357
+ } finally {
358
+ worker.removeEventListener("message", early);
359
+ }
360
+ const shell = new Shell(worker, answered, rpc);
361
+ shell.#ring = ring;
362
+ shell.#dprCap = options.dprCap ?? 2;
363
+ shell.#onLog = options.onLog;
364
+ shell.#onCrash = options.onCrash;
365
+ shell.#onFirstFrame = options.onFirstFrame;
366
+ shell.#playing = options.autoplay ?? true;
367
+ shell.#viewport = { width: canvas.clientWidth || canvas.width / dpr, height: canvas.clientHeight || canvas.height / dpr, dpr, coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false };
368
+ shell.#beat = shell.#watched = performance.now();
369
+ shell.#watchdog = setInterval(() => shell.#watch(), 1000);
370
+ if (options.platform) shell.setPlatform(options.platform);
371
+ else shell.setPlatform(shell.#platform);
372
+ shell.#wire(canvas, ring);
373
+ return shell;
374
+ }
375
+
376
+ /**
377
+ * Call one probe member (JSON-RPC 2.0) through the request ring when the
378
+ * page is cross-origin isolated, else through postMessage. Resolves with
379
+ * the member's result (a protocol-5 refusal is `false` or `null`); rejects
380
+ * with the JSON-RPC error's message, its `code` on the error. A `capture` resolves once its
381
+ * readback has landed (the Worker retries it on the following frames).
382
+ */
383
+ probe(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
384
+ if (this.#stopped) return Promise.reject(this.#stopped);
385
+ const id = this.#rpcNext++;
386
+ const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params });
387
+ return new Promise((resolve, reject) => {
388
+ this.#rpcPending.set(id, { resolve, reject });
389
+ if (this.#rpc && writeFrame(this.#rpc.requests, frame)) void this.#pump();
390
+ else this.worker.postMessage({ type: "rpc", frame });
391
+ });
392
+ }
393
+
394
+ #answer(frame: string): void {
395
+ const m = JSON.parse(frame) as { id?: number; result?: unknown; error?: { code?: number; message?: string } };
396
+ const p = typeof m.id === "number" ? this.#rpcPending.get(m.id) : undefined;
397
+ if (!p) return;
398
+ this.#rpcPending.delete(m.id!);
399
+ // The JSON-RPC code rides on the error: -32601 is a member this build does not serve.
400
+ if (m.error) p.reject(Object.assign(new Error(m.error.message ?? "probe error"), { code: m.error.code ?? -32603 }));
401
+ else p.resolve(m.result);
402
+ }
403
+
404
+ /** Read answers from the ring while calls are outstanding. */
405
+ async #pump(): Promise<void> {
406
+ if (this.#pumping || !this.#rpc) return;
407
+ this.#pumping = true;
408
+ try {
409
+ while (this.#rpcPending.size > 0 && !this.#stopped) {
410
+ await waitForFrames(this.#rpc.answers, 100);
411
+ for (const frame of readFrames(this.#rpc.answers)) this.#answer(frame);
412
+ }
413
+ } finally {
414
+ this.#pumping = false;
415
+ }
416
+ }
417
+
418
+ /** Call the engine in the Worker: `advance`, `command`, `hash`, `frame`, or `probe` with a member name. */
419
+ call(method: string, ...args: unknown[]): Promise<unknown> {
420
+ if (this.#stopped) return Promise.reject(this.#stopped);
421
+ const id = this.#next++;
422
+ return new Promise((resolve, reject) => {
423
+ this.#pending.set(id, { resolve, reject });
424
+ this.worker.postMessage({ type: "call", id, method, args });
425
+ });
426
+ }
427
+
428
+ /** What answers og.platform's calls from now on (og.platform.info reports its host and capabilities). */
429
+ setPlatform(host: PlatformHost): void {
430
+ this.#platform = host;
431
+ this.worker.postMessage({ type: "platform-host", host: host.host, capabilities: host.capabilities });
432
+ }
433
+
434
+ /** What stopped the engine (a panic, an error, a hang), or null while it runs. */
435
+ get error(): HostError | null {
436
+ return this.#error;
437
+ }
438
+
439
+ /** The engine failed: nothing waits for an answer it will not give, and the page hears why once. */
440
+ #crashed(message: string, error: HostError): void {
441
+ if (this.#error) return;
442
+ this.#error = error;
443
+ this.#stop(`the engine stopped: ${message}`);
444
+ clearInterval(this.#watchdog);
445
+ this.#onCrash?.(message, error);
446
+ }
447
+
448
+ /**
449
+ * The frame deadline: a playing page whose Worker sent no `beat` for
450
+ * FRAME_DEADLINE_MS is stuck; the Worker is terminated and the page told.
451
+ * A hidden page (no display frames) and a paused clock do not count.
452
+ */
453
+ #watch(): void {
454
+ const now = performance.now();
455
+ const late = now - this.#watched > 3000;
456
+ this.#watched = now;
457
+ const hidden = globalThis.document?.visibilityState === "hidden";
458
+ // A page that was blocked or throttled itself has not been hearing the Worker: start over.
459
+ if (!this.#playing || hidden || this.#stopped || late) {
460
+ this.#beat = now;
461
+ return;
462
+ }
463
+ if (now - this.#beat < FRAME_DEADLINE_MS) return;
464
+ const tick = this.#beatTick;
465
+ const message = `the engine Worker sent no frame for ${FRAME_DEADLINE_MS / 1000} s${tick === null ? "" : ` (last at tick ${tick})`}: it is stuck, so it was stopped`;
466
+ this.#crashed(message, { kind: "hang", message, tick, lane: null, location: null });
467
+ this.worker.terminate();
468
+ }
469
+
470
+ /** Start or stop ticking with the display. */
471
+ play(on: boolean): void {
472
+ this.#playing = on;
473
+ this.#beat = performance.now();
474
+ this.worker.postMessage({ type: "play", on });
475
+ }
476
+
477
+ /** Whether the clock ticks with the display (the last `play`). */
478
+ get playing(): boolean {
479
+ return this.#playing;
480
+ }
481
+
482
+ /**
483
+ * The canvas was laid out at `cssWidth`×`cssHeight`: resize the render
484
+ * target (device pixels, with the DPR cap) and tell the UI its viewport,
485
+ * with the page's own control over the canvas (`covered`, Viewport.covered:
486
+ * the shell's Back) that anchored groups keep out of.
487
+ */
488
+ resize(cssWidth: number, cssHeight: number, safeArea?: Record<string, number>, covered?: Record<string, number>): { width: number; height: number; dpr: number } {
489
+ const size = pixelSize(cssWidth, cssHeight, globalThis.devicePixelRatio || 1, this.#dprCap);
490
+ const viewport = {
491
+ width: cssWidth,
492
+ height: cssHeight,
493
+ dpr: size.dpr,
494
+ ...(safeArea ? { safeArea } : {}),
495
+ ...(covered ? { covered } : {}),
496
+ coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,
497
+ };
498
+ this.#viewport = viewport;
499
+ this.worker.postMessage({ type: "resize", width: size.width, height: size.height, viewport: { ...viewport, ...pagePreferences() } });
500
+ return size;
501
+ }
502
+
503
+ /** The DOM mirror of the UI's accessibility tree (a11y.ts). */
504
+ get a11y(): A11yMirror | undefined {
505
+ return this.#a11y;
506
+ }
507
+
508
+ /** Deliver one event as page input (the ring when isolated, else a message). */
509
+ inject(event: InjectedInput): void {
510
+ this.#send(this.#ring, event);
511
+ }
512
+
513
+ #send(ring: SharedArrayBuffer | undefined, event: InjectedInput): void {
514
+ if (ring) writeRing(ring, event);
515
+ else this.worker.postMessage({ type: "input", event });
516
+ }
517
+
518
+ #wire(canvas: HTMLCanvasElement, ring: SharedArrayBuffer | undefined): void {
519
+ const listen = <K extends keyof HTMLElementEventMap>(target: EventTarget, type: K, fn: (e: HTMLElementEventMap[K]) => void) => {
520
+ target.addEventListener(type, fn as EventListener, { passive: false });
521
+ this.#listeners.push(() => target.removeEventListener(type, fn as EventListener));
522
+ };
523
+ // When an event happened on a clock the Worker shares (its `at` within the tick, input.ts stampAt).
524
+ const stamp = (e: Event) => performance.timeOrigin + e.timeStamp;
525
+ // The UI's text fields take the page's text through a hidden textarea
526
+ // (text-input.ts); its keys still reach the engine, except an input
527
+ // method's while it composes.
528
+ // The accessibility tree over the canvas; what a screen reader does there reaches the UI as input.
529
+ const mirror = new A11yMirror(canvas, (e) => this.#send(ring, e));
530
+ this.#a11y = mirror;
531
+ // The textarea is named after the field's node in the mirror (text-input.ts).
532
+ const text = new TextEntry(canvas, (e) => this.#send(ring, e), mirror);
533
+ this.#text = text;
534
+ this.#listeners.push(() => text.dispose());
535
+ this.#listeners.push(() => mirror.dispose());
536
+ // The page's motion, contrast and transparency preferences, followed as the reader changes them.
537
+ for (const [, q] of PAGE_PREFERENCES) {
538
+ const media = globalThis.matchMedia?.(q);
539
+ if (!media?.addEventListener) continue;
540
+ const changed = () => this.worker.postMessage({ type: "viewport", viewport: { ...this.#viewport, ...pagePreferences() } });
541
+ media.addEventListener("change", changed);
542
+ this.#listeners.push(() => media.removeEventListener("change", changed));
543
+ }
544
+ const typing = (e: KeyboardEvent) => {
545
+ if (text.owns(e.target)) return e.isComposing || text.composing || e.keyCode === 229;
546
+ // The mirror's elements (a slider's native range among them) pass their keys to the game.
547
+ if (mirror.owns(e.target)) return false;
548
+ const t = e.target as { tagName?: string; isContentEditable?: boolean } | null;
549
+ return !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable === true);
550
+ };
551
+ // Keys that went down while Command was held: a Mac's browser sends no
552
+ // keyup for them, so they are released when Command is.
553
+ const underCommand = new Set<string>();
554
+ listen(globalThis as unknown as EventTarget, "keydown", (e) => {
555
+ if (typing(e) || !e.code) return;
556
+ // In the text target Space types and shortcuts copy, cut and paste.
557
+ const shortcut = text.owns(e.target) && (e.ctrlKey || e.metaKey || e.code === "Space");
558
+ if (KEPT_KEYS.has(e.code) && !shortcut) e.preventDefault();
559
+ if (e.metaKey && !MODIFIER_KEYS.has(e.code)) underCommand.add(e.code);
560
+ if (!e.repeat) this.#send(ring, { kind: "key", key: e.code, action: "down", time: stamp(e) });
561
+ });
562
+ listen(globalThis as unknown as EventTarget, "keyup", (e) => {
563
+ if (typing(e) || !e.code) return;
564
+ underCommand.delete(e.code);
565
+ this.#send(ring, { kind: "key", key: e.code, action: "up", time: stamp(e) });
566
+ if ((e.code === "MetaLeft" || e.code === "MetaRight") && !e.metaKey) {
567
+ for (const key of underCommand) this.#send(ring, { kind: "key", key, action: "up", time: stamp(e) });
568
+ underCommand.clear();
569
+ }
570
+ });
571
+ // Relative to the canvas's CSS box, whichever element the event landed on.
572
+ const at = (e: PointerEvent | WheelEvent) => {
573
+ const r = canvas.getBoundingClientRect();
574
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
575
+ };
576
+ const point = (e: PointerEvent, action: "move" | "down" | "up", id: number) => {
577
+ const p = at(e);
578
+ this.#send(ring, {
579
+ kind: "pointer",
580
+ action,
581
+ x: p.x,
582
+ y: p.y,
583
+ button: action === "move" ? "none" : (BUTTONS[e.button] ?? "left"),
584
+ ...(id > 0 ? { id } : {}),
585
+ time: stamp(e),
586
+ });
587
+ };
588
+ listen(canvas, "pointerdown", (e) => {
589
+ let id = this.#pointers.get(e.pointerId);
590
+ if (id === undefined) {
591
+ const taken = new Set(this.#pointers.values());
592
+ id = [0, 1, 2, 3, 4].find((i) => !taken.has(i));
593
+ if (id === undefined) return; // a sixth finger: ignored
594
+ this.#pointers.set(e.pointerId, id);
595
+ }
596
+ try {
597
+ canvas.setPointerCapture(e.pointerId);
598
+ } catch {
599
+ /* a synthetic event has no active pointer */
600
+ }
601
+ if (e.pointerType !== "mouse") e.preventDefault();
602
+ point(e, "down", id);
603
+ });
604
+ listen(canvas, "pointermove", (e) => {
605
+ const id = this.#pointers.get(e.pointerId);
606
+ if (id !== undefined) point(e, "move", id);
607
+ // A hovering mouse moves the primary pointer while nothing is held.
608
+ else if (this.#pointers.size === 0 && e.pointerType === "mouse") point(e, "move", 0);
609
+ });
610
+ const end = (e: PointerEvent) => {
611
+ const id = this.#pointers.get(e.pointerId);
612
+ if (id === undefined) return;
613
+ this.#pointers.delete(e.pointerId);
614
+ point(e, "up", id);
615
+ };
616
+ listen(canvas, "pointerup", end);
617
+ listen(canvas, "pointercancel", end);
618
+ listen(canvas, "contextmenu", (e) => e.preventDefault());
619
+ listen(canvas, "wheel", (e) => {
620
+ e.preventDefault();
621
+ const p = at(e);
622
+ this.#send(ring, { kind: "wheel", x: p.x, y: p.y, deltaX: e.deltaX, deltaY: e.deltaY });
623
+ });
624
+ }
625
+
626
+ /** The engine answers nothing more: every pending probe and call fails with `reason`, and so does every later one. */
627
+ #stop(reason: string): void {
628
+ if (this.#stopped) return;
629
+ this.#stopped = Object.assign(new Error(reason), { code: -32603 });
630
+ for (const pending of [this.#pending, this.#rpcPending]) {
631
+ for (const p of pending.values()) p.reject(this.#stopped);
632
+ pending.clear();
633
+ }
634
+ }
635
+
636
+ /** Remove the page listeners and stop the Worker. */
637
+ dispose(): void {
638
+ clearInterval(this.#watchdog);
639
+ this.#stop("the shell was disposed");
640
+ for (const off of this.#listeners.splice(0)) off();
641
+ this.worker.terminate();
642
+ }
643
+ }