@threenative/core 0.1.0 → 0.3.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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * The UI bridge — one message channel between the game and the UI, identical on every host.
3
+ *
4
+ * The UI runs in the platform's own browser-class renderer (a `WebView` on Android, a
5
+ * `WKWebView` on iOS, a child web view on desktop) while the game runs in the native
6
+ * runtime beside it. They are two JavaScript realms in, usually, two processes, so
7
+ * everything that crosses is a message.
8
+ *
9
+ * Every host already offers a `MessagePort`-shaped primitive — `addWebMessageListener` on
10
+ * Android, `WKScriptMessageHandler` on iOS, an IPC handler on desktop — so this file adopts
11
+ * that shape rather than inventing one, and each host only has to fill in two slots:
12
+ *
13
+ * - **outbound**, a function that takes one JSON string;
14
+ * - **inbound**, a global the host calls with one JSON string.
15
+ *
16
+ * On the web target there is no host and no second realm: the game and the UI are the same
17
+ * page, so both ends connect to an in-process broker and the same `src/ui/` code runs
18
+ * unchanged. That is the whole point — the transport differs, the protocol does not.
19
+ *
20
+ * Fail closed: a message that is not a JSON object with a string `type` throws on the way
21
+ * out, and a malformed frame throws on the way in. A dropped message is a UI that silently
22
+ * stops updating, which is the failure this refuses to have.
23
+ */
24
+ /** Which side of the bridge a caller is on. `ui` runs in the web view; `game` in the runtime. */
25
+ type UiBridgeEnd = "game" | "ui";
26
+ /** How a connected bridge actually moves bytes. Reported, never chosen by a game. */
27
+ type UiBridgeTransport = "host" | "in-process";
28
+ /** Every frame on the bridge is a JSON object whose `type` names it. */
29
+ interface IUiMessage {
30
+ readonly type: string;
31
+ readonly [key: string]: unknown;
32
+ }
33
+ interface IUiBridge {
34
+ /** Which end this handle is. */
35
+ readonly end: UiBridgeEnd;
36
+ /** `host` when a platform channel was found, `in-process` on the web target. */
37
+ readonly transport: UiBridgeTransport;
38
+ /**
39
+ * Whether anything is listening on the other end.
40
+ *
41
+ * A game whose UI renderer is `native` has no peer and never will, and publishing state to
42
+ * nobody is a JSON serialisation of the whole store several times a second for no reader.
43
+ * Reported rather than assumed: on a host transport this asks the host, so it also answers
44
+ * "did the overlay actually come up" honestly.
45
+ */
46
+ hasPeer(): boolean;
47
+ /** Send one message to the other end. Throws if it is not a typed JSON object. */
48
+ post(message: IUiMessage): void;
49
+ /** Listen for messages from the other end. Returns an unsubscribe function. */
50
+ onMessage(listener: (message: IUiMessage) => void): () => void;
51
+ /** Detach every listener and release the host slots this end installed. */
52
+ close(): void;
53
+ }
54
+ /** The message the UI end publishes whenever its interactive rectangles move. */
55
+ declare const HIT_REGIONS_MESSAGE = "tn:hit-regions";
56
+ /** The message the game end publishes when its shared state changes. */
57
+ declare const GAME_STATE_MESSAGE = "tn:state";
58
+ /** The message the UI end sends when the player acts on a control. */
59
+ declare const UI_INTENT_MESSAGE = "tn:intent";
60
+ /**
61
+ * The intent the UI layer sends once, when its tree has rendered and its rectangles are published.
62
+ *
63
+ * Namespaced so it cannot collide with a game's own vocabulary, and framework-sent so a game does
64
+ * not have to remember to announce itself. It is what lets "the overlay never came up" be told
65
+ * apart from "the game has no HUD" — two states that look identical in a screenshot.
66
+ */
67
+ declare const UI_READY_INTENT = "tn:ready";
68
+ /**
69
+ * The globals each host fills in. Named here so a host implementation and this file cannot
70
+ * drift: an Android, iOS or desktop host that writes different names has no bridge at all,
71
+ * and a typo is a silent dead channel rather than a build error.
72
+ */
73
+ declare const UI_BRIDGE_GLOBALS: {
74
+ /** UI end, inbound: the host calls this with one JSON string. */
75
+ readonly uiReceive: "__tnUiReceive";
76
+ /** UI end, outbound: an injected object with `postMessage(string)`. */
77
+ readonly uiHost: "tnHost";
78
+ /** Game end, inbound: the runtime calls this with one JSON string. */
79
+ readonly gameReceive: "__tnUiGameReceive";
80
+ /** Game end, outbound: the runtime installs this and forwards to the web view. */
81
+ readonly gamePost: "__tnUiPost";
82
+ };
83
+ /**
84
+ * The realm a bridge end installs into.
85
+ *
86
+ * Indexed rather than typed as `Window`, because the host globals it looks for are ones no
87
+ * TypeScript DOM library has heard of and the tests drive it with a plain object.
88
+ */
89
+ interface IScope {
90
+ [key: string]: unknown;
91
+ }
92
+ interface IConnectOptions {
93
+ readonly end: UiBridgeEnd;
94
+ /** The realm to install into. Defaults to `globalThis`; injected by tests and by hosts. */
95
+ readonly scope?: IScope;
96
+ }
97
+ /**
98
+ * Connect one end of the bridge.
99
+ *
100
+ * The transport is discovered, never configured: a game asks for `ui` or `game` and gets the
101
+ * platform channel when one exists and the in-process broker when it does not. That is what
102
+ * keeps the backend — which web view, which host API — out of every game's source.
103
+ * @situation connect game code to a platform-owned UI realm
104
+ * @situation share one UI bridge implementation across web, Android, iOS, and desktop
105
+ * @example const bridge = connectUiBridge({ end: "game" });
106
+ */
107
+ declare function connectUiBridge(options: IConnectOptions): IUiBridge;
108
+
109
+ /**
110
+ * What crosses the bridge, in both directions: published state out, intents back.
111
+ *
112
+ * The UI is a different realm from the game on every native target, so it cannot hold the game
113
+ * object, subscribe to its store, or call its methods. It holds a **mirror** — the last state
114
+ * the game published — and it sends **intents**, which the game is free to ignore.
115
+ *
116
+ * That asymmetry is deliberate and it is what keeps one `src/ui/` honest. A HUD written against
117
+ * a live store works on web and silently has nothing to read on a phone; a HUD written against
118
+ * the mirror behaves identically on both, because on web the mirror is fed by the same
119
+ * publication through an in-process channel.
120
+ *
121
+ * Publications are **coalesced**: many store writes inside one turn produce one frame. React
122
+ * must never re-render on the game loop, and neither may the bridge carry a frame per tick.
123
+ */
124
+ /**
125
+ * The minimum a store must offer to be published.
126
+ *
127
+ * `getPublishedState` is optional and preferred when present: ThreeNative's game store keeps a
128
+ * live `getState()` that moves every tick and a `getPublishedState()` that moves at most ten
129
+ * times a second. The UI wants the throttled one — the live one would put a bridge frame on
130
+ * every tick, which is the thing React must never do.
131
+ */
132
+ interface IPublishableStore<T> {
133
+ getState(): T;
134
+ getPublishedState?(): T;
135
+ subscribe(listener: () => void): () => void;
136
+ }
137
+ interface IUiStatePublisher {
138
+ /** Send the current state now, whether or not it changed. */
139
+ publish(): void;
140
+ /** Stop publishing. The mirror keeps whatever it last received. */
141
+ stop(): void;
142
+ }
143
+ interface IUiStateMirror<T> {
144
+ /** The last state the game published, or undefined before the first frame arrives. */
145
+ get(): T | undefined;
146
+ /** Called after each accepted publication. Returns an unsubscribe function. */
147
+ subscribe(listener: () => void): () => void;
148
+ /** Stop listening. */
149
+ stop(): void;
150
+ }
151
+ interface IPublishOptions {
152
+ /**
153
+ * How a coalesced publication is deferred. Defaults to a microtask, which collapses every
154
+ * write in one turn into one frame. A game with a chatty store may pass a frame scheduler.
155
+ */
156
+ readonly schedule?: (flush: () => void) => void;
157
+ }
158
+ /**
159
+ * Publish a store to the UI over `bridge`.
160
+ *
161
+ * Only the `game` end publishes: the UI is a mirror, and a UI that could write the game's state
162
+ * directly would be a second source of truth that only diverges on the platform where the two
163
+ * are actually separate processes.
164
+ * @situation publish game state to a HUD in another realm
165
+ * @situation keep a web and native UI mirror on the same throttled state stream
166
+ * @example const publisher = publishUiState(bridge, store);
167
+ */
168
+ declare function publishUiState<T>(bridge: IUiBridge, store: IPublishableStore<T>, options?: IPublishOptions): IUiStatePublisher;
169
+ /**
170
+ * Mirror the game's published state on the UI end.
171
+ *
172
+ * Fail closed: a `tn:state` frame with no `state` object throws rather than leaving the mirror
173
+ * showing stale values, which is the failure mode where a HUD keeps displaying the last health
174
+ * it saw and nobody can tell it stopped updating.
175
+ * @situation read published game state from a UI HUD
176
+ * @situation subscribe a web or native UI to the game's mirrored state
177
+ * @example const mirror = subscribeUiState<{ health: number }>(bridge);
178
+ */
179
+ declare function subscribeUiState<T>(bridge: IUiBridge): IUiStateMirror<T>;
180
+ /**
181
+ * Send one intent from the UI to the game. The game may ignore it; that is not an error.
182
+ * @situation send a button or menu action from a UI HUD to game code
183
+ * @situation keep UI input portable across web and native hosts
184
+ * @example sendUiIntent(bridge, "restart");
185
+ */
186
+ declare function sendUiIntent(bridge: IUiBridge, intent: string, payload?: unknown): void;
187
+ /**
188
+ * Receive UI intents on the game end. Returns an unsubscribe function.
189
+ * @situation handle a HUD button action in game code
190
+ * @situation route native and web UI commands through one listener
191
+ * @example const stop = onUiIntent(bridge, (intent) => handleIntent(intent));
192
+ */
193
+ declare function onUiIntent(bridge: IUiBridge, listener: (intent: string, payload: unknown) => void): () => void;
194
+
195
+ /**
196
+ * The interactive-rect registry — how a touch decides whether it belongs to the UI or the game.
197
+ *
198
+ * A web view is a native view with a **rectangular** hit-test region owned by the platform.
199
+ * CSS hit-testing happens inside that surface, after the native view has already claimed the
200
+ * gesture, so `pointer-events: none` does not hand a touch back to the game beneath. It is a
201
+ * useful property inside the page and it is not the pass-through mechanism.
202
+ *
203
+ * The mechanism is this: the page marks its interactive islands with `data-tn-interactive`,
204
+ * this registry publishes where they are, and the native input host does the hit test before
205
+ * either surface sees the gesture.
206
+ *
207
+ * ```tsx
208
+ * <button data-tn-interactive onClick={restart}>Restart</button>
209
+ * ```
210
+ *
211
+ * Three properties make it correct, and each one is a defect this file exists to prevent:
212
+ *
213
+ * - **Published, never queried.** The host owns a snapshot. Asking the page per touch means an
214
+ * async round trip inside the input path: latency, and a race with the frame that moved it.
215
+ * - **Decided on pointer-down, held to pointer-up.** That rule belongs to the host, because
216
+ * only the host sees the whole gesture; this file just keeps the snapshot true.
217
+ * - **Republished per frame while a transition is live.** A sliding menu is drawn where the
218
+ * compositor put it this frame, and a rect published before the slide points at empty space.
219
+ *
220
+ * Rects are **normalized to the viewport**, 0..1, so no host has to know about CSS pixels,
221
+ * device pixel ratio, or the page's zoom to place them on its own surface. That is also what
222
+ * lets a sibling-layer host and an offscreen-texture host read the same payload.
223
+ */
224
+ /** One interactive rectangle, normalized to the UI viewport: `0,0` top-left, `1,1` bottom-right. */
225
+ interface IHitRegion {
226
+ readonly x: number;
227
+ readonly y: number;
228
+ readonly width: number;
229
+ readonly height: number;
230
+ }
231
+ interface IHitRegionRegistry {
232
+ /** Recompute and publish now. Called for you; exposed for a game that moves a rect itself. */
233
+ refresh(): void;
234
+ /** The regions as last published. */
235
+ regions(): readonly IHitRegion[];
236
+ /** Stop observing and publish an empty set, so the host stops consuming touches. */
237
+ stop(): void;
238
+ }
239
+ interface IRegistryOptions {
240
+ /** The connected bridge whose `ui` end publishes the regions. */
241
+ readonly bridge: IUiBridge;
242
+ /** The realm to observe. Defaults to `globalThis`; injected by tests. */
243
+ readonly scope?: IScopeLike;
244
+ /** The marker attribute. Defaults to `data-tn-interactive`; a game should not change it. */
245
+ readonly attribute?: string;
246
+ }
247
+ /** The attribute a game puts on an element it wants to receive touches. */
248
+ declare const INTERACTIVE_ATTRIBUTE = "data-tn-interactive";
249
+ interface IRectLike {
250
+ readonly left: number;
251
+ readonly top: number;
252
+ readonly width: number;
253
+ readonly height: number;
254
+ }
255
+ interface IElementLike {
256
+ getClientRects?: () => ArrayLike<IRectLike>;
257
+ getBoundingClientRect: () => IRectLike;
258
+ /** Used only to name an unmarked control in the development warning below. */
259
+ closest?: (selector: string) => IElementLike | null;
260
+ tagName?: string;
261
+ id?: string;
262
+ className?: string;
263
+ }
264
+ /** Anything that can be listened to. The document and the window both satisfy it structurally. */
265
+ interface IEventTargetLike {
266
+ addEventListener?: (type: string, listener: () => void, options?: unknown) => void;
267
+ removeEventListener?: (type: string, listener: () => void, options?: unknown) => void;
268
+ }
269
+ interface IDocumentLike extends IEventTargetLike {
270
+ querySelectorAll: (selector: string) => ArrayLike<IElementLike>;
271
+ documentElement?: {
272
+ clientWidth?: number;
273
+ clientHeight?: number;
274
+ };
275
+ }
276
+ /**
277
+ * The realm the registry observes, as the parts of it this file actually touches.
278
+ *
279
+ * Declared structurally rather than as `Window`, because the registry has to be drivable from a
280
+ * test with no DOM — and because the host it really runs in is a web view whose globals the type
281
+ * system here has never seen.
282
+ */
283
+ interface IScopeLike extends IEventTargetLike {
284
+ [key: string]: unknown;
285
+ document?: IDocumentLike;
286
+ console?: {
287
+ warn?: (message: string) => void;
288
+ };
289
+ innerWidth?: number;
290
+ innerHeight?: number;
291
+ requestAnimationFrame?: (callback: () => void) => number;
292
+ cancelAnimationFrame?: (handle: number) => void;
293
+ }
294
+ /**
295
+ * Start publishing interactive rectangles over `bridge`.
296
+ *
297
+ * Fail closed: with no document there is nothing to measure, and a registry that quietly
298
+ * published nothing would look exactly like a UI with no buttons — every touch would fall
299
+ * through to the game and the bug would present as "the button does nothing".
300
+ * @situation give native input hosts the rectangles claimed by UI controls
301
+ * @situation keep touch hit testing aligned with a moving web or native HUD
302
+ * @example const regions = publishHitRegions({ bridge });
303
+ */
304
+ declare function publishHitRegions(options: IRegistryOptions): IHitRegionRegistry;
305
+
306
+ export { GAME_STATE_MESSAGE, HIT_REGIONS_MESSAGE, type IHitRegion, type IHitRegionRegistry, INTERACTIVE_ATTRIBUTE, type IPublishableStore, type IUiBridge, type IUiMessage, type IUiStateMirror, type IUiStatePublisher, UI_BRIDGE_GLOBALS, UI_INTENT_MESSAGE, UI_READY_INTENT, type UiBridgeEnd, type UiBridgeTransport, connectUiBridge, onUiIntent, publishHitRegions, publishUiState, sendUiIntent, subscribeUiState };