@zvk/game-bridge 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/CHANGELOG.md +5 -0
- package/LICENSE.md +21 -0
- package/README.md +38 -0
- package/dist/core/channel.d.ts +67 -0
- package/dist/core/channel.js +153 -0
- package/dist/core/coalesced-publisher.d.ts +35 -0
- package/dist/core/coalesced-publisher.js +127 -0
- package/dist/core/game-bridge.d.ts +60 -0
- package/dist/core/game-bridge.js +118 -0
- package/dist/core/intents.d.ts +33 -0
- package/dist/core/intents.js +43 -0
- package/dist/core/serializable.d.ts +3 -0
- package/dist/core/serializable.js +71 -0
- package/dist/core/types.d.ts +17 -0
- package/dist/core/types.js +1 -0
- package/dist/dom/index.d.ts +68 -0
- package/dist/dom/index.js +244 -0
- package/dist/geometry/anchors.d.ts +48 -0
- package/dist/geometry/anchors.js +96 -0
- package/dist/geometry/index.d.ts +6 -0
- package/dist/geometry/index.js +6 -0
- package/dist/geometry/matrix.d.ts +45 -0
- package/dist/geometry/matrix.js +77 -0
- package/dist/geometry/placement.d.ts +24 -0
- package/dist/geometry/placement.js +49 -0
- package/dist/geometry/projection.d.ts +118 -0
- package/dist/geometry/projection.js +194 -0
- package/dist/geometry/types.d.ts +62 -0
- package/dist/geometry/types.js +79 -0
- package/dist/geometry/visibility.d.ts +30 -0
- package/dist/geometry/visibility.js +128 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/phaser/index.d.ts +187 -0
- package/dist/phaser/index.js +347 -0
- package/dist/react/index.d.ts +20 -0
- package/dist/react/index.js +47 -0
- package/dist/test-utils/assertions.d.ts +2 -0
- package/dist/test-utils/assertions.js +9 -0
- package/dist/test-utils/fixtures.d.ts +7 -0
- package/dist/test-utils/fixtures.js +9 -0
- package/dist/test-utils/index.d.ts +3 -0
- package/dist/test-utils/index.js +3 -0
- package/dist/test-utils/manual-scheduler.d.ts +10 -0
- package/dist/test-utils/manual-scheduler.js +43 -0
- package/docs/content/core-contracts.md +5 -0
- package/docs/content/dom.md +5 -0
- package/docs/content/errors-and-performance.md +5 -0
- package/docs/content/geometry.md +5 -0
- package/docs/content/index.md +5 -0
- package/docs/content/migration.md +5 -0
- package/docs/content/phaser.md +5 -0
- package/docs/content/react.md +5 -0
- package/docs/examples/react-phaser-overlay.tsx +14 -0
- package/package.json +109 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function createIntentSink(options = {}) {
|
|
2
|
+
if (options.onIntent !== undefined && typeof options.onIntent !== "function") {
|
|
3
|
+
throw new TypeError("onIntent must be a function when provided");
|
|
4
|
+
}
|
|
5
|
+
if (options.now !== undefined && typeof options.now !== "function") {
|
|
6
|
+
throw new TypeError("now must be a function when provided");
|
|
7
|
+
}
|
|
8
|
+
let nextSequence = 0;
|
|
9
|
+
let destroyed = false;
|
|
10
|
+
let handler = options.onIntent;
|
|
11
|
+
const now = options.now ?? Date.now;
|
|
12
|
+
const sink = {
|
|
13
|
+
dispatch: (intent, dispatchOptions = {}) => {
|
|
14
|
+
if (destroyed)
|
|
15
|
+
return { status: "destroyed", sequence: nextSequence };
|
|
16
|
+
const issuedAtMs = dispatchOptions.issuedAtMs ?? now();
|
|
17
|
+
if (!Number.isFinite(issuedAtMs)) {
|
|
18
|
+
throw new RangeError("issuedAtMs must be finite");
|
|
19
|
+
}
|
|
20
|
+
const sequence = nextSequence;
|
|
21
|
+
nextSequence += 1;
|
|
22
|
+
const envelope = {
|
|
23
|
+
protocolVersion: 1,
|
|
24
|
+
sequence,
|
|
25
|
+
issuedAtMs,
|
|
26
|
+
...(dispatchOptions.source === undefined ? {} : { source: dispatchOptions.source }),
|
|
27
|
+
intent,
|
|
28
|
+
};
|
|
29
|
+
if (handler === undefined)
|
|
30
|
+
return { status: "unhandled", sequence };
|
|
31
|
+
handler(envelope);
|
|
32
|
+
return { status: "handled", sequence };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
sink,
|
|
37
|
+
getNextSequence: () => nextSequence,
|
|
38
|
+
destroy: () => {
|
|
39
|
+
destroyed = true;
|
|
40
|
+
handler = undefined;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function childPath(path, key) {
|
|
2
|
+
return /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
|
|
3
|
+
}
|
|
4
|
+
function fail(path, reason) {
|
|
5
|
+
throw new TypeError(`Expected JSON-compatible data at ${path}: ${reason}`);
|
|
6
|
+
}
|
|
7
|
+
export function assertJsonValue(value, label = "$") {
|
|
8
|
+
const ancestors = new Set();
|
|
9
|
+
const visit = (candidate, path) => {
|
|
10
|
+
if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean")
|
|
11
|
+
return;
|
|
12
|
+
if (typeof candidate === "number") {
|
|
13
|
+
if (!Number.isFinite(candidate))
|
|
14
|
+
fail(path, "number must be finite");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (typeof candidate !== "object")
|
|
18
|
+
fail(path, `${typeof candidate} is not JSON-compatible`);
|
|
19
|
+
const object = candidate;
|
|
20
|
+
if (ancestors.has(object))
|
|
21
|
+
fail(path, "cycle detected");
|
|
22
|
+
ancestors.add(object);
|
|
23
|
+
try {
|
|
24
|
+
if (Array.isArray(candidate)) {
|
|
25
|
+
for (let index = 0; index < candidate.length; index += 1) {
|
|
26
|
+
if (!Object.hasOwn(candidate, index))
|
|
27
|
+
fail(`${path}[${index}]`, "sparse array slots are not allowed");
|
|
28
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, index);
|
|
29
|
+
if (descriptor === undefined || !descriptor.enumerable) {
|
|
30
|
+
fail(`${path}[${index}]`, "non-enumerable array slots are not allowed");
|
|
31
|
+
}
|
|
32
|
+
if (!("value" in descriptor))
|
|
33
|
+
fail(`${path}[${index}]`, "accessor array slots are not allowed");
|
|
34
|
+
visit(descriptor.value, `${path}[${index}]`);
|
|
35
|
+
}
|
|
36
|
+
const unexpected = Reflect.ownKeys(candidate).filter((key) => key !== "length" && (typeof key === "symbol" || !/^\d+$/.test(key)));
|
|
37
|
+
if (unexpected.length > 0)
|
|
38
|
+
fail(path, "arrays may not contain symbol or named properties");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const prototype = Object.getPrototypeOf(candidate);
|
|
42
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
43
|
+
fail(path, "only plain objects are allowed");
|
|
44
|
+
}
|
|
45
|
+
for (const key of Reflect.ownKeys(candidate)) {
|
|
46
|
+
if (typeof key === "symbol")
|
|
47
|
+
fail(path, "symbol keys are not allowed");
|
|
48
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
|
|
49
|
+
if (descriptor === undefined || !descriptor.enumerable) {
|
|
50
|
+
fail(childPath(path, key), "non-enumerable properties are not allowed");
|
|
51
|
+
}
|
|
52
|
+
if (!("value" in descriptor))
|
|
53
|
+
fail(childPath(path, key), "accessor properties are not allowed");
|
|
54
|
+
visit(descriptor.value, childPath(path, key));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
ancestors.delete(object);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
visit(value, label);
|
|
62
|
+
}
|
|
63
|
+
export function isJsonValue(value) {
|
|
64
|
+
try {
|
|
65
|
+
assertJsonValue(value);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type JsonPrimitive = null | boolean | number | string;
|
|
2
|
+
export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
|
|
3
|
+
readonly [key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
export type JsonObject = {
|
|
6
|
+
readonly [key: string]: JsonValue;
|
|
7
|
+
};
|
|
8
|
+
export type GameBridgeDiagnosticSeverity = "info" | "warning" | "error";
|
|
9
|
+
export type GameBridgeDiagnosticCode = "dom-unavailable" | "camera-unavailable" | "camera-id-unavailable" | "canvas-disconnected" | "overlay-disconnected" | "zero-sized-surface" | "invalid-backing-size" | "non-finite-measurement" | "invalid-clock-id" | "non-finite-transform" | "non-invertible-transform" | "inconsistent-transform-snapshot" | "unsupported-css-transform" | "unsupported-camera-transform" | "scheduler-unavailable" | "resize-observer-unavailable" | "capture-callback-threw" | "invalid-anchor" | "duplicate-anchor-id" | "source-frame-regression" | "stale-snapshot" | "clock-mismatch" | "outside-camera" | "outside-canvas" | "outside-overlay" | "ambiguous-camera" | "destroyed";
|
|
10
|
+
export interface GameBridgeDiagnostic {
|
|
11
|
+
readonly code: GameBridgeDiagnosticCode;
|
|
12
|
+
readonly severity: GameBridgeDiagnosticSeverity;
|
|
13
|
+
readonly message: string;
|
|
14
|
+
readonly anchorId?: string;
|
|
15
|
+
readonly cameraId?: string;
|
|
16
|
+
readonly capturedAtMs?: number;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { BridgeScheduler } from "../index.js";
|
|
2
|
+
import type { AffineTransform2D, Rect2, Size2 } from "../geometry/index.js";
|
|
3
|
+
export type DomReferenceBox = "content-box" | "padding-box" | "border-box";
|
|
4
|
+
export interface CanvasDomMetrics {
|
|
5
|
+
readonly capturedAtMs: number;
|
|
6
|
+
readonly clockId: string;
|
|
7
|
+
readonly connected: boolean;
|
|
8
|
+
readonly backingSize: Size2<"backing">;
|
|
9
|
+
readonly canvasCssSize: Size2<"canvas-css">;
|
|
10
|
+
readonly canvasContentClientRect: Rect2<"client">;
|
|
11
|
+
readonly overlayClientRect: Rect2<"client">;
|
|
12
|
+
readonly backingToCanvasCss: AffineTransform2D<"backing", "canvas-css">;
|
|
13
|
+
readonly canvasCssToClient: AffineTransform2D<"canvas-css", "client">;
|
|
14
|
+
readonly clientToOverlay: AffineTransform2D<"client", "overlay">;
|
|
15
|
+
}
|
|
16
|
+
export interface DomMeasurementError {
|
|
17
|
+
readonly code: "dom-unavailable" | "canvas-disconnected" | "overlay-disconnected" | "zero-sized-surface" | "invalid-backing-size" | "unsupported-css-transform" | "non-finite-measurement" | "invalid-clock-id";
|
|
18
|
+
readonly message: string;
|
|
19
|
+
}
|
|
20
|
+
export type MeasureGameSurfaceResult = {
|
|
21
|
+
readonly ok: true;
|
|
22
|
+
readonly metrics: CanvasDomMetrics;
|
|
23
|
+
} | {
|
|
24
|
+
readonly ok: false;
|
|
25
|
+
readonly error: DomMeasurementError;
|
|
26
|
+
};
|
|
27
|
+
export interface MeasureGameSurfaceOptions {
|
|
28
|
+
readonly canvas: HTMLCanvasElement;
|
|
29
|
+
readonly overlay: HTMLElement;
|
|
30
|
+
readonly overlayBox?: DomReferenceBox;
|
|
31
|
+
readonly capturedAtMs?: number;
|
|
32
|
+
readonly clockId?: string;
|
|
33
|
+
readonly cssTransform?: AffineTransform2D<"canvas-css", "client">;
|
|
34
|
+
}
|
|
35
|
+
export declare function readElementReferenceRect(element: Element, box: DomReferenceBox): Rect2<"client">;
|
|
36
|
+
export declare function measureGameSurface(options: MeasureGameSurfaceOptions): MeasureGameSurfaceResult;
|
|
37
|
+
export type AnimationFrameSchedulerResult = {
|
|
38
|
+
readonly ok: true;
|
|
39
|
+
readonly scheduler: BridgeScheduler<number>;
|
|
40
|
+
} | {
|
|
41
|
+
readonly ok: false;
|
|
42
|
+
readonly error: {
|
|
43
|
+
readonly code: "scheduler-unavailable";
|
|
44
|
+
readonly message: string;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
export declare function createAnimationFrameScheduler(target?: Pick<Window, "requestAnimationFrame" | "cancelAnimationFrame">): AnimationFrameSchedulerResult;
|
|
48
|
+
export interface GameSurfaceObserver {
|
|
49
|
+
readonly measure: () => MeasureGameSurfaceResult;
|
|
50
|
+
readonly disconnect: () => void;
|
|
51
|
+
}
|
|
52
|
+
export type ObserveGameSurfaceResult = {
|
|
53
|
+
readonly ok: true;
|
|
54
|
+
readonly observer: GameSurfaceObserver;
|
|
55
|
+
} | {
|
|
56
|
+
readonly ok: false;
|
|
57
|
+
readonly error: DomMeasurementError | {
|
|
58
|
+
readonly code: "resize-observer-unavailable" | "scheduler-unavailable";
|
|
59
|
+
readonly message: string;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
export interface ObserveGameSurfaceOptions extends MeasureGameSurfaceOptions {
|
|
63
|
+
readonly onChange: (result: MeasureGameSurfaceResult) => void;
|
|
64
|
+
readonly scheduler?: BridgeScheduler;
|
|
65
|
+
readonly observeScroll?: boolean;
|
|
66
|
+
readonly observeVisualViewport?: boolean;
|
|
67
|
+
}
|
|
68
|
+
export declare function observeGameSurface(options: ObserveGameSurfaceOptions): ObserveGameSurfaceResult;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
const error = (code, message) => ({
|
|
2
|
+
ok: false,
|
|
3
|
+
error: { code, message },
|
|
4
|
+
});
|
|
5
|
+
const number = (value) => Number.parseFloat(value) || 0;
|
|
6
|
+
function computedStyle(element) {
|
|
7
|
+
const view = element.ownerDocument?.defaultView;
|
|
8
|
+
return view && typeof view.getComputedStyle === "function"
|
|
9
|
+
? view.getComputedStyle(element)
|
|
10
|
+
: null;
|
|
11
|
+
}
|
|
12
|
+
function insetRect(rect, top, right, bottom, left) {
|
|
13
|
+
return {
|
|
14
|
+
x: (rect.left ?? rect.x ?? 0) + left,
|
|
15
|
+
y: (rect.top ?? rect.y ?? 0) + top,
|
|
16
|
+
width: Math.max(0, rect.width - left - right),
|
|
17
|
+
height: Math.max(0, rect.height - top - bottom),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function readElementReferenceRect(element, box) {
|
|
21
|
+
const rect = element.getBoundingClientRect();
|
|
22
|
+
if (box === "border-box") {
|
|
23
|
+
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
|
|
24
|
+
}
|
|
25
|
+
const style = computedStyle(element);
|
|
26
|
+
if (!style) {
|
|
27
|
+
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
|
|
28
|
+
}
|
|
29
|
+
const borderTop = number(style.borderTopWidth);
|
|
30
|
+
const borderRight = number(style.borderRightWidth);
|
|
31
|
+
const borderBottom = number(style.borderBottomWidth);
|
|
32
|
+
const borderLeft = number(style.borderLeftWidth);
|
|
33
|
+
const declaredWidth = number(style.width);
|
|
34
|
+
const declaredHeight = number(style.height);
|
|
35
|
+
const paddingWidth = number(style.paddingLeft) + number(style.paddingRight);
|
|
36
|
+
const paddingHeight = number(style.paddingTop) + number(style.paddingBottom);
|
|
37
|
+
const borderWidth = borderLeft + borderRight;
|
|
38
|
+
const borderHeight = borderTop + borderBottom;
|
|
39
|
+
const naturalBorderWidth = style.boxSizing === "border-box" ? declaredWidth : declaredWidth + paddingWidth + borderWidth;
|
|
40
|
+
const naturalBorderHeight = style.boxSizing === "border-box" ? declaredHeight : declaredHeight + paddingHeight + borderHeight;
|
|
41
|
+
const scaleX = naturalBorderWidth > 0 ? rect.width / naturalBorderWidth : 1;
|
|
42
|
+
const scaleY = naturalBorderHeight > 0 ? rect.height / naturalBorderHeight : 1;
|
|
43
|
+
const paddingRect = insetRect(rect, borderTop * scaleY, borderRight * scaleX, borderBottom * scaleY, borderLeft * scaleX);
|
|
44
|
+
if (box === "padding-box")
|
|
45
|
+
return paddingRect;
|
|
46
|
+
return insetRect(paddingRect, number(style.paddingTop) * scaleY, number(style.paddingRight) * scaleX, number(style.paddingBottom) * scaleY, number(style.paddingLeft) * scaleX);
|
|
47
|
+
}
|
|
48
|
+
function hasUnsupportedTransform(element, stopAfter = null) {
|
|
49
|
+
for (let current = element; current; current = current.parentElement) {
|
|
50
|
+
const style = computedStyle(current);
|
|
51
|
+
const transform = style?.transform;
|
|
52
|
+
if (transform && transform !== "none") {
|
|
53
|
+
if (transform.startsWith("matrix3d(") || style?.perspective && style.perspective !== "none")
|
|
54
|
+
return true;
|
|
55
|
+
const match = /^matrix\(([^)]+)\)$/.exec(transform);
|
|
56
|
+
if (!match)
|
|
57
|
+
return true;
|
|
58
|
+
const values = match[1]?.split(",").map(Number);
|
|
59
|
+
if (!values || values.length !== 6 || values.some((value) => !Number.isFinite(value)))
|
|
60
|
+
return true;
|
|
61
|
+
if (Math.abs(values[1] ?? 0) > 1e-12 || Math.abs(values[2] ?? 0) > 1e-12)
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (current === stopAfter)
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
function resolveClock(options) {
|
|
70
|
+
const hasTime = options.capturedAtMs !== undefined;
|
|
71
|
+
const hasId = options.clockId !== undefined;
|
|
72
|
+
if (hasTime !== hasId) {
|
|
73
|
+
return hasTime
|
|
74
|
+
? { ok: false, result: error("invalid-clock-id", "capturedAtMs and clockId must be supplied together") }
|
|
75
|
+
: { ok: false, result: error("non-finite-measurement", "capturedAtMs and clockId must be supplied together") };
|
|
76
|
+
}
|
|
77
|
+
if (hasTime) {
|
|
78
|
+
if (!Number.isFinite(options.capturedAtMs))
|
|
79
|
+
return { ok: false, result: error("non-finite-measurement", "capturedAtMs must be finite") };
|
|
80
|
+
if (!options.clockId?.trim())
|
|
81
|
+
return { ok: false, result: error("invalid-clock-id", "clockId must be non-empty") };
|
|
82
|
+
return { ok: true, capturedAtMs: options.capturedAtMs, clockId: options.clockId };
|
|
83
|
+
}
|
|
84
|
+
const performance = options.canvas.ownerDocument?.defaultView?.performance;
|
|
85
|
+
if (!performance || typeof performance.now !== "function")
|
|
86
|
+
return { ok: false, result: error("dom-unavailable", "A browser performance clock is unavailable") };
|
|
87
|
+
return { ok: true, capturedAtMs: performance.now(), clockId: "performance" };
|
|
88
|
+
}
|
|
89
|
+
export function measureGameSurface(options) {
|
|
90
|
+
const { canvas, overlay } = options;
|
|
91
|
+
if (!canvas?.ownerDocument?.defaultView || !overlay?.ownerDocument?.defaultView)
|
|
92
|
+
return error("dom-unavailable", "DOM measurement is unavailable");
|
|
93
|
+
if (!canvas.isConnected)
|
|
94
|
+
return error("canvas-disconnected", "Canvas is disconnected");
|
|
95
|
+
if (!overlay.isConnected)
|
|
96
|
+
return error("overlay-disconnected", "Overlay is disconnected");
|
|
97
|
+
const clock = resolveClock(options);
|
|
98
|
+
if (!clock.ok)
|
|
99
|
+
return clock.result;
|
|
100
|
+
if (!Number.isFinite(canvas.width) || !Number.isFinite(canvas.height) || canvas.width <= 0 || canvas.height <= 0)
|
|
101
|
+
return error("invalid-backing-size", "Canvas backing dimensions must be finite and positive");
|
|
102
|
+
if ((!options.cssTransform && hasUnsupportedTransform(canvas)) || hasUnsupportedTransform(overlay))
|
|
103
|
+
return error("unsupported-css-transform", "Rotation, skew, or perspective requires an explicit supported transform");
|
|
104
|
+
const content = readElementReferenceRect(canvas, "content-box");
|
|
105
|
+
const overlayRect = readElementReferenceRect(overlay, options.overlayBox ?? "padding-box");
|
|
106
|
+
const values = [content.x, content.y, content.width, content.height, overlayRect.x, overlayRect.y, overlayRect.width, overlayRect.height];
|
|
107
|
+
if (values.some((value) => !Number.isFinite(value)))
|
|
108
|
+
return error("non-finite-measurement", "DOM measurement contained a non-finite value");
|
|
109
|
+
if (content.width <= 0 || content.height <= 0 || overlayRect.width <= 0 || overlayRect.height <= 0)
|
|
110
|
+
return error("zero-sized-surface", "Canvas or overlay has zero size");
|
|
111
|
+
const style = computedStyle(canvas);
|
|
112
|
+
const declaredWidth = style ? number(style.width) : 0;
|
|
113
|
+
const declaredHeight = style ? number(style.height) : 0;
|
|
114
|
+
const cssWidth = declaredWidth > 0
|
|
115
|
+
? (style?.boxSizing === "border-box" ? Math.max(0, declaredWidth - number(style.borderLeftWidth) - number(style.borderRightWidth) - number(style.paddingLeft) - number(style.paddingRight)) : declaredWidth)
|
|
116
|
+
: content.width;
|
|
117
|
+
const cssHeight = declaredHeight > 0
|
|
118
|
+
? (style?.boxSizing === "border-box" ? Math.max(0, declaredHeight - number(style.borderTopWidth) - number(style.borderBottomWidth) - number(style.paddingTop) - number(style.paddingBottom)) : declaredHeight)
|
|
119
|
+
: content.height;
|
|
120
|
+
if (cssWidth <= 0 || cssHeight <= 0)
|
|
121
|
+
return error("zero-sized-surface", "Canvas CSS content box has zero size");
|
|
122
|
+
const backingToCanvasCss = {
|
|
123
|
+
from: "backing", to: "canvas-css", a: cssWidth / canvas.width, b: 0, c: 0,
|
|
124
|
+
d: cssHeight / canvas.height, e: 0, f: 0,
|
|
125
|
+
};
|
|
126
|
+
const canvasCssToClient = options.cssTransform ?? {
|
|
127
|
+
from: "canvas-css", to: "client", a: content.width / cssWidth, b: 0, c: 0, d: content.height / cssHeight, e: content.x, f: content.y,
|
|
128
|
+
};
|
|
129
|
+
const clientToOverlay = {
|
|
130
|
+
from: "client", to: "overlay", a: 1, b: 0, c: 0, d: 1, e: -overlayRect.x, f: -overlayRect.y,
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
metrics: Object.freeze({
|
|
135
|
+
capturedAtMs: clock.capturedAtMs,
|
|
136
|
+
clockId: clock.clockId,
|
|
137
|
+
connected: true,
|
|
138
|
+
backingSize: Object.freeze({ width: canvas.width, height: canvas.height }),
|
|
139
|
+
canvasCssSize: Object.freeze({ width: cssWidth, height: cssHeight }),
|
|
140
|
+
canvasContentClientRect: Object.freeze(content),
|
|
141
|
+
overlayClientRect: Object.freeze(overlayRect),
|
|
142
|
+
backingToCanvasCss: Object.freeze(backingToCanvasCss),
|
|
143
|
+
canvasCssToClient: Object.freeze(canvasCssToClient),
|
|
144
|
+
clientToOverlay: Object.freeze(clientToOverlay),
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function createAnimationFrameScheduler(target) {
|
|
149
|
+
const candidate = target ?? (typeof window === "undefined" ? undefined : window);
|
|
150
|
+
if (!candidate)
|
|
151
|
+
return { ok: false, error: { code: "scheduler-unavailable", message: "Animation frame scheduling is unavailable" } };
|
|
152
|
+
if (typeof candidate.requestAnimationFrame !== "function" || typeof candidate.cancelAnimationFrame !== "function")
|
|
153
|
+
throw new TypeError("Animation frame scheduler members must be functions");
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
scheduler: {
|
|
157
|
+
request: (flush) => candidate.requestAnimationFrame(flush),
|
|
158
|
+
cancel: (handle) => candidate.cancelAnimationFrame(handle),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function measurementKey(result) {
|
|
163
|
+
if (!result.ok)
|
|
164
|
+
return `error:${result.error.code}:${result.error.message}`;
|
|
165
|
+
const { capturedAtMs: _time, ...meaning } = result.metrics;
|
|
166
|
+
return JSON.stringify(meaning);
|
|
167
|
+
}
|
|
168
|
+
export function observeGameSurface(options) {
|
|
169
|
+
const view = options.canvas?.ownerDocument?.defaultView;
|
|
170
|
+
if (!view)
|
|
171
|
+
return { ok: false, error: { code: "dom-unavailable", message: "DOM observation is unavailable" } };
|
|
172
|
+
const ResizeObserverConstructor = view.ResizeObserver;
|
|
173
|
+
if (typeof ResizeObserverConstructor !== "function")
|
|
174
|
+
return { ok: false, error: { code: "resize-observer-unavailable", message: "ResizeObserver is unavailable" } };
|
|
175
|
+
const animationScheduler = options.scheduler ? undefined : createAnimationFrameScheduler(view);
|
|
176
|
+
if (animationScheduler && !animationScheduler.ok)
|
|
177
|
+
return animationScheduler;
|
|
178
|
+
const request = options.scheduler
|
|
179
|
+
? options.scheduler.request
|
|
180
|
+
: (flush) => animationScheduler.scheduler.request(flush);
|
|
181
|
+
const cancel = options.scheduler
|
|
182
|
+
? options.scheduler.cancel
|
|
183
|
+
: (handle) => animationScheduler.scheduler.cancel(handle);
|
|
184
|
+
let disconnected = false;
|
|
185
|
+
let pending;
|
|
186
|
+
let scheduled = false;
|
|
187
|
+
let lastKey = "";
|
|
188
|
+
const measure = () => measureGameSurface(options);
|
|
189
|
+
const emit = () => {
|
|
190
|
+
pending = undefined;
|
|
191
|
+
scheduled = false;
|
|
192
|
+
if (disconnected)
|
|
193
|
+
return;
|
|
194
|
+
const result = measure();
|
|
195
|
+
const key = measurementKey(result);
|
|
196
|
+
if (key !== lastKey) {
|
|
197
|
+
lastKey = key;
|
|
198
|
+
options.onChange(result);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const schedule = () => {
|
|
202
|
+
if (disconnected || scheduled)
|
|
203
|
+
return;
|
|
204
|
+
scheduled = true;
|
|
205
|
+
pending = request(emit);
|
|
206
|
+
};
|
|
207
|
+
const resizeObserver = new ResizeObserverConstructor(schedule);
|
|
208
|
+
resizeObserver.observe(options.canvas);
|
|
209
|
+
resizeObserver.observe(options.overlay);
|
|
210
|
+
view.addEventListener("resize", schedule);
|
|
211
|
+
view.document.addEventListener("fullscreenchange", schedule);
|
|
212
|
+
if (options.observeScroll !== false)
|
|
213
|
+
view.addEventListener("scroll", schedule, true);
|
|
214
|
+
const visualViewport = view.visualViewport;
|
|
215
|
+
const observeViewport = options.observeVisualViewport ?? Boolean(visualViewport);
|
|
216
|
+
if (observeViewport && visualViewport) {
|
|
217
|
+
visualViewport.addEventListener("resize", schedule);
|
|
218
|
+
visualViewport.addEventListener("scroll", schedule);
|
|
219
|
+
}
|
|
220
|
+
emit();
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
observer: {
|
|
224
|
+
measure,
|
|
225
|
+
disconnect: () => {
|
|
226
|
+
if (disconnected)
|
|
227
|
+
return;
|
|
228
|
+
disconnected = true;
|
|
229
|
+
resizeObserver.disconnect();
|
|
230
|
+
view.removeEventListener("resize", schedule);
|
|
231
|
+
view.document.removeEventListener("fullscreenchange", schedule);
|
|
232
|
+
view.removeEventListener("scroll", schedule, true);
|
|
233
|
+
if (visualViewport) {
|
|
234
|
+
visualViewport.removeEventListener("resize", schedule);
|
|
235
|
+
visualViewport.removeEventListener("scroll", schedule);
|
|
236
|
+
}
|
|
237
|
+
if (scheduled)
|
|
238
|
+
cancel(pending);
|
|
239
|
+
pending = undefined;
|
|
240
|
+
scheduled = false;
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { GameBridgeDiagnostic, JsonValue } from "../core/types.js";
|
|
2
|
+
import { type ProjectionContext2D, type ProjectedWorldPoint, type ProjectionPointError, type ProjectionRounding } from "./projection.js";
|
|
3
|
+
import { type Point2, type Vector2 } from "./types.js";
|
|
4
|
+
export interface WorldAnchorSnapshot<TMetadata extends JsonValue = JsonValue> {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly worldPoint: Point2<"world">;
|
|
7
|
+
readonly worldOffset?: Vector2<"world">;
|
|
8
|
+
readonly overlayOffset?: Vector2<"overlay">;
|
|
9
|
+
readonly sourceVisible?: boolean;
|
|
10
|
+
readonly metadata?: TMetadata;
|
|
11
|
+
}
|
|
12
|
+
export interface AnchorValidationError {
|
|
13
|
+
readonly code: "empty-anchor-id" | "invalid-anchor";
|
|
14
|
+
readonly message: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ProjectedWorldAnchor<TMetadata extends JsonValue = JsonValue> extends ProjectedWorldPoint {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly metadata?: TMetadata;
|
|
19
|
+
}
|
|
20
|
+
export interface ProjectWorldAnchorsOptions {
|
|
21
|
+
readonly rounding?: ProjectionRounding;
|
|
22
|
+
readonly cameraMargins?: import("./visibility.js").VisibilityMargins2;
|
|
23
|
+
readonly invalidAnchorPolicy?: "omit" | "fail-batch";
|
|
24
|
+
readonly duplicateIdPolicy?: "fail-batch" | "keep-first";
|
|
25
|
+
}
|
|
26
|
+
export interface InvalidWorldAnchorProjection {
|
|
27
|
+
readonly index: number;
|
|
28
|
+
readonly id?: string;
|
|
29
|
+
readonly error: ProjectionPointError | AnchorValidationError;
|
|
30
|
+
}
|
|
31
|
+
export type ProjectWorldAnchorsResult<TMetadata extends JsonValue = JsonValue> = {
|
|
32
|
+
readonly ok: true;
|
|
33
|
+
readonly anchors: readonly ProjectedWorldAnchor<TMetadata>[];
|
|
34
|
+
readonly invalid: readonly InvalidWorldAnchorProjection[];
|
|
35
|
+
readonly diagnostics: readonly GameBridgeDiagnostic[];
|
|
36
|
+
} | {
|
|
37
|
+
readonly ok: false;
|
|
38
|
+
readonly error: {
|
|
39
|
+
readonly code: "duplicate-anchor-id" | "invalid-anchor-batch";
|
|
40
|
+
readonly anchorId?: string;
|
|
41
|
+
readonly message: string;
|
|
42
|
+
};
|
|
43
|
+
readonly invalid: readonly InvalidWorldAnchorProjection[];
|
|
44
|
+
readonly diagnostics: readonly GameBridgeDiagnostic[];
|
|
45
|
+
};
|
|
46
|
+
export declare function projectWorldAnchors<TMetadata extends JsonValue = JsonValue>(context: ProjectionContext2D, anchors: readonly WorldAnchorSnapshot<TMetadata>[], options?: ProjectWorldAnchorsOptions): ProjectWorldAnchorsResult<TMetadata>;
|
|
47
|
+
export declare function areProjectedWorldAnchorsEqual(left: readonly ProjectedWorldAnchor[], right: readonly ProjectedWorldAnchor[]): boolean;
|
|
48
|
+
export declare function reuseProjectedAnchors<TMetadata extends JsonValue>(previous: readonly ProjectedWorldAnchor<TMetadata>[], next: readonly ProjectedWorldAnchor<TMetadata>[]): readonly ProjectedWorldAnchor<TMetadata>[];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { projectWorldPoint } from "./projection.js";
|
|
2
|
+
import { containsPoint } from "./visibility.js";
|
|
3
|
+
import { point2 } from "./types.js";
|
|
4
|
+
const finitePoint = (value) => value === undefined || (Number.isFinite(value.x) && Number.isFinite(value.y));
|
|
5
|
+
function diagnostic(message, anchorId, duplicate = false) {
|
|
6
|
+
return anchorId === undefined
|
|
7
|
+
? { code: duplicate ? "duplicate-anchor-id" : "invalid-anchor", severity: "warning", message }
|
|
8
|
+
: { code: duplicate ? "duplicate-anchor-id" : "invalid-anchor", severity: "warning", message, anchorId };
|
|
9
|
+
}
|
|
10
|
+
function roundedOverlay(context, point, rounding) {
|
|
11
|
+
if (rounding === "none")
|
|
12
|
+
return point;
|
|
13
|
+
if (rounding === "integer")
|
|
14
|
+
return point2("overlay", Math.round(point.x), Math.round(point.y));
|
|
15
|
+
const stepX = Math.hypot(context.source.backingToCanvasCss.a, context.source.backingToCanvasCss.b) || 1;
|
|
16
|
+
const stepY = Math.hypot(context.source.backingToCanvasCss.c, context.source.backingToCanvasCss.d) || 1;
|
|
17
|
+
return point2("overlay", Math.round(point.x / stepX) * stepX, Math.round(point.y / stepY) * stepY);
|
|
18
|
+
}
|
|
19
|
+
export function projectWorldAnchors(context, anchors, options = {}) {
|
|
20
|
+
const projected = [];
|
|
21
|
+
const invalid = [];
|
|
22
|
+
const diagnostics = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
for (let index = 0; index < anchors.length; index += 1) {
|
|
25
|
+
const anchor = anchors[index];
|
|
26
|
+
if (typeof anchor.id !== "string" || anchor.id.trim() === "") {
|
|
27
|
+
const error = { code: "empty-anchor-id", message: "Anchor ID must be non-empty" };
|
|
28
|
+
invalid.push({ index, ...(typeof anchor.id === "string" ? { id: anchor.id } : {}), error });
|
|
29
|
+
diagnostics.push(diagnostic(error.message));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (seen.has(anchor.id)) {
|
|
33
|
+
const error = { code: "invalid-anchor", message: `Duplicate anchor ID: ${anchor.id}` };
|
|
34
|
+
invalid.push({ index, id: anchor.id, error });
|
|
35
|
+
diagnostics.push(diagnostic(error.message, anchor.id, true));
|
|
36
|
+
if ((options.duplicateIdPolicy ?? "fail-batch") === "fail-batch") {
|
|
37
|
+
return { ok: false, error: { code: "duplicate-anchor-id", anchorId: anchor.id, message: error.message }, invalid, diagnostics };
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
seen.add(anchor.id);
|
|
42
|
+
if (!finitePoint(anchor.worldPoint) || !finitePoint(anchor.worldOffset) || !finitePoint(anchor.overlayOffset)) {
|
|
43
|
+
const error = { code: "invalid-anchor", message: `Anchor ${anchor.id} contains a non-finite point or offset` };
|
|
44
|
+
invalid.push({ index, id: anchor.id, error });
|
|
45
|
+
diagnostics.push(diagnostic(error.message, anchor.id));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const world = point2("world", anchor.worldPoint.x + (anchor.worldOffset?.x ?? 0), anchor.worldPoint.y + (anchor.worldOffset?.y ?? 0));
|
|
49
|
+
const result = projectWorldPoint(context, world, {
|
|
50
|
+
rounding: "none",
|
|
51
|
+
...(options.cameraMargins === undefined ? {} : { cameraMargins: options.cameraMargins }),
|
|
52
|
+
...(anchor.sourceVisible === undefined ? {} : { sourceVisible: anchor.sourceVisible }),
|
|
53
|
+
});
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
invalid.push({ index, id: anchor.id, error: result.error });
|
|
56
|
+
diagnostics.push(diagnostic(result.error.message, anchor.id));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const overlay = roundedOverlay(context, point2("overlay", result.value.overlayPoint.x + (anchor.overlayOffset?.x ?? 0), result.value.overlayPoint.y + (anchor.overlayOffset?.y ?? 0)), options.rounding ?? "none");
|
|
60
|
+
const normalized = point2("normalized", overlay.x / context.overlayClip.width, overlay.y / context.overlayClip.height);
|
|
61
|
+
const inOverlay = containsPoint(context.overlayClip, overlay);
|
|
62
|
+
const visibility = { ...result.value.visibility, inOverlay,
|
|
63
|
+
kind: result.value.visibility.kind === "source-hidden" || result.value.visibility.kind === "camera-hidden" || result.value.visibility.kind === "outside-camera" || result.value.visibility.kind === "outside-canvas"
|
|
64
|
+
? result.value.visibility.kind : inOverlay ? "visible" : "outside-overlay" };
|
|
65
|
+
projected.push({ ...result.value, id: anchor.id, overlayPoint: overlay, normalizedPoint: normalized, visibility, ...(anchor.metadata === undefined ? {} : { metadata: anchor.metadata }) });
|
|
66
|
+
}
|
|
67
|
+
if (invalid.length > 0 && options.invalidAnchorPolicy === "fail-batch") {
|
|
68
|
+
return { ok: false, error: { code: "invalid-anchor-batch", message: "One or more anchors were invalid" }, invalid, diagnostics };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, anchors: projected, invalid, diagnostics };
|
|
71
|
+
}
|
|
72
|
+
function equalPoint(left, right) { return left.x === right.x && left.y === right.y; }
|
|
73
|
+
function equalAnchor(left, right) {
|
|
74
|
+
return left.id === right.id && left.metadata === right.metadata && equalPoint(left.worldPoint, right.worldPoint) && equalPoint(left.gamePoint, right.gamePoint) &&
|
|
75
|
+
equalPoint(left.canvasCssPoint, right.canvasCssPoint) && equalPoint(left.clientPoint, right.clientPoint) && equalPoint(left.overlayPoint, right.overlayPoint) &&
|
|
76
|
+
equalPoint(left.normalizedPoint, right.normalizedPoint) && left.visibility.kind === right.visibility.kind && left.visibility.inCamera === right.visibility.inCamera &&
|
|
77
|
+
left.visibility.inCanvas === right.visibility.inCanvas && left.visibility.inOverlay === right.visibility.inOverlay;
|
|
78
|
+
}
|
|
79
|
+
export function areProjectedWorldAnchorsEqual(left, right) {
|
|
80
|
+
return left === right || (left.length === right.length && left.every((anchor, index) => equalAnchor(anchor, right[index])));
|
|
81
|
+
}
|
|
82
|
+
export function reuseProjectedAnchors(previous, next) {
|
|
83
|
+
const byId = new Map(previous.map((anchor) => [anchor.id, anchor]));
|
|
84
|
+
let changed = previous.length !== next.length;
|
|
85
|
+
const reused = next.map((anchor, index) => {
|
|
86
|
+
const prior = byId.get(anchor.id);
|
|
87
|
+
if (prior !== undefined && equalAnchor(prior, anchor)) {
|
|
88
|
+
if (prior !== previous[index])
|
|
89
|
+
changed = true;
|
|
90
|
+
return prior;
|
|
91
|
+
}
|
|
92
|
+
changed = true;
|
|
93
|
+
return anchor;
|
|
94
|
+
});
|
|
95
|
+
return changed ? reused : previous;
|
|
96
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ConvexQuad2, type GameCoordinateSpace, type Point2, type Rect2 } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_AFFINE_EPSILON = 1e-12;
|
|
3
|
+
export interface AffineTransform2D<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace> {
|
|
4
|
+
readonly from: TFrom;
|
|
5
|
+
readonly to: TTo;
|
|
6
|
+
readonly a: number;
|
|
7
|
+
readonly b: number;
|
|
8
|
+
readonly c: number;
|
|
9
|
+
readonly d: number;
|
|
10
|
+
readonly e: number;
|
|
11
|
+
readonly f: number;
|
|
12
|
+
}
|
|
13
|
+
export type InvertTransformResult<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace> = {
|
|
14
|
+
readonly ok: true;
|
|
15
|
+
readonly value: AffineTransform2D<TTo, TFrom>;
|
|
16
|
+
} | {
|
|
17
|
+
readonly ok: false;
|
|
18
|
+
readonly error: {
|
|
19
|
+
readonly code: "non-finite-transform" | "non-invertible-transform";
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export declare function affineTransform<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace>(from: TFrom, to: TTo, values: Readonly<{
|
|
23
|
+
a: number;
|
|
24
|
+
b: number;
|
|
25
|
+
c: number;
|
|
26
|
+
d: number;
|
|
27
|
+
e: number;
|
|
28
|
+
f: number;
|
|
29
|
+
}>): AffineTransform2D<TFrom, TTo>;
|
|
30
|
+
export declare function identityTransform<TSpace extends GameCoordinateSpace>(space: TSpace): AffineTransform2D<TSpace, TSpace>;
|
|
31
|
+
export declare function translationTransform<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace>(from: TFrom, to: TTo, x: number, y: number): AffineTransform2D<TFrom, TTo>;
|
|
32
|
+
export declare function scaleTransform<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace>(from: TFrom, to: TTo, scaleX: number, scaleY: number): AffineTransform2D<TFrom, TTo>;
|
|
33
|
+
export declare function rotationTransform<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace>(from: TFrom, to: TTo, radians: number, origin?: Readonly<{
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
}>): AffineTransform2D<TFrom, TTo>;
|
|
37
|
+
export declare function composeTransforms<A extends GameCoordinateSpace, B extends GameCoordinateSpace, C extends GameCoordinateSpace>(first: AffineTransform2D<A, B>, second: AffineTransform2D<B, C>): AffineTransform2D<A, C>;
|
|
38
|
+
export declare const determinant: (value: AffineTransform2D<GameCoordinateSpace, GameCoordinateSpace>) => number;
|
|
39
|
+
export declare function isFiniteTransform(value: AffineTransform2D<GameCoordinateSpace, GameCoordinateSpace>): boolean;
|
|
40
|
+
export declare function isInvertibleTransform(value: AffineTransform2D<GameCoordinateSpace, GameCoordinateSpace>, epsilon?: number): boolean;
|
|
41
|
+
export declare function invertTransform<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace>(value: AffineTransform2D<TFrom, TTo>, epsilon?: number): InvertTransformResult<TFrom, TTo>;
|
|
42
|
+
export declare function transformPoint<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace>(value: AffineTransform2D<TFrom, TTo>, input: Point2<TFrom>): Point2<TTo>;
|
|
43
|
+
export declare function transformRectQuad<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace>(value: AffineTransform2D<TFrom, TTo>, input: Rect2<TFrom>): ConvexQuad2<TTo>;
|
|
44
|
+
export declare function transformRectBounds<TFrom extends GameCoordinateSpace, TTo extends GameCoordinateSpace>(value: AffineTransform2D<TFrom, TTo>, input: Rect2<TFrom>): Rect2<TTo>;
|
|
45
|
+
export declare function approximatelyEqualTransform(left: AffineTransform2D<GameCoordinateSpace, GameCoordinateSpace>, right: AffineTransform2D<GameCoordinateSpace, GameCoordinateSpace>, epsilon?: number): boolean;
|