@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,77 @@
|
|
|
1
|
+
import { convexQuad2, point2, rect2 } from "./types.js";
|
|
2
|
+
export const DEFAULT_AFFINE_EPSILON = 1e-12;
|
|
3
|
+
function validateEpsilon(epsilon) {
|
|
4
|
+
if (!Number.isFinite(epsilon) || epsilon < 0)
|
|
5
|
+
throw new RangeError("epsilon must be finite and non-negative");
|
|
6
|
+
}
|
|
7
|
+
function transform(from, to, a, b, c, d, e, f) {
|
|
8
|
+
return { from, to, a, b, c, d, e, f };
|
|
9
|
+
}
|
|
10
|
+
export function affineTransform(from, to, values) {
|
|
11
|
+
if (![values.a, values.b, values.c, values.d, values.e, values.f].every(Number.isFinite)) {
|
|
12
|
+
throw new RangeError("Affine transform components must be finite");
|
|
13
|
+
}
|
|
14
|
+
return transform(from, to, values.a, values.b, values.c, values.d, values.e, values.f);
|
|
15
|
+
}
|
|
16
|
+
export function identityTransform(space) {
|
|
17
|
+
return transform(space, space, 1, 0, 0, 1, 0, 0);
|
|
18
|
+
}
|
|
19
|
+
export function translationTransform(from, to, x, y) {
|
|
20
|
+
return affineTransform(from, to, { a: 1, b: 0, c: 0, d: 1, e: x, f: y });
|
|
21
|
+
}
|
|
22
|
+
export function scaleTransform(from, to, scaleX, scaleY) {
|
|
23
|
+
return affineTransform(from, to, { a: scaleX, b: 0, c: 0, d: scaleY, e: 0, f: 0 });
|
|
24
|
+
}
|
|
25
|
+
export function rotationTransform(from, to, radians, origin = { x: 0, y: 0 }) {
|
|
26
|
+
if (![radians, origin.x, origin.y].every(Number.isFinite))
|
|
27
|
+
throw new RangeError("Rotation values must be finite");
|
|
28
|
+
const cosine = Math.cos(radians);
|
|
29
|
+
const sine = Math.sin(radians);
|
|
30
|
+
return transform(from, to, cosine, sine, -sine, cosine, origin.x - cosine * origin.x + sine * origin.y, origin.y - sine * origin.x - cosine * origin.y);
|
|
31
|
+
}
|
|
32
|
+
export function composeTransforms(first, second) {
|
|
33
|
+
return transform(first.from, second.to, second.a * first.a + second.c * first.b, second.b * first.a + second.d * first.b, second.a * first.c + second.c * first.d, second.b * first.c + second.d * first.d, second.a * first.e + second.c * first.f + second.e, second.b * first.e + second.d * first.f + second.f);
|
|
34
|
+
}
|
|
35
|
+
export const determinant = (value) => value.a * value.d - value.b * value.c;
|
|
36
|
+
export function isFiniteTransform(value) {
|
|
37
|
+
return [value.a, value.b, value.c, value.d, value.e, value.f].every(Number.isFinite);
|
|
38
|
+
}
|
|
39
|
+
export function isInvertibleTransform(value, epsilon = DEFAULT_AFFINE_EPSILON) {
|
|
40
|
+
validateEpsilon(epsilon);
|
|
41
|
+
if (!isFiniteTransform(value))
|
|
42
|
+
return false;
|
|
43
|
+
const scale = Math.max(1, value.a ** 2 + value.b ** 2 + value.c ** 2 + value.d ** 2);
|
|
44
|
+
return Math.abs(determinant(value)) > epsilon * scale;
|
|
45
|
+
}
|
|
46
|
+
export function invertTransform(value, epsilon = DEFAULT_AFFINE_EPSILON) {
|
|
47
|
+
validateEpsilon(epsilon);
|
|
48
|
+
if (!isFiniteTransform(value))
|
|
49
|
+
return { ok: false, error: { code: "non-finite-transform" } };
|
|
50
|
+
if (!isInvertibleTransform(value, epsilon))
|
|
51
|
+
return { ok: false, error: { code: "non-invertible-transform" } };
|
|
52
|
+
const det = determinant(value);
|
|
53
|
+
return { ok: true, value: transform(value.to, value.from, value.d / det, -value.b / det, -value.c / det, value.a / det, (value.c * value.f - value.d * value.e) / det, (value.b * value.e - value.a * value.f) / det) };
|
|
54
|
+
}
|
|
55
|
+
export function transformPoint(value, input) {
|
|
56
|
+
return point2(value.to, value.a * input.x + value.c * input.y + value.e, value.b * input.x + value.d * input.y + value.f);
|
|
57
|
+
}
|
|
58
|
+
export function transformRectQuad(value, input) {
|
|
59
|
+
return convexQuad2(value.to, transformPoint(value, point2(value.from, input.x, input.y)), transformPoint(value, point2(value.from, input.x + input.width, input.y)), transformPoint(value, point2(value.from, input.x + input.width, input.y + input.height)), transformPoint(value, point2(value.from, input.x, input.y + input.height)));
|
|
60
|
+
}
|
|
61
|
+
export function transformRectBounds(value, input) {
|
|
62
|
+
const quad = transformRectQuad(value, input);
|
|
63
|
+
const xs = [quad.topLeft.x, quad.topRight.x, quad.bottomRight.x, quad.bottomLeft.x];
|
|
64
|
+
const ys = [quad.topLeft.y, quad.topRight.y, quad.bottomRight.y, quad.bottomLeft.y];
|
|
65
|
+
const left = Math.min(...xs);
|
|
66
|
+
const top = Math.min(...ys);
|
|
67
|
+
return rect2(value.to, left, top, Math.max(...xs) - left, Math.max(...ys) - top);
|
|
68
|
+
}
|
|
69
|
+
export function approximatelyEqualTransform(left, right, epsilon = DEFAULT_AFFINE_EPSILON) {
|
|
70
|
+
validateEpsilon(epsilon);
|
|
71
|
+
if (left.from !== right.from || left.to !== right.to)
|
|
72
|
+
return false;
|
|
73
|
+
return ["a", "b", "c", "d", "e", "f"].every((key) => {
|
|
74
|
+
const magnitude = Math.max(1, Math.abs(left[key]), Math.abs(right[key]));
|
|
75
|
+
return Math.abs(left[key] - right[key]) <= epsilon * magnitude;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Point2, type Rect2, type Size2 } from "./types.js";
|
|
2
|
+
import { type Insets2 } from "./visibility.js";
|
|
3
|
+
export type AnchorPlacement = "top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end" | "right" | "right-start" | "right-end";
|
|
4
|
+
export interface AnchoredRectRequest {
|
|
5
|
+
readonly anchor: Point2<"overlay"> | Rect2<"overlay">;
|
|
6
|
+
readonly floatingSize: Size2<"overlay">;
|
|
7
|
+
readonly bounds: Rect2<"overlay">;
|
|
8
|
+
readonly preferred: AnchorPlacement;
|
|
9
|
+
readonly fallbacks?: readonly AnchorPlacement[];
|
|
10
|
+
readonly gap?: number;
|
|
11
|
+
readonly safeInsets?: Insets2;
|
|
12
|
+
readonly avoid?: readonly Rect2<"overlay">[];
|
|
13
|
+
readonly clamp?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface AnchoredRectPlacement {
|
|
16
|
+
readonly rect: Rect2<"overlay">;
|
|
17
|
+
readonly placement: AnchorPlacement;
|
|
18
|
+
readonly fits: boolean;
|
|
19
|
+
readonly clampedX: boolean;
|
|
20
|
+
readonly clampedY: boolean;
|
|
21
|
+
readonly overflow: Insets2;
|
|
22
|
+
readonly avoidedArea: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function placeAnchoredRect(request: AnchoredRectRequest): AnchoredRectPlacement;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { rect2 } from "./types.js";
|
|
2
|
+
import { insetRect, overflowInsets, overlapArea } from "./visibility.js";
|
|
3
|
+
const noInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
4
|
+
function anchorRect(anchor) {
|
|
5
|
+
return "width" in anchor ? anchor : rect2("overlay", anchor.x, anchor.y, 0, 0);
|
|
6
|
+
}
|
|
7
|
+
function candidate(request, placement, safe) {
|
|
8
|
+
const anchor = anchorRect(request.anchor);
|
|
9
|
+
const gap = request.gap ?? 0;
|
|
10
|
+
if (!Number.isFinite(gap) || gap < 0)
|
|
11
|
+
throw new RangeError("gap must be finite and non-negative");
|
|
12
|
+
const side = placement.split("-")[0];
|
|
13
|
+
const alignment = placement.split("-")[1] ?? "center";
|
|
14
|
+
let x;
|
|
15
|
+
let y;
|
|
16
|
+
if (side === "top" || side === "bottom") {
|
|
17
|
+
y = side === "top" ? anchor.y - gap - request.floatingSize.height : anchor.y + anchor.height + gap;
|
|
18
|
+
x = alignment === "start" ? anchor.x : alignment === "end" ? anchor.x + anchor.width - request.floatingSize.width : anchor.x + (anchor.width - request.floatingSize.width) / 2;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
x = side === "left" ? anchor.x - gap - request.floatingSize.width : anchor.x + anchor.width + gap;
|
|
22
|
+
y = alignment === "start" ? anchor.y : alignment === "end" ? anchor.y + anchor.height - request.floatingSize.height : anchor.y + (anchor.height - request.floatingSize.height) / 2;
|
|
23
|
+
}
|
|
24
|
+
let rect = rect2("overlay", x, y, request.floatingSize.width, request.floatingSize.height);
|
|
25
|
+
let overflow = overflowInsets(rect, safe);
|
|
26
|
+
const score = overflow.top + overflow.right + overflow.bottom + overflow.left;
|
|
27
|
+
const avoidedArea = (request.avoid ?? []).reduce((sum, avoided) => sum + overlapArea(rect, avoided), 0);
|
|
28
|
+
let clampedX = false;
|
|
29
|
+
let clampedY = false;
|
|
30
|
+
if (request.clamp) {
|
|
31
|
+
const nextX = rect.width > safe.width ? safe.x : Math.min(Math.max(rect.x, safe.x), safe.x + safe.width - rect.width);
|
|
32
|
+
const nextY = rect.height > safe.height ? safe.y : Math.min(Math.max(rect.y, safe.y), safe.y + safe.height - rect.height);
|
|
33
|
+
clampedX = nextX !== rect.x;
|
|
34
|
+
clampedY = nextY !== rect.y;
|
|
35
|
+
rect = rect2("overlay", nextX, nextY, rect.width, rect.height);
|
|
36
|
+
overflow = overflowInsets(rect, safe);
|
|
37
|
+
}
|
|
38
|
+
return { rect, placement, fits: score === 0 && avoidedArea === 0, clampedX, clampedY, overflow, avoidedArea, score };
|
|
39
|
+
}
|
|
40
|
+
export function placeAnchoredRect(request) {
|
|
41
|
+
if (request.floatingSize.width < 0 || request.floatingSize.height < 0)
|
|
42
|
+
throw new RangeError("Floating size must be non-negative");
|
|
43
|
+
const safe = insetRect(request.bounds, request.safeInsets ?? noInsets);
|
|
44
|
+
const placements = [request.preferred, ...(request.fallbacks ?? [])];
|
|
45
|
+
const ranked = placements.map((placement, index) => ({ value: candidate(request, placement, safe), index }));
|
|
46
|
+
ranked.sort((left, right) => left.value.score - right.value.score || left.value.avoidedArea - right.value.avoidedArea || left.index - right.index || left.value.placement.localeCompare(right.value.placement));
|
|
47
|
+
const { score: _, ...result } = ranked[0].value;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { approximatelyEqualTransform, type AffineTransform2D } from "./matrix.js";
|
|
2
|
+
import { type ConvexQuad2, type Point2, type Rect2, type Size2 } from "./types.js";
|
|
3
|
+
import { type VisibilityMargins2 } from "./visibility.js";
|
|
4
|
+
export interface GameBridgeTransformSnapshotV1 {
|
|
5
|
+
readonly protocolVersion: 1;
|
|
6
|
+
readonly cameraId: string;
|
|
7
|
+
readonly capturedAtMs: number;
|
|
8
|
+
readonly clockId: string;
|
|
9
|
+
readonly worldToCamera: AffineTransform2D<"world", "camera">;
|
|
10
|
+
readonly cameraToGame: AffineTransform2D<"camera", "game">;
|
|
11
|
+
readonly gameToBacking: AffineTransform2D<"game", "backing">;
|
|
12
|
+
readonly backingToCanvasCss: AffineTransform2D<"backing", "canvas-css">;
|
|
13
|
+
readonly canvasCssToClient: AffineTransform2D<"canvas-css", "client">;
|
|
14
|
+
readonly clientToOverlay: AffineTransform2D<"client", "overlay">;
|
|
15
|
+
readonly cameraViewport: Rect2<"game">;
|
|
16
|
+
readonly gameLogicalSize: Size2<"game">;
|
|
17
|
+
readonly gameViewportInBacking: Rect2<"backing">;
|
|
18
|
+
readonly canvasBackingSize: Size2<"backing">;
|
|
19
|
+
readonly canvasCssSize: Size2<"canvas-css">;
|
|
20
|
+
readonly canvasContentClientRect: Rect2<"client">;
|
|
21
|
+
readonly overlayClientRect: Rect2<"client">;
|
|
22
|
+
readonly cameraVisible: boolean;
|
|
23
|
+
readonly roundPixels: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface ProjectionContext2D {
|
|
26
|
+
readonly source: GameBridgeTransformSnapshotV1;
|
|
27
|
+
readonly worldToGame: AffineTransform2D<"world", "game">;
|
|
28
|
+
readonly gameToWorld: AffineTransform2D<"game", "world">;
|
|
29
|
+
readonly worldToClient: AffineTransform2D<"world", "client">;
|
|
30
|
+
readonly clientToWorld: AffineTransform2D<"client", "world">;
|
|
31
|
+
readonly worldToOverlay: AffineTransform2D<"world", "overlay">;
|
|
32
|
+
readonly overlayToWorld: AffineTransform2D<"overlay", "world">;
|
|
33
|
+
readonly gameToOverlay: AffineTransform2D<"game", "overlay">;
|
|
34
|
+
readonly overlayToGame: AffineTransform2D<"overlay", "game">;
|
|
35
|
+
readonly cameraClipInGame: Rect2<"game">;
|
|
36
|
+
readonly canvasClipInClient: ConvexQuad2<"client">;
|
|
37
|
+
readonly overlayClip: Rect2<"overlay">;
|
|
38
|
+
}
|
|
39
|
+
export interface ProjectionCompileError {
|
|
40
|
+
readonly code: "unsupported-protocol-version" | "invalid-camera-id" | "invalid-clock-id" | "non-finite-transform" | "non-invertible-transform" | "negative-dimension" | "zero-sized-surface" | "transform-space-mismatch" | "inconsistent-transform-snapshot";
|
|
41
|
+
readonly message: string;
|
|
42
|
+
}
|
|
43
|
+
export type CompileProjectionContextResult = {
|
|
44
|
+
readonly ok: true;
|
|
45
|
+
readonly context: ProjectionContext2D;
|
|
46
|
+
} | {
|
|
47
|
+
readonly ok: false;
|
|
48
|
+
readonly error: ProjectionCompileError;
|
|
49
|
+
};
|
|
50
|
+
export type ProjectionVisibilityKind = "visible" | "outside-camera" | "outside-canvas" | "outside-overlay" | "camera-hidden" | "source-hidden";
|
|
51
|
+
export interface ProjectionVisibility {
|
|
52
|
+
readonly kind: ProjectionVisibilityKind;
|
|
53
|
+
readonly inCamera: boolean;
|
|
54
|
+
readonly inCanvas: boolean;
|
|
55
|
+
readonly inOverlay: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface ProjectedWorldPoint {
|
|
58
|
+
readonly worldPoint: Point2<"world">;
|
|
59
|
+
readonly gamePoint: Point2<"game">;
|
|
60
|
+
readonly canvasCssPoint: Point2<"canvas-css">;
|
|
61
|
+
readonly clientPoint: Point2<"client">;
|
|
62
|
+
readonly overlayPoint: Point2<"overlay">;
|
|
63
|
+
readonly normalizedPoint: Point2<"normalized">;
|
|
64
|
+
readonly visibility: ProjectionVisibility;
|
|
65
|
+
}
|
|
66
|
+
export interface ProjectionPointError {
|
|
67
|
+
readonly code: "invalid-point";
|
|
68
|
+
readonly message: string;
|
|
69
|
+
}
|
|
70
|
+
export type ProjectWorldPointResult = {
|
|
71
|
+
readonly ok: true;
|
|
72
|
+
readonly value: ProjectedWorldPoint;
|
|
73
|
+
} | {
|
|
74
|
+
readonly ok: false;
|
|
75
|
+
readonly error: ProjectionPointError;
|
|
76
|
+
};
|
|
77
|
+
export interface ProjectWorldPointsResult {
|
|
78
|
+
readonly points: readonly ProjectedWorldPoint[];
|
|
79
|
+
readonly invalid: readonly {
|
|
80
|
+
readonly index: number;
|
|
81
|
+
readonly error: ProjectionPointError;
|
|
82
|
+
}[];
|
|
83
|
+
}
|
|
84
|
+
export type ProjectionRounding = "none" | "integer" | "device-pixel";
|
|
85
|
+
export interface ProjectPointOptions {
|
|
86
|
+
readonly rounding?: ProjectionRounding;
|
|
87
|
+
readonly cameraMargins?: VisibilityMargins2;
|
|
88
|
+
readonly sourceVisible?: boolean;
|
|
89
|
+
}
|
|
90
|
+
export type OutsideProjectionPolicy = "reject" | "allow" | "clamp-to-camera";
|
|
91
|
+
export interface InverseProjectionOptions {
|
|
92
|
+
readonly outside?: OutsideProjectionPolicy;
|
|
93
|
+
readonly maxSnapshotAgeMs?: number;
|
|
94
|
+
readonly nowMs?: number;
|
|
95
|
+
readonly clockId?: string;
|
|
96
|
+
}
|
|
97
|
+
export type InverseProjectionResult = {
|
|
98
|
+
readonly ok: true;
|
|
99
|
+
readonly worldPoint: Point2<"world">;
|
|
100
|
+
readonly gamePoint: Point2<"game">;
|
|
101
|
+
readonly wasClamped: boolean;
|
|
102
|
+
} | {
|
|
103
|
+
readonly ok: false;
|
|
104
|
+
readonly error: {
|
|
105
|
+
readonly code: "outside-camera" | "outside-canvas" | "stale-snapshot" | "clock-mismatch" | "non-invertible-transform" | "invalid-point";
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
export declare function compileProjectionContext(snapshot: GameBridgeTransformSnapshotV1): CompileProjectionContextResult;
|
|
109
|
+
export declare function toNormalizedPoint(point: Point2<"overlay">, overlaySize: Size2<"overlay">): Point2<"normalized">;
|
|
110
|
+
export declare function fromNormalizedPoint(point: Point2<"normalized">, overlaySize: Size2<"overlay">): Point2<"overlay">;
|
|
111
|
+
export declare function projectWorldPoint(context: ProjectionContext2D, point: Point2<"world">, options?: ProjectPointOptions): ProjectWorldPointResult;
|
|
112
|
+
export declare function projectGamePoint(context: ProjectionContext2D, point: Point2<"game">, options?: ProjectPointOptions): ProjectWorldPointResult;
|
|
113
|
+
export declare function projectWorldPoints(context: ProjectionContext2D, points: readonly Point2<"world">[], options?: ProjectPointOptions, output?: ProjectedWorldPoint[]): ProjectWorldPointsResult;
|
|
114
|
+
export declare function clientPointToWorld(context: ProjectionContext2D, point: Point2<"client">, options?: InverseProjectionOptions): InverseProjectionResult;
|
|
115
|
+
export declare function overlayPointToGame(context: ProjectionContext2D, point: Point2<"overlay">, options?: InverseProjectionOptions): InverseProjectionResult;
|
|
116
|
+
export declare const overlayPointToWorld: typeof overlayPointToGame;
|
|
117
|
+
export declare function areTransformSnapshotsEqual(left: GameBridgeTransformSnapshotV1, right: GameBridgeTransformSnapshotV1): boolean;
|
|
118
|
+
export { approximatelyEqualTransform };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { DEFAULT_AFFINE_EPSILON, approximatelyEqualTransform, composeTransforms, invertTransform, isFiniteTransform, transformPoint, transformRectBounds, transformRectQuad, } from "./matrix.js";
|
|
2
|
+
import { isFinitePoint, point2, rect2, } from "./types.js";
|
|
3
|
+
import { clampPointToHalfOpenRect, containsPoint, containsPointInConvexQuad, expandRect, } from "./visibility.js";
|
|
4
|
+
const compileError = (code, message) => ({ ok: false, error: { code, message } });
|
|
5
|
+
const approximately = (left, right) => Math.abs(left - right) <= DEFAULT_AFFINE_EPSILON * Math.max(1, Math.abs(left), Math.abs(right));
|
|
6
|
+
const equalRect = (left, right) => approximately(left.x, right.x) && approximately(left.y, right.y) && approximately(left.width, right.width) && approximately(left.height, right.height);
|
|
7
|
+
const expectedLabels = [
|
|
8
|
+
["worldToCamera", "world", "camera"], ["cameraToGame", "camera", "game"],
|
|
9
|
+
["gameToBacking", "game", "backing"], ["backingToCanvasCss", "backing", "canvas-css"],
|
|
10
|
+
["canvasCssToClient", "canvas-css", "client"], ["clientToOverlay", "client", "overlay"],
|
|
11
|
+
];
|
|
12
|
+
function rawRectIsValid(value) {
|
|
13
|
+
if (![value.x, value.y, value.width, value.height].every(Number.isFinite))
|
|
14
|
+
return "finite";
|
|
15
|
+
return value.width < 0 || value.height < 0 ? "negative" : "ok";
|
|
16
|
+
}
|
|
17
|
+
function rawSizeIsValid(value) {
|
|
18
|
+
if (![value.width, value.height].every(Number.isFinite))
|
|
19
|
+
return "finite";
|
|
20
|
+
return value.width < 0 || value.height < 0 ? "negative" : "ok";
|
|
21
|
+
}
|
|
22
|
+
export function compileProjectionContext(snapshot) {
|
|
23
|
+
if (snapshot.protocolVersion !== 1)
|
|
24
|
+
return compileError("unsupported-protocol-version", "Only protocol version 1 is supported");
|
|
25
|
+
if (typeof snapshot.cameraId !== "string" || snapshot.cameraId.trim() === "")
|
|
26
|
+
return compileError("invalid-camera-id", "cameraId must be non-empty");
|
|
27
|
+
if (typeof snapshot.clockId !== "string" || snapshot.clockId.trim() === "" || !Number.isFinite(snapshot.capturedAtMs))
|
|
28
|
+
return compileError("invalid-clock-id", "clockId must be non-empty and capturedAtMs finite");
|
|
29
|
+
for (const [key, from, to] of expectedLabels) {
|
|
30
|
+
const value = snapshot[key];
|
|
31
|
+
if (value.from !== from || value.to !== to)
|
|
32
|
+
return compileError("transform-space-mismatch", `${String(key)} must map ${from} to ${to}`);
|
|
33
|
+
if (!isFiniteTransform(value))
|
|
34
|
+
return compileError("non-finite-transform", `${String(key)} contains a non-finite component`);
|
|
35
|
+
}
|
|
36
|
+
const dimensionValues = [snapshot.cameraViewport, snapshot.gameLogicalSize, snapshot.gameViewportInBacking, snapshot.canvasBackingSize, snapshot.canvasCssSize, snapshot.canvasContentClientRect, snapshot.overlayClientRect];
|
|
37
|
+
for (const value of dimensionValues) {
|
|
38
|
+
const validity = "x" in value ? rawRectIsValid(value) : rawSizeIsValid(value);
|
|
39
|
+
if (validity === "finite")
|
|
40
|
+
return compileError("non-finite-transform", "Snapshot dimensions and coordinates must be finite");
|
|
41
|
+
if (validity === "negative")
|
|
42
|
+
return compileError("negative-dimension", "Snapshot dimensions must be non-negative");
|
|
43
|
+
if (value.width === 0 || value.height === 0)
|
|
44
|
+
return compileError("zero-sized-surface", "Projection surfaces must be non-zero");
|
|
45
|
+
}
|
|
46
|
+
const { cameraToGame } = snapshot;
|
|
47
|
+
if (![cameraToGame.a, cameraToGame.d].every((value) => approximately(value, 1)) || ![cameraToGame.b, cameraToGame.c].every((value) => approximately(value, 0)) ||
|
|
48
|
+
!approximately(cameraToGame.e, snapshot.cameraViewport.x) || !approximately(cameraToGame.f, snapshot.cameraViewport.y)) {
|
|
49
|
+
return compileError("inconsistent-transform-snapshot", "cameraToGame must be an identity-linear viewport translation");
|
|
50
|
+
}
|
|
51
|
+
const gameBounds = rect2("game", 0, 0, snapshot.gameLogicalSize.width, snapshot.gameLogicalSize.height);
|
|
52
|
+
const backingBounds = rect2("backing", 0, 0, snapshot.canvasBackingSize.width, snapshot.canvasBackingSize.height);
|
|
53
|
+
const cssBounds = rect2("canvas-css", 0, 0, snapshot.canvasCssSize.width, snapshot.canvasCssSize.height);
|
|
54
|
+
if (!equalRect(transformRectBounds(snapshot.gameToBacking, gameBounds), snapshot.gameViewportInBacking))
|
|
55
|
+
return compileError("inconsistent-transform-snapshot", "gameToBacking disagrees with gameViewportInBacking");
|
|
56
|
+
if (!equalRect(transformRectBounds(snapshot.backingToCanvasCss, backingBounds), cssBounds))
|
|
57
|
+
return compileError("inconsistent-transform-snapshot", "backingToCanvasCss disagrees with canvasCssSize");
|
|
58
|
+
if (!equalRect(transformRectBounds(snapshot.canvasCssToClient, cssBounds), snapshot.canvasContentClientRect))
|
|
59
|
+
return compileError("inconsistent-transform-snapshot", "canvasCssToClient disagrees with canvasContentClientRect");
|
|
60
|
+
const overlayMapped = transformRectBounds(snapshot.clientToOverlay, snapshot.overlayClientRect);
|
|
61
|
+
if (!equalRect(overlayMapped, { x: 0, y: 0, width: snapshot.overlayClientRect.width, height: snapshot.overlayClientRect.height }) ||
|
|
62
|
+
!approximately(snapshot.clientToOverlay.a, 1) || !approximately(snapshot.clientToOverlay.b, 0) || !approximately(snapshot.clientToOverlay.c, 0) || !approximately(snapshot.clientToOverlay.d, 1))
|
|
63
|
+
return compileError("inconsistent-transform-snapshot", "clientToOverlay must preserve the axis-aligned overlay reference box");
|
|
64
|
+
const worldToGame = composeTransforms(snapshot.worldToCamera, snapshot.cameraToGame);
|
|
65
|
+
const gameToCanvasCss = composeTransforms(snapshot.gameToBacking, snapshot.backingToCanvasCss);
|
|
66
|
+
const gameToClient = composeTransforms(gameToCanvasCss, snapshot.canvasCssToClient);
|
|
67
|
+
const worldToClient = composeTransforms(worldToGame, gameToClient);
|
|
68
|
+
const gameToOverlay = composeTransforms(gameToClient, snapshot.clientToOverlay);
|
|
69
|
+
const worldToOverlay = composeTransforms(worldToClient, snapshot.clientToOverlay);
|
|
70
|
+
const gameToWorld = invertTransform(worldToGame);
|
|
71
|
+
const clientToWorld = invertTransform(worldToClient);
|
|
72
|
+
const overlayToWorld = invertTransform(worldToOverlay);
|
|
73
|
+
const overlayToGame = invertTransform(gameToOverlay);
|
|
74
|
+
if (!gameToWorld.ok || !clientToWorld.ok || !overlayToWorld.ok || !overlayToGame.ok)
|
|
75
|
+
return compileError("non-invertible-transform", "The required projection path is singular");
|
|
76
|
+
return { ok: true, context: {
|
|
77
|
+
source: snapshot, worldToGame, gameToWorld: gameToWorld.value, worldToClient, clientToWorld: clientToWorld.value,
|
|
78
|
+
worldToOverlay, overlayToWorld: overlayToWorld.value, gameToOverlay, overlayToGame: overlayToGame.value,
|
|
79
|
+
cameraClipInGame: snapshot.cameraViewport,
|
|
80
|
+
canvasClipInClient: transformRectQuad(snapshot.canvasCssToClient, cssBounds),
|
|
81
|
+
overlayClip: rect2("overlay", 0, 0, snapshot.overlayClientRect.width, snapshot.overlayClientRect.height),
|
|
82
|
+
} };
|
|
83
|
+
}
|
|
84
|
+
function calculateVisibility(context, game, client, overlay, options) {
|
|
85
|
+
const cameraRect = options.cameraMargins === undefined ? context.cameraClipInGame : expandRect(context.cameraClipInGame, options.cameraMargins);
|
|
86
|
+
const inCamera = containsPoint(cameraRect, game);
|
|
87
|
+
const inCanvas = containsPointInConvexQuad(context.canvasClipInClient, client);
|
|
88
|
+
const inOverlay = containsPoint(context.overlayClip, overlay);
|
|
89
|
+
const kind = options.sourceVisible === false ? "source-hidden" : !context.source.cameraVisible ? "camera-hidden" : !inCamera ? "outside-camera" : !inCanvas ? "outside-canvas" : !inOverlay ? "outside-overlay" : "visible";
|
|
90
|
+
return { kind, inCamera, inCanvas, inOverlay };
|
|
91
|
+
}
|
|
92
|
+
function roundOverlay(context, point, rounding) {
|
|
93
|
+
if (rounding === "none")
|
|
94
|
+
return point;
|
|
95
|
+
if (rounding === "integer")
|
|
96
|
+
return point2("overlay", Math.round(point.x), Math.round(point.y));
|
|
97
|
+
const stepX = Math.hypot(context.source.backingToCanvasCss.a, context.source.backingToCanvasCss.b) || 1;
|
|
98
|
+
const stepY = Math.hypot(context.source.backingToCanvasCss.c, context.source.backingToCanvasCss.d) || 1;
|
|
99
|
+
return point2("overlay", Math.round(point.x / stepX) * stepX, Math.round(point.y / stepY) * stepY);
|
|
100
|
+
}
|
|
101
|
+
export function toNormalizedPoint(point, overlaySize) {
|
|
102
|
+
if (!isFinitePoint(point) || overlaySize.width <= 0 || overlaySize.height <= 0)
|
|
103
|
+
throw new RangeError("Point must be finite and overlay size must be positive");
|
|
104
|
+
return point2("normalized", point.x / overlaySize.width, point.y / overlaySize.height);
|
|
105
|
+
}
|
|
106
|
+
export function fromNormalizedPoint(point, overlaySize) {
|
|
107
|
+
if (!isFinitePoint(point) || overlaySize.width <= 0 || overlaySize.height <= 0)
|
|
108
|
+
throw new RangeError("Point must be finite and overlay size must be positive");
|
|
109
|
+
return point2("overlay", point.x * overlaySize.width, point.y * overlaySize.height);
|
|
110
|
+
}
|
|
111
|
+
function projectedFromGame(context, game, world, options) {
|
|
112
|
+
if (!isFinitePoint(game) || !isFinitePoint(world))
|
|
113
|
+
return { ok: false, error: { code: "invalid-point", message: "Point coordinates must be finite" } };
|
|
114
|
+
const backing = transformPoint(context.source.gameToBacking, game);
|
|
115
|
+
const canvasCss = transformPoint(context.source.backingToCanvasCss, backing);
|
|
116
|
+
const client = transformPoint(context.source.canvasCssToClient, canvasCss);
|
|
117
|
+
const unroundedOverlay = transformPoint(context.source.clientToOverlay, client);
|
|
118
|
+
const overlay = roundOverlay(context, unroundedOverlay, options.rounding ?? "none");
|
|
119
|
+
const normalized = toNormalizedPoint(overlay, { width: context.overlayClip.width, height: context.overlayClip.height });
|
|
120
|
+
return { ok: true, value: { worldPoint: world, gamePoint: game, canvasCssPoint: canvasCss, clientPoint: client, overlayPoint: overlay, normalizedPoint: normalized, visibility: calculateVisibility(context, game, client, overlay, options) } };
|
|
121
|
+
}
|
|
122
|
+
export function projectWorldPoint(context, point, options = {}) {
|
|
123
|
+
if (!isFinitePoint(point))
|
|
124
|
+
return { ok: false, error: { code: "invalid-point", message: "Point coordinates must be finite" } };
|
|
125
|
+
return projectedFromGame(context, transformPoint(context.worldToGame, point), point, options);
|
|
126
|
+
}
|
|
127
|
+
export function projectGamePoint(context, point, options = {}) {
|
|
128
|
+
if (!isFinitePoint(point))
|
|
129
|
+
return { ok: false, error: { code: "invalid-point", message: "Point coordinates must be finite" } };
|
|
130
|
+
return projectedFromGame(context, point, transformPoint(context.gameToWorld, point), options);
|
|
131
|
+
}
|
|
132
|
+
export function projectWorldPoints(context, points, options = {}, output = []) {
|
|
133
|
+
output.length = 0;
|
|
134
|
+
const invalid = [];
|
|
135
|
+
points.forEach((point, index) => { const result = projectWorldPoint(context, point, options); if (result.ok)
|
|
136
|
+
output.push(result.value);
|
|
137
|
+
else
|
|
138
|
+
invalid.push({ index, error: result.error }); });
|
|
139
|
+
return { points: output, invalid };
|
|
140
|
+
}
|
|
141
|
+
function checkAge(context, options) {
|
|
142
|
+
if (options.maxSnapshotAgeMs === undefined)
|
|
143
|
+
return null;
|
|
144
|
+
if (!Number.isFinite(options.maxSnapshotAgeMs) || options.maxSnapshotAgeMs < 0 || !Number.isFinite(options.nowMs) || options.clockId !== context.source.clockId)
|
|
145
|
+
return { ok: false, error: { code: "clock-mismatch" } };
|
|
146
|
+
return options.nowMs - context.source.capturedAtMs > options.maxSnapshotAgeMs ? { ok: false, error: { code: "stale-snapshot" } } : null;
|
|
147
|
+
}
|
|
148
|
+
function inverseFromGame(context, gameInput, inCanvas, options) {
|
|
149
|
+
const age = checkAge(context, options);
|
|
150
|
+
if (age !== null)
|
|
151
|
+
return age;
|
|
152
|
+
if (!isFinitePoint(gameInput))
|
|
153
|
+
return { ok: false, error: { code: "invalid-point" } };
|
|
154
|
+
const outside = options.outside ?? "reject";
|
|
155
|
+
if (outside !== "allow" && !inCanvas)
|
|
156
|
+
return { ok: false, error: { code: "outside-canvas" } };
|
|
157
|
+
const inCamera = containsPoint(context.cameraClipInGame, gameInput);
|
|
158
|
+
if (outside === "reject" && !inCamera)
|
|
159
|
+
return { ok: false, error: { code: "outside-camera" } };
|
|
160
|
+
const wasClamped = outside === "clamp-to-camera" && !inCamera;
|
|
161
|
+
const game = wasClamped ? clampPointToHalfOpenRect(gameInput, context.cameraClipInGame) : gameInput;
|
|
162
|
+
return { ok: true, gamePoint: game, worldPoint: transformPoint(context.gameToWorld, game), wasClamped };
|
|
163
|
+
}
|
|
164
|
+
export function clientPointToWorld(context, point, options = {}) {
|
|
165
|
+
if (!isFinitePoint(point))
|
|
166
|
+
return { ok: false, error: { code: "invalid-point" } };
|
|
167
|
+
return inverseFromGame(context, transformPoint(composeTransforms(context.clientToWorld, context.worldToGame), point), containsPointInConvexQuad(context.canvasClipInClient, point), options);
|
|
168
|
+
}
|
|
169
|
+
export function overlayPointToGame(context, point, options = {}) {
|
|
170
|
+
if (!isFinitePoint(point))
|
|
171
|
+
return { ok: false, error: { code: "invalid-point" } };
|
|
172
|
+
const overlayToClient = invertTransform(context.source.clientToOverlay);
|
|
173
|
+
if (!overlayToClient.ok)
|
|
174
|
+
return { ok: false, error: { code: "non-invertible-transform" } };
|
|
175
|
+
const client = transformPoint(overlayToClient.value, point);
|
|
176
|
+
return inverseFromGame(context, transformPoint(context.overlayToGame, point), containsPointInConvexQuad(context.canvasClipInClient, client), options);
|
|
177
|
+
}
|
|
178
|
+
export const overlayPointToWorld = overlayPointToGame;
|
|
179
|
+
function exactTransform(left, right) {
|
|
180
|
+
return left.from === right.from && left.to === right.to && left.a === right.a && left.b === right.b && left.c === right.c && left.d === right.d && left.e === right.e && left.f === right.f;
|
|
181
|
+
}
|
|
182
|
+
export function areTransformSnapshotsEqual(left, right) {
|
|
183
|
+
if (left === right)
|
|
184
|
+
return true;
|
|
185
|
+
return left.protocolVersion === right.protocolVersion && left.cameraId === right.cameraId && left.capturedAtMs === right.capturedAtMs && left.clockId === right.clockId &&
|
|
186
|
+
left.cameraVisible === right.cameraVisible && left.roundPixels === right.roundPixels &&
|
|
187
|
+
expectedLabels.every(([key]) => exactTransform(left[key], right[key])) &&
|
|
188
|
+
equalRectExact(left.cameraViewport, right.cameraViewport) && equalSizeExact(left.gameLogicalSize, right.gameLogicalSize) &&
|
|
189
|
+
equalRectExact(left.gameViewportInBacking, right.gameViewportInBacking) && equalSizeExact(left.canvasBackingSize, right.canvasBackingSize) &&
|
|
190
|
+
equalSizeExact(left.canvasCssSize, right.canvasCssSize) && equalRectExact(left.canvasContentClientRect, right.canvasContentClientRect) && equalRectExact(left.overlayClientRect, right.overlayClientRect);
|
|
191
|
+
}
|
|
192
|
+
const equalRectExact = (left, right) => left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
|
|
193
|
+
const equalSizeExact = (left, right) => left.width === right.width && left.height === right.height;
|
|
194
|
+
export { approximatelyEqualTransform };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type GameCoordinateSpace = "world" | "camera" | "game" | "backing" | "canvas-css" | "client" | "overlay" | "normalized";
|
|
2
|
+
declare const coordinateSpaceBrand: unique symbol;
|
|
3
|
+
export type CoordinateSpaceBrand<TSpace extends GameCoordinateSpace> = {
|
|
4
|
+
readonly [coordinateSpaceBrand]: TSpace;
|
|
5
|
+
};
|
|
6
|
+
export type Point2<TSpace extends GameCoordinateSpace> = Readonly<{
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
}> & CoordinateSpaceBrand<TSpace>;
|
|
10
|
+
export type Vector2<TSpace extends GameCoordinateSpace> = Point2<TSpace>;
|
|
11
|
+
export type Size2<TSpace extends GameCoordinateSpace> = Readonly<{
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}> & CoordinateSpaceBrand<TSpace>;
|
|
15
|
+
export type Rect2<TSpace extends GameCoordinateSpace> = Readonly<{
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
}> & CoordinateSpaceBrand<TSpace>;
|
|
21
|
+
export type ConvexQuad2<TSpace extends GameCoordinateSpace> = Readonly<{
|
|
22
|
+
topLeft: Point2<TSpace>;
|
|
23
|
+
topRight: Point2<TSpace>;
|
|
24
|
+
bottomRight: Point2<TSpace>;
|
|
25
|
+
bottomLeft: Point2<TSpace>;
|
|
26
|
+
}> & CoordinateSpaceBrand<TSpace>;
|
|
27
|
+
export interface GeometryParseError {
|
|
28
|
+
readonly code: "invalid-value" | "unexpected-field";
|
|
29
|
+
readonly message: string;
|
|
30
|
+
}
|
|
31
|
+
export type GeometryParseResult<T> = {
|
|
32
|
+
readonly ok: true;
|
|
33
|
+
readonly value: T;
|
|
34
|
+
} | {
|
|
35
|
+
readonly ok: false;
|
|
36
|
+
readonly error: GeometryParseError;
|
|
37
|
+
};
|
|
38
|
+
export interface GeometryParseOptions {
|
|
39
|
+
readonly strict?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare function point2<TSpace extends GameCoordinateSpace>(_space: TSpace, x: number, y: number): Point2<TSpace>;
|
|
42
|
+
export declare const worldPoint: (x: number, y: number) => Point2<"world">;
|
|
43
|
+
export declare const worldVector: (x: number, y: number) => Vector2<"world">;
|
|
44
|
+
export declare const cameraPoint: (x: number, y: number) => Point2<"camera">;
|
|
45
|
+
export declare const gamePoint: (x: number, y: number) => Point2<"game">;
|
|
46
|
+
export declare const backingPoint: (x: number, y: number) => Point2<"backing">;
|
|
47
|
+
export declare const canvasCssPoint: (x: number, y: number) => Point2<"canvas-css">;
|
|
48
|
+
export declare const clientPoint: (x: number, y: number) => Point2<"client">;
|
|
49
|
+
export declare const overlayPoint: (x: number, y: number) => Point2<"overlay">;
|
|
50
|
+
export declare const overlayVector: (x: number, y: number) => Vector2<"overlay">;
|
|
51
|
+
export declare const normalizedPoint: (x: number, y: number) => Point2<"normalized">;
|
|
52
|
+
export declare function size2<TSpace extends GameCoordinateSpace>(_space: TSpace, width: number, height: number): Size2<TSpace>;
|
|
53
|
+
export declare function rect2<TSpace extends GameCoordinateSpace>(_space: TSpace, x: number, y: number, width: number, height: number): Rect2<TSpace>;
|
|
54
|
+
export declare function convexQuad2<TSpace extends GameCoordinateSpace>(_space: TSpace, topLeft: Point2<TSpace>, topRight: Point2<TSpace>, bottomRight: Point2<TSpace>, bottomLeft: Point2<TSpace>): ConvexQuad2<TSpace>;
|
|
55
|
+
export declare function parsePoint2<TSpace extends GameCoordinateSpace>(space: TSpace, value: unknown, options?: GeometryParseOptions): GeometryParseResult<Point2<TSpace>>;
|
|
56
|
+
export declare function parseSize2<TSpace extends GameCoordinateSpace>(space: TSpace, value: unknown, options?: GeometryParseOptions): GeometryParseResult<Size2<TSpace>>;
|
|
57
|
+
export declare function parseRect2<TSpace extends GameCoordinateSpace>(space: TSpace, value: unknown, options?: GeometryParseOptions): GeometryParseResult<Rect2<TSpace>>;
|
|
58
|
+
export declare function isFinitePoint(point: Readonly<{
|
|
59
|
+
x: number;
|
|
60
|
+
y: number;
|
|
61
|
+
}>): boolean;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
function assertFinite(value, name) {
|
|
2
|
+
if (!Number.isFinite(value)) {
|
|
3
|
+
throw new RangeError(`${name} must be finite`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
function assertDimension(value, name) {
|
|
7
|
+
assertFinite(value, name);
|
|
8
|
+
if (value < 0) {
|
|
9
|
+
throw new RangeError(`${name} must be non-negative`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function point2(_space, x, y) {
|
|
13
|
+
assertFinite(x, "x");
|
|
14
|
+
assertFinite(y, "y");
|
|
15
|
+
return { x, y };
|
|
16
|
+
}
|
|
17
|
+
export const worldPoint = (x, y) => point2("world", x, y);
|
|
18
|
+
export const worldVector = (x, y) => point2("world", x, y);
|
|
19
|
+
export const cameraPoint = (x, y) => point2("camera", x, y);
|
|
20
|
+
export const gamePoint = (x, y) => point2("game", x, y);
|
|
21
|
+
export const backingPoint = (x, y) => point2("backing", x, y);
|
|
22
|
+
export const canvasCssPoint = (x, y) => point2("canvas-css", x, y);
|
|
23
|
+
export const clientPoint = (x, y) => point2("client", x, y);
|
|
24
|
+
export const overlayPoint = (x, y) => point2("overlay", x, y);
|
|
25
|
+
export const overlayVector = (x, y) => point2("overlay", x, y);
|
|
26
|
+
export const normalizedPoint = (x, y) => point2("normalized", x, y);
|
|
27
|
+
export function size2(_space, width, height) {
|
|
28
|
+
assertDimension(width, "width");
|
|
29
|
+
assertDimension(height, "height");
|
|
30
|
+
return { width, height };
|
|
31
|
+
}
|
|
32
|
+
export function rect2(_space, x, y, width, height) {
|
|
33
|
+
assertFinite(x, "x");
|
|
34
|
+
assertFinite(y, "y");
|
|
35
|
+
assertDimension(width, "width");
|
|
36
|
+
assertDimension(height, "height");
|
|
37
|
+
return { x, y, width, height };
|
|
38
|
+
}
|
|
39
|
+
export function convexQuad2(_space, topLeft, topRight, bottomRight, bottomLeft) {
|
|
40
|
+
return { topLeft, topRight, bottomRight, bottomLeft };
|
|
41
|
+
}
|
|
42
|
+
function parseRecord(value, fields, dimensions, options) {
|
|
43
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
44
|
+
return { ok: false, error: { code: "invalid-value", message: "Expected a plain object" } };
|
|
45
|
+
}
|
|
46
|
+
const record = value;
|
|
47
|
+
for (const field of fields) {
|
|
48
|
+
if (!Object.prototype.propertyIsEnumerable.call(record, field) || typeof record[field] !== "number" || !Number.isFinite(record[field])) {
|
|
49
|
+
return { ok: false, error: { code: "invalid-value", message: `${field} must be an own enumerable finite number` } };
|
|
50
|
+
}
|
|
51
|
+
if (dimensions.includes(field) && record[field] < 0) {
|
|
52
|
+
return { ok: false, error: { code: "invalid-value", message: `${field} must be non-negative` } };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (options.strict) {
|
|
56
|
+
const unexpected = Object.keys(record).find((field) => !fields.includes(field));
|
|
57
|
+
if (unexpected !== undefined) {
|
|
58
|
+
return { ok: false, error: { code: "unexpected-field", message: `Unexpected field: ${unexpected}` } };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, value: record };
|
|
62
|
+
}
|
|
63
|
+
export function parsePoint2(space, value, options = {}) {
|
|
64
|
+
const parsed = parseRecord(value, ["x", "y"], [], options);
|
|
65
|
+
return parsed.ok ? { ok: true, value: point2(space, parsed.value.x, parsed.value.y) } : parsed;
|
|
66
|
+
}
|
|
67
|
+
export function parseSize2(space, value, options = {}) {
|
|
68
|
+
const parsed = parseRecord(value, ["width", "height"], ["width", "height"], options);
|
|
69
|
+
return parsed.ok ? { ok: true, value: size2(space, parsed.value.width, parsed.value.height) } : parsed;
|
|
70
|
+
}
|
|
71
|
+
export function parseRect2(space, value, options = {}) {
|
|
72
|
+
const parsed = parseRecord(value, ["x", "y", "width", "height"], ["width", "height"], options);
|
|
73
|
+
return parsed.ok
|
|
74
|
+
? { ok: true, value: rect2(space, parsed.value.x, parsed.value.y, parsed.value.width, parsed.value.height) }
|
|
75
|
+
: parsed;
|
|
76
|
+
}
|
|
77
|
+
export function isFinitePoint(point) {
|
|
78
|
+
return Number.isFinite(point.x) && Number.isFinite(point.y);
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ConvexQuad2, type GameCoordinateSpace, type Point2, type Rect2 } from "./types.js";
|
|
2
|
+
export interface Insets2 {
|
|
3
|
+
readonly top: number;
|
|
4
|
+
readonly right: number;
|
|
5
|
+
readonly bottom: number;
|
|
6
|
+
readonly left: number;
|
|
7
|
+
}
|
|
8
|
+
export interface VisibilityMargins2 {
|
|
9
|
+
readonly top: number;
|
|
10
|
+
readonly right: number;
|
|
11
|
+
readonly bottom: number;
|
|
12
|
+
readonly left: number;
|
|
13
|
+
}
|
|
14
|
+
export interface RayRectIntersection<TSpace extends GameCoordinateSpace> {
|
|
15
|
+
readonly point: Point2<TSpace>;
|
|
16
|
+
readonly edge: "top" | "right" | "bottom" | "left";
|
|
17
|
+
readonly distance: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function insetRect<TSpace extends GameCoordinateSpace>(rect: Rect2<TSpace>, insets: Insets2): Rect2<TSpace>;
|
|
20
|
+
export declare function expandRect<TSpace extends GameCoordinateSpace>(rect: Rect2<TSpace>, margins: VisibilityMargins2): Rect2<TSpace>;
|
|
21
|
+
export declare function containsPoint<TSpace extends GameCoordinateSpace>(rect: Rect2<TSpace>, point: Point2<TSpace>): boolean;
|
|
22
|
+
export declare function containsPointInConvexQuad<TSpace extends GameCoordinateSpace>(quad: ConvexQuad2<TSpace>, point: Point2<TSpace>): boolean;
|
|
23
|
+
export declare function intersectRects<TSpace extends GameCoordinateSpace>(left: Rect2<TSpace>, right: Rect2<TSpace>): Rect2<TSpace> | null;
|
|
24
|
+
export declare function unionRects<TSpace extends GameCoordinateSpace>(left: Rect2<TSpace>, right: Rect2<TSpace>): Rect2<TSpace>;
|
|
25
|
+
export declare function clampPointToRect<TSpace extends GameCoordinateSpace>(point: Point2<TSpace>, rect: Rect2<TSpace>): Point2<TSpace>;
|
|
26
|
+
export declare function clampPointToHalfOpenRect<TSpace extends GameCoordinateSpace>(point: Point2<TSpace>, rect: Rect2<TSpace>): Point2<TSpace>;
|
|
27
|
+
export declare function closestPointOnRectEdge<TSpace extends GameCoordinateSpace>(point: Point2<TSpace>, rect: Rect2<TSpace>): Point2<TSpace>;
|
|
28
|
+
export declare function intersectRayWithRect<TSpace extends GameCoordinateSpace>(origin: Point2<TSpace>, direction: Point2<TSpace>, rect: Rect2<TSpace>): RayRectIntersection<TSpace> | null;
|
|
29
|
+
export declare function overflowInsets<TSpace extends GameCoordinateSpace>(inner: Rect2<TSpace>, outer: Rect2<TSpace>): Insets2;
|
|
30
|
+
export declare function overlapArea<TSpace extends GameCoordinateSpace>(left: Rect2<TSpace>, right: Rect2<TSpace>): number;
|