@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/src/web/a11y.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// The accessibility mirror (crates/og-ui/README.md §Accessibility tree): the
|
|
2
|
+
// UI is drawn into a canvas, which a screen reader cannot read, so the shell
|
|
3
|
+
// keeps a DOM copy of `og.ui.a11y()` over it. Each node with a role becomes
|
|
4
|
+
// an invisible element at the node's bounds, nested as the tree nests it,
|
|
5
|
+
// with its ARIA role, name, value and states; the Worker sends the tree
|
|
6
|
+
// with a frame's answer only when its digest changed (og-runtime's
|
|
7
|
+
// `a11y_changed`), so an unchanged screen costs the page nothing.
|
|
8
|
+
//
|
|
9
|
+
// - Focus follows og-ui's: the element of the focused path takes DOM focus
|
|
10
|
+
// (unless the page's focus is in a field of its own, or the UI's text
|
|
11
|
+
// control has the hidden textarea, text-input.ts). While that textarea
|
|
12
|
+
// has the focus it is the field for assistive technology (named after
|
|
13
|
+
// the field's node): the field's own element is aria-hidden and its
|
|
14
|
+
// parent owns the textarea (`standIn`).
|
|
15
|
+
// - Toasts are announced: a status node that appears is written into a
|
|
16
|
+
// polite live region, an alert into an assertive one.
|
|
17
|
+
// - The DOM reaches the UI as input: a screen reader's click on an element
|
|
18
|
+
// is `{ kind: "a11y", action: "activate", path }` and its moving focus
|
|
19
|
+
// onto one is `focus`; `adjust` sends `increment`/`decrement` for an
|
|
20
|
+
// adjustable one. A slider and a stepper's spinbutton are mirrored as an
|
|
21
|
+
// `<input type="range">` (with its role: `slider`, `spinbutton`) held at
|
|
22
|
+
// the UI's value, one step from either end: a screen reader's own adjust
|
|
23
|
+
// gesture (VoiceOver's swipe up or down, TalkBack's volume keys, an arrow
|
|
24
|
+
// while interacting) moves the native value, or sets it from
|
|
25
|
+
// `aria-valuenow` and the step, and fires `input`, which becomes
|
|
26
|
+
// `increment` or `decrement` and puts the value back (the UI owns the
|
|
27
|
+
// value and its step; `aria-valuenow` and `aria-valuetext` say it, and
|
|
28
|
+
// `aria-valuemin` and `aria-valuemax` its bounds from the tree's
|
|
29
|
+
// `valueMin` and `valueMax`, over the native range's own). A
|
|
30
|
+
// stepper's − and + follow its range as siblings (an input holds no
|
|
31
|
+
// children). Keys pressed while a
|
|
32
|
+
// mirrored element has the DOM focus still go to the engine as keys (the
|
|
33
|
+
// shell listens on the page and keeps the arrows from the native range),
|
|
34
|
+
// where the focused control takes them: arrows move a focused slider.
|
|
35
|
+
// og-ui acts on the requests at the next frame against the controls it
|
|
36
|
+
// drew (nav.rs `apply_a11y`).
|
|
37
|
+
//
|
|
38
|
+
// The elements take no pointer events (the canvas does) and draw nothing:
|
|
39
|
+
// they exist for assistive technology, and a screen reader's focus ring
|
|
40
|
+
// lands where the control is drawn.
|
|
41
|
+
|
|
42
|
+
import type { InjectedInput } from "./input.ts";
|
|
43
|
+
|
|
44
|
+
/** One node of the UI's accessibility tree (IDL ui.A11yNode). */
|
|
45
|
+
export interface A11yNode {
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly parent: string;
|
|
48
|
+
readonly role: string;
|
|
49
|
+
readonly name: string;
|
|
50
|
+
/** Read after the name: a text field's placeholder; empty for none. */
|
|
51
|
+
readonly description: string;
|
|
52
|
+
readonly value: string;
|
|
53
|
+
readonly states: readonly string[];
|
|
54
|
+
/** A range's smallest value (aria-valuemin); null (or absent) for none. */
|
|
55
|
+
readonly valueMin?: number | null;
|
|
56
|
+
/** A range's largest value (aria-valuemax); null (or absent) for none. */
|
|
57
|
+
readonly valueMax?: number | null;
|
|
58
|
+
/** A range's value as a number (aria-valuenow); null (or absent) for none. */
|
|
59
|
+
readonly valueNow?: number | null;
|
|
60
|
+
readonly x: number;
|
|
61
|
+
readonly y: number;
|
|
62
|
+
readonly width: number;
|
|
63
|
+
readonly height: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The UI's accessibility tree (IDL ui.A11yTree). */
|
|
67
|
+
export interface A11yTree {
|
|
68
|
+
readonly nodes: readonly A11yNode[];
|
|
69
|
+
readonly focusOrder: readonly string[];
|
|
70
|
+
readonly focused: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Roles whose `checked` state is reported either way. */
|
|
74
|
+
const CHECKABLE = new Set(["checkbox", "radio", "switch"]);
|
|
75
|
+
/** Roles whose `selected` state is reported either way. */
|
|
76
|
+
const SELECTABLE = new Set(["tab", "option"]);
|
|
77
|
+
/** Roles whose `expanded` state is reported either way. */
|
|
78
|
+
const EXPANDABLE = new Set(["combobox"]);
|
|
79
|
+
/** Roles with a numeric value (aria-valuenow). */
|
|
80
|
+
const RANGES = new Set(["slider", "spinbutton", "progressbar", "meter"]);
|
|
81
|
+
/** Roles mirrored as a native range: a screen reader adjusts those itself (their children follow the range as siblings). */
|
|
82
|
+
const ADJUSTABLE = new Set(["slider", "spinbutton"]);
|
|
83
|
+
/** Roles read as their text content rather than a label. */
|
|
84
|
+
const TEXTS = new Set(["text", "heading", "status", "alert", "tooltip"]);
|
|
85
|
+
|
|
86
|
+
/** The DOM mirror of the UI's accessibility tree, over the canvas. */
|
|
87
|
+
export class A11yMirror {
|
|
88
|
+
readonly root: HTMLDivElement;
|
|
89
|
+
readonly #canvas: HTMLCanvasElement;
|
|
90
|
+
readonly #send: (event: InjectedInput) => void;
|
|
91
|
+
readonly #polite: HTMLDivElement;
|
|
92
|
+
readonly #assertive: HTMLDivElement;
|
|
93
|
+
readonly #elements = new Map<string, HTMLElement>();
|
|
94
|
+
#tree: A11yTree = { nodes: [], focusOrder: [], focused: "" };
|
|
95
|
+
/** Status and alert nodes already announced, by path and text. */
|
|
96
|
+
#announced = new Set<string>();
|
|
97
|
+
/** The path this mirror last moved DOM focus to (its own focus events are not the reader's). */
|
|
98
|
+
#focusing = "";
|
|
99
|
+
/** The text field the text entry's textarea stands in for (text-input.ts), and the textarea. */
|
|
100
|
+
#standIn: { path: string; element: HTMLElement } | null = null;
|
|
101
|
+
/** What standing in changed: the field's element hidden, its parent owning the textarea. */
|
|
102
|
+
#stood: { hidden: HTMLElement; owner: HTMLElement } | null = null;
|
|
103
|
+
#off: Array<() => void> = [];
|
|
104
|
+
|
|
105
|
+
constructor(canvas: HTMLCanvasElement, send: (event: InjectedInput) => void) {
|
|
106
|
+
this.#canvas = canvas;
|
|
107
|
+
this.#send = send;
|
|
108
|
+
const doc = canvas.ownerDocument;
|
|
109
|
+
const root = doc.createElement("div");
|
|
110
|
+
root.dataset.ogA11y = "";
|
|
111
|
+
Object.assign(root.style, { position: "fixed", left: "0px", top: "0px", width: "0px", height: "0px", pointerEvents: "none", overflow: "visible", zIndex: "0" } satisfies Partial<CSSStyleDeclaration>);
|
|
112
|
+
const region = (live: "polite" | "assertive") => {
|
|
113
|
+
const r = doc.createElement("div");
|
|
114
|
+
r.setAttribute("aria-live", live);
|
|
115
|
+
r.setAttribute("aria-atomic", "false");
|
|
116
|
+
r.setAttribute("role", live === "polite" ? "status" : "alert");
|
|
117
|
+
r.dataset.ogA11yLive = live;
|
|
118
|
+
Object.assign(r.style, HIDDEN);
|
|
119
|
+
root.appendChild(r);
|
|
120
|
+
return r;
|
|
121
|
+
};
|
|
122
|
+
this.#polite = region("polite");
|
|
123
|
+
this.#assertive = region("assertive");
|
|
124
|
+
doc.body.appendChild(root);
|
|
125
|
+
this.root = root;
|
|
126
|
+
const on = <K extends keyof HTMLElementEventMap>(type: K, fn: (e: HTMLElementEventMap[K]) => void) => {
|
|
127
|
+
root.addEventListener(type, fn as EventListener);
|
|
128
|
+
this.#off.push(() => root.removeEventListener(type, fn as EventListener));
|
|
129
|
+
};
|
|
130
|
+
// A screen reader activates an element with a click (its default action).
|
|
131
|
+
on("click", (e) => {
|
|
132
|
+
const path = pathOf(e.target);
|
|
133
|
+
if (path === null) return;
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
this.#send({ kind: "a11y", action: "activate", path });
|
|
136
|
+
});
|
|
137
|
+
// A screen reader adjusted a mirrored range: one step, and back to the UI's value.
|
|
138
|
+
const adjusted = (e: Event) => {
|
|
139
|
+
const el = e.target as HTMLInputElement | null;
|
|
140
|
+
if (!el || el.tagName !== "INPUT" || el.type !== "range") return;
|
|
141
|
+
const path = pathOf(el);
|
|
142
|
+
const v = Number(el.value);
|
|
143
|
+
const at = Number(el.dataset.ogValue ?? "1");
|
|
144
|
+
el.value = String(at);
|
|
145
|
+
if (path === null || !Number.isFinite(v) || v === at) return;
|
|
146
|
+
this.#send({ kind: "a11y", action: v > at ? "increment" : "decrement", path });
|
|
147
|
+
};
|
|
148
|
+
on("input", adjusted);
|
|
149
|
+
on("change", adjusted);
|
|
150
|
+
// A screen reader moved focus onto an element: the UI's focus follows it.
|
|
151
|
+
on("focusin", (e) => {
|
|
152
|
+
const path = pathOf(e.target);
|
|
153
|
+
if (path === null || path === this.#focusing || path === this.#tree.focused) return;
|
|
154
|
+
this.#send({ kind: "a11y", action: "focus", path });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The tree as last mirrored. */
|
|
159
|
+
get tree(): A11yTree {
|
|
160
|
+
return this.#tree;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Whether an event target is one of the mirror's elements (its keys are the game's, not a field's). */
|
|
164
|
+
owns(target: EventTarget | null): boolean {
|
|
165
|
+
return target instanceof Node && this.root.contains(target);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The element mirroring the node at `path`, if it is in the tree. */
|
|
169
|
+
element(path: string): HTMLElement | undefined {
|
|
170
|
+
return this.#elements.get(path);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The node at `path` in the tree as last mirrored. */
|
|
174
|
+
node(path: string): A11yNode | undefined {
|
|
175
|
+
return this.#tree.nodes.find((n) => n.path === path);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The text entry's textarea (text-input.ts) takes the page's text for the
|
|
180
|
+
* field at `path` and has the focus: the field's element steps aside
|
|
181
|
+
* (aria-hidden) and its parent owns the textarea (aria-owns), so a reader
|
|
182
|
+
* meets one text box where the field is. null: none.
|
|
183
|
+
*/
|
|
184
|
+
standIn(path: string | null, element: HTMLElement | null): void {
|
|
185
|
+
this.#standIn = path !== null && element ? { path, element } : null;
|
|
186
|
+
this.#stand();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#stand(): void {
|
|
190
|
+
const field = this.#standIn ? this.#elements.get(this.#standIn.path) : undefined;
|
|
191
|
+
const owner = field ? ((field.parentElement as HTMLElement | null) ?? this.root) : undefined;
|
|
192
|
+
if (this.#stood && (this.#stood.hidden !== field || this.#stood.owner !== owner)) {
|
|
193
|
+
this.#stood.hidden.removeAttribute("aria-hidden");
|
|
194
|
+
this.#stood.owner.removeAttribute("aria-owns");
|
|
195
|
+
this.#stood = null;
|
|
196
|
+
}
|
|
197
|
+
if (!field || !owner || !this.#standIn) return;
|
|
198
|
+
field.setAttribute("aria-hidden", "true");
|
|
199
|
+
owner.setAttribute("aria-owns", this.#standIn.element.id);
|
|
200
|
+
this.#stood = { hidden: field, owner };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Increment (or decrement) the adjustable control at `path`, as a screen reader's range action does. */
|
|
204
|
+
adjust(path: string, up: boolean): void {
|
|
205
|
+
this.#send({ kind: "a11y", action: up ? "increment" : "decrement", path });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The UI's tree changed: bring the DOM in line with it. */
|
|
209
|
+
update(tree: A11yTree): void {
|
|
210
|
+
const doc = this.root.ownerDocument;
|
|
211
|
+
this.#place();
|
|
212
|
+
const byPath = new Map(tree.nodes.map((n) => [n.path, n]));
|
|
213
|
+
// A native range holds no children: a node under one is placed beside it.
|
|
214
|
+
const ranged = (path: string) => ADJUSTABLE.has(byPath.get(path)?.role ?? "");
|
|
215
|
+
const host = (path: string): string => {
|
|
216
|
+
let p = path;
|
|
217
|
+
while (p && ranged(p)) p = byPath.get(p)?.parent ?? "";
|
|
218
|
+
return p;
|
|
219
|
+
};
|
|
220
|
+
// Remove what left the tree.
|
|
221
|
+
for (const [path, el] of this.#elements) {
|
|
222
|
+
if (!byPath.has(path)) {
|
|
223
|
+
el.remove();
|
|
224
|
+
this.#elements.delete(path);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const node of tree.nodes) {
|
|
228
|
+
let el = this.#elements.get(node.path);
|
|
229
|
+
// A slider or a spinbutton is a native range a screen reader adjusts; anything else a div.
|
|
230
|
+
const range = ADJUSTABLE.has(node.role);
|
|
231
|
+
if (el && (el.tagName === "INPUT") !== range) {
|
|
232
|
+
el.remove();
|
|
233
|
+
el = undefined;
|
|
234
|
+
}
|
|
235
|
+
if (!el) {
|
|
236
|
+
el = doc.createElement(range ? "input" : "div");
|
|
237
|
+
if (el instanceof HTMLInputElement) {
|
|
238
|
+
Object.assign(el, { type: "range", min: "0", max: "2", step: "1", value: "1" });
|
|
239
|
+
el.dataset.ogValue = "1";
|
|
240
|
+
}
|
|
241
|
+
el.dataset.ogPath = node.path;
|
|
242
|
+
Object.assign(el.style, NODE);
|
|
243
|
+
this.#elements.set(node.path, el);
|
|
244
|
+
}
|
|
245
|
+
const above = host(node.parent);
|
|
246
|
+
const parent = (above && this.#elements.get(above)) || this.root;
|
|
247
|
+
const px = above ? (byPath.get(above)?.x ?? 0) : 0;
|
|
248
|
+
const py = above ? (byPath.get(above)?.y ?? 0) : 0;
|
|
249
|
+
// Tree order is DOM order: append moves an existing element to the end of its parent.
|
|
250
|
+
if (el.parentElement !== parent || parent.lastElementChild !== el) parent.appendChild(el);
|
|
251
|
+
this.#describe(el, node, tree);
|
|
252
|
+
Object.assign(el.style, {
|
|
253
|
+
left: `${node.x - px}px`,
|
|
254
|
+
top: `${node.y - py}px`,
|
|
255
|
+
width: `${Math.max(1, node.width)}px`,
|
|
256
|
+
height: `${Math.max(1, node.height)}px`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
// The live regions stay last, after every layer.
|
|
260
|
+
this.root.append(this.#polite, this.#assertive);
|
|
261
|
+
this.#announce(tree);
|
|
262
|
+
this.#tree = tree;
|
|
263
|
+
this.#stand();
|
|
264
|
+
this.#follow(tree);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#describe(el: HTMLElement, node: A11yNode, tree: A11yTree): void {
|
|
268
|
+
const states = new Set(node.states);
|
|
269
|
+
const set = (name: string, value: string | null) => {
|
|
270
|
+
if (value === null) el.removeAttribute(name);
|
|
271
|
+
else if (el.getAttribute(name) !== value) el.setAttribute(name, value);
|
|
272
|
+
};
|
|
273
|
+
set("role", node.role);
|
|
274
|
+
const text = TEXTS.has(node.role);
|
|
275
|
+
// Text reads as its content; controls are labelled.
|
|
276
|
+
if (text) {
|
|
277
|
+
if (el.firstChild?.nodeType !== 3 || el.textContent !== node.name) {
|
|
278
|
+
for (const c of [...el.childNodes]) if (c.nodeType === 3) c.remove();
|
|
279
|
+
el.insertBefore(el.ownerDocument.createTextNode(node.name), el.firstChild);
|
|
280
|
+
}
|
|
281
|
+
set("aria-label", null);
|
|
282
|
+
} else {
|
|
283
|
+
for (const c of [...el.childNodes]) if (c.nodeType === 3) c.remove();
|
|
284
|
+
set("aria-label", node.name || null);
|
|
285
|
+
}
|
|
286
|
+
// A text box's description slot carries its text (below); its
|
|
287
|
+
// placeholder, which the tree gives as the description, is aria-placeholder.
|
|
288
|
+
const box = node.role === "textbox" || node.role === "combobox";
|
|
289
|
+
set("aria-description", box ? null : node.description || null);
|
|
290
|
+
set("aria-placeholder", node.role === "textbox" ? node.description || null : null);
|
|
291
|
+
if (node.role === "heading") set("aria-level", "2");
|
|
292
|
+
if (node.role === "dialog") set("aria-modal", "true");
|
|
293
|
+
const focusable = states.has("focusable") || tree.focusOrder.includes(node.path);
|
|
294
|
+
// A native range is in the page's Tab order unless told otherwise; the UI owns focus.
|
|
295
|
+
set("tabindex", focusable || el instanceof HTMLInputElement ? "-1" : null);
|
|
296
|
+
set("aria-disabled", states.has("disabled") ? "true" : null);
|
|
297
|
+
set("aria-checked", CHECKABLE.has(node.role) ? String(states.has("checked")) : null);
|
|
298
|
+
set("aria-selected", SELECTABLE.has(node.role) ? String(states.has("selected")) : null);
|
|
299
|
+
set("aria-expanded", EXPANDABLE.has(node.role) ? String(states.has("expanded")) : null);
|
|
300
|
+
set("aria-pressed", node.role === "button" && states.has("pressed") ? "true" : null);
|
|
301
|
+
set("aria-busy", states.has("busy") ? "true" : null);
|
|
302
|
+
set("aria-current", states.has("focused") ? "true" : null);
|
|
303
|
+
if (RANGES.has(node.role)) {
|
|
304
|
+
// The tree's numbers (valueNow, valueMin, valueMax); a tree without
|
|
305
|
+
// them reads the value text, a percent taking 0 to 100.
|
|
306
|
+
const n = node.valueNow ?? Number.parseFloat(node.value);
|
|
307
|
+
const percent = node.value.endsWith("%");
|
|
308
|
+
const min = node.valueMin ?? (percent ? 0 : null);
|
|
309
|
+
const max = node.valueMax ?? (percent ? 100 : null);
|
|
310
|
+
set("aria-valuenow", Number.isFinite(n) ? String(n) : null);
|
|
311
|
+
set("aria-valuemin", min !== null && Number.isFinite(min) ? String(min) : null);
|
|
312
|
+
set("aria-valuemax", max !== null && Number.isFinite(max) ? String(max) : null);
|
|
313
|
+
// A native range sits at the UI's value, a step from either end, so
|
|
314
|
+
// a reader's step (from the native value or from aria-valuenow) lands
|
|
315
|
+
// on one side of it.
|
|
316
|
+
if (el instanceof HTMLInputElement) {
|
|
317
|
+
const at = Number.isFinite(n) ? n : 1;
|
|
318
|
+
if (el.dataset.ogValue !== String(at)) {
|
|
319
|
+
Object.assign(el, { min: String(at - 1), max: String(at + 1), step: "1" });
|
|
320
|
+
el.value = String(at);
|
|
321
|
+
el.dataset.ogValue = String(at);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
set("aria-valuetext", node.value || null);
|
|
325
|
+
} else {
|
|
326
|
+
for (const a of ["aria-valuenow", "aria-valuemin", "aria-valuemax"]) set(a, null);
|
|
327
|
+
if (node.role === "textbox" || node.role === "combobox") {
|
|
328
|
+
set("aria-valuetext", null);
|
|
329
|
+
set("aria-description", node.value || null);
|
|
330
|
+
} else set("aria-valuetext", node.value || null);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Status and alert nodes that appeared are said once through the live regions. */
|
|
335
|
+
#announce(tree: A11yTree): void {
|
|
336
|
+
const now = new Set<string>();
|
|
337
|
+
for (const n of tree.nodes) {
|
|
338
|
+
if ((n.role !== "status" && n.role !== "alert") || !n.name) continue;
|
|
339
|
+
const key = `${n.path}\u0000${n.name}`;
|
|
340
|
+
now.add(key);
|
|
341
|
+
if (this.#announced.has(key)) continue;
|
|
342
|
+
const region = n.role === "alert" ? this.#assertive : this.#polite;
|
|
343
|
+
const line = region.ownerDocument.createElement("div");
|
|
344
|
+
line.textContent = n.name;
|
|
345
|
+
region.appendChild(line);
|
|
346
|
+
// Keep the region short: a reader has said what was added.
|
|
347
|
+
while (region.childElementCount > 3) region.firstElementChild?.remove();
|
|
348
|
+
}
|
|
349
|
+
this.#announced = now;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The DOM focus follows the UI's focus. */
|
|
353
|
+
#follow(tree: A11yTree): void {
|
|
354
|
+
const doc = this.root.ownerDocument;
|
|
355
|
+
const active = doc.activeElement as HTMLElement | null;
|
|
356
|
+
// The page's own fields and the text control's textarea keep their focus.
|
|
357
|
+
const ours = !active || active === doc.body || active === this.#canvas || this.root.contains(active);
|
|
358
|
+
if (!ours) return;
|
|
359
|
+
const el = tree.focused ? this.#elements.get(tree.focused) : undefined;
|
|
360
|
+
if (!el || el === active || el.getAttribute("role") === "textbox") return;
|
|
361
|
+
this.#focusing = tree.focused;
|
|
362
|
+
try {
|
|
363
|
+
el.focus({ preventScroll: true });
|
|
364
|
+
} finally {
|
|
365
|
+
this.#focusing = "";
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Over the canvas, in its CSS pixels (the tree's unit). */
|
|
370
|
+
#place(): void {
|
|
371
|
+
const box = this.#canvas.getBoundingClientRect();
|
|
372
|
+
const sx = box.width / (this.#canvas.clientWidth || box.width || 1);
|
|
373
|
+
const sy = box.height / (this.#canvas.clientHeight || box.height || 1);
|
|
374
|
+
Object.assign(this.root.style, {
|
|
375
|
+
left: `${box.left}px`,
|
|
376
|
+
top: `${box.top}px`,
|
|
377
|
+
width: `${box.width}px`,
|
|
378
|
+
height: `${box.height}px`,
|
|
379
|
+
transform: sx === 1 && sy === 1 ? "" : `scale(${sx}, ${sy})`,
|
|
380
|
+
transformOrigin: "0 0",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
dispose(): void {
|
|
385
|
+
for (const off of this.#off.splice(0)) off();
|
|
386
|
+
this.#standIn = null;
|
|
387
|
+
this.#stand();
|
|
388
|
+
this.root.remove();
|
|
389
|
+
this.#elements.clear();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The path of the mirrored element an event landed on (or inside). */
|
|
394
|
+
function pathOf(target: EventTarget | null): string | null {
|
|
395
|
+
let el = target as HTMLElement | null;
|
|
396
|
+
while (el && el.dataset) {
|
|
397
|
+
if (el.dataset.ogPath !== undefined) return el.dataset.ogPath;
|
|
398
|
+
el = el.parentElement;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** A mirrored node: where the control is drawn, drawing nothing. */
|
|
404
|
+
const NODE: Partial<CSSStyleDeclaration> = {
|
|
405
|
+
position: "absolute",
|
|
406
|
+
margin: "0",
|
|
407
|
+
padding: "0",
|
|
408
|
+
border: "0",
|
|
409
|
+
opacity: "0",
|
|
410
|
+
overflow: "visible",
|
|
411
|
+
pointerEvents: "none",
|
|
412
|
+
background: "transparent",
|
|
413
|
+
color: "transparent",
|
|
414
|
+
outline: "none",
|
|
415
|
+
fontSize: "1px",
|
|
416
|
+
whiteSpace: "nowrap",
|
|
417
|
+
appearance: "none",
|
|
418
|
+
boxSizing: "border-box",
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
/** A live region: read, never seen. */
|
|
422
|
+
const HIDDEN: Partial<CSSStyleDeclaration> = {
|
|
423
|
+
position: "absolute",
|
|
424
|
+
width: "1px",
|
|
425
|
+
height: "1px",
|
|
426
|
+
overflow: "hidden",
|
|
427
|
+
clipPath: "inset(50%)",
|
|
428
|
+
whiteSpace: "nowrap",
|
|
429
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The AudioWorklet that plays the engine's mix (docs/design.md §2.2, §2.7;
|
|
2
|
+
// crates/og-audio/README.md §Ring). The mixer runs in the engine Worker;
|
|
3
|
+
// this processor only copies its PCM to the output, from a SharedArrayBuffer
|
|
4
|
+
// ring when the page is cross-origin isolated, else from chunks posted on a
|
|
5
|
+
// MessagePort. Loaded with `audioWorklet.addModule(<this module, bundled>)`;
|
|
6
|
+
// the processor is `og-audio`. It imports nothing, so it can be loaded on
|
|
7
|
+
// its own; audio.ts imports its ring layout and functions.
|
|
8
|
+
//
|
|
9
|
+
// Ring (one SharedArrayBuffer): an Int32 header of RING_HEADER slots, then
|
|
10
|
+
// `capacity * 2` f32 (interleaved stereo frames, starting at byte
|
|
11
|
+
// RING_HEADER * 4). Header slots:
|
|
12
|
+
// 0 write frames written, monotonic (int32, wraps)
|
|
13
|
+
// 1 read frames read, monotonic (int32, wraps)
|
|
14
|
+
// 2 capacity frames, a power of two
|
|
15
|
+
// 3 channels 2
|
|
16
|
+
// 4 underruns frames of silence played because the ring ran dry
|
|
17
|
+
// 5 sampleRate the AudioContext's rate the mixer renders at
|
|
18
|
+
// 6 primed 1 once the reader has seen `prime` frames (0 after an underrun)
|
|
19
|
+
// 7 prime frames to buffer before (re)starting
|
|
20
|
+
// Single producer (the Worker), single consumer (this processor): the
|
|
21
|
+
// producer writes data then publishes `write`; the consumer reads data then
|
|
22
|
+
// publishes `read` (Atomics on the header, plain access to the data).
|
|
23
|
+
|
|
24
|
+
export const RING_HEADER = 8;
|
|
25
|
+
export const RING_WRITE = 0;
|
|
26
|
+
export const RING_READ = 1;
|
|
27
|
+
export const RING_CAPACITY = 2;
|
|
28
|
+
export const RING_CHANNELS = 3;
|
|
29
|
+
export const RING_UNDERRUNS = 4;
|
|
30
|
+
export const RING_SAMPLE_RATE = 5;
|
|
31
|
+
export const RING_PRIMED = 6;
|
|
32
|
+
export const RING_PRIME = 7;
|
|
33
|
+
|
|
34
|
+
/** The processor's registered name. */
|
|
35
|
+
export const PROCESSOR = "og-audio";
|
|
36
|
+
|
|
37
|
+
/** Frames waiting in the ring. */
|
|
38
|
+
export function ringAvailable(header: Int32Array): number {
|
|
39
|
+
return (Atomics.load(header, RING_WRITE) - Atomics.load(header, RING_READ)) | 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Consumer: fills `left`/`right` (one render quantum) from the ring; returns
|
|
44
|
+
* the frames that were silence because the ring was dry. Playback waits
|
|
45
|
+
* until `prime` frames are buffered, at the start and after a dry spell.
|
|
46
|
+
*/
|
|
47
|
+
export function readAudioRing(header: Int32Array, data: Float32Array, left: Float32Array, right: Float32Array): number {
|
|
48
|
+
const n = left.length;
|
|
49
|
+
const available = ringAvailable(header);
|
|
50
|
+
if (Atomics.load(header, RING_PRIMED) === 0) {
|
|
51
|
+
if (available < header[RING_PRIME]!) {
|
|
52
|
+
left.fill(0);
|
|
53
|
+
right.fill(0);
|
|
54
|
+
return 0; // still buffering: not an underrun
|
|
55
|
+
}
|
|
56
|
+
Atomics.store(header, RING_PRIMED, 1);
|
|
57
|
+
}
|
|
58
|
+
const mask = header[RING_CAPACITY]! - 1;
|
|
59
|
+
const read = Atomics.load(header, RING_READ);
|
|
60
|
+
const take = Math.min(n, available);
|
|
61
|
+
for (let i = 0; i < take; i++) {
|
|
62
|
+
const at = ((read + i) & mask) * 2;
|
|
63
|
+
left[i] = data[at]!;
|
|
64
|
+
right[i] = data[at + 1]!;
|
|
65
|
+
}
|
|
66
|
+
for (let i = take; i < n; i++) {
|
|
67
|
+
left[i] = 0;
|
|
68
|
+
right[i] = 0;
|
|
69
|
+
}
|
|
70
|
+
Atomics.store(header, RING_READ, (read + take) | 0);
|
|
71
|
+
const dry = n - take;
|
|
72
|
+
if (dry > 0) {
|
|
73
|
+
Atomics.add(header, RING_UNDERRUNS, dry);
|
|
74
|
+
Atomics.store(header, RING_PRIMED, 0);
|
|
75
|
+
}
|
|
76
|
+
return dry;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── the processor (only where AudioWorkletGlobalScope defines it) ─────────
|
|
80
|
+
|
|
81
|
+
interface ProcessorPort {
|
|
82
|
+
onmessage: ((e: { data: unknown }) => void) | null;
|
|
83
|
+
postMessage(message: unknown): void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface WorkletScope {
|
|
87
|
+
registerProcessor?(name: string, ctor: unknown): void;
|
|
88
|
+
AudioWorkletProcessor?: new () => { readonly port: ProcessorPort };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What the main thread passes as `processorOptions`. */
|
|
92
|
+
export interface WorkletOptions {
|
|
93
|
+
/** The ring, when the page is cross-origin isolated. */
|
|
94
|
+
readonly ring?: SharedArrayBuffer;
|
|
95
|
+
/** Without a ring: frames to queue before playing, and the most to keep. */
|
|
96
|
+
readonly prime?: number;
|
|
97
|
+
readonly maxQueued?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const scope = globalThis as unknown as WorkletScope;
|
|
101
|
+
|
|
102
|
+
if (typeof scope.registerProcessor === "function" && scope.AudioWorkletProcessor) {
|
|
103
|
+
const Base = scope.AudioWorkletProcessor;
|
|
104
|
+
|
|
105
|
+
class OgAudioProcessor extends Base {
|
|
106
|
+
readonly #header: Int32Array | null;
|
|
107
|
+
readonly #data: Float32Array | null;
|
|
108
|
+
/** Posted chunks (interleaved stereo), oldest first, and the read offset into the first. */
|
|
109
|
+
readonly #queue: Float32Array[] = [];
|
|
110
|
+
#offset = 0;
|
|
111
|
+
#queued = 0;
|
|
112
|
+
#primed = false;
|
|
113
|
+
readonly #prime: number;
|
|
114
|
+
readonly #maxQueued: number;
|
|
115
|
+
#underruns = 0;
|
|
116
|
+
#dropped = 0;
|
|
117
|
+
#quanta = 0;
|
|
118
|
+
|
|
119
|
+
constructor(options: { processorOptions?: WorkletOptions }) {
|
|
120
|
+
super();
|
|
121
|
+
const o = options.processorOptions ?? {};
|
|
122
|
+
this.#header = o.ring ? new Int32Array(o.ring, 0, RING_HEADER) : null;
|
|
123
|
+
this.#data = o.ring ? new Float32Array(o.ring, RING_HEADER * 4) : null;
|
|
124
|
+
this.#prime = o.prime ?? 2048;
|
|
125
|
+
this.#maxQueued = o.maxQueued ?? 24_000;
|
|
126
|
+
// The Worker's chunks arrive on a port the main thread hands over.
|
|
127
|
+
this.port.onmessage = (e) => {
|
|
128
|
+
const m = e.data as { type?: string; port?: ProcessorPort };
|
|
129
|
+
if (m.type === "port" && m.port) m.port.onmessage = (c) => this.#push(c.data as Float32Array);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#push(chunk: Float32Array): void {
|
|
134
|
+
this.#queue.push(chunk);
|
|
135
|
+
this.#queued += chunk.length / 2;
|
|
136
|
+
// A producer far ahead (no backpressure on a port): drop the oldest.
|
|
137
|
+
while (this.#queued > this.#maxQueued && this.#queue.length > 1) {
|
|
138
|
+
const old = this.#queue.shift()!;
|
|
139
|
+
const frames = (old.length - this.#offset) / 2;
|
|
140
|
+
this.#queued -= frames;
|
|
141
|
+
this.#dropped += frames;
|
|
142
|
+
this.#offset = 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#readQueue(left: Float32Array, right: Float32Array): void {
|
|
147
|
+
const n = left.length;
|
|
148
|
+
if (!this.#primed && this.#queued < this.#prime) {
|
|
149
|
+
left.fill(0);
|
|
150
|
+
right.fill(0);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.#primed = true;
|
|
154
|
+
let i = 0;
|
|
155
|
+
while (i < n && this.#queue.length > 0) {
|
|
156
|
+
const chunk = this.#queue[0]!;
|
|
157
|
+
while (i < n && this.#offset < chunk.length) {
|
|
158
|
+
left[i] = chunk[this.#offset]!;
|
|
159
|
+
right[i] = chunk[this.#offset + 1]!;
|
|
160
|
+
this.#offset += 2;
|
|
161
|
+
i++;
|
|
162
|
+
}
|
|
163
|
+
if (this.#offset >= chunk.length) {
|
|
164
|
+
this.#queue.shift();
|
|
165
|
+
this.#offset = 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
this.#queued -= i;
|
|
169
|
+
if (i < n) {
|
|
170
|
+
left.fill(0, i);
|
|
171
|
+
right.fill(0, i);
|
|
172
|
+
this.#underruns += n - i;
|
|
173
|
+
this.#primed = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
|
|
178
|
+
const out = outputs[0];
|
|
179
|
+
if (!out || out.length === 0) return true;
|
|
180
|
+
const left = out[0]!;
|
|
181
|
+
const right = out[1] ?? left;
|
|
182
|
+
if (this.#header && this.#data) readAudioRing(this.#header, this.#data, left, right);
|
|
183
|
+
else this.#readQueue(left, right);
|
|
184
|
+
// About once a second without a ring: report what the queue did.
|
|
185
|
+
if (!this.#header && ++this.#quanta % 375 === 0) {
|
|
186
|
+
this.port.postMessage({ type: "stats", queued: this.#queued, underruns: this.#underruns, dropped: this.#dropped });
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
scope.registerProcessor(PROCESSOR, OgAudioProcessor);
|
|
193
|
+
}
|