@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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// og.platform on the web: the shell's answer to the engine Worker's
|
|
2
|
+
// platform calls. With a Player framing the page, each call goes to it over
|
|
3
|
+
// Player protocol 2 (./player.ts, origin-checked postMessage; the Player
|
|
4
|
+
// owns every credential; or the Stage overlay in the page's own document),
|
|
5
|
+
// as its hello's capabilities allow; an answer's `time` (the Player's clock)
|
|
6
|
+
// passes on to og.platform; without one, or
|
|
7
|
+
// where the Player answers no such method, the browser's own storage answers
|
|
8
|
+
// (../src/platform-local.ts over localStorage). The mapping:
|
|
9
|
+
// docs/web-export.md §Platform services.
|
|
10
|
+
//
|
|
11
|
+
// store.get/set/list store.get / store.set / store.list, each key with its
|
|
12
|
+
// own revision: og.platform's revision N sends
|
|
13
|
+
// revision N, `create` sends revision 0 (only a key
|
|
14
|
+
// never written), revision 0 sends none (the last
|
|
15
|
+
// write wins); a conflict carries the key's revision.
|
|
16
|
+
// A Player with save.* but no store.* (older ones)
|
|
17
|
+
// keeps the store in the save's `store` object, read
|
|
18
|
+
// with save.read (or save.get) and written back at the
|
|
19
|
+
// save's revision; neither: the browser's storage.
|
|
20
|
+
// leaderboard.submit leaderboard.submit { score, board, order } → { board, rank, best }
|
|
21
|
+
// leaderboard.top leaderboard.top { n, board } → { board, sort, top, me }
|
|
22
|
+
// leaderboard.around leaderboard.around { n, board } → { board, sort, entries, me }
|
|
23
|
+
// (older Players: leaderboard.top { n: 100 }, cut around me)
|
|
24
|
+
// telemetry an `event` named game.custom.<name>
|
|
25
|
+
// point point.award { reason, amount } → { granted, balance }
|
|
26
|
+
// ai.mark ai.mark { kind, label, model? } → { marked }
|
|
27
|
+
|
|
28
|
+
import { answerLocally, fail, localPlatform, type LocalDocument, type LocalPersistence, type PlatformAnswer, type PlatformCall, type PlatformHost } from "../src/platform-local.ts";
|
|
29
|
+
export { storagePersistence } from "../src/platform-local.ts";
|
|
30
|
+
import type { PlayerResult } from "./player.ts";
|
|
31
|
+
|
|
32
|
+
/** The part of the Player link this needs (connectPlayer's answer). */
|
|
33
|
+
export interface PlayerLink {
|
|
34
|
+
readonly framed: boolean;
|
|
35
|
+
readonly hosted: boolean;
|
|
36
|
+
readonly capabilities: readonly string[];
|
|
37
|
+
request(method: string, args?: Record<string, unknown>): Promise<PlayerResult>;
|
|
38
|
+
event(name: string, attrs?: Record<string, string | number | boolean | null>): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Save = { store?: Record<string, { value: unknown; revision: number }> } & Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
function asSave(data: unknown): Save {
|
|
44
|
+
return data && typeof data === "object" && !Array.isArray(data) ? (data as Save) : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The Player's `time` on an answer, when it stamped one: og.platform's clock is then the Player's. */
|
|
48
|
+
const stamp = (r: PlayerResult | undefined): { time?: number } => (typeof r?.time === "number" ? { time: r.time } : {});
|
|
49
|
+
|
|
50
|
+
/** The Player's answer as og.platform's (a conflict keeps the revision it names; the Player's `time` passes on). */
|
|
51
|
+
const fromPlayer = (r: PlayerResult, data: (d: unknown) => unknown): PlatformAnswer =>
|
|
52
|
+
r.ok
|
|
53
|
+
? { ok: true, data: data(r.data), ...stamp(r) }
|
|
54
|
+
: { ok: false, error: { code: r.error.code, message: r.error.message, retryable: r.error.retryable, ...(typeof r.error.revision === "number" ? { revision: r.error.revision } : {}) }, ...stamp(r) };
|
|
55
|
+
|
|
56
|
+
const STORE = ["store.get", "store.set", "store.list"];
|
|
57
|
+
type Entry = { rank: number; name: string; score: number; me?: boolean };
|
|
58
|
+
|
|
59
|
+
/** og.platform's leaderboard answer: `order` from the Player's `sort`. */
|
|
60
|
+
const board = (a: Record<string, unknown>, d: unknown, list: "top" | "entries") => {
|
|
61
|
+
const t = (d ?? {}) as { board?: string; sort?: string; top?: Entry[]; entries?: Entry[]; me?: { rank: number } | null };
|
|
62
|
+
const me = t.me ?? null;
|
|
63
|
+
const entries = (t[list] ?? []).map((e) => ({ rank: e.rank, name: e.name, score: e.score, me: e.me ?? (!!me && e.rank === me.rank) }));
|
|
64
|
+
return { board: t.board ?? a["board"], order: t.sort === "asc" ? "asc" : "desc", entries, me };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The Player-backed platform, falling back to `local` (the browser's storage). */
|
|
68
|
+
export function playerPlatform(player: PlayerLink, local: LocalPersistence): PlatformHost {
|
|
69
|
+
const fallback = localPlatform(local);
|
|
70
|
+
const has = (m: string) => player.capabilities.includes(m);
|
|
71
|
+
const keyed = STORE.every(has);
|
|
72
|
+
const saved = (has("save.read") || has("save.get")) && has("save.set");
|
|
73
|
+
const capabilities = [
|
|
74
|
+
...STORE,
|
|
75
|
+
"telemetry",
|
|
76
|
+
...(has("leaderboard.submit") ? ["leaderboard.submit"] : []),
|
|
77
|
+
...(has("leaderboard.top") ? ["leaderboard.top"] : []),
|
|
78
|
+
...(has("leaderboard.around") || has("leaderboard.top") ? ["leaderboard.around"] : []),
|
|
79
|
+
...(has("point.award") ? ["point"] : []),
|
|
80
|
+
...(has("ai.mark") ? ["ai.mark"] : []),
|
|
81
|
+
];
|
|
82
|
+
let queue: Promise<void> = Promise.resolve();
|
|
83
|
+
|
|
84
|
+
const viaLocal = (call: PlatformCall): PlatformAnswer | null => {
|
|
85
|
+
let out: PlatformAnswer | null = null;
|
|
86
|
+
fallback.handle([call], (_r, a) => (out = a));
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The keyed store, through the Player's store.* methods. */
|
|
91
|
+
const keyedStore = async (call: PlatformCall): Promise<PlatformAnswer | null> => {
|
|
92
|
+
const a = call.args;
|
|
93
|
+
if (call.method === "store.get") return fromPlayer(await player.request("store.get", { key: a["key"] }), (d) => d);
|
|
94
|
+
if (call.method === "store.list") return fromPlayer(await player.request("store.list"), (d) => ({ keys: ((d ?? {}) as { keys?: unknown[] }).keys ?? [] }));
|
|
95
|
+
const revision = a["create"] === true ? 0 : Number(a["revision"] ?? 0) > 0 ? Number(a["revision"]) : undefined;
|
|
96
|
+
const r = await player.request("store.set", { key: a["key"], value: a["value"] ?? null, ...(revision === undefined ? {} : { revision }) });
|
|
97
|
+
return r.ok || r.error.code !== "unavailable" ? fromPlayer(r, (d) => d) : viaLocal(call);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The store in the save's `store` object (a Player with save.* and no store.*). */
|
|
101
|
+
const savedStore = async (call: PlatformCall): Promise<PlatformAnswer | null> => {
|
|
102
|
+
const read = has("save.read");
|
|
103
|
+
const got = await player.request(read ? "save.read" : "save.get");
|
|
104
|
+
if (!got.ok) return got.error.code === "unavailable" ? viaLocal(call) : fromPlayer(got, () => null);
|
|
105
|
+
const at = read ? ((got.data ?? {}) as { data?: unknown; revision?: number | null }) : { data: got.data, revision: undefined };
|
|
106
|
+
const save = asSave(at.data);
|
|
107
|
+
const doc: LocalDocument = { version: 1, player: "", store: { ...(save.store ?? {}) }, boards: {}, points: { balance: 0, grants: [] }, marks: [], events: [] };
|
|
108
|
+
const answer = answerLocally(doc, call);
|
|
109
|
+
if (call.method !== "store.set" || !answer?.ok) return answer && { ...answer, ...stamp(got) };
|
|
110
|
+
// Written back at the revision it was read at: a write from another device in between is a conflict.
|
|
111
|
+
const revision = typeof at.revision === "number" ? at.revision : undefined;
|
|
112
|
+
const put = await player.request("save.set", { data: { ...save, store: doc.store }, ...(revision === undefined ? {} : { revision }) });
|
|
113
|
+
if (!put.ok) return fromPlayer(put, () => null);
|
|
114
|
+
const queued = (put.data as { queued?: boolean } | null)?.queued === true;
|
|
115
|
+
return { ok: true, data: { ...(answer.data as object), ...(queued ? { queued: true } : {}) }, ...stamp(put.time === undefined ? got : put) };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const one = async (call: PlatformCall): Promise<PlatformAnswer | null> => {
|
|
119
|
+
const a = call.args;
|
|
120
|
+
if (STORE.includes(call.method)) return keyed ? keyedStore(call) : saved ? savedStore(call) : viaLocal(call);
|
|
121
|
+
const n = Math.max(1, Math.min(100, Number(a["count"] ?? 10)));
|
|
122
|
+
switch (call.method) {
|
|
123
|
+
case "telemetry":
|
|
124
|
+
player.event(`game.custom.${String(a["name"])}`, a["attrs"] as Record<string, string | number | boolean | null>);
|
|
125
|
+
return null;
|
|
126
|
+
case "leaderboard.submit":
|
|
127
|
+
if (!has("leaderboard.submit")) return fail("unavailable", "this Player answers no leaderboard.submit here");
|
|
128
|
+
return fromPlayer(await player.request("leaderboard.submit", { score: a["score"], board: a["board"], order: a["order"] }), (d) => {
|
|
129
|
+
const r = (d ?? {}) as { board?: string; rank?: number | null; best?: number | null; queued?: boolean };
|
|
130
|
+
return { board: r.board ?? a["board"], rank: r.rank ?? null, best: r.best ?? null, ...(r.queued ? { queued: true } : {}) };
|
|
131
|
+
});
|
|
132
|
+
case "leaderboard.top":
|
|
133
|
+
if (!has("leaderboard.top")) return fail("unavailable", "this Player answers no leaderboard.top here");
|
|
134
|
+
return fromPlayer(await player.request("leaderboard.top", { n, board: a["board"] }), (d) => board(a, d, "top"));
|
|
135
|
+
case "leaderboard.around": {
|
|
136
|
+
if (has("leaderboard.around")) return fromPlayer(await player.request("leaderboard.around", { n, board: a["board"] }), (d) => board(a, d, "entries"));
|
|
137
|
+
if (!has("leaderboard.top")) return fail("unavailable", "this Player answers no leaderboard.around here");
|
|
138
|
+
// An older Player: the top 100, cut around the player.
|
|
139
|
+
return fromPlayer(await player.request("leaderboard.top", { n: 100, board: a["board"] }), (d) => {
|
|
140
|
+
const b = board(a, d, "top");
|
|
141
|
+
const at = b.entries.findIndex((e) => e.me);
|
|
142
|
+
const from = b.me && at >= 0 ? Math.max(0, Math.min(b.entries.length - n, at - Math.floor(n / 2))) : 0;
|
|
143
|
+
return { ...b, entries: b.entries.slice(from, from + n) };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
case "point":
|
|
147
|
+
if (!has("point.award")) return fail("unavailable", "this Player answers no point.award here");
|
|
148
|
+
return fromPlayer(await player.request("point.award", { reason: a["reason"], amount: a["amount"] ?? 1 }), (d) => {
|
|
149
|
+
const r = (d ?? {}) as { granted?: number; balance?: number | null };
|
|
150
|
+
return { granted: r.granted ?? 0, balance: r.balance ?? null };
|
|
151
|
+
});
|
|
152
|
+
case "ai.mark":
|
|
153
|
+
if (!has("ai.mark")) return fail("unavailable", "this Player answers no ai.mark here");
|
|
154
|
+
return fromPlayer(await player.request("ai.mark", { kind: a["kind"], label: a["label"], ...(typeof a["model"] === "string" ? { model: a["model"] } : {}) }), () => ({ marked: true }));
|
|
155
|
+
default:
|
|
156
|
+
return fail("unavailable", `the Player has no ${call.method} method in protocol 2`);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
host: "player",
|
|
162
|
+
capabilities,
|
|
163
|
+
handle(calls, answer) {
|
|
164
|
+
// In order: a store write through the save reads it first, so calls never interleave.
|
|
165
|
+
for (const call of calls) {
|
|
166
|
+
queue = queue.then(async () => {
|
|
167
|
+
let r: PlatformAnswer | null;
|
|
168
|
+
try {
|
|
169
|
+
r = await one(call);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
r = fail("failed", e instanceof Error ? e.message : String(e));
|
|
172
|
+
}
|
|
173
|
+
if (r) answer(call.request, r);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A host that holds calls until `ready` names the real one: a framed page's
|
|
182
|
+
* first calls (the first tick, behind the gate) wait for the Player's hello
|
|
183
|
+
* instead of reaching the browser's storage.
|
|
184
|
+
*/
|
|
185
|
+
export function deferredPlatform(ready: Promise<PlatformHost>, capabilities: readonly string[]): PlatformHost {
|
|
186
|
+
let real: PlatformHost | null = null;
|
|
187
|
+
const held: Array<[readonly PlatformCall[], (request: number, result: PlatformAnswer) => void]> = [];
|
|
188
|
+
void ready.then((h) => {
|
|
189
|
+
real = h;
|
|
190
|
+
for (const [calls, answer] of held.splice(0)) h.handle(calls, answer);
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
host: "player",
|
|
194
|
+
capabilities,
|
|
195
|
+
handle(calls, answer) {
|
|
196
|
+
if (real) real.handle(calls, answer);
|
|
197
|
+
else held.push([calls, answer]);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** How long a framed page waits for a Player's hello before the browser's storage answers. */
|
|
203
|
+
export const HELLO_WAIT_MS = 1500;
|
package/shell/player.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// The Player wire, protocol 2 (origingame/docs/player-protocol.md; types in
|
|
2
|
+
// origin/packages/contracts/src/player-wire.ts), game side: the shell says
|
|
3
|
+
// `hello` at startup and owns its readiness: `loading` with the real bytes
|
|
4
|
+
// received, `ready` at the first rendered frame, `fail` before ready,
|
|
5
|
+
// `crash` after it (an error name and a fingerprint only), one `sample`
|
|
6
|
+
// with the startup milestones and the renderer. It hears `hello`, `start`,
|
|
7
|
+
// `gesture`, `pause` and `resume` from the Player, and the `result` of each
|
|
8
|
+
// `call` it made (og.platform's calls, through ./platform.ts).
|
|
9
|
+
//
|
|
10
|
+
// Rules: post only to `window.parent`, naming this page's own origin (the
|
|
11
|
+
// Player and its releases share the play origin); accept only messages from
|
|
12
|
+
// `window.parent` at this origin with `og: "player", protocol: 2`.
|
|
13
|
+
//
|
|
14
|
+
// A top-level page with no `window.__OG_GAME` that was told its own origin
|
|
15
|
+
// as the Stage host (`window.__OG_STAGE_HOST__`, decided as the og-probe
|
|
16
|
+
// wire decides it: ./wire.ts `stageInPage`) has the platform's Stage overlay
|
|
17
|
+
// drawn in its own document, and that overlay is its Player: the shell posts
|
|
18
|
+
// to its own window at its own origin and hears only its own window at that
|
|
19
|
+
// origin. Every post there reaches the shell too, so a `hello` that names a
|
|
20
|
+
// `substrate` or lists no `capabilities` (the shell's own) is not the
|
|
21
|
+
// Player's. The hello repeats every 250 ms until the overlay answers (or
|
|
22
|
+
// HELLO_WAIT_MS passes), in case its listener comes up after the shell.
|
|
23
|
+
// Any other unframed page sends nothing and hears nothing.
|
|
24
|
+
//
|
|
25
|
+
// This is the `frame` transport. A top-level page the platform published
|
|
26
|
+
// (`window.__OG_GAME`) speaks the same protocol to its play origin over
|
|
27
|
+
// same-origin HTTP instead (`page`, ./transport.ts); og build declares both
|
|
28
|
+
// in `<meta name="og-player" content="frame page">`.
|
|
29
|
+
|
|
30
|
+
import { stageInPage } from "./wire.ts";
|
|
31
|
+
|
|
32
|
+
export const PLAYER_WIRE = "player";
|
|
33
|
+
export const PLAYER_WIRE_PROTOCOL = 2;
|
|
34
|
+
|
|
35
|
+
export interface PlayerEvents {
|
|
36
|
+
/** The Player answered hello (`started`: play began already; `paused`: it covers the game). */
|
|
37
|
+
hello?(m: { started: boolean; paused: boolean; capabilities: readonly string[] }): void;
|
|
38
|
+
start?(): void;
|
|
39
|
+
gesture?(): void;
|
|
40
|
+
pause?(): void;
|
|
41
|
+
resume?(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A short fingerprint of an error message: `[a-z0-9-]{1,64}` (FNV-1a of the first line, hex). */
|
|
45
|
+
export function fingerprint(message: string): string {
|
|
46
|
+
const line = (message.split("\n")[0] ?? "").replace(/\d+/g, "#");
|
|
47
|
+
let h = 0x811c9dc5;
|
|
48
|
+
for (let i = 0; i < line.length; i++) {
|
|
49
|
+
h ^= line.charCodeAt(i);
|
|
50
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
51
|
+
}
|
|
52
|
+
return `oe-${h.toString(16).padStart(8, "0")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A `result`: the data, or the Player's error; `time` when the Player stamped it (UTC ms, its clock). */
|
|
56
|
+
export type PlayerResult =
|
|
57
|
+
| { readonly ok: true; readonly data: unknown; readonly time?: number }
|
|
58
|
+
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly retryable: boolean; readonly status?: number; readonly revision?: number }; readonly time?: number };
|
|
59
|
+
|
|
60
|
+
/** A message's `time` field, when it is a finite number. */
|
|
61
|
+
export const timeOf = (m: Record<string, unknown>): { time?: number } =>
|
|
62
|
+
typeof m["time"] === "number" && Number.isFinite(m["time"]) ? { time: m["time"] } : {};
|
|
63
|
+
|
|
64
|
+
/** How long a call waits for its `result` before it fails with `timeout`. */
|
|
65
|
+
export const CALL_TIMEOUT_MS = 15_000;
|
|
66
|
+
|
|
67
|
+
/** Who the bridge speaks to: the framing Player, the Stage overlay in this document, or no one. */
|
|
68
|
+
export type PlayerPeer = "parent" | "self" | null;
|
|
69
|
+
|
|
70
|
+
/** How long the shell repeats its hello to the Stage overlay in its own document. */
|
|
71
|
+
const SELF_HELLO_EVERY_MS = 250;
|
|
72
|
+
const SELF_HELLO_TRIES = 6;
|
|
73
|
+
|
|
74
|
+
export function connectPlayer(events: PlayerEvents) {
|
|
75
|
+
const w = globalThis as unknown as Window & Record<string, unknown>;
|
|
76
|
+
const framed = (() => {
|
|
77
|
+
try {
|
|
78
|
+
return w.parent !== w;
|
|
79
|
+
} catch {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
const peer: PlayerPeer = framed ? "parent" : w["__OG_GAME"] === undefined && stageInPage(w) ? "self" : null;
|
|
84
|
+
const target = (): Window => (peer === "self" ? w : w.parent);
|
|
85
|
+
const origin = w.location.origin;
|
|
86
|
+
let lastValue = -1;
|
|
87
|
+
let readySent = false;
|
|
88
|
+
// After fail or crash the session is over: a late ready is not sent.
|
|
89
|
+
let ended = false;
|
|
90
|
+
let heard = false;
|
|
91
|
+
let started = false;
|
|
92
|
+
let paused = false;
|
|
93
|
+
let capabilities: readonly string[] = [];
|
|
94
|
+
let nextCall = 1;
|
|
95
|
+
const calls = new Map<string, (r: PlayerResult) => void>();
|
|
96
|
+
|
|
97
|
+
const post = (kind: string, fields: Record<string, unknown> = {}) => {
|
|
98
|
+
if (!peer || !origin || origin === "null") return;
|
|
99
|
+
try {
|
|
100
|
+
target().postMessage({ og: PLAYER_WIRE, protocol: PLAYER_WIRE_PROTOCOL, kind, ...fields }, origin);
|
|
101
|
+
} catch {
|
|
102
|
+
/* a cross-origin parent is not our Player */
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const onMessage = (e: MessageEvent) => {
|
|
107
|
+
if (!peer || e.source !== target() || e.origin !== origin) return;
|
|
108
|
+
const m = e.data as Record<string, unknown> | null;
|
|
109
|
+
if (!m || typeof m !== "object" || m["og"] !== PLAYER_WIRE || m["protocol"] !== PLAYER_WIRE_PROTOCOL) return;
|
|
110
|
+
switch (m["kind"]) {
|
|
111
|
+
case "hello":
|
|
112
|
+
// In one window the shell hears its own hello: only the Player's lists capabilities.
|
|
113
|
+
if (peer === "self" && ("substrate" in m || !Array.isArray(m["capabilities"]))) break;
|
|
114
|
+
heard = true;
|
|
115
|
+
if (m["started"] === true) started = true;
|
|
116
|
+
paused = m["paused"] === true;
|
|
117
|
+
capabilities = Array.isArray(m["capabilities"]) ? (m["capabilities"] as unknown[]).filter((c): c is string => typeof c === "string") : [];
|
|
118
|
+
events.hello?.({ started: m["started"] === true, paused, capabilities });
|
|
119
|
+
break;
|
|
120
|
+
case "result": {
|
|
121
|
+
const id = m["id"];
|
|
122
|
+
const done = typeof id === "string" ? calls.get(id) : undefined;
|
|
123
|
+
if (!done) break;
|
|
124
|
+
calls.delete(id as string);
|
|
125
|
+
if (m["ok"] === true) done({ ok: true, data: m["data"] ?? null, ...timeOf(m) });
|
|
126
|
+
else {
|
|
127
|
+
const e = (m["error"] ?? {}) as Record<string, unknown>;
|
|
128
|
+
const code = typeof e["code"] === "string" ? e["code"] : "failed";
|
|
129
|
+
done({
|
|
130
|
+
ok: false,
|
|
131
|
+
error: {
|
|
132
|
+
code,
|
|
133
|
+
message: typeof e["message"] === "string" ? e["message"] : "",
|
|
134
|
+
retryable: e["retryable"] === true,
|
|
135
|
+
...(typeof e["status"] === "number" ? { status: e["status"] } : {}),
|
|
136
|
+
...(typeof e["revision"] === "number" ? { revision: e["revision"] } : {}),
|
|
137
|
+
},
|
|
138
|
+
...timeOf(m),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "start":
|
|
144
|
+
heard = true;
|
|
145
|
+
started = true;
|
|
146
|
+
events.start?.();
|
|
147
|
+
break;
|
|
148
|
+
case "gesture":
|
|
149
|
+
events.gesture?.();
|
|
150
|
+
break;
|
|
151
|
+
case "pause":
|
|
152
|
+
paused = true;
|
|
153
|
+
events.pause?.();
|
|
154
|
+
break;
|
|
155
|
+
case "resume":
|
|
156
|
+
paused = false;
|
|
157
|
+
events.resume?.();
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
if (peer) addEventListener("message", onMessage);
|
|
162
|
+
post("hello", { substrate: "origin-engine" });
|
|
163
|
+
if (peer === "self") {
|
|
164
|
+
let tries = 1;
|
|
165
|
+
const again = setInterval(() => {
|
|
166
|
+
if (heard || tries++ >= SELF_HELLO_TRIES) clearInterval(again);
|
|
167
|
+
else post("hello", { substrate: "origin-engine" });
|
|
168
|
+
}, SELF_HELLO_EVERY_MS);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
/** Whether the page is framed (a Player may be listening). */
|
|
173
|
+
framed,
|
|
174
|
+
/** Who answers: the framing Player (`parent`), the Stage overlay in this document (`self`), or no one. */
|
|
175
|
+
peer,
|
|
176
|
+
/** Whether a Player answered hello (or sent start). */
|
|
177
|
+
get hosted(): boolean {
|
|
178
|
+
return heard;
|
|
179
|
+
},
|
|
180
|
+
/** Whether the Player said play began. */
|
|
181
|
+
get started(): boolean {
|
|
182
|
+
return started;
|
|
183
|
+
},
|
|
184
|
+
/** Whether the Player covers the game now. */
|
|
185
|
+
get paused(): boolean {
|
|
186
|
+
return paused;
|
|
187
|
+
},
|
|
188
|
+
/** The methods the Player's hello said it answers (empty before it, or with no Player). */
|
|
189
|
+
get capabilities(): readonly string[] {
|
|
190
|
+
return capabilities;
|
|
191
|
+
},
|
|
192
|
+
loading(value: number | null, label?: string) {
|
|
193
|
+
if (readySent) return;
|
|
194
|
+
if (value !== null) {
|
|
195
|
+
const v = Math.round(Math.min(1, Math.max(0, value)) * 1000) / 1000;
|
|
196
|
+
if (v <= lastValue) return;
|
|
197
|
+
lastValue = v;
|
|
198
|
+
post("loading", { value: v, ...(label ? { label } : {}) });
|
|
199
|
+
} else post("loading", { value: null });
|
|
200
|
+
},
|
|
201
|
+
ready() {
|
|
202
|
+
if (readySent || ended) return;
|
|
203
|
+
readySent = true;
|
|
204
|
+
post("ready");
|
|
205
|
+
},
|
|
206
|
+
fail(code: string, message: string, retryable: boolean) {
|
|
207
|
+
if (readySent) return;
|
|
208
|
+
ended = true;
|
|
209
|
+
post("fail", { code, message: message.slice(0, 400), retryable });
|
|
210
|
+
lastValue = -1;
|
|
211
|
+
},
|
|
212
|
+
crash(name: string, message: string) {
|
|
213
|
+
ended = true;
|
|
214
|
+
post("crash", { name: /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(name) ? name : "Error", fingerprint: fingerprint(message) });
|
|
215
|
+
},
|
|
216
|
+
sample(fields: { renderer?: string; timings?: Record<string, number> }) {
|
|
217
|
+
post("sample", { substrate: "origin-engine", ...fields });
|
|
218
|
+
},
|
|
219
|
+
event(name: string, attrs?: Record<string, string | number | boolean | null>) {
|
|
220
|
+
post("event", { name, ...(attrs ? { attrs } : {}) });
|
|
221
|
+
},
|
|
222
|
+
/** One `call`, answered by its `result` (or `timeout` after CALL_TIMEOUT_MS; `unavailable` with no Player). */
|
|
223
|
+
request(method: string, args?: Record<string, unknown>, timeoutMs = CALL_TIMEOUT_MS): Promise<PlayerResult> {
|
|
224
|
+
if (!peer) return Promise.resolve({ ok: false, error: { code: "unavailable", message: "no Player frames this page", retryable: false } });
|
|
225
|
+
const id = `oe-${nextCall++}`;
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
if (calls.delete(id)) resolve({ ok: false, error: { code: "timeout", message: `the Player did not answer ${method} in ${timeoutMs} ms`, retryable: true } });
|
|
229
|
+
}, timeoutMs);
|
|
230
|
+
calls.set(id, (r) => {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
resolve(r);
|
|
233
|
+
});
|
|
234
|
+
post("call", { id, method, ...(args ? { args } : {}) });
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
package/shell/shell.css
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* The player shell (docs/web-export.md): one ink surface, the canvas laid out
|
|
2
|
+
by the shell (letterbox or fill), the boot splash, the play gate, the error
|
|
3
|
+
card, the install hint and the "made with" badge. Tokens: brand/tokens.css. */
|
|
4
|
+
html, body {
|
|
5
|
+
margin: 0;
|
|
6
|
+
height: 100%;
|
|
7
|
+
background: var(--og-ink);
|
|
8
|
+
color: var(--color-fg);
|
|
9
|
+
font: 14px/1.4 var(--font-sans);
|
|
10
|
+
overscroll-behavior: none;
|
|
11
|
+
-webkit-tap-highlight-color: transparent;
|
|
12
|
+
-webkit-text-size-adjust: 100%;
|
|
13
|
+
}
|
|
14
|
+
body { position: fixed; inset: 0; overflow: hidden; height: 100dvh; }
|
|
15
|
+
#og-stage {
|
|
16
|
+
position: absolute;
|
|
17
|
+
inset: 0;
|
|
18
|
+
height: 100dvh;
|
|
19
|
+
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
}
|
|
25
|
+
#og-canvas {
|
|
26
|
+
display: block;
|
|
27
|
+
width: 100%;
|
|
28
|
+
height: 100%;
|
|
29
|
+
touch-action: none;
|
|
30
|
+
user-select: none;
|
|
31
|
+
-webkit-user-select: none;
|
|
32
|
+
-webkit-touch-callout: none;
|
|
33
|
+
outline: none;
|
|
34
|
+
}
|
|
35
|
+
.og-layer {
|
|
36
|
+
position: absolute;
|
|
37
|
+
inset: 0;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
gap: 20px;
|
|
43
|
+
padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
}
|
|
46
|
+
.og-layer[hidden] { display: none; }
|
|
47
|
+
#og-boot { background: var(--og-ink); color: var(--og-paper); transition: opacity 240ms var(--ease-out); }
|
|
48
|
+
#og-boot[data-done] { opacity: 0; pointer-events: none; }
|
|
49
|
+
/* The cover's 32 px derivative, upscaled behind the boot splash (a top-level platform page). */
|
|
50
|
+
#og-backdrop { position: absolute; inset: 0; overflow: hidden; opacity: 0; transition: opacity 240ms var(--ease-out); pointer-events: none; }
|
|
51
|
+
#og-backdrop[data-shown] { opacity: 0.45; }
|
|
52
|
+
#og-backdrop img { width: 100%; height: 100%; object-fit: cover; filter: blur(24px) saturate(1.1); transform: scale(1.15); }
|
|
53
|
+
#og-boot > :not(#og-backdrop) { position: relative; }
|
|
54
|
+
.og-boot-mark { width: 72px; height: 72px; color: var(--og-paper); }
|
|
55
|
+
.og-mark:not([data-motion="progress"]) :is([data-part="ghost"], [data-part="fill"]),
|
|
56
|
+
.og-mark[data-motion="progress"] [data-part="arms"] { display: none; }
|
|
57
|
+
.og-boot-label { min-height: 1.4em; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
|
|
58
|
+
#og-gate { background: rgb(20 22 27 / 0.55); cursor: pointer; }
|
|
59
|
+
#og-gate button {
|
|
60
|
+
appearance: none;
|
|
61
|
+
border: 1px solid rgb(255 255 255 / 0.28);
|
|
62
|
+
background: rgb(255 255 255 / 0.1);
|
|
63
|
+
color: var(--og-paper);
|
|
64
|
+
font: 600 16px/1 var(--font-sans);
|
|
65
|
+
padding: 14px 28px;
|
|
66
|
+
border-radius: var(--radius-button);
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
backdrop-filter: blur(12px);
|
|
69
|
+
-webkit-backdrop-filter: blur(12px);
|
|
70
|
+
}
|
|
71
|
+
#og-gate button:focus-visible { outline: 2px solid var(--og-paper); outline-offset: 3px; }
|
|
72
|
+
#og-error { background: var(--og-ink); overflow: auto; justify-content: flex-start; padding-top: max(48px, env(safe-area-inset-top)); }
|
|
73
|
+
.og-card {
|
|
74
|
+
width: min(560px, 100%);
|
|
75
|
+
box-sizing: border-box;
|
|
76
|
+
background: var(--color-surface);
|
|
77
|
+
border: 1px solid var(--color-border);
|
|
78
|
+
border-radius: var(--radius-card);
|
|
79
|
+
padding: 20px;
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: 12px;
|
|
83
|
+
}
|
|
84
|
+
.og-card h1 { margin: 0; font-size: 17px; font-weight: 600; }
|
|
85
|
+
.og-card p { margin: 0; color: var(--color-fg-muted); overflow-wrap: anywhere; }
|
|
86
|
+
.og-card details { border-top: 1px solid var(--color-border); padding-top: 10px; }
|
|
87
|
+
.og-card summary { cursor: pointer; color: var(--color-fg-muted); }
|
|
88
|
+
.og-card pre {
|
|
89
|
+
margin: 8px 0 0;
|
|
90
|
+
max-height: 40dvh;
|
|
91
|
+
overflow: auto;
|
|
92
|
+
font: 12px/1.45 var(--font-mono);
|
|
93
|
+
white-space: pre-wrap;
|
|
94
|
+
overflow-wrap: anywhere;
|
|
95
|
+
color: var(--color-fg);
|
|
96
|
+
}
|
|
97
|
+
.og-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
98
|
+
.og-actions button {
|
|
99
|
+
appearance: none;
|
|
100
|
+
border: 1px solid var(--color-border);
|
|
101
|
+
background: var(--color-surface-raised);
|
|
102
|
+
color: var(--color-fg);
|
|
103
|
+
font: 500 14px/1 var(--font-sans);
|
|
104
|
+
padding: 10px 16px;
|
|
105
|
+
border-radius: var(--radius-button);
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
}
|
|
108
|
+
.og-actions button:first-child { background: var(--og-paper); color: var(--og-ink); border-color: var(--og-paper); }
|
|
109
|
+
#og-install {
|
|
110
|
+
position: absolute;
|
|
111
|
+
left: 50%;
|
|
112
|
+
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 24px);
|
|
113
|
+
transform: translateX(-50%);
|
|
114
|
+
width: max-content;
|
|
115
|
+
max-width: calc(100% - 32px);
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 10px;
|
|
119
|
+
background: var(--color-surface);
|
|
120
|
+
border: 1px solid var(--color-border);
|
|
121
|
+
border-radius: var(--radius-card);
|
|
122
|
+
padding: 8px 8px 8px 14px;
|
|
123
|
+
font-size: 13px;
|
|
124
|
+
}
|
|
125
|
+
#og-install[hidden] { display: none; }
|
|
126
|
+
#og-install button { appearance: none; border: 0; background: var(--color-surface-raised); color: var(--color-fg); font: 500 13px/1 var(--font-sans); padding: 8px 12px; border-radius: var(--radius-button); cursor: pointer; }
|
|
127
|
+
#og-made-with {
|
|
128
|
+
position: absolute;
|
|
129
|
+
right: max(10px, env(safe-area-inset-right));
|
|
130
|
+
bottom: max(10px, env(safe-area-inset-bottom));
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 6px;
|
|
134
|
+
color: var(--og-paper);
|
|
135
|
+
opacity: 0.55;
|
|
136
|
+
font: 500 11px/1 var(--font-sans);
|
|
137
|
+
text-decoration: none;
|
|
138
|
+
letter-spacing: 0.01em;
|
|
139
|
+
transition: opacity 240ms var(--ease-out);
|
|
140
|
+
}
|
|
141
|
+
#og-made-with:hover, #og-made-with:focus-visible { opacity: 0.9; }
|
|
142
|
+
#og-made-with[data-playing] { opacity: 0.3; }
|
|
143
|
+
#og-made-with[data-playing] span { display: none; }
|
|
144
|
+
.og-engine-mark { width: 14px; height: 14px; }
|
|
145
|
+
/* Back to the Portal page that opened the game: the platform Player's Back
|
|
146
|
+
(a dark capsule with a light rim and a full-strength glyph, legible over
|
|
147
|
+
any frame). It says its name on boot, gate and error and for the first
|
|
148
|
+
seconds of play, then keeps only its glyph; it never fades while playing,
|
|
149
|
+
since players who cannot see it cannot leave. */
|
|
150
|
+
#og-back {
|
|
151
|
+
position: absolute;
|
|
152
|
+
z-index: 2;
|
|
153
|
+
left: max(12px, env(safe-area-inset-left));
|
|
154
|
+
top: max(12px, env(safe-area-inset-top));
|
|
155
|
+
display: inline-flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
justify-content: center;
|
|
158
|
+
height: 40px;
|
|
159
|
+
min-width: 40px;
|
|
160
|
+
appearance: none;
|
|
161
|
+
border: 1px solid rgb(255 255 255 / 0.28);
|
|
162
|
+
background: rgb(20 20 22 / 0.78);
|
|
163
|
+
box-shadow: 0 2px 10px rgb(0 0 0 / 0.45);
|
|
164
|
+
color: var(--og-paper);
|
|
165
|
+
font: 600 14px/1 var(--font-sans);
|
|
166
|
+
padding: 0 14px 0 9px;
|
|
167
|
+
border-radius: var(--radius-button);
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
backdrop-filter: blur(12px);
|
|
170
|
+
-webkit-backdrop-filter: blur(12px);
|
|
171
|
+
transition: background-color 180ms var(--ease-out);
|
|
172
|
+
}
|
|
173
|
+
#og-back[hidden] { display: none; }
|
|
174
|
+
#og-back svg { width: 20px; height: 20px; flex: none; }
|
|
175
|
+
#og-back span { display: block; max-width: 6em; margin-left: 4px; overflow: hidden; white-space: nowrap; }
|
|
176
|
+
#og-back:hover { background: rgb(58 58 60 / 0.92); }
|
|
177
|
+
#og-back:focus-visible { outline: 2px solid var(--og-paper); outline-offset: 2px; }
|
|
178
|
+
#og-back[data-playing] { animation: og-back-tuck 240ms var(--ease-out) 4s forwards; }
|
|
179
|
+
#og-back[data-playing] span { animation: og-back-label 240ms var(--ease-out) 4s forwards; }
|
|
180
|
+
@keyframes og-back-tuck { to { padding: 0 9px; } }
|
|
181
|
+
@keyframes og-back-label { to { max-width: 0; margin-left: 0; opacity: 0; } }
|
|
182
|
+
@media (prefers-reduced-motion: reduce) {
|
|
183
|
+
#og-boot, #og-made-with, #og-backdrop, #og-back { transition: none; }
|
|
184
|
+
#og-back[data-playing], #og-back[data-playing] span { animation-duration: 1ms; }
|
|
185
|
+
}
|