@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.
- package/bin/og.mjs +25 -0
- package/dist/web/origin-engine.d.ts +1233 -0
- package/dist/web/origin-engine.js +8628 -0
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
- package/dist/web-publish/origin-engine-lanes.js +8509 -0
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +1176 -0
- package/dist/web-publish/origin-engine.js +8313 -0
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
- package/dist/web-publish-2d/origin-engine.js +8313 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/knowledge/README.md +31 -0
- package/knowledge/knowledge.json +13 -0
- package/knowledge/looks/catalogue.json +1871 -0
- package/knowledge/package.json +33 -0
- package/knowledge/rules/platform.json +329 -0
- package/knowledge/scaffold/README.md +27 -0
- package/knowledge/scaffold/distribution.json +48 -0
- package/knowledge/scaffold/origin-engine.json +19 -0
- package/knowledge/scaffold/template/.origin/setup +14 -0
- package/knowledge/scaffold/template/LICENSES.md +23 -0
- package/knowledge/scaffold/template/README.md +37 -0
- package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
- package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
- package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
- package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
- package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
- package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
- package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
- package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
- package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
- package/knowledge/scaffold/template/gitattributes +22 -0
- package/knowledge/scaffold/template/package.json +18 -0
- package/knowledge/scaffold/template/project.ogt +25 -0
- package/knowledge/scaffold/template/src/main.ts +246 -0
- package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
- package/knowledge/scaffold/template/tsconfig.json +17 -0
- package/knowledge/scripts/lib.ts +203 -0
- package/knowledge/scripts/mount.ts +210 -0
- package/knowledge/scripts/pack.ts +24 -0
- package/knowledge/scripts/scaffold.ts +72 -0
- package/knowledge/scripts/validate.ts +749 -0
- package/knowledge/skills/og-ai/SKILL.md +386 -0
- package/knowledge/skills/og-audio/SKILL.md +191 -0
- package/knowledge/skills/og-build/SKILL.md +330 -0
- package/knowledge/skills/og-core/SKILL.md +285 -0
- package/knowledge/skills/og-feel/SKILL.md +385 -0
- package/knowledge/skills/og-game/SKILL.md +1479 -0
- package/knowledge/skills/og-i18n/SKILL.md +220 -0
- package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
- package/knowledge/skills/og-taste/SKILL.md +537 -0
- package/knowledge/skills/og-ui/SKILL.md +444 -0
- package/knowledge/wiki/ai.md +627 -0
- package/knowledge/wiki/anim.md +1187 -0
- package/knowledge/wiki/atmosphere.md +636 -0
- package/knowledge/wiki/audio.md +732 -0
- package/knowledge/wiki/camera.md +711 -0
- package/knowledge/wiki/commands.md +357 -0
- package/knowledge/wiki/game.md +220 -0
- package/knowledge/wiki/i18n.md +213 -0
- package/knowledge/wiki/index.md +1027 -0
- package/knowledge/wiki/input.md +1714 -0
- package/knowledge/wiki/load.md +1280 -0
- package/knowledge/wiki/math.md +166 -0
- package/knowledge/wiki/nav.md +1154 -0
- package/knowledge/wiki/physics.md +3142 -0
- package/knowledge/wiki/platform.md +857 -0
- package/knowledge/wiki/probe.md +1692 -0
- package/knowledge/wiki/reflect.md +285 -0
- package/knowledge/wiki/render.md +3511 -0
- package/knowledge/wiki/rng.md +393 -0
- package/knowledge/wiki/stream.md +376 -0
- package/knowledge/wiki/system.md +197 -0
- package/knowledge/wiki/systems.md +43 -0
- package/knowledge/wiki/terrain.md +727 -0
- package/knowledge/wiki/tilemap.md +422 -0
- package/knowledge/wiki/time.md +150 -0
- package/knowledge/wiki/transform.md +230 -0
- package/knowledge/wiki/tween.md +580 -0
- package/knowledge/wiki/ui.md +8000 -0
- package/knowledge/wiki/world.md +909 -0
- package/package.json +68 -4
- package/schema/api.schema.json +29675 -0
- package/shell/README.md +22 -0
- package/shell/brand/README.md +23 -0
- package/shell/brand/icon-192.png +0 -0
- package/shell/brand/icon-512.png +0 -0
- package/shell/brand/mark-on-paper.svg +6 -0
- package/shell/brand/origin-mark.svg +5 -0
- package/shell/brand/origin-motion.css +251 -0
- package/shell/brand/tokens.css +20 -0
- package/shell/brand.ts +132 -0
- package/shell/page.ts +715 -0
- package/shell/platform.ts +203 -0
- package/shell/player.ts +238 -0
- package/shell/shell.css +185 -0
- package/shell/sw.ts +177 -0
- package/shell/transport.ts +515 -0
- package/shell/wire.ts +369 -0
- package/src/e6.ts +69 -0
- package/src/env.d.ts +100 -0
- package/src/generated/abi.ts +980 -0
- package/src/index.ts +193 -0
- package/src/node/audio.ts +86 -0
- package/src/node/build.ts +109 -0
- package/src/node/device.ts +981 -0
- package/src/node/doctor.ts +60 -0
- package/src/node/index.ts +102 -0
- package/src/node/platform.ts +24 -0
- package/src/node/playtest-run.ts +249 -0
- package/src/node/png.ts +130 -0
- package/src/node/polish.ts +976 -0
- package/src/node/run.ts +373 -0
- package/src/node/web-build.ts +808 -0
- package/src/p2.ts +35 -0
- package/src/platform-local.ts +211 -0
- package/src/playtest.ts +131 -0
- package/src/runtime.ts +1509 -0
- package/src/systems/cameras.ts +247 -0
- package/src/ui-kit/bind.ts +141 -0
- package/src/ui-kit/huds.ts +303 -0
- package/src/ui-kit/index.ts +36 -0
- package/src/ui-kit/menus.ts +187 -0
- package/src/ui-kit/settings.ts +131 -0
- package/src/ui-kit/store.ts +222 -0
- package/src/ui-kit/story.ts +89 -0
- package/src/ui-kit/util.ts +106 -0
- package/src/web/a11y.ts +429 -0
- package/src/web/audio-worklet.ts +193 -0
- package/src/web/audio.ts +307 -0
- package/src/web/basis.ts +239 -0
- package/src/web/gamepad.ts +123 -0
- package/src/web/host.ts +166 -0
- package/src/web/input.ts +237 -0
- package/src/web/lanes.ts +21 -0
- package/src/web/play.ts +20 -0
- package/src/web/rpc-ring.ts +110 -0
- package/src/web/settle.ts +122 -0
- package/src/web/shell.ts +643 -0
- package/src/web/text-input.ts +248 -0
- package/src/web/worker.ts +1002 -0
- package/types/bindings.json +13654 -0
- package/types/index.d.ts +20090 -0
- package/vendor/basis/LICENSE +201 -0
- package/vendor/basis/NOTICE +15 -0
- package/vendor/basis/README.md +18 -0
- package/vendor/basis/basis_transcoder.js +19 -0
- package/vendor/basis/basis_transcoder.wasm +0 -0
- package/README.md +0 -3
package/shell/page.ts
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
// The player shell: the main thread of an exported game page (docs/design.md
|
|
2
|
+
// §2.2, §4, §6, §9; docs/web-export.md). `og build` inlines the boot markup,
|
|
3
|
+
// the brand marks and this page's configuration into index.html and bundles
|
|
4
|
+
// this module as play.js. It owns everything that is not the game:
|
|
5
|
+
//
|
|
6
|
+
// - the boot splash (the brand symbol playing `genesis` once on ink), then
|
|
7
|
+
// `progress` with the real bytes received (wasm + cooked bundle);
|
|
8
|
+
// - the play gate (ready to interact): the first gesture unlocks audio,
|
|
9
|
+
// asks for fullscreen when the project wants it, takes a wake lock and
|
|
10
|
+
// starts the clock; a Player that frames the page owns the gate instead
|
|
11
|
+
// (protocol 2 `start`, `gesture`, `pause`, `resume`);
|
|
12
|
+
// - layout: letterbox (the project's aspect, centred on ink inside the safe
|
|
13
|
+
// area) or fill (the whole `100dvh` viewport, the safe-area insets passed to
|
|
14
|
+
// the UI), the device pixel ratio capped, resize and orientation changes
|
|
15
|
+
// resized in the Worker;
|
|
16
|
+
// - visibility: a hidden page pauses the clock and the audio and resumes both;
|
|
17
|
+
// - input: keyboard, pointers (touch ids carried), gamepads as keys;
|
|
18
|
+
// - the error card (§6): the reason, the last 200 log lines and the world
|
|
19
|
+
// dump (state in a publish build), with Reload and Copy report;
|
|
20
|
+
// - the probe: `globalThis.__runtime_probe__` (protocol 5, every member a
|
|
21
|
+
// Promise, plus `lanes`, `memory()` (what the engine holds), `boot`
|
|
22
|
+
// (where the Worker's boot went) and
|
|
23
|
+
// `error`: what stopped the engine, a panic with its tick and lane, an
|
|
24
|
+
// error or a hang) and the platform's `og-probe`
|
|
25
|
+
// postMessage wire; the played
|
|
26
|
+
// frames' timings on `globalThis.__og_timings__` (`og run --device`);
|
|
27
|
+
// - PWA: the service worker (publish builds) and the add-to-home-screen hint
|
|
28
|
+
// only when the install criteria hold;
|
|
29
|
+
// - "Made with Origin Engine" in the corner unless the project turns it off.
|
|
30
|
+
|
|
31
|
+
import { Shell } from "../src/web/shell.ts";
|
|
32
|
+
import { AudioShell } from "../src/web/audio.ts";
|
|
33
|
+
import { pollGamepads } from "../src/web/gamepad.ts";
|
|
34
|
+
import { mountWire } from "./wire.ts";
|
|
35
|
+
import { connectPlayer } from "./player.ts";
|
|
36
|
+
import { connectPage, pageGame, type PageConnection, type PageInfo } from "./transport.ts";
|
|
37
|
+
import { deferredPlatform, HELLO_WAIT_MS, playerPlatform, storagePersistence } from "./platform.ts";
|
|
38
|
+
import { localPlatform, memoryPersistence, type PlatformHost } from "../src/platform-local.ts";
|
|
39
|
+
|
|
40
|
+
/** The page's configuration (`<script type="application/json" id="og-config">`, written by og build). */
|
|
41
|
+
export interface ShellConfig {
|
|
42
|
+
readonly engine: string;
|
|
43
|
+
readonly build: "publish" | "dev";
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly profile: "2d" | "3d";
|
|
46
|
+
/** project.ogt's text, for the engine's tick, seed, scenes and scene (CreateOptions.project). */
|
|
47
|
+
readonly project?: string;
|
|
48
|
+
readonly isolation: "required" | "preferred" | "off";
|
|
49
|
+
readonly fit: "letterbox" | "fill";
|
|
50
|
+
/** Width over height for letterbox; null takes the screen's. */
|
|
51
|
+
readonly aspect: number | null;
|
|
52
|
+
readonly orientation: "any" | "landscape" | "portrait";
|
|
53
|
+
readonly dprCap: number;
|
|
54
|
+
readonly fullscreen: "gesture" | "off";
|
|
55
|
+
readonly gate: "tap" | "none";
|
|
56
|
+
readonly madeWith: boolean;
|
|
57
|
+
readonly serviceWorker: boolean;
|
|
58
|
+
/** Bytes the boot fetches; `lanes` is the lanes variant's module, 0 when the build ships none. */
|
|
59
|
+
readonly expect: { readonly wasm: number; readonly cooked: number; readonly lanes: number };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const PROBE_GLOBAL = "__runtime_probe__";
|
|
63
|
+
const TIMINGS_GLOBAL = "__og_timings__";
|
|
64
|
+
const REPLAY_SEED_PARAM = "replay-seed";
|
|
65
|
+
const LOG_LINES = 200;
|
|
66
|
+
const INSTALL_DISMISSED = "og-install-dismissed";
|
|
67
|
+
|
|
68
|
+
const MEMBERS = [
|
|
69
|
+
"hello", "tick", "state", "advance", "pause", "resume", "input", "accepted", "pick", "entity",
|
|
70
|
+
"bounds", "patch", "run", "capture", "sweep", "reload", "snapshot", "restore", "dump", "commands",
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
/** Positional protocol-5 arguments to JSON-RPC params, as og-probe reads them. */
|
|
74
|
+
export function probeParams(member: string, args: unknown[]): Record<string, unknown> {
|
|
75
|
+
switch (member) {
|
|
76
|
+
case "advance":
|
|
77
|
+
return { steps: args[0] };
|
|
78
|
+
case "input":
|
|
79
|
+
return { event: args[0] };
|
|
80
|
+
case "pick":
|
|
81
|
+
return { x: args[0], y: args[1] };
|
|
82
|
+
case "entity":
|
|
83
|
+
case "bounds":
|
|
84
|
+
case "restore":
|
|
85
|
+
return { id: args[0] };
|
|
86
|
+
case "patch":
|
|
87
|
+
return { id: args[0], parameter: args[1], value: args[2] };
|
|
88
|
+
case "run":
|
|
89
|
+
return { program: args[0] };
|
|
90
|
+
case "capture":
|
|
91
|
+
return { width: args[0] ?? 0, height: args[1] ?? 0 };
|
|
92
|
+
case "commands":
|
|
93
|
+
return { script: args[0] };
|
|
94
|
+
case "sweep":
|
|
95
|
+
case "dump":
|
|
96
|
+
return (args[0] as Record<string, unknown>) ?? {};
|
|
97
|
+
default:
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const zh = /^zh\b/i.test(globalThis.navigator?.language ?? "");
|
|
103
|
+
const T = zh
|
|
104
|
+
? { loading: "正在加载", play: "点击开始", tapToPlay: "点击开始", startFailed: "游戏无法启动", stopped: "游戏已停止", reload: "重新加载", copy: "复制报告", copied: "已复制", log: "日志", dump: "世界快照", install: "添加到主屏幕", installIos: "点按分享,再选“添加到主屏幕”", dismiss: "不用了", back: "返回", madeWith: "Made with Origin Engine" }
|
|
105
|
+
: { loading: "Loading", play: "Play", tapToPlay: "Tap to play", startFailed: "The game could not start", stopped: "The game stopped", reload: "Reload", copy: "Copy report", copied: "Copied", log: "Log", dump: "World", install: "Install", installIos: "Tap Share, then Add to Home Screen", dismiss: "Not now", back: "Back", madeWith: "Made with Origin Engine" };
|
|
106
|
+
|
|
107
|
+
export function readConfig(doc: Document = document): ShellConfig {
|
|
108
|
+
const text = doc.getElementById("og-config")?.textContent ?? "{}";
|
|
109
|
+
const c = JSON.parse(text) as Partial<ShellConfig>;
|
|
110
|
+
return {
|
|
111
|
+
engine: c.engine ?? "0.0.0",
|
|
112
|
+
build: c.build === "publish" ? "publish" : "dev",
|
|
113
|
+
name: c.name ?? "Origin Engine",
|
|
114
|
+
profile: c.profile === "2d" ? "2d" : "3d",
|
|
115
|
+
...(typeof c.project === "string" ? { project: c.project } : {}),
|
|
116
|
+
isolation: c.isolation ?? "preferred",
|
|
117
|
+
fit: c.fit === "fill" ? "fill" : "letterbox",
|
|
118
|
+
aspect: typeof c.aspect === "number" && c.aspect > 0 ? c.aspect : null,
|
|
119
|
+
orientation: c.orientation ?? "any",
|
|
120
|
+
dprCap: typeof c.dprCap === "number" && c.dprCap > 0 ? c.dprCap : 2,
|
|
121
|
+
fullscreen: c.fullscreen === "gesture" ? "gesture" : "off",
|
|
122
|
+
gate: c.gate === "none" ? "none" : "tap",
|
|
123
|
+
madeWith: c.madeWith !== false,
|
|
124
|
+
serviceWorker: c.serviceWorker === true,
|
|
125
|
+
expect: { wasm: c.expect?.wasm ?? 0, cooked: c.expect?.cooked ?? 0, lanes: c.expect?.lanes ?? 0 },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The canvas's CSS box for a viewport (CSS px) and its safe-area insets. */
|
|
130
|
+
export function layoutCanvas(
|
|
131
|
+
viewport: { width: number; height: number },
|
|
132
|
+
insets: { top: number; right: number; bottom: number; left: number },
|
|
133
|
+
fit: "letterbox" | "fill",
|
|
134
|
+
aspect: number | null,
|
|
135
|
+
): { left: number; top: number; width: number; height: number; safeArea: { top: number; right: number; bottom: number; left: number } } {
|
|
136
|
+
if (fit === "fill" || aspect === null) {
|
|
137
|
+
return { left: 0, top: 0, width: Math.max(1, viewport.width), height: Math.max(1, viewport.height), safeArea: insets };
|
|
138
|
+
}
|
|
139
|
+
const aw = Math.max(1, viewport.width - insets.left - insets.right);
|
|
140
|
+
const ah = Math.max(1, viewport.height - insets.top - insets.bottom);
|
|
141
|
+
const width = Math.min(aw, ah * aspect);
|
|
142
|
+
const height = width / aspect;
|
|
143
|
+
return {
|
|
144
|
+
left: insets.left + (aw - width) / 2,
|
|
145
|
+
top: insets.top + (ah - height) / 2,
|
|
146
|
+
width: Math.max(1, Math.round(width)),
|
|
147
|
+
height: Math.max(1, Math.round(height)),
|
|
148
|
+
safeArea: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function safeAreaInsets(): { top: number; right: number; bottom: number; left: number } {
|
|
153
|
+
const probe = document.createElement("div");
|
|
154
|
+
probe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)";
|
|
155
|
+
document.body.append(probe);
|
|
156
|
+
const s = getComputedStyle(probe);
|
|
157
|
+
const out = { top: parseFloat(s.paddingTop) || 0, right: parseFloat(s.paddingRight) || 0, bottom: parseFloat(s.paddingBottom) || 0, left: parseFloat(s.paddingLeft) || 0 };
|
|
158
|
+
probe.remove();
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T | null;
|
|
163
|
+
|
|
164
|
+
/** What the shell keeps of the engine's log and the page's console. */
|
|
165
|
+
class LogTail {
|
|
166
|
+
readonly lines: string[] = [];
|
|
167
|
+
push(lines: readonly string[]) {
|
|
168
|
+
for (const l of lines) this.lines.push(l);
|
|
169
|
+
if (this.lines.length > LOG_LINES) this.lines.splice(0, this.lines.length - LOG_LINES);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface PlayHandle {
|
|
174
|
+
readonly shell: Shell;
|
|
175
|
+
readonly config: ShellConfig;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function play(): Promise<PlayHandle> {
|
|
179
|
+
const config = readConfig();
|
|
180
|
+
const query = new URLSearchParams(location.search);
|
|
181
|
+
const t0 = performance.now();
|
|
182
|
+
const log = new LogTail();
|
|
183
|
+
const playerEvents: import("./player.ts").PlayerEvents = {};
|
|
184
|
+
// A top-level page the platform published (`window.__OG_GAME`) speaks Player protocol 2 to
|
|
185
|
+
// its play origin over HTTP (./transport.ts); a framed page to its Player over postMessage;
|
|
186
|
+
// a top-level Stage build told its own origin (the Stage overlay in this document) to its
|
|
187
|
+
// own window over postMessage (./player.ts); any other page plays alone.
|
|
188
|
+
const top = (() => {
|
|
189
|
+
try {
|
|
190
|
+
return window.parent === window;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
const game = top ? pageGame((globalThis as { __OG_GAME?: unknown }).__OG_GAME) : null;
|
|
196
|
+
const page: PageConnection | null = game
|
|
197
|
+
? connectPage(game, {
|
|
198
|
+
fetch: (url, init) => fetch(url, init),
|
|
199
|
+
storage: (() => {
|
|
200
|
+
try {
|
|
201
|
+
return localStorage;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
})(),
|
|
206
|
+
location,
|
|
207
|
+
replaceUrl: (url) => {
|
|
208
|
+
try {
|
|
209
|
+
history.replaceState(history.state, "", url);
|
|
210
|
+
} catch {
|
|
211
|
+
/* sandboxed */
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
now: () => Date.now(),
|
|
215
|
+
clock: () => performance.now(),
|
|
216
|
+
random: (n) => crypto.getRandomValues(new Uint8Array(n)),
|
|
217
|
+
}, playerEvents)
|
|
218
|
+
: null;
|
|
219
|
+
const own = page ? null : connectPlayer(playerEvents);
|
|
220
|
+
const player = page ?? own!;
|
|
221
|
+
// A Player answers over postMessage: the one framing the page, or the Stage overlay in it.
|
|
222
|
+
const linked = !!own?.peer;
|
|
223
|
+
const stageSelf = own?.peer === "self";
|
|
224
|
+
const canvas = $<HTMLCanvasElement>("og-canvas") ?? document.body.appendChild(document.createElement("canvas"));
|
|
225
|
+
const boot = $("og-boot");
|
|
226
|
+
const mark = boot?.querySelector<SVGSVGElement>(".og-mark") ?? null;
|
|
227
|
+
const label = $("og-boot-label");
|
|
228
|
+
const badge = $("og-made-with");
|
|
229
|
+
if (badge) badge.hidden = !config.madeWith;
|
|
230
|
+
const back = $<HTMLButtonElement>("og-back");
|
|
231
|
+
// What the Player page drew around the game, drawn by the page itself: the cover's
|
|
232
|
+
// backdrop behind the boot splash and Back to the Portal page that opened the game.
|
|
233
|
+
const paintPage = (info: PageInfo) => {
|
|
234
|
+
const backdrop = $("og-backdrop");
|
|
235
|
+
const image = info.backdrop ?? info.cover;
|
|
236
|
+
if (backdrop && image) {
|
|
237
|
+
const img = document.createElement("img");
|
|
238
|
+
img.alt = "";
|
|
239
|
+
img.decoding = "async";
|
|
240
|
+
// An isolated page (COEP require-corp) loads the Portal's image through CORS.
|
|
241
|
+
if (globalThis.crossOriginIsolated) img.crossOrigin = "anonymous";
|
|
242
|
+
img.addEventListener("load", () => backdrop.setAttribute("data-shown", ""), { once: true });
|
|
243
|
+
img.src = image;
|
|
244
|
+
backdrop.replaceChildren(img);
|
|
245
|
+
}
|
|
246
|
+
if (back && info.back) {
|
|
247
|
+
const target = info.back;
|
|
248
|
+
back.setAttribute("aria-label", T.back);
|
|
249
|
+
back.title = T.back;
|
|
250
|
+
const text = back.querySelector("span");
|
|
251
|
+
if (text) text.textContent = T.back;
|
|
252
|
+
back.hidden = false;
|
|
253
|
+
// The UI keeps its anchored groups out of Back from now on (Viewport.covered).
|
|
254
|
+
if (shellRef) shellRef.resize(box.width, box.height, box.safeArea, backCover());
|
|
255
|
+
back.onclick = () => {
|
|
256
|
+
let fromPortal = false;
|
|
257
|
+
try {
|
|
258
|
+
fromPortal = !!info.portal && new URL(document.referrer).origin === info.portal;
|
|
259
|
+
} catch {
|
|
260
|
+
/* no referrer */
|
|
261
|
+
}
|
|
262
|
+
// Only a web page: a `javascript:` or `data:` back would run on the play origin.
|
|
263
|
+
let web = false;
|
|
264
|
+
try {
|
|
265
|
+
web = /^https?:$/.test(new URL(target, location.href).protocol);
|
|
266
|
+
} catch {
|
|
267
|
+
/* not a URL */
|
|
268
|
+
}
|
|
269
|
+
if (fromPortal && history.length > 1) history.back();
|
|
270
|
+
else if (web) location.assign(target);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// ── boot: genesis once, then progress with the real value ────────────
|
|
276
|
+
let progress = 0;
|
|
277
|
+
let genesisDone = !mark || matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
278
|
+
const showProgress = () => {
|
|
279
|
+
if (!mark || !genesisDone || phase !== "loading") return;
|
|
280
|
+
if (mark.dataset["motion"] !== "progress") mark.dataset["motion"] = "progress";
|
|
281
|
+
const percent = Math.round(progress * 100);
|
|
282
|
+
mark.querySelector<SVGElement>("[data-part=wipe]")?.style.setProperty("stroke-dashoffset", String(100 - percent));
|
|
283
|
+
if (percent >= 100) mark.setAttribute("data-complete", "");
|
|
284
|
+
};
|
|
285
|
+
// The motion never delays the answer: progress shows at the end of genesis, or at once.
|
|
286
|
+
mark?.querySelector('[data-part="outer"]')?.addEventListener("animationend", () => {
|
|
287
|
+
genesisDone = true;
|
|
288
|
+
showProgress();
|
|
289
|
+
});
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
genesisDone = true;
|
|
292
|
+
showProgress();
|
|
293
|
+
}, 1200);
|
|
294
|
+
let phase: "loading" | "gate" | "playing" | "failed" = "loading";
|
|
295
|
+
const setProgress = (value: number) => {
|
|
296
|
+
if (value <= progress) return;
|
|
297
|
+
progress = Math.min(1, value);
|
|
298
|
+
boot?.setAttribute("aria-valuenow", String(Math.round(progress * 100)));
|
|
299
|
+
if (label) label.textContent = `${T.loading} ${Math.round(progress * 100)}%`;
|
|
300
|
+
player.loading(progress);
|
|
301
|
+
showProgress();
|
|
302
|
+
};
|
|
303
|
+
if (label) label.textContent = T.loading;
|
|
304
|
+
|
|
305
|
+
// ── the error card (§6) ──────────────────────────────────────────────
|
|
306
|
+
let shellRef: Shell | null = null;
|
|
307
|
+
const fail = async (title: string, message: string, after: boolean) => {
|
|
308
|
+
if (phase === "failed") return;
|
|
309
|
+
phase = "failed";
|
|
310
|
+
if (after) player.crash("EngineCrash", message);
|
|
311
|
+
else player.fail("engine_start", message, true);
|
|
312
|
+
let dump = "";
|
|
313
|
+
if (shellRef) {
|
|
314
|
+
const withTimeout = (p: Promise<unknown>) => {
|
|
315
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
316
|
+
return Promise.race([p, new Promise((r) => (timer = setTimeout(() => r(null), 1500)))]).finally(() => clearTimeout(timer));
|
|
317
|
+
};
|
|
318
|
+
const world = await withTimeout(shellRef.probe(config.build === "publish" ? "state" : "dump", config.build === "publish" ? {} : { what: "world" }).catch(() => null));
|
|
319
|
+
if (world) dump = JSON.stringify(world, null, 1).slice(0, 200_000);
|
|
320
|
+
}
|
|
321
|
+
const card = $("og-error");
|
|
322
|
+
if (!card) return;
|
|
323
|
+
if (mark) {
|
|
324
|
+
mark.dataset["motion"] = "drift";
|
|
325
|
+
mark.removeAttribute("data-complete");
|
|
326
|
+
}
|
|
327
|
+
boot?.removeAttribute("data-done");
|
|
328
|
+
if (boot) boot.hidden = false;
|
|
329
|
+
back?.removeAttribute("data-playing");
|
|
330
|
+
$("og-gate")?.setAttribute("hidden", "");
|
|
331
|
+
card.querySelector("h1")!.textContent = title;
|
|
332
|
+
card.querySelector("p")!.textContent = message;
|
|
333
|
+
const pre = card.querySelector<HTMLElement>("#og-error-log");
|
|
334
|
+
if (pre) pre.textContent = log.lines.join("\n") || "(no log lines)";
|
|
335
|
+
const dumpPre = card.querySelector<HTMLElement>("#og-error-dump");
|
|
336
|
+
const dumpBox = dumpPre?.closest("details") as HTMLElement | null;
|
|
337
|
+
if (dumpPre && dumpBox) {
|
|
338
|
+
dumpPre.textContent = dump;
|
|
339
|
+
dumpBox.hidden = !dump;
|
|
340
|
+
}
|
|
341
|
+
card.hidden = false;
|
|
342
|
+
card.querySelector<HTMLButtonElement>("[data-action=reload]")?.addEventListener("click", () => location.reload());
|
|
343
|
+
const copy = card.querySelector<HTMLButtonElement>("[data-action=copy]");
|
|
344
|
+
copy?.addEventListener("click", async () => {
|
|
345
|
+
const report = [`${config.name} — Origin Engine ${config.engine} (${config.build})`, navigator.userAgent, "", title, message, "", "log:", ...log.lines, "", dump].join("\n");
|
|
346
|
+
try {
|
|
347
|
+
await navigator.clipboard.writeText(report);
|
|
348
|
+
copy.textContent = T.copied;
|
|
349
|
+
} catch {
|
|
350
|
+
/* the report stays on screen */
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
addEventListener("error", (e) => log.push([`page: ${e.message}`]));
|
|
355
|
+
|
|
356
|
+
// ── layout ───────────────────────────────────────────────────────────
|
|
357
|
+
const place = () => {
|
|
358
|
+
const vv = globalThis.visualViewport;
|
|
359
|
+
const viewport = { width: vv?.width ?? innerWidth, height: vv?.height ?? innerHeight };
|
|
360
|
+
const box = layoutCanvas(viewport, safeAreaInsets(), config.fit, config.aspect);
|
|
361
|
+
canvas.style.position = "absolute";
|
|
362
|
+
canvas.style.left = `${box.left}px`;
|
|
363
|
+
canvas.style.top = `${box.top}px`;
|
|
364
|
+
canvas.style.width = `${box.width}px`;
|
|
365
|
+
canvas.style.height = `${box.height}px`;
|
|
366
|
+
return box;
|
|
367
|
+
};
|
|
368
|
+
let box = place();
|
|
369
|
+
// Where Back covers the canvas, in the canvas's CSS px (Viewport.covered):
|
|
370
|
+
// the tucked control, as wide as it is tall (its name shows beside it only
|
|
371
|
+
// for the first seconds of play); none while it is hidden or off the canvas.
|
|
372
|
+
const backCover = (): { x: number; y: number; width: number; height: number } | undefined => {
|
|
373
|
+
if (!back || back.hidden) return undefined;
|
|
374
|
+
const r = back.getBoundingClientRect();
|
|
375
|
+
const c = canvas.getBoundingClientRect();
|
|
376
|
+
const x = r.left - c.left;
|
|
377
|
+
const y = r.top - c.top;
|
|
378
|
+
const side = r.height;
|
|
379
|
+
if (side <= 0 || x + side <= 0 || y + side <= 0 || x >= c.width || y >= c.height) return undefined;
|
|
380
|
+
return { x, y, width: side, height: side };
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// ── the engine ───────────────────────────────────────────────────────
|
|
384
|
+
const seedText = query.get(REPLAY_SEED_PARAM);
|
|
385
|
+
const seed = seedText !== null && /^\d+$/.test(seedText) && Number(seedText) <= 0xffffffff ? Number(seedText) : undefined;
|
|
386
|
+
const autoplayParam = query.get("autoplay");
|
|
387
|
+
// `autoplay=0`: a harness drives the clock through the probe (no gate, no play).
|
|
388
|
+
const harness = autoplayParam === "0";
|
|
389
|
+
let webgl = query.get("webgl") === "1";
|
|
390
|
+
if (!webgl) {
|
|
391
|
+
try {
|
|
392
|
+
const gpu = (navigator as { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;
|
|
393
|
+
webgl = !gpu || !(await gpu.requestAdapter());
|
|
394
|
+
} catch {
|
|
395
|
+
webgl = true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (config.isolation === "required" && !globalThis.crossOriginIsolated) {
|
|
399
|
+
log.push(["warn: this game declares crossOriginIsolation required, but the page is not cross-origin isolated (serve it with COOP/COEP: see the headers file); it runs single-threaded"]);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// og.platform: a framed page's calls wait for the Player's hello (then the Player answers,
|
|
403
|
+
// falling back to this browser's storage where it cannot); unframed, the browser's storage.
|
|
404
|
+
// On the Stage (the overlay in this document) nothing goes to the shared Stage origin's
|
|
405
|
+
// storage: where the overlay answers nothing, this load's memory does.
|
|
406
|
+
const platformStore = stageSelf ? memoryPersistence() : storagePersistence(`og.platform:${location.pathname}`);
|
|
407
|
+
let platformReady: (h: PlatformHost) => void = () => {};
|
|
408
|
+
const hostReady = new Promise<PlatformHost>((r) => (platformReady = r));
|
|
409
|
+
const helloForPlatform = () => platformReady(playerPlatform(player, platformStore));
|
|
410
|
+
playerEvents.hello = helloForPlatform;
|
|
411
|
+
if (player.hosted) helloForPlatform();
|
|
412
|
+
else if (linked) setTimeout(() => platformReady(localPlatform(platformStore)), HELLO_WAIT_MS);
|
|
413
|
+
// A top-level platform page's calls wait for its play origin's hello; without one, the browser's storage.
|
|
414
|
+
if (page)
|
|
415
|
+
void page.hello.then((ok) => {
|
|
416
|
+
if (!ok) return platformReady(localPlatform(platformStore));
|
|
417
|
+
helloForPlatform();
|
|
418
|
+
// The cover and Back as soon as the play origin names them, while the engine still loads.
|
|
419
|
+
if (page.page) paintPage(page.page);
|
|
420
|
+
});
|
|
421
|
+
const platform = linked || page ? deferredPlatform(hostReady, ["store.get", "store.set", "store.list", "telemetry"]) : localPlatform(platformStore);
|
|
422
|
+
|
|
423
|
+
let firstFrameAt = 0;
|
|
424
|
+
// Ready is said once, never after the page failed; a crash before it is a start failure (Player protocol: fail before ready, crash after).
|
|
425
|
+
let readied = false;
|
|
426
|
+
const ready = () => {
|
|
427
|
+
if (readied || phase === "failed") return;
|
|
428
|
+
readied = true;
|
|
429
|
+
player.ready();
|
|
430
|
+
};
|
|
431
|
+
let shell: Shell;
|
|
432
|
+
try {
|
|
433
|
+
shell = await Shell.start({
|
|
434
|
+
platform,
|
|
435
|
+
canvas,
|
|
436
|
+
worker: new Worker(new URL("./game.js", location.href), { type: "module" }),
|
|
437
|
+
glue: new URL("./origin-engine.js", location.href).href,
|
|
438
|
+
wasm: new URL("./origin-engine_bg.wasm", location.href).href,
|
|
439
|
+
...(config.expect.lanes > 0
|
|
440
|
+
? { lanes: { glue: new URL("./origin-engine-lanes.js", location.href).href, wasm: new URL("./origin-engine-lanes_bg.wasm", location.href).href, bytes: config.expect.lanes } }
|
|
441
|
+
: {}),
|
|
442
|
+
root: new URL(".", location.href).href.replace(/\/$/, ""),
|
|
443
|
+
basis: { js: new URL("./basis/basis_transcoder.js", location.href).href, wasm: new URL("./basis/basis_transcoder.wasm", location.href).href },
|
|
444
|
+
webgl,
|
|
445
|
+
autoplay: false,
|
|
446
|
+
// Behind the gate, the first screen: one tick builds the game's UI (a harness keeps tick 0).
|
|
447
|
+
prime: !harness,
|
|
448
|
+
...(seed === undefined ? {} : { seed }),
|
|
449
|
+
profile: config.profile,
|
|
450
|
+
...(config.project === undefined ? {} : { project: config.project }),
|
|
451
|
+
dprCap: config.dprCap,
|
|
452
|
+
...(config.expect.wasm + config.expect.cooked > 0 ? { expect: config.expect } : {}),
|
|
453
|
+
onProgress: (v) => setProgress(v),
|
|
454
|
+
onLog: (lines) => log.push(lines),
|
|
455
|
+
onCrash: (message) => void fail(readied ? T.stopped : T.startFailed, message, readied),
|
|
456
|
+
onFirstFrame: () => {
|
|
457
|
+
firstFrameAt = performance.now();
|
|
458
|
+
performance.mark?.("og:first-frame");
|
|
459
|
+
ready();
|
|
460
|
+
player.sample({ renderer: shell?.tier ?? "unknown", timings: { firstFrame: Math.round(firstFrameAt), boot: Math.round(firstFrameAt - t0) } });
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
} catch (e) {
|
|
464
|
+
await fail(T.startFailed, e instanceof Error ? e.message : String(e), false);
|
|
465
|
+
throw e;
|
|
466
|
+
}
|
|
467
|
+
shellRef = shell;
|
|
468
|
+
performance.mark?.("og:ready");
|
|
469
|
+
const lanes = shell.ready.lanes;
|
|
470
|
+
log.push([lanes.on ? `lanes: on, ${lanes.threads} Workers` : `lanes: off (${lanes.why})`]);
|
|
471
|
+
const resize = () => {
|
|
472
|
+
box = place();
|
|
473
|
+
shell.resize(box.width, box.height, box.safeArea, backCover());
|
|
474
|
+
};
|
|
475
|
+
let pending = 0;
|
|
476
|
+
const onResize = () => {
|
|
477
|
+
if (pending) cancelAnimationFrame(pending);
|
|
478
|
+
pending = requestAnimationFrame(() => {
|
|
479
|
+
pending = 0;
|
|
480
|
+
resize();
|
|
481
|
+
});
|
|
482
|
+
};
|
|
483
|
+
addEventListener("resize", onResize);
|
|
484
|
+
globalThis.visualViewport?.addEventListener("resize", onResize);
|
|
485
|
+
screen.orientation?.addEventListener?.("change", onResize);
|
|
486
|
+
// The first layout's safe area reaches the UI too.
|
|
487
|
+
shell.resize(box.width, box.height, box.safeArea, backCover());
|
|
488
|
+
|
|
489
|
+
// ── the probe: in-page and over the og-probe wire ────────────────────
|
|
490
|
+
// `lanes`: the mode the engine got at boot (on with its Workers, or why it runs single-threaded).
|
|
491
|
+
const probe: Record<string, unknown> = { version: 5, substrate: "origin-engine", lanes: Object.freeze({ ...lanes }) };
|
|
492
|
+
// `error`: what stopped the engine (a panic with its tick and lane, an error, a hang), null while it runs.
|
|
493
|
+
Object.defineProperty(probe, "error", { get: () => shell.error, enumerable: true });
|
|
494
|
+
// `boot`: where the Worker's boot went (src/web/worker.ts BootPhases), null before the first frame.
|
|
495
|
+
Object.defineProperty(probe, "boot", { get: () => shell.boot, enumerable: true });
|
|
496
|
+
for (const m of MEMBERS) probe[m] = (...args: unknown[]) => shell.probe(m, probeParams(m, args));
|
|
497
|
+
// `memory`: what the engine holds (ProbeMemory), from the Worker in every build (a publish probe has no `memory`).
|
|
498
|
+
probe["memory"] = () => shell.call("memory");
|
|
499
|
+
(globalThis as Record<string, unknown>)[PROBE_GLOBAL] = Object.freeze(probe);
|
|
500
|
+
// The played frames' display intervals and engine work, ms (`og run --device` reads them; `true` resets).
|
|
501
|
+
(globalThis as Record<string, unknown>)[TIMINGS_GLOBAL] = (reset?: boolean) => shell.call("timings", reset === true);
|
|
502
|
+
mountWire({
|
|
503
|
+
probe: () => (method, params) => shell.probe(method, params ?? {}),
|
|
504
|
+
targetSize: () => ({ width: canvas.width || Math.round(box.width * Math.min(devicePixelRatio || 1, config.dprCap)), height: canvas.height || Math.round(box.height * Math.min(devicePixelRatio || 1, config.dprCap)) }),
|
|
505
|
+
canvas: () => canvas,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// ── audio, wake lock, fullscreen, visibility ─────────────────────────
|
|
509
|
+
const audio = new AudioShell({ worker: shell.worker, worklet: new URL("./audio-worklet.js", location.href).href });
|
|
510
|
+
audio.listen();
|
|
511
|
+
let wake: { release(): Promise<void> } | null = null;
|
|
512
|
+
const lockWake = async () => {
|
|
513
|
+
try {
|
|
514
|
+
const nav = navigator as { wakeLock?: { request(kind: "screen"): Promise<{ release(): Promise<void> }> } };
|
|
515
|
+
if (nav.wakeLock && document.visibilityState === "visible" && !wake) {
|
|
516
|
+
wake = await nav.wakeLock.request("screen");
|
|
517
|
+
(wake as unknown as EventTarget).addEventListener?.("release", () => (wake = null));
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
/* refused (battery saver, no gesture): play goes on */
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
const fullscreen = async () => {
|
|
524
|
+
if (config.fullscreen !== "gesture" || player.framed || document.fullscreenElement) return;
|
|
525
|
+
try {
|
|
526
|
+
await document.documentElement.requestFullscreen?.({ navigationUI: "hide" });
|
|
527
|
+
if (config.orientation !== "any") await (screen.orientation as unknown as { lock?(o: string): Promise<void> }).lock?.(config.orientation);
|
|
528
|
+
} catch {
|
|
529
|
+
/* fullscreen or the lock refused: the page plays as laid out */
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
let wanted = false;
|
|
533
|
+
let pausedByPlayer = false;
|
|
534
|
+
const start = () => {
|
|
535
|
+
if (phase === "failed") return;
|
|
536
|
+
phase = "playing";
|
|
537
|
+
performance.mark?.("og:start");
|
|
538
|
+
wanted = true;
|
|
539
|
+
$("og-gate")?.setAttribute("hidden", "");
|
|
540
|
+
badge?.setAttribute("data-playing", "");
|
|
541
|
+
back?.setAttribute("data-playing", "");
|
|
542
|
+
page?.start();
|
|
543
|
+
if (!harness) shell.play(!pausedByPlayer && document.visibilityState === "visible");
|
|
544
|
+
void lockWake();
|
|
545
|
+
};
|
|
546
|
+
const pauseFor = (on: boolean) => {
|
|
547
|
+
if (!wanted || harness || phase === "failed") return;
|
|
548
|
+
const run = !on && document.visibilityState === "visible" && !pausedByPlayer;
|
|
549
|
+
if (run === shell.playing) return;
|
|
550
|
+
shell.play(run);
|
|
551
|
+
void audio.suspend(!run);
|
|
552
|
+
if (run) void lockWake();
|
|
553
|
+
else {
|
|
554
|
+
void wake?.release();
|
|
555
|
+
wake = null;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
document.addEventListener("visibilitychange", () => pauseFor(document.visibilityState !== "visible"));
|
|
559
|
+
addEventListener("pagehide", () => {
|
|
560
|
+
pauseFor(true);
|
|
561
|
+
page?.end({ ready: firstFrameAt > 0, failed: phase === "failed" });
|
|
562
|
+
});
|
|
563
|
+
pollGamepads((e) => shell.inject(e));
|
|
564
|
+
|
|
565
|
+
// A Player owns the gate: start on its `start` (or a hello that says play began).
|
|
566
|
+
// A top-level page owns its own gate; its hello only names what its play origin answers.
|
|
567
|
+
let byPlayer = player.hosted && !page;
|
|
568
|
+
if (page) {
|
|
569
|
+
playerEvents.hello = () => {
|
|
570
|
+
helloForPlatform();
|
|
571
|
+
shell.setPlatform(playerPlatform(player, platformStore));
|
|
572
|
+
};
|
|
573
|
+
if (page.hosted) playerEvents.hello({ started: false, paused: false, capabilities: page.capabilities });
|
|
574
|
+
} else playerEvents.hello = (m) => {
|
|
575
|
+
byPlayer = true;
|
|
576
|
+
helloForPlatform();
|
|
577
|
+
// What it answers may have changed with this hello.
|
|
578
|
+
shell.setPlatform(playerPlatform(player, platformStore));
|
|
579
|
+
pausedByPlayer = m.paused;
|
|
580
|
+
if (m.started && phase === "gate") start();
|
|
581
|
+
};
|
|
582
|
+
playerEvents.start = () => {
|
|
583
|
+
byPlayer = true;
|
|
584
|
+
if (phase === "gate") start();
|
|
585
|
+
};
|
|
586
|
+
playerEvents.gesture = () => void audio.unlock().catch(() => {});
|
|
587
|
+
playerEvents.pause = () => {
|
|
588
|
+
pausedByPlayer = true;
|
|
589
|
+
pauseFor(true);
|
|
590
|
+
};
|
|
591
|
+
playerEvents.resume = () => {
|
|
592
|
+
pausedByPlayer = false;
|
|
593
|
+
pauseFor(false);
|
|
594
|
+
};
|
|
595
|
+
pausedByPlayer = player.paused;
|
|
596
|
+
// A framed page (or the Stage overlay's own document) waits briefly for a Player's hello before drawing its own gate.
|
|
597
|
+
if (linked && !byPlayer) await new Promise((r) => setTimeout(r, 300));
|
|
598
|
+
|
|
599
|
+
// ── ready: reveal, then the gate ─────────────────────────────────────
|
|
600
|
+
setProgress(1);
|
|
601
|
+
boot?.setAttribute("data-done", "");
|
|
602
|
+
setTimeout(() => boot && phase !== "failed" && (boot.hidden = true), 300);
|
|
603
|
+
if (!firstFrameAt) {
|
|
604
|
+
// The Worker drew the first frame behind the gate; a page without a renderer is ready now.
|
|
605
|
+
setTimeout(() => {
|
|
606
|
+
if (!firstFrameAt) ready();
|
|
607
|
+
}, 2000);
|
|
608
|
+
}
|
|
609
|
+
if (harness) {
|
|
610
|
+
phase = "playing";
|
|
611
|
+
} else if (byPlayer) {
|
|
612
|
+
phase = "gate";
|
|
613
|
+
if (player.started) start();
|
|
614
|
+
} else if (config.gate === "none") {
|
|
615
|
+
start();
|
|
616
|
+
} else {
|
|
617
|
+
phase = "gate";
|
|
618
|
+
const gate = $("og-gate");
|
|
619
|
+
const button = gate?.querySelector("button");
|
|
620
|
+
if (button) button.textContent = matchMedia?.("(pointer: coarse)").matches ? T.tapToPlay : T.play;
|
|
621
|
+
if (gate) {
|
|
622
|
+
gate.hidden = false;
|
|
623
|
+
const go = (e: Event) => {
|
|
624
|
+
e.preventDefault();
|
|
625
|
+
gate.removeEventListener("pointerup", go);
|
|
626
|
+
removeEventListener("keydown", key, true);
|
|
627
|
+
void audio.unlock().catch((err) => log.push([`warn: audio: ${err instanceof Error ? err.message : String(err)}`]));
|
|
628
|
+
void fullscreen();
|
|
629
|
+
start();
|
|
630
|
+
canvas.focus?.({ preventScroll: true });
|
|
631
|
+
};
|
|
632
|
+
// Enter or Space anywhere passes the gate too; the key does not reach the game.
|
|
633
|
+
const key = (e: KeyboardEvent) => {
|
|
634
|
+
if (e.code !== "Enter" && e.code !== "Space" && e.code !== "NumpadEnter") return;
|
|
635
|
+
e.stopImmediatePropagation();
|
|
636
|
+
go(e);
|
|
637
|
+
};
|
|
638
|
+
gate.addEventListener("pointerup", go);
|
|
639
|
+
addEventListener("keydown", key, true);
|
|
640
|
+
} else start();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ── PWA ──────────────────────────────────────────────────────────────
|
|
644
|
+
// The shared Stage origin installs no service worker.
|
|
645
|
+
if (config.serviceWorker && !harness && !stageSelf) {
|
|
646
|
+
// The variant the engine booted, which the service worker caches whole (sw.ts `og-cache`).
|
|
647
|
+
const booted = lanes.on ? ["./origin-engine-lanes.js", "./origin-engine-lanes_bg.wasm"] : ["./origin-engine.js", "./origin-engine_bg.wasm"];
|
|
648
|
+
void installPwa(player.framed, log, booted.map((f) => new URL(f, location.href).href));
|
|
649
|
+
}
|
|
650
|
+
return { shell, config };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Register the service worker and, once it is active, have it cache the
|
|
655
|
+
* engine variant this page booted (`booted`: its files' URLs), whatever it
|
|
656
|
+
* guessed when it installed; show the install hint only when the criteria hold.
|
|
657
|
+
*/
|
|
658
|
+
async function installPwa(framed: boolean, log: LogTail, booted: readonly string[]): Promise<void> {
|
|
659
|
+
if (!("serviceWorker" in navigator) || !isSecureContext) return;
|
|
660
|
+
try {
|
|
661
|
+
await navigator.serviceWorker.register("./sw.js", { scope: "./" });
|
|
662
|
+
} catch (e) {
|
|
663
|
+
log.push([`warn: service worker: ${e instanceof Error ? e.message : String(e)}`]);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
void navigator.serviceWorker.ready
|
|
667
|
+
.then((reg) => reg.active?.postMessage({ type: "og-cache", files: booted }))
|
|
668
|
+
.catch((e) => log.push([`warn: service worker: ${e instanceof Error ? e.message : String(e)}`]));
|
|
669
|
+
const standalone = matchMedia?.("(display-mode: standalone)").matches || (navigator as { standalone?: boolean }).standalone === true;
|
|
670
|
+
const manifest = document.querySelector('link[rel="manifest"]');
|
|
671
|
+
let dismissed = false;
|
|
672
|
+
try {
|
|
673
|
+
dismissed = localStorage.getItem(INSTALL_DISMISSED) === "1";
|
|
674
|
+
} catch {
|
|
675
|
+
/* storage refused: ask once per load */
|
|
676
|
+
}
|
|
677
|
+
if (framed || standalone || !manifest || dismissed) return;
|
|
678
|
+
const hint = $("og-install");
|
|
679
|
+
if (!hint) return;
|
|
680
|
+
const text = hint.querySelector("span");
|
|
681
|
+
const [act, no] = Array.from(hint.querySelectorAll("button"));
|
|
682
|
+
const dismiss = () => {
|
|
683
|
+
hint.hidden = true;
|
|
684
|
+
try {
|
|
685
|
+
localStorage.setItem(INSTALL_DISMISSED, "1");
|
|
686
|
+
} catch {
|
|
687
|
+
/* not remembered */
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
no?.addEventListener("click", dismiss);
|
|
691
|
+
if (no) no.textContent = T.dismiss;
|
|
692
|
+
// Chromium: the browser decides the criteria hold and fires beforeinstallprompt.
|
|
693
|
+
addEventListener("beforeinstallprompt", (e) => {
|
|
694
|
+
e.preventDefault();
|
|
695
|
+
const prompt = e as Event & { prompt(): Promise<void> };
|
|
696
|
+
if (text) text.textContent = T.install;
|
|
697
|
+
if (act) {
|
|
698
|
+
act.hidden = false;
|
|
699
|
+
act.textContent = T.install;
|
|
700
|
+
act.onclick = () => {
|
|
701
|
+
hint.hidden = true;
|
|
702
|
+
void prompt.prompt();
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
hint.hidden = false;
|
|
706
|
+
});
|
|
707
|
+
// iOS Safari has no prompt: the hint names the Share sheet.
|
|
708
|
+
const ua = navigator.userAgent;
|
|
709
|
+
const ios = /iP(hone|ad|od)/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
|
710
|
+
if (ios && /Safari/.test(ua) && !/Chrome|Chromium|CriOS|FxiOS|EdgiOS|Android/.test(ua)) {
|
|
711
|
+
if (text) text.textContent = T.installIos;
|
|
712
|
+
if (act) act.hidden = true;
|
|
713
|
+
hint.hidden = false;
|
|
714
|
+
}
|
|
715
|
+
}
|