@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/capabilities.json +3542 -0
- package/dist/audio-Dp2mXpD3.d.ts +94 -0
- package/dist/canvas-layer-CtrZHgIh.d.ts +608 -0
- package/dist/game-CYIaKhgl.d.ts +980 -0
- package/dist/hot.d.ts +33 -5
- package/dist/hot.js +10 -2
- package/dist/index.d.ts +1480 -41
- package/dist/index.js +12604 -1104
- package/dist/playtest.d.ts +36 -5
- package/dist/playtest.js +160 -468
- package/dist/react.d.ts +175 -0
- package/dist/react.js +635 -0
- package/dist/ui-layer.d.ts +306 -0
- package/dist/ui-layer.js +425 -0
- package/dist/world.d.ts +55 -0
- package/dist/world.js +175 -0
- package/mcp/assets.mjs +5 -0
- package/mcp/engine-server.mjs +253 -0
- package/mcp/engine.mjs +23 -0
- package/mcp/install.mjs +77 -0
- package/mcp/launch.mjs +72 -0
- package/mcp/sculpt.mjs +5 -0
- package/mcp/servers.mjs +59 -0
- package/package.json +49 -6
- package/patches/three@0.185.1.patch +287 -0
- package/scripts/apply-three-patch.mjs +297 -0
- package/scripts/bundle-engine-mcp.mjs +15 -0
- package/scripts/ensure-mcp.mjs +36 -0
- package/scripts/generate-version.mjs +13 -0
- package/scripts/postinstall.mjs +6 -0
- package/dist/audio-3vkjtiuo.d.ts +0 -35
- package/dist/game-DRPs3M7r.d.ts +0 -269
package/dist/ui-layer.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// src/ui-bridge.ts
|
|
2
|
+
var HIT_REGIONS_MESSAGE = "tn:hit-regions";
|
|
3
|
+
var GAME_STATE_MESSAGE = "tn:state";
|
|
4
|
+
var UI_INTENT_MESSAGE = "tn:intent";
|
|
5
|
+
var UI_READY_INTENT = "tn:ready";
|
|
6
|
+
var UI_BRIDGE_GLOBALS = {
|
|
7
|
+
/** UI end, inbound: the host calls this with one JSON string. */
|
|
8
|
+
uiReceive: "__tnUiReceive",
|
|
9
|
+
/** UI end, outbound: an injected object with `postMessage(string)`. */
|
|
10
|
+
uiHost: "tnHost",
|
|
11
|
+
/** Game end, inbound: the runtime calls this with one JSON string. */
|
|
12
|
+
gameReceive: "__tnUiGameReceive",
|
|
13
|
+
/** Game end, outbound: the runtime installs this and forwards to the web view. */
|
|
14
|
+
gamePost: "__tnUiPost"
|
|
15
|
+
};
|
|
16
|
+
function assertSendable(message) {
|
|
17
|
+
if (typeof message !== "object" || message === null || Array.isArray(message)) {
|
|
18
|
+
throw new Error("TN_UI_BRIDGE_MESSAGE_INVALID: a bridge message must be a JSON object.");
|
|
19
|
+
}
|
|
20
|
+
if (typeof message.type !== "string" || message.type.length === 0) {
|
|
21
|
+
throw new Error("TN_UI_BRIDGE_MESSAGE_INVALID: a bridge message must carry a string 'type'.");
|
|
22
|
+
}
|
|
23
|
+
let frame;
|
|
24
|
+
try {
|
|
25
|
+
frame = JSON.stringify(message);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`TN_UI_BRIDGE_MESSAGE_INVALID: '${message.type}' is not JSON-serialisable: ${error instanceof Error ? error.message : String(error)}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (frame === void 0) {
|
|
32
|
+
throw new Error(`TN_UI_BRIDGE_MESSAGE_INVALID: '${message.type}' serialised to nothing.`);
|
|
33
|
+
}
|
|
34
|
+
return frame;
|
|
35
|
+
}
|
|
36
|
+
function parseFrame(frame) {
|
|
37
|
+
const text = typeof frame === "string" ? frame : frame?.data;
|
|
38
|
+
if (typeof text !== "string") {
|
|
39
|
+
throw new Error("TN_UI_BRIDGE_FRAME_INVALID: expected a JSON string from the host.");
|
|
40
|
+
}
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(text);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`TN_UI_BRIDGE_FRAME_INVALID: not JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
50
|
+
throw new Error("TN_UI_BRIDGE_FRAME_INVALID: a bridge frame must be a JSON object.");
|
|
51
|
+
}
|
|
52
|
+
if (typeof parsed.type !== "string") {
|
|
53
|
+
throw new Error("TN_UI_BRIDGE_FRAME_INVALID: a bridge frame must carry a string 'type'.");
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
function findOutbound(scope, end) {
|
|
58
|
+
if (end === "game") {
|
|
59
|
+
const post = scope[UI_BRIDGE_GLOBALS.gamePost];
|
|
60
|
+
return typeof post === "function" ? (frame) => post(frame) : void 0;
|
|
61
|
+
}
|
|
62
|
+
const injected = scope[UI_BRIDGE_GLOBALS.uiHost];
|
|
63
|
+
if (typeof injected?.postMessage === "function") {
|
|
64
|
+
return (frame) => injected.postMessage(frame);
|
|
65
|
+
}
|
|
66
|
+
const webkit = scope.webkit;
|
|
67
|
+
const handler = webkit?.messageHandlers?.[UI_BRIDGE_GLOBALS.uiHost];
|
|
68
|
+
if (typeof handler?.postMessage === "function") {
|
|
69
|
+
return (frame) => handler.postMessage(frame);
|
|
70
|
+
}
|
|
71
|
+
const ipc = scope.ipc;
|
|
72
|
+
if (typeof ipc?.postMessage === "function") {
|
|
73
|
+
return (frame) => ipc.postMessage(frame);
|
|
74
|
+
}
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
var BROKER_KEY = "__tnUiBridgeBroker";
|
|
78
|
+
function broker(scope) {
|
|
79
|
+
const existing = scope[BROKER_KEY];
|
|
80
|
+
if (existing !== void 0) return existing;
|
|
81
|
+
const created = { game: /* @__PURE__ */ new Set(), ui: /* @__PURE__ */ new Set() };
|
|
82
|
+
scope[BROKER_KEY] = created;
|
|
83
|
+
return created;
|
|
84
|
+
}
|
|
85
|
+
function connectUiBridge(options) {
|
|
86
|
+
const { end } = options;
|
|
87
|
+
if (end !== "game" && end !== "ui") {
|
|
88
|
+
throw new Error(`TN_UI_BRIDGE_END_INVALID: expected 'game' or 'ui', got '${String(end)}'.`);
|
|
89
|
+
}
|
|
90
|
+
const scope = options.scope ?? globalThis;
|
|
91
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
92
|
+
const deliver = (frame) => {
|
|
93
|
+
const message = parseFrame(frame);
|
|
94
|
+
for (const listener of [...listeners]) listener(message);
|
|
95
|
+
};
|
|
96
|
+
const outbound = findOutbound(scope, end);
|
|
97
|
+
const inboundKey = end === "ui" ? UI_BRIDGE_GLOBALS.uiReceive : UI_BRIDGE_GLOBALS.gameReceive;
|
|
98
|
+
let closed = false;
|
|
99
|
+
if (outbound === void 0) {
|
|
100
|
+
const bus = broker(scope);
|
|
101
|
+
const peer = end === "ui" ? "game" : "ui";
|
|
102
|
+
const own = (message) => {
|
|
103
|
+
for (const listener of [...listeners]) listener(message);
|
|
104
|
+
};
|
|
105
|
+
bus[end].add(own);
|
|
106
|
+
return {
|
|
107
|
+
end,
|
|
108
|
+
transport: "in-process",
|
|
109
|
+
hasPeer: () => bus[peer].size > 0,
|
|
110
|
+
post(message) {
|
|
111
|
+
const frame = assertSendable(message);
|
|
112
|
+
if (closed) throw new Error("TN_UI_BRIDGE_CLOSED: post on a closed bridge.");
|
|
113
|
+
const copy = JSON.parse(frame);
|
|
114
|
+
for (const listener of [...bus[peer]]) listener(copy);
|
|
115
|
+
},
|
|
116
|
+
onMessage(listener) {
|
|
117
|
+
listeners.add(listener);
|
|
118
|
+
return () => listeners.delete(listener);
|
|
119
|
+
},
|
|
120
|
+
close() {
|
|
121
|
+
closed = true;
|
|
122
|
+
listeners.clear();
|
|
123
|
+
bus[end].delete(own);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
scope[inboundKey] = deliver;
|
|
128
|
+
const injected = scope[UI_BRIDGE_GLOBALS.uiHost];
|
|
129
|
+
if (end === "ui" && injected !== void 0 && typeof injected === "object") {
|
|
130
|
+
injected.onmessage = (event) => deliver(event);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
end,
|
|
134
|
+
transport: "host",
|
|
135
|
+
hasPeer() {
|
|
136
|
+
const attached = scope.__tnUiOverlayAttached;
|
|
137
|
+
if (end === "ui") return true;
|
|
138
|
+
return typeof attached === "function" ? attached() === true : false;
|
|
139
|
+
},
|
|
140
|
+
post(message) {
|
|
141
|
+
const frame = assertSendable(message);
|
|
142
|
+
if (closed) throw new Error("TN_UI_BRIDGE_CLOSED: post on a closed bridge.");
|
|
143
|
+
outbound(frame);
|
|
144
|
+
},
|
|
145
|
+
onMessage(listener) {
|
|
146
|
+
listeners.add(listener);
|
|
147
|
+
return () => listeners.delete(listener);
|
|
148
|
+
},
|
|
149
|
+
close() {
|
|
150
|
+
closed = true;
|
|
151
|
+
listeners.clear();
|
|
152
|
+
if (scope[inboundKey] === deliver) scope[inboundKey] = void 0;
|
|
153
|
+
if (end === "ui" && injected !== void 0 && typeof injected === "object") {
|
|
154
|
+
injected.onmessage = void 0;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/ui-state.ts
|
|
161
|
+
var microtask = (flush) => {
|
|
162
|
+
void Promise.resolve().then(flush);
|
|
163
|
+
};
|
|
164
|
+
function publishUiState(bridge, store, options = {}) {
|
|
165
|
+
if (bridge.end !== "game") {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`TN_UI_STATE_WRONG_END: state is published by the 'game' end, not '${bridge.end}'.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const schedule = options.schedule ?? microtask;
|
|
171
|
+
const read = () => store.getPublishedState === void 0 ? store.getState() : store.getPublishedState();
|
|
172
|
+
let stopped = false;
|
|
173
|
+
let queued = false;
|
|
174
|
+
let lastFrame = "";
|
|
175
|
+
const publish = () => {
|
|
176
|
+
if (stopped) return;
|
|
177
|
+
const state = read();
|
|
178
|
+
lastFrame = JSON.stringify(state);
|
|
179
|
+
bridge.post({ type: GAME_STATE_MESSAGE, state });
|
|
180
|
+
};
|
|
181
|
+
const flush = () => {
|
|
182
|
+
queued = false;
|
|
183
|
+
if (stopped) return;
|
|
184
|
+
if (!bridge.hasPeer()) return;
|
|
185
|
+
if (JSON.stringify(read()) === lastFrame) return;
|
|
186
|
+
publish();
|
|
187
|
+
};
|
|
188
|
+
const unsubscribe = store.subscribe(() => {
|
|
189
|
+
if (stopped || queued) return;
|
|
190
|
+
queued = true;
|
|
191
|
+
schedule(flush);
|
|
192
|
+
});
|
|
193
|
+
if (bridge.hasPeer()) publish();
|
|
194
|
+
return {
|
|
195
|
+
publish,
|
|
196
|
+
stop() {
|
|
197
|
+
stopped = true;
|
|
198
|
+
unsubscribe();
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function subscribeUiState(bridge) {
|
|
203
|
+
if (bridge.end !== "ui") {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`TN_UI_STATE_WRONG_END: state is mirrored by the 'ui' end, not '${bridge.end}'.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
209
|
+
let current;
|
|
210
|
+
const off = bridge.onMessage((message) => {
|
|
211
|
+
if (message.type !== GAME_STATE_MESSAGE) return;
|
|
212
|
+
const state = message.state;
|
|
213
|
+
if (typeof state !== "object" || state === null || Array.isArray(state)) {
|
|
214
|
+
throw new Error("TN_UI_STATE_FRAME_INVALID: a published state frame must carry an object.");
|
|
215
|
+
}
|
|
216
|
+
current = state;
|
|
217
|
+
for (const listener of [...listeners]) listener();
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
get: () => current,
|
|
221
|
+
subscribe(listener) {
|
|
222
|
+
listeners.add(listener);
|
|
223
|
+
return () => listeners.delete(listener);
|
|
224
|
+
},
|
|
225
|
+
stop() {
|
|
226
|
+
off();
|
|
227
|
+
listeners.clear();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function sendUiIntent(bridge, intent, payload) {
|
|
232
|
+
if (bridge.end !== "ui") {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`TN_UI_INTENT_WRONG_END: intents are sent by the 'ui' end, not '${bridge.end}'.`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (typeof intent !== "string" || intent.length === 0) {
|
|
238
|
+
throw new Error("TN_UI_INTENT_INVALID: an intent needs a non-empty name.");
|
|
239
|
+
}
|
|
240
|
+
bridge.post({
|
|
241
|
+
type: UI_INTENT_MESSAGE,
|
|
242
|
+
intent,
|
|
243
|
+
...payload === void 0 ? {} : { payload }
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function onUiIntent(bridge, listener) {
|
|
247
|
+
if (bridge.end !== "game") {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`TN_UI_INTENT_WRONG_END: intents are received by the 'game' end, not '${bridge.end}'.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return bridge.onMessage((message) => {
|
|
253
|
+
if (message.type !== UI_INTENT_MESSAGE) return;
|
|
254
|
+
if (typeof message.intent !== "string" || message.intent.length === 0) {
|
|
255
|
+
throw new Error("TN_UI_INTENT_INVALID: an intent frame must carry a non-empty name.");
|
|
256
|
+
}
|
|
257
|
+
listener(message.intent, message.payload);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/ui-hit-regions.ts
|
|
262
|
+
var INTERACTIVE_ATTRIBUTE = "data-tn-interactive";
|
|
263
|
+
function quantize(value) {
|
|
264
|
+
return Math.round(value * 1e5) / 1e5;
|
|
265
|
+
}
|
|
266
|
+
var TRANSITION_START = ["transitionrun", "transitionstart", "animationstart"];
|
|
267
|
+
var TRANSITION_END = [
|
|
268
|
+
"transitionend",
|
|
269
|
+
"transitioncancel",
|
|
270
|
+
"animationend",
|
|
271
|
+
"animationcancel"
|
|
272
|
+
];
|
|
273
|
+
var LAYOUT_EVENTS = ["resize", "orientationchange", "scroll"];
|
|
274
|
+
function publishHitRegions(options) {
|
|
275
|
+
const scope = options.scope ?? globalThis;
|
|
276
|
+
const document = scope.document;
|
|
277
|
+
if (document === void 0 || typeof document.querySelectorAll !== "function") {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"TN_UI_HIT_REGIONS_NO_DOCUMENT: the interactive-rect registry runs in the UI page and found no document."
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const selector = `[${options.attribute ?? INTERACTIVE_ATTRIBUTE}]`;
|
|
283
|
+
const bridge = options.bridge;
|
|
284
|
+
if (bridge.end !== "ui") {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`TN_UI_HIT_REGIONS_WRONG_END: hit regions are published by the 'ui' end, not '${bridge.end}'.`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const unmarkedWarning = { warned: false };
|
|
290
|
+
let published = [];
|
|
291
|
+
let publishedFrame = "";
|
|
292
|
+
let stopped = false;
|
|
293
|
+
let liveTransitions = 0;
|
|
294
|
+
let frameHandle;
|
|
295
|
+
const viewport = () => ({
|
|
296
|
+
width: scope.innerWidth ?? document.documentElement?.clientWidth ?? 0,
|
|
297
|
+
height: scope.innerHeight ?? document.documentElement?.clientHeight ?? 0
|
|
298
|
+
});
|
|
299
|
+
const boxesOf = (element) => {
|
|
300
|
+
const list = element.getClientRects?.();
|
|
301
|
+
if (list === void 0 || list.length === 0) return [element.getBoundingClientRect()];
|
|
302
|
+
const boxes = [];
|
|
303
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
304
|
+
const rect = list[index];
|
|
305
|
+
if (rect !== void 0) boxes.push(rect);
|
|
306
|
+
}
|
|
307
|
+
return boxes;
|
|
308
|
+
};
|
|
309
|
+
const normalize = (boxes, width, height) => boxes.filter((rect) => rect.width > 0 && rect.height > 0).map((rect) => ({
|
|
310
|
+
x: quantize(rect.left / width),
|
|
311
|
+
y: quantize(rect.top / height),
|
|
312
|
+
width: quantize(rect.width / width),
|
|
313
|
+
height: quantize(rect.height / height)
|
|
314
|
+
}));
|
|
315
|
+
const measure = () => {
|
|
316
|
+
const { width, height } = viewport();
|
|
317
|
+
if (width <= 0 || height <= 0) return [];
|
|
318
|
+
const regions = [];
|
|
319
|
+
const elements = document.querySelectorAll(selector);
|
|
320
|
+
for (let index = 0; index < elements.length; index += 1) {
|
|
321
|
+
const element = elements[index];
|
|
322
|
+
if (element !== void 0) regions.push(...normalize(boxesOf(element), width, height));
|
|
323
|
+
}
|
|
324
|
+
warnAboutUnmarkedControls(document, selector, scope, unmarkedWarning);
|
|
325
|
+
return regions;
|
|
326
|
+
};
|
|
327
|
+
const publish = (regions) => {
|
|
328
|
+
const frame = JSON.stringify(regions);
|
|
329
|
+
if (frame === publishedFrame) return;
|
|
330
|
+
publishedFrame = frame;
|
|
331
|
+
published = regions;
|
|
332
|
+
bridge.post({ type: HIT_REGIONS_MESSAGE, regions });
|
|
333
|
+
};
|
|
334
|
+
const refresh = () => {
|
|
335
|
+
if (stopped) return;
|
|
336
|
+
publish(measure());
|
|
337
|
+
};
|
|
338
|
+
const requestFrame = scope.requestAnimationFrame;
|
|
339
|
+
const cancelFrame = scope.cancelAnimationFrame;
|
|
340
|
+
const pump = () => {
|
|
341
|
+
frameHandle = void 0;
|
|
342
|
+
if (stopped || liveTransitions <= 0) return;
|
|
343
|
+
refresh();
|
|
344
|
+
if (requestFrame !== void 0) frameHandle = requestFrame(pump);
|
|
345
|
+
};
|
|
346
|
+
const onTransitionStart = () => {
|
|
347
|
+
liveTransitions += 1;
|
|
348
|
+
if (frameHandle === void 0 && requestFrame !== void 0) frameHandle = requestFrame(pump);
|
|
349
|
+
};
|
|
350
|
+
const onTransitionEnd = () => {
|
|
351
|
+
liveTransitions = Math.max(0, liveTransitions - 1);
|
|
352
|
+
refresh();
|
|
353
|
+
};
|
|
354
|
+
const listen = (on, host, types, listener) => {
|
|
355
|
+
const method = host[on];
|
|
356
|
+
if (typeof method !== "function") return;
|
|
357
|
+
for (const type of types) method.call(host, type, listener, true);
|
|
358
|
+
};
|
|
359
|
+
listen("addEventListener", document, TRANSITION_START, onTransitionStart);
|
|
360
|
+
listen("addEventListener", document, TRANSITION_END, onTransitionEnd);
|
|
361
|
+
listen("addEventListener", scope, LAYOUT_EVENTS, refresh);
|
|
362
|
+
const MutationObserverCtor = scope.MutationObserver;
|
|
363
|
+
const mutations = MutationObserverCtor === void 0 ? void 0 : new MutationObserverCtor(refresh);
|
|
364
|
+
mutations?.observe(document, {
|
|
365
|
+
attributes: true,
|
|
366
|
+
childList: true,
|
|
367
|
+
subtree: true,
|
|
368
|
+
characterData: true
|
|
369
|
+
});
|
|
370
|
+
const ResizeObserverCtor = scope.ResizeObserver;
|
|
371
|
+
const resizes = ResizeObserverCtor === void 0 ? void 0 : new ResizeObserverCtor(refresh);
|
|
372
|
+
if (resizes !== void 0 && document.documentElement !== void 0) {
|
|
373
|
+
resizes.observe(document.documentElement);
|
|
374
|
+
}
|
|
375
|
+
refresh();
|
|
376
|
+
return {
|
|
377
|
+
refresh,
|
|
378
|
+
regions: () => published,
|
|
379
|
+
stop() {
|
|
380
|
+
if (stopped) return;
|
|
381
|
+
stopped = true;
|
|
382
|
+
if (frameHandle !== void 0 && cancelFrame !== void 0) cancelFrame(frameHandle);
|
|
383
|
+
frameHandle = void 0;
|
|
384
|
+
listen("removeEventListener", document, TRANSITION_START, onTransitionStart);
|
|
385
|
+
listen("removeEventListener", document, TRANSITION_END, onTransitionEnd);
|
|
386
|
+
listen("removeEventListener", scope, LAYOUT_EVENTS, refresh);
|
|
387
|
+
mutations?.disconnect();
|
|
388
|
+
resizes?.disconnect();
|
|
389
|
+
published = [];
|
|
390
|
+
publishedFrame = "[]";
|
|
391
|
+
bridge.post({ type: HIT_REGIONS_MESSAGE, regions: [] });
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
var UNMARKED_CONTROL_SELECTOR = 'button, [role="button"], a[href], input, select, textarea';
|
|
396
|
+
function warnAboutUnmarkedControls(document, selector, scope, once) {
|
|
397
|
+
if (once.warned || !isDevelopment()) return;
|
|
398
|
+
if (typeof document.querySelectorAll !== "function") return;
|
|
399
|
+
const candidates = document.querySelectorAll(UNMARKED_CONTROL_SELECTOR);
|
|
400
|
+
const unmarked = [];
|
|
401
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
402
|
+
const element = candidates[index];
|
|
403
|
+
if (element === void 0) continue;
|
|
404
|
+
if (element.closest?.(selector) != null) continue;
|
|
405
|
+
unmarked.push(describe(element));
|
|
406
|
+
}
|
|
407
|
+
if (unmarked.length === 0) return;
|
|
408
|
+
once.warned = true;
|
|
409
|
+
const names = unmarked.slice(0, 5).join(", ");
|
|
410
|
+
const rest = unmarked.length > 5 ? ` and ${unmarked.length - 5} more` : "";
|
|
411
|
+
scope.console?.warn?.(
|
|
412
|
+
`TN_UI_UNMARKED_CONTROLS: ${unmarked.length} control(s) publish no hit region, so on native the press reaches the game instead: ${names}${rest}. Mark each with ${INTERACTIVE_ATTRIBUTE}, or wrap them in an element that carries it.`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
function describe(element) {
|
|
416
|
+
const tag = (element.tagName ?? "element").toLowerCase();
|
|
417
|
+
const id = element.id === void 0 || element.id === "" ? "" : `#${element.id}`;
|
|
418
|
+
const first = (element.className ?? "").split(/\s+/u).filter(Boolean)[0];
|
|
419
|
+
return `<${tag}${id}${first === void 0 ? "" : `.${first}`}>`;
|
|
420
|
+
}
|
|
421
|
+
function isDevelopment() {
|
|
422
|
+
return import.meta.env?.DEV === true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export { GAME_STATE_MESSAGE, HIT_REGIONS_MESSAGE, INTERACTIVE_ATTRIBUTE, UI_BRIDGE_GLOBALS, UI_INTENT_MESSAGE, UI_READY_INTENT, connectUiBridge, onUiIntent, publishHitRegions, publishUiState, sendUiIntent, subscribeUiState };
|
package/dist/world.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Vector3, BufferGeometry } from 'three';
|
|
2
|
+
|
|
3
|
+
interface IHeightfieldOrigin {
|
|
4
|
+
readonly x: number;
|
|
5
|
+
readonly z: number;
|
|
6
|
+
}
|
|
7
|
+
interface IHeightfieldOptions {
|
|
8
|
+
readonly columns: number;
|
|
9
|
+
readonly depth: number;
|
|
10
|
+
readonly heights: Float32Array;
|
|
11
|
+
readonly origin: IHeightfieldOrigin;
|
|
12
|
+
readonly rows: number;
|
|
13
|
+
readonly width: number;
|
|
14
|
+
}
|
|
15
|
+
interface IHeightfieldSamplerOptions extends Omit<IHeightfieldOptions, "heights"> {
|
|
16
|
+
/** Game-owned terrain function. It is evaluated once and never retained. */
|
|
17
|
+
readonly sampleHeight: (x: number, z: number) => number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One height buffer shared by world queries, rendered geometry, and a physics heightfield.
|
|
21
|
+
*
|
|
22
|
+
* The game supplies every value, so changing the terrain's shape never requires a package edit.
|
|
23
|
+
* `fromSampler` evaluates that game function exactly once at each vertex and retains only the
|
|
24
|
+
* resulting numbers. Queries interpolate those same numbers instead of evaluating the function
|
|
25
|
+
* again.
|
|
26
|
+
*
|
|
27
|
+
* @situation build terrain geometry and collision from one game-authored height function
|
|
28
|
+
* @situation query the same ground height or normal that a player sees and collides with
|
|
29
|
+
* @situation build islands and coastlines from terrain
|
|
30
|
+
* @constraint sampleHeight owns the terrain shape and stays in game source; the framework stores and interpolates its output
|
|
31
|
+
* @constraint rows and columns are vertex counts; geometry is row-major z-then-x and collider export transposes once into Rapier's column-major matrix order
|
|
32
|
+
* @override rows, columns, width, depth, origin, and sampleHeight are explicit on every field
|
|
33
|
+
* @example const field = Heightfield.fromSampler({ rows: 65, columns: 65, width: 64, depth: 64, origin: { x: 0, z: 0 }, sampleHeight: terrainHeight });
|
|
34
|
+
*/
|
|
35
|
+
declare class Heightfield {
|
|
36
|
+
#private;
|
|
37
|
+
readonly columns: number;
|
|
38
|
+
readonly depth: number;
|
|
39
|
+
readonly origin: IHeightfieldOrigin;
|
|
40
|
+
readonly rows: number;
|
|
41
|
+
readonly width: number;
|
|
42
|
+
constructor(options: IHeightfieldOptions);
|
|
43
|
+
static fromSampler(options: IHeightfieldSamplerOptions): Heightfield;
|
|
44
|
+
sample(channel: string, x: number, z: number): number;
|
|
45
|
+
/** A copy of the canonical row-major samples, safe for game-side analysis. */
|
|
46
|
+
get heights(): Float32Array;
|
|
47
|
+
heightAt(x: number, z: number): number;
|
|
48
|
+
normalAt(x: number, z: number, target?: Vector3): Vector3;
|
|
49
|
+
/** The same values transposed once into Rapier's column-major height-matrix order. */
|
|
50
|
+
toColliderHeights(): Float32Array;
|
|
51
|
+
/** Builds an ordinary Three.js geometry without choosing its surface. */
|
|
52
|
+
toGeometry(): BufferGeometry;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { Heightfield, type IHeightfieldOptions, type IHeightfieldOrigin, type IHeightfieldSamplerOptions };
|
package/dist/world.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Vector3, BufferGeometry, BufferAttribute } from 'three';
|
|
2
|
+
|
|
3
|
+
// src/world.ts
|
|
4
|
+
function finite(value, name) {
|
|
5
|
+
if (!Number.isFinite(value)) throw new Error(`Heightfield ${name} must be finite.`);
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
function positive(value, name) {
|
|
9
|
+
finite(value, name);
|
|
10
|
+
if (value <= 0) throw new Error(`Heightfield ${name} must be greater than zero.`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function count(value, name) {
|
|
14
|
+
if (!Number.isInteger(value) || value < 2)
|
|
15
|
+
throw new Error(`Heightfield ${name} must be an integer of at least 2.`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
var Heightfield = class _Heightfield {
|
|
19
|
+
columns;
|
|
20
|
+
depth;
|
|
21
|
+
origin;
|
|
22
|
+
rows;
|
|
23
|
+
width;
|
|
24
|
+
#cellDepth;
|
|
25
|
+
#cellWidth;
|
|
26
|
+
#colliderHeights;
|
|
27
|
+
#heights;
|
|
28
|
+
#minimumX;
|
|
29
|
+
#minimumZ;
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.columns = count(options.columns, "columns");
|
|
32
|
+
this.rows = count(options.rows, "rows");
|
|
33
|
+
this.width = positive(options.width, "width");
|
|
34
|
+
this.depth = positive(options.depth, "depth");
|
|
35
|
+
this.origin = {
|
|
36
|
+
x: finite(options.origin.x, "origin.x"),
|
|
37
|
+
z: finite(options.origin.z, "origin.z")
|
|
38
|
+
};
|
|
39
|
+
const expected = this.rows * this.columns;
|
|
40
|
+
if (options.heights.length !== expected)
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Heightfield expected ${expected} heights, received ${options.heights.length}.`
|
|
43
|
+
);
|
|
44
|
+
for (const height of options.heights) finite(height, "height sample");
|
|
45
|
+
this.#heights = options.heights.slice();
|
|
46
|
+
this.#colliderHeights = new Float32Array(expected);
|
|
47
|
+
for (let column = 0; column < this.columns; column += 1) {
|
|
48
|
+
for (let row = 0; row < this.rows; row += 1) {
|
|
49
|
+
this.#colliderHeights[column * this.rows + row] = this.#height(row * this.columns + column);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
this.#cellWidth = this.width / (this.columns - 1);
|
|
53
|
+
this.#cellDepth = this.depth / (this.rows - 1);
|
|
54
|
+
this.#minimumX = this.origin.x - this.width / 2;
|
|
55
|
+
this.#minimumZ = this.origin.z - this.depth / 2;
|
|
56
|
+
}
|
|
57
|
+
static fromSampler(options) {
|
|
58
|
+
const columns = count(options.columns, "columns");
|
|
59
|
+
const rows = count(options.rows, "rows");
|
|
60
|
+
const width = positive(options.width, "width");
|
|
61
|
+
const depth = positive(options.depth, "depth");
|
|
62
|
+
const originX = finite(options.origin.x, "origin.x");
|
|
63
|
+
const originZ = finite(options.origin.z, "origin.z");
|
|
64
|
+
const minimumX = originX - width / 2;
|
|
65
|
+
const minimumZ = originZ - depth / 2;
|
|
66
|
+
const cellWidth = width / (columns - 1);
|
|
67
|
+
const cellDepth = depth / (rows - 1);
|
|
68
|
+
const heights = new Float32Array(rows * columns);
|
|
69
|
+
for (let row = 0; row < rows; row += 1) {
|
|
70
|
+
const z = minimumZ + row * cellDepth;
|
|
71
|
+
for (let column = 0; column < columns; column += 1) {
|
|
72
|
+
const x = minimumX + column * cellWidth;
|
|
73
|
+
heights[row * columns + column] = finite(options.sampleHeight(x, z), "sampleHeight result");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return new _Heightfield({ columns, depth, heights, origin: options.origin, rows, width });
|
|
77
|
+
}
|
|
78
|
+
sample(channel, x, z) {
|
|
79
|
+
if (channel !== "height") throw new Error(`Heightfield unknown channel '${channel}'.`);
|
|
80
|
+
return this.heightAt(x, z);
|
|
81
|
+
}
|
|
82
|
+
/** A copy of the canonical row-major samples, safe for game-side analysis. */
|
|
83
|
+
get heights() {
|
|
84
|
+
return this.#heights.slice();
|
|
85
|
+
}
|
|
86
|
+
heightAt(x, z) {
|
|
87
|
+
finite(x, "query x");
|
|
88
|
+
finite(z, "query z");
|
|
89
|
+
const column = (x - this.#minimumX) / this.#cellWidth;
|
|
90
|
+
const row = (z - this.#minimumZ) / this.#cellDepth;
|
|
91
|
+
const epsilon = 1e-9;
|
|
92
|
+
if (column < -epsilon || row < -epsilon || column > this.columns - 1 + epsilon || row > this.rows - 1 + epsilon)
|
|
93
|
+
throw new Error(`Heightfield query (${x}, ${z}) is outside its resident region.`);
|
|
94
|
+
return this.#interpolate(
|
|
95
|
+
Math.min(this.columns - 1, Math.max(0, column)),
|
|
96
|
+
Math.min(this.rows - 1, Math.max(0, row))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
normalAt(x, z, target = new Vector3()) {
|
|
100
|
+
this.heightAt(x, z);
|
|
101
|
+
const leftX = Math.max(this.#minimumX, x - this.#cellWidth);
|
|
102
|
+
const rightX = Math.min(this.#minimumX + this.width, x + this.#cellWidth);
|
|
103
|
+
const nearZ = Math.max(this.#minimumZ, z - this.#cellDepth);
|
|
104
|
+
const farZ = Math.min(this.#minimumZ + this.depth, z + this.#cellDepth);
|
|
105
|
+
const slopeX = (this.heightAt(rightX, z) - this.heightAt(leftX, z)) / (rightX - leftX);
|
|
106
|
+
const slopeZ = (this.heightAt(x, farZ) - this.heightAt(x, nearZ)) / (farZ - nearZ);
|
|
107
|
+
return target.set(-slopeX, 1, -slopeZ).normalize();
|
|
108
|
+
}
|
|
109
|
+
/** The same values transposed once into Rapier's column-major height-matrix order. */
|
|
110
|
+
toColliderHeights() {
|
|
111
|
+
return this.#colliderHeights.slice();
|
|
112
|
+
}
|
|
113
|
+
/** Builds an ordinary Three.js geometry without choosing its surface. */
|
|
114
|
+
toGeometry() {
|
|
115
|
+
const count2 = this.rows * this.columns;
|
|
116
|
+
const positions = new Float32Array(count2 * 3);
|
|
117
|
+
const normals = new Float32Array(count2 * 3);
|
|
118
|
+
const normal = new Vector3();
|
|
119
|
+
for (let row = 0; row < this.rows; row += 1) {
|
|
120
|
+
const localZ = -this.depth / 2 + row * this.#cellDepth;
|
|
121
|
+
const worldZ = this.origin.z + localZ;
|
|
122
|
+
for (let column = 0; column < this.columns; column += 1) {
|
|
123
|
+
const index = row * this.columns + column;
|
|
124
|
+
const localX = -this.width / 2 + column * this.#cellWidth;
|
|
125
|
+
const worldX = this.origin.x + localX;
|
|
126
|
+
positions[index * 3] = localX;
|
|
127
|
+
positions[index * 3 + 1] = this.#height(index);
|
|
128
|
+
positions[index * 3 + 2] = localZ;
|
|
129
|
+
this.normalAt(worldX, worldZ, normal);
|
|
130
|
+
normals[index * 3] = normal.x;
|
|
131
|
+
normals[index * 3 + 1] = normal.y;
|
|
132
|
+
normals[index * 3 + 2] = normal.z;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const indices = new Uint32Array((this.rows - 1) * (this.columns - 1) * 6);
|
|
136
|
+
let offset = 0;
|
|
137
|
+
for (let row = 0; row < this.rows - 1; row += 1) {
|
|
138
|
+
for (let column = 0; column < this.columns - 1; column += 1) {
|
|
139
|
+
const upperLeft = row * this.columns + column;
|
|
140
|
+
const upperRight = upperLeft + 1;
|
|
141
|
+
const lowerLeft = upperLeft + this.columns;
|
|
142
|
+
const lowerRight = lowerLeft + 1;
|
|
143
|
+
indices.set([upperLeft, lowerLeft, upperRight, upperRight, lowerLeft, lowerRight], offset);
|
|
144
|
+
offset += 6;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const geometry = new BufferGeometry();
|
|
148
|
+
geometry.setAttribute("position", new BufferAttribute(positions, 3));
|
|
149
|
+
geometry.setAttribute("normal", new BufferAttribute(normals, 3));
|
|
150
|
+
geometry.setIndex(new BufferAttribute(indices, 1));
|
|
151
|
+
return geometry;
|
|
152
|
+
}
|
|
153
|
+
#interpolate(column, row) {
|
|
154
|
+
const column0 = Math.floor(column);
|
|
155
|
+
const row0 = Math.floor(row);
|
|
156
|
+
const column1 = Math.min(this.columns - 1, column0 + 1);
|
|
157
|
+
const row1 = Math.min(this.rows - 1, row0 + 1);
|
|
158
|
+
const columnMix = column - column0;
|
|
159
|
+
const rowMix = row - row0;
|
|
160
|
+
const upperLeft = this.#height(row0 * this.columns + column0);
|
|
161
|
+
const upperRight = this.#height(row0 * this.columns + column1);
|
|
162
|
+
const lowerLeft = this.#height(row1 * this.columns + column0);
|
|
163
|
+
const lowerRight = this.#height(row1 * this.columns + column1);
|
|
164
|
+
const upper = upperLeft + (upperRight - upperLeft) * columnMix;
|
|
165
|
+
const lower = lowerLeft + (lowerRight - lowerLeft) * columnMix;
|
|
166
|
+
return upper + (lower - upper) * rowMix;
|
|
167
|
+
}
|
|
168
|
+
#height(index) {
|
|
169
|
+
const height = this.#heights[index];
|
|
170
|
+
if (height === void 0) throw new Error(`Heightfield internal sample ${index} is missing.`);
|
|
171
|
+
return height;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export { Heightfield };
|