@threenative/core 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/dist/audio-3vkjtiuo.d.ts +35 -0
- package/dist/game-DRPs3M7r.d.ts +269 -0
- package/dist/hot.d.ts +17 -0
- package/dist/hot.js +109 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +1514 -0
- package/dist/playtest.d.ts +11 -0
- package/dist/playtest.js +614 -0
- package/package.json +47 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { JsonValue } from '@threenative/playtest';
|
|
2
|
+
import { b as GamePluginHooks } from './game-DRPs3M7r.js';
|
|
3
|
+
import 'three';
|
|
4
|
+
import 'zustand/vanilla';
|
|
5
|
+
|
|
6
|
+
declare function playtest<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined>(options?: PlaytestOptions): GamePluginHooks<TState, TPhysics>;
|
|
7
|
+
interface PlaytestOptions {
|
|
8
|
+
readonly events?: () => JsonValue[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { type PlaytestOptions, playtest };
|
package/dist/playtest.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { Vector2, Vector3, Box3, Matrix4, Frustum, Object3D } from 'three';
|
|
2
|
+
|
|
3
|
+
// ../playtest/dist/index.js
|
|
4
|
+
var PLAYTEST_CAPABILITY_REGISTRY = [
|
|
5
|
+
capability("browser.canvas", "Samples canvas pixels and health."),
|
|
6
|
+
capability("browser.console", "Captures browser console and page errors."),
|
|
7
|
+
capability("browser.dom", "Reads bounded DOM observations."),
|
|
8
|
+
capability("browser.input", "Delivers keyboard and pointer input."),
|
|
9
|
+
capability("browser.network", "Captures failed browser requests."),
|
|
10
|
+
capability("browser.screenshot", "Captures viewport screenshots."),
|
|
11
|
+
capability("browser.trace", "Captures a bounded Playwright trace."),
|
|
12
|
+
capability("camera.observe", "Samples the active camera transform and projection."),
|
|
13
|
+
capability("entity.bounds", "Projects registered entity bounds into the viewport."),
|
|
14
|
+
capability("entity.observe", "Samples registered entity transforms and visibility."),
|
|
15
|
+
capability("entity.setup", "Applies bounded transforms to registered entities."),
|
|
16
|
+
capability("runtime.animation", "Samples application-owned animation state."),
|
|
17
|
+
capability("runtime.audio", "Samples application-owned audio runtime state."),
|
|
18
|
+
capability("runtime.components", "Samples registered JSON-safe component state."),
|
|
19
|
+
capability("runtime.contacts", "Samples bounded collision and trigger contacts."),
|
|
20
|
+
capability("runtime.diagnostics", "Samples application-owned runtime diagnostics."),
|
|
21
|
+
capability("runtime.events", "Drains bounded application event observations."),
|
|
22
|
+
capability("runtime.fixedStep", "Advances an application-owned deterministic tick."),
|
|
23
|
+
capability("runtime.physics", "Samples bounded application-owned physics observations."),
|
|
24
|
+
capability("runtime.resources", "Reads and writes registered JSON-safe application state."),
|
|
25
|
+
capability("runtime.state", "Samples application-owned state-machine state."),
|
|
26
|
+
capability("runtime.tags", "Samples bounded application-owned entity tags."),
|
|
27
|
+
capability("runtime.ui", "Samples registered JSON-safe UI and HUD state."),
|
|
28
|
+
capability("runtime.world", "Samples bounded runtime world metadata.")
|
|
29
|
+
];
|
|
30
|
+
new Set(PLAYTEST_CAPABILITY_REGISTRY.map(({ name }) => name));
|
|
31
|
+
function capability(name, description) {
|
|
32
|
+
return { description, name, protocolVersion: 1 };
|
|
33
|
+
}
|
|
34
|
+
var PLAYTEST_PROTOCOL_LIMITS = {
|
|
35
|
+
maxEventsPerDrain: 1e3};
|
|
36
|
+
function assertJsonSafe(value, path = "$") {
|
|
37
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === "number") {
|
|
41
|
+
if (Number.isFinite(value)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw new TypeError(`${path} must contain only finite JSON numbers.`);
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
value.forEach((item, index) => assertJsonSafe(item, `${path}[${index}]`));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "object") {
|
|
51
|
+
for (const [key, item] of Object.entries(value)) {
|
|
52
|
+
assertJsonSafe(item, `${path}.${key}`);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new TypeError(`${path} must be JSON-safe.`);
|
|
57
|
+
}
|
|
58
|
+
var PLAYTEST_BRIDGE_GLOBAL = "__THREENATIVE_PLAYTEST_BRIDGE__";
|
|
59
|
+
var PLAYTEST_PROTOCOL_VERSION = 1;
|
|
60
|
+
var PLAYTEST_PROTOCOL_LIMITS2 = {
|
|
61
|
+
maxEntitiesPerSample: 100,
|
|
62
|
+
maxEventsPerDrain: 1e3,
|
|
63
|
+
maxPayloadBytes: 1e6,
|
|
64
|
+
operationTimeoutMs: 5e3
|
|
65
|
+
};
|
|
66
|
+
function jsonByteLength(value) {
|
|
67
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
68
|
+
}
|
|
69
|
+
function assertJsonSafe2(value, path = "$") {
|
|
70
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (typeof value === "number") {
|
|
74
|
+
if (Number.isFinite(value)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
throw new TypeError(`${path} must contain only finite JSON numbers.`);
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
value.forEach((item, index) => assertJsonSafe2(item, `${path}[${index}]`));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === "object") {
|
|
84
|
+
for (const [key, item] of Object.entries(value)) {
|
|
85
|
+
assertJsonSafe2(item, `${path}.${key}`);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
throw new TypeError(`${path} must be JSON-safe.`);
|
|
90
|
+
}
|
|
91
|
+
var ThreePlaytestEntityRegistry = class {
|
|
92
|
+
#entries = /* @__PURE__ */ new Map();
|
|
93
|
+
register(entry) {
|
|
94
|
+
const path = entry.path ?? objectPath(entry.object);
|
|
95
|
+
const existing = this.#entries.get(entry.id);
|
|
96
|
+
if (existing !== void 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Duplicate playtest entity id '${entry.id}' conflicts between '${existing.path}' and '${path}'. Register a unique stable id for each observed object.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
this.#entries.set(entry.id, { ...entry, path });
|
|
102
|
+
}
|
|
103
|
+
get(id) {
|
|
104
|
+
return this.#entries.get(id);
|
|
105
|
+
}
|
|
106
|
+
replace(entries) {
|
|
107
|
+
this.#entries.clear();
|
|
108
|
+
entries.forEach((entry) => this.register(entry));
|
|
109
|
+
}
|
|
110
|
+
select(ids) {
|
|
111
|
+
if (ids === void 0) return [...this.#entries.values()];
|
|
112
|
+
return ids.flatMap((id) => {
|
|
113
|
+
const entry = this.#entries.get(id);
|
|
114
|
+
return entry === void 0 ? [] : [entry];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
function objectPath(object) {
|
|
119
|
+
const parts = [];
|
|
120
|
+
let current = object;
|
|
121
|
+
while (current !== null) {
|
|
122
|
+
parts.unshift(current.name || `${current.type}[${current.uuid.slice(0, 8)}]`);
|
|
123
|
+
current = current.parent;
|
|
124
|
+
}
|
|
125
|
+
return parts.join("/");
|
|
126
|
+
}
|
|
127
|
+
function sampleThreeObservations(input, request) {
|
|
128
|
+
input.scene.updateMatrixWorld(true);
|
|
129
|
+
input.camera.updateMatrixWorld(true);
|
|
130
|
+
const rendererSize = input.renderer.getDrawingBufferSize(new Vector2());
|
|
131
|
+
const entities = input.registry.select(request.entities).map(({ id, object }) => observeEntity(id, object, input.camera, rendererSize.x, rendererSize.y));
|
|
132
|
+
return {
|
|
133
|
+
clock: {
|
|
134
|
+
mode: input.clockMode,
|
|
135
|
+
...input.tick === void 0 ? { timeMs: performance.now() } : { tick: input.tick }
|
|
136
|
+
},
|
|
137
|
+
...input.diagnostics === void 0 ? {} : { diagnostics: input.diagnostics() },
|
|
138
|
+
entities,
|
|
139
|
+
...input.gameplay === void 0 ? {} : { gameplay: input.gameplay() },
|
|
140
|
+
...input.resources === void 0 ? {} : { resources: input.resources() }
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function observeEntity(id, object, camera, viewportWidth, viewportHeight) {
|
|
144
|
+
const position = object.getWorldPosition(new Vector3());
|
|
145
|
+
const bounds = projectedBounds(object, camera, viewportWidth, viewportHeight);
|
|
146
|
+
return {
|
|
147
|
+
...bounds === void 0 ? {} : { bounds },
|
|
148
|
+
id,
|
|
149
|
+
transform: {
|
|
150
|
+
position: [position.x, position.y, position.z],
|
|
151
|
+
rotation: [object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w],
|
|
152
|
+
scale: [object.scale.x, object.scale.y, object.scale.z]
|
|
153
|
+
},
|
|
154
|
+
visible: object.visible && bounds !== void 0 && bounds.width > 0 && bounds.height > 0
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function projectedBounds(object, camera, viewportWidth, viewportHeight) {
|
|
158
|
+
const worldBounds = new Box3().setFromObject(object);
|
|
159
|
+
if (worldBounds.isEmpty()) return void 0;
|
|
160
|
+
const projection = new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
161
|
+
if (!new Frustum().setFromProjectionMatrix(projection).intersectsBox(worldBounds)) return void 0;
|
|
162
|
+
const min = worldBounds.min;
|
|
163
|
+
const max = worldBounds.max;
|
|
164
|
+
const points = [
|
|
165
|
+
new Vector3(min.x, min.y, min.z),
|
|
166
|
+
new Vector3(min.x, min.y, max.z),
|
|
167
|
+
new Vector3(min.x, max.y, min.z),
|
|
168
|
+
new Vector3(min.x, max.y, max.z),
|
|
169
|
+
new Vector3(max.x, min.y, min.z),
|
|
170
|
+
new Vector3(max.x, min.y, max.z),
|
|
171
|
+
new Vector3(max.x, max.y, min.z),
|
|
172
|
+
new Vector3(max.x, max.y, max.z)
|
|
173
|
+
].map((point) => point.project(camera));
|
|
174
|
+
const minX = Math.min(...points.map(({ x }) => x));
|
|
175
|
+
const maxX = Math.max(...points.map(({ x }) => x));
|
|
176
|
+
const minY = Math.min(...points.map(({ y }) => y));
|
|
177
|
+
const maxY = Math.max(...points.map(({ y }) => y));
|
|
178
|
+
return {
|
|
179
|
+
height: Math.max(0, (maxY - minY) * 0.5 * viewportHeight),
|
|
180
|
+
width: Math.max(0, (maxX - minX) * 0.5 * viewportWidth),
|
|
181
|
+
x: (minX + 1) * 0.5 * viewportWidth,
|
|
182
|
+
y: (1 - maxY) * 0.5 * viewportHeight
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
var BRIDGE_METHODS = /* @__PURE__ */ new Set([
|
|
186
|
+
"advance",
|
|
187
|
+
"applySetup",
|
|
188
|
+
"describe",
|
|
189
|
+
"drainEvents",
|
|
190
|
+
"focus",
|
|
191
|
+
"ready",
|
|
192
|
+
"sample"
|
|
193
|
+
]);
|
|
194
|
+
function connectDevicePlaytestBridge(bridge, endpoint) {
|
|
195
|
+
const nativeInstallation = connectNativeMailbox(bridge);
|
|
196
|
+
if (nativeInstallation !== void 0) return nativeInstallation;
|
|
197
|
+
let closed = false;
|
|
198
|
+
let reportedError = false;
|
|
199
|
+
let scheduled;
|
|
200
|
+
const schedule = () => {
|
|
201
|
+
if (closed) return;
|
|
202
|
+
if (typeof globalThis.requestAnimationFrame === "function") {
|
|
203
|
+
scheduled = globalThis.requestAnimationFrame(() => void poll());
|
|
204
|
+
} else {
|
|
205
|
+
scheduled = setTimeout(() => void poll(), 50);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const poll = async () => {
|
|
209
|
+
if (closed) return;
|
|
210
|
+
try {
|
|
211
|
+
const response = await fetch(endpoint);
|
|
212
|
+
if (response.status !== 204) {
|
|
213
|
+
if (!response.ok) throw new Error(`Device bridge poll failed with HTTP ${response.status}.`);
|
|
214
|
+
const request = parseRequest(await response.json());
|
|
215
|
+
await postResponse(endpoint, await dispatch(bridge, request));
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (!reportedError) {
|
|
219
|
+
reportedError = true;
|
|
220
|
+
console.error(`TN_PLAYTEST_DEVICE_TRANSPORT: ${error instanceof Error ? error.message : String(error)}`);
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
schedule();
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
schedule();
|
|
227
|
+
return {
|
|
228
|
+
close: () => {
|
|
229
|
+
closed = true;
|
|
230
|
+
if (scheduled === void 0) return;
|
|
231
|
+
if (typeof globalThis.cancelAnimationFrame === "function" && typeof scheduled === "number") {
|
|
232
|
+
globalThis.cancelAnimationFrame(scheduled);
|
|
233
|
+
} else clearTimeout(scheduled);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function connectNativeMailbox(bridge) {
|
|
238
|
+
const globals = globalThis;
|
|
239
|
+
const mailbox = globals.TN_PLAYTEST_MAILBOX;
|
|
240
|
+
const host = globals.__THREENATIVE_NATIVE__?.playtest;
|
|
241
|
+
if (typeof mailbox?.request !== "string" || typeof mailbox.response !== "string" || typeof host?.receive !== "function" || typeof host.respond !== "function") return void 0;
|
|
242
|
+
let closed = false;
|
|
243
|
+
let frame;
|
|
244
|
+
const respond = (response) => {
|
|
245
|
+
if (!host.respond(mailbox.response, JSON.stringify(response))) {
|
|
246
|
+
console.error("TN_PLAYTEST_DEVICE_TRANSPORT: native mailbox response failed");
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const poll = () => {
|
|
250
|
+
if (closed) return;
|
|
251
|
+
const raw = host.receive(mailbox.request);
|
|
252
|
+
if (raw !== void 0) {
|
|
253
|
+
try {
|
|
254
|
+
const request = parseRequest(JSON.parse(raw));
|
|
255
|
+
void dispatch(bridge, request).then(respond);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
respond({
|
|
258
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
259
|
+
id: "invalid"
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
frame = globalThis.requestAnimationFrame(poll);
|
|
264
|
+
};
|
|
265
|
+
respond({ id: "ready", result: null });
|
|
266
|
+
frame = globalThis.requestAnimationFrame(poll);
|
|
267
|
+
return {
|
|
268
|
+
close: () => {
|
|
269
|
+
closed = true;
|
|
270
|
+
if (frame !== void 0) globalThis.cancelAnimationFrame(frame);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function readPlaytestEndpoint() {
|
|
275
|
+
const value = globalThis.TN_PLAYTEST_ENDPOINT;
|
|
276
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
277
|
+
}
|
|
278
|
+
async function dispatch(bridge, request) {
|
|
279
|
+
try {
|
|
280
|
+
if (request.method.startsWith("input.")) {
|
|
281
|
+
return { id: request.id, result: dispatchInput(request.method, request.argument) };
|
|
282
|
+
}
|
|
283
|
+
if (!BRIDGE_METHODS.has(request.method)) {
|
|
284
|
+
throw new Error(`Bridge operation '${request.method}' is unavailable.`);
|
|
285
|
+
}
|
|
286
|
+
const operation = bridge[request.method];
|
|
287
|
+
if (typeof operation !== "function") throw new Error(`Bridge operation '${request.method}' is unavailable.`);
|
|
288
|
+
const result = await operation.call(bridge, request.argument);
|
|
289
|
+
if (result !== void 0) assertBounded(result);
|
|
290
|
+
return { id: request.id, ...result === void 0 ? {} : { result } };
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return { error: { message: error instanceof Error ? error.message : String(error) }, id: request.id };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function dispatchInput(method, argument) {
|
|
296
|
+
const host = globalThis.__THREENATIVE_NATIVE__?.playtestInput;
|
|
297
|
+
if (!isRecord(argument)) throw new Error(`Device ${method} requires an object argument.`);
|
|
298
|
+
if (method === "input.keyDown" || method === "input.keyUp") {
|
|
299
|
+
if (typeof argument.key !== "string") throw new Error(`Device ${method} requires a key.`);
|
|
300
|
+
const code = argument.key;
|
|
301
|
+
const key = /^Key[A-Z]$/u.test(code) ? code.slice(3).toLowerCase() : code;
|
|
302
|
+
if (typeof host?.keyboard !== "function") throw new Error("Native playtest keyboard input is unavailable.");
|
|
303
|
+
host.keyboard(method === "input.keyDown" ? "keydown" : "keyup", key, code);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
if (method === "input.pointer") {
|
|
307
|
+
if (typeof argument.x !== "number" || typeof argument.y !== "number" || typeof argument.buttons !== "number") {
|
|
308
|
+
throw new Error("Device input.pointer requires numeric x, y, and buttons.");
|
|
309
|
+
}
|
|
310
|
+
if (typeof host?.pointer !== "function") throw new Error("Native playtest pointer input is unavailable.");
|
|
311
|
+
const type = argument.type === "down" ? "pointerdown" : argument.type === "up" ? "pointerup" : "pointermove";
|
|
312
|
+
host.pointer(type, argument.x, argument.y, argument.buttons);
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`Device input operation '${method}' is unavailable.`);
|
|
316
|
+
}
|
|
317
|
+
function parseRequest(value) {
|
|
318
|
+
assertBounded(value);
|
|
319
|
+
if (!isRecord(value) || typeof value.id !== "string" || typeof value.method !== "string") {
|
|
320
|
+
throw new Error("Device request must contain string id and method fields.");
|
|
321
|
+
}
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
async function postResponse(endpoint, response) {
|
|
325
|
+
const result = await fetch(endpoint, {
|
|
326
|
+
body: JSON.stringify(response),
|
|
327
|
+
headers: { "content-type": "application/json" },
|
|
328
|
+
method: "POST"
|
|
329
|
+
});
|
|
330
|
+
if (!result.ok) throw new Error(`Device bridge response failed with HTTP ${result.status}.`);
|
|
331
|
+
}
|
|
332
|
+
function assertBounded(value) {
|
|
333
|
+
assertJsonSafe2(value);
|
|
334
|
+
if (jsonByteLength(value) > PLAYTEST_PROTOCOL_LIMITS2.maxPayloadBytes) {
|
|
335
|
+
throw new Error(`Device bridge payload exceeds ${PLAYTEST_PROTOCOL_LIMITS2.maxPayloadBytes} bytes.`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function isRecord(value) {
|
|
339
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
340
|
+
}
|
|
341
|
+
function installThreePlaytestBridge(options) {
|
|
342
|
+
const host = globalThis;
|
|
343
|
+
const previous = host[PLAYTEST_BRIDGE_GLOBAL];
|
|
344
|
+
const registry = new ThreePlaytestEntityRegistry();
|
|
345
|
+
syncEntities(registry, options.entities);
|
|
346
|
+
let tick = 0;
|
|
347
|
+
const capabilities = () => [
|
|
348
|
+
"camera.observe",
|
|
349
|
+
"entity.bounds",
|
|
350
|
+
"entity.observe",
|
|
351
|
+
"entity.setup",
|
|
352
|
+
...options.fixedStep === void 0 ? [] : ["runtime.fixedStep"],
|
|
353
|
+
...options.resources === void 0 ? [] : ["runtime.resources"],
|
|
354
|
+
...options.diagnostics === void 0 ? [] : ["runtime.diagnostics"],
|
|
355
|
+
...Object.keys(options.components?.() ?? {}).length === 0 ? [] : ["runtime.components"],
|
|
356
|
+
...options.events === void 0 ? [] : ["runtime.events"],
|
|
357
|
+
...options.gameplay === void 0 ? [] : ["runtime.animation", "runtime.state"],
|
|
358
|
+
...options.gameplayChannels?.().includes("runtime.contacts") === true ? ["runtime.contacts"] : [],
|
|
359
|
+
...options.gameplayChannels?.().includes("runtime.tags") === true ? ["runtime.tags"] : []
|
|
360
|
+
];
|
|
361
|
+
const bridge = {
|
|
362
|
+
...options.fixedStep === void 0 ? {} : {
|
|
363
|
+
advance: async (ticks) => {
|
|
364
|
+
if (!Number.isInteger(ticks) || ticks <= 0) throw new Error("advance ticks must be a positive integer.");
|
|
365
|
+
await options.fixedStep(ticks);
|
|
366
|
+
const startTick = tick;
|
|
367
|
+
tick += ticks;
|
|
368
|
+
return { clock: { mode: "fixed-step", tick }, ticks: tick - startTick };
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
applySetup: async (request) => applySetup(registry, options.resources, request),
|
|
372
|
+
...options.events === void 0 ? {} : {
|
|
373
|
+
drainEvents: async (limit = PLAYTEST_PROTOCOL_LIMITS2.maxEventsPerDrain) => {
|
|
374
|
+
if (!Number.isInteger(limit) || limit < 0) throw new Error("drainEvents limit must be a non-negative integer.");
|
|
375
|
+
const events = options.events();
|
|
376
|
+
assertJsonSafe2(events);
|
|
377
|
+
return events.slice(0, Math.min(limit, PLAYTEST_PROTOCOL_LIMITS2.maxEventsPerDrain));
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
describe: () => ({
|
|
381
|
+
capabilities: capabilities(),
|
|
382
|
+
limits: PLAYTEST_PROTOCOL_LIMITS2,
|
|
383
|
+
name: "@threenative/playtest/three",
|
|
384
|
+
protocolVersion: PLAYTEST_PROTOCOL_VERSION
|
|
385
|
+
}),
|
|
386
|
+
ready: () => ({ ready: true }),
|
|
387
|
+
sample: (request) => {
|
|
388
|
+
syncEntities(registry, options.entities);
|
|
389
|
+
const snapshot = sampleThreeObservations({
|
|
390
|
+
camera: options.camera,
|
|
391
|
+
clockMode: options.fixedStep === void 0 ? "render-frame" : "fixed-step",
|
|
392
|
+
diagnostics: options.diagnostics,
|
|
393
|
+
gameplay: options.gameplay,
|
|
394
|
+
registry,
|
|
395
|
+
renderer: options.renderer,
|
|
396
|
+
resources: options.resources === void 0 ? void 0 : () => options.resources.read(),
|
|
397
|
+
scene: options.scene,
|
|
398
|
+
tick: options.fixedStep === void 0 ? void 0 : tick
|
|
399
|
+
}, request);
|
|
400
|
+
const components = options.components?.();
|
|
401
|
+
const result = components === void 0 || Object.keys(components).length === 0 ? snapshot : { ...snapshot, components };
|
|
402
|
+
assertJsonSafe2(result);
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
host[PLAYTEST_BRIDGE_GLOBAL] = bridge;
|
|
407
|
+
const endpoint = readPlaytestEndpoint();
|
|
408
|
+
let device;
|
|
409
|
+
if (endpoint !== void 0) device = connectDevicePlaytestBridge(bridge, endpoint);
|
|
410
|
+
return {
|
|
411
|
+
bridge,
|
|
412
|
+
dispose: () => {
|
|
413
|
+
device?.close();
|
|
414
|
+
if (host[PLAYTEST_BRIDGE_GLOBAL] !== bridge) return;
|
|
415
|
+
if (previous === void 0) delete host[PLAYTEST_BRIDGE_GLOBAL];
|
|
416
|
+
else host[PLAYTEST_BRIDGE_GLOBAL] = previous;
|
|
417
|
+
},
|
|
418
|
+
registerEntity: (entry) => registry.register(entry),
|
|
419
|
+
syncEntities: () => syncEntities(registry, options.entities)
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function syncEntities(registry, entities) {
|
|
423
|
+
if (entities === void 0) return;
|
|
424
|
+
registry.replace(typeof entities === "function" ? entities() : entities);
|
|
425
|
+
}
|
|
426
|
+
function applySetup(registry, resources, request) {
|
|
427
|
+
request.entities?.forEach(({ entity, transform }) => {
|
|
428
|
+
const object = registry.get(entity)?.object;
|
|
429
|
+
if (object === void 0) throw new Error(`Setup entity '${entity}' is not registered.`);
|
|
430
|
+
if (transform.position !== void 0) object.position.fromArray(transform.position);
|
|
431
|
+
if (transform.rotation !== void 0) object.quaternion.fromArray(transform.rotation);
|
|
432
|
+
if (transform.scale !== void 0) object.scale.fromArray(transform.scale);
|
|
433
|
+
object.updateMatrix();
|
|
434
|
+
});
|
|
435
|
+
request.resources?.forEach(({ id, path, value }) => {
|
|
436
|
+
assertJsonSafe2(value);
|
|
437
|
+
if (resources?.write === void 0) throw new Error(`Resource setup '${id}' requires a writable resources provider.`);
|
|
438
|
+
if (!resources.write(id, path, value)) throw new Error(`Resource setup '${id}' was rejected by the application provider.`);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
var buses = /* @__PURE__ */ new Set();
|
|
442
|
+
function audioRuntimeSnapshot() {
|
|
443
|
+
let queued = 0;
|
|
444
|
+
let voices = 0;
|
|
445
|
+
for (const bus of buses) {
|
|
446
|
+
queued += bus.queued;
|
|
447
|
+
voices += bus.voices;
|
|
448
|
+
}
|
|
449
|
+
return { queued, voices };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/playtest.ts
|
|
453
|
+
var CORE_VERSION = "0.1.0";
|
|
454
|
+
var currentAgent = typeof navigator === "undefined" ? "node" : navigator.userAgent;
|
|
455
|
+
function playtest(options = {}) {
|
|
456
|
+
let dispose;
|
|
457
|
+
let contactHistory = [];
|
|
458
|
+
return {
|
|
459
|
+
setup: (ctx, runtime) => {
|
|
460
|
+
const diagnostics = [];
|
|
461
|
+
const seed = runtime?.seed ?? null;
|
|
462
|
+
const replayRuntime = runtime?.seed === null || runtime?.seed === void 0 ? void 0 : {
|
|
463
|
+
agent: currentAgent,
|
|
464
|
+
core: CORE_VERSION,
|
|
465
|
+
randomState: ctx.random.state,
|
|
466
|
+
rapier: runtime.rapier ?? null,
|
|
467
|
+
step: runtime.step
|
|
468
|
+
};
|
|
469
|
+
const installation = installThreePlaytestBridge({
|
|
470
|
+
camera: ctx.camera,
|
|
471
|
+
diagnostics: () => [...diagnostics],
|
|
472
|
+
entities: () => bridgeEntities(ctx),
|
|
473
|
+
components: () => componentObservations(ctx.entities.snapshot()),
|
|
474
|
+
...runtime === void 0 ? {} : { fixedStep: (ticks) => advance(runtime, ticks) },
|
|
475
|
+
...options.events === void 0 ? {} : { events: options.events },
|
|
476
|
+
gameplay: () => gameplayObservations(ctx, contactHistory, seed, replayRuntime),
|
|
477
|
+
gameplayChannels: () => gameplayChannels(),
|
|
478
|
+
renderer: ctx.renderer.raw,
|
|
479
|
+
resources: stateResources(ctx),
|
|
480
|
+
scene: ctx.scene
|
|
481
|
+
});
|
|
482
|
+
installRuntimeChannels(installation.bridge);
|
|
483
|
+
dispose = installation.dispose;
|
|
484
|
+
return () => {
|
|
485
|
+
dispose?.();
|
|
486
|
+
dispose = void 0;
|
|
487
|
+
contactHistory = [];
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function installRuntimeChannels(bridge) {
|
|
493
|
+
const describe = bridge.describe;
|
|
494
|
+
bridge.describe = () => Promise.resolve(describe()).then((description) => addRuntimeCapabilities(description));
|
|
495
|
+
}
|
|
496
|
+
function addRuntimeCapabilities(description) {
|
|
497
|
+
const capabilities = [...description.capabilities];
|
|
498
|
+
for (const capability2 of ["runtime.audio", "runtime.world"]) {
|
|
499
|
+
if (!capabilities.includes(capability2)) capabilities.push(capability2);
|
|
500
|
+
}
|
|
501
|
+
return { ...description, capabilities };
|
|
502
|
+
}
|
|
503
|
+
function runtimeObservation(seed, replayRuntime) {
|
|
504
|
+
return {
|
|
505
|
+
audio: audioRuntimeSnapshot(),
|
|
506
|
+
world: {
|
|
507
|
+
seed,
|
|
508
|
+
...replayRuntime === void 0 ? {} : { runtime: replayRuntime }
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function advance(runtime, ticks) {
|
|
513
|
+
runtime.fixedStep(ticks);
|
|
514
|
+
}
|
|
515
|
+
function bridgeEntities(ctx) {
|
|
516
|
+
return [
|
|
517
|
+
{ id: "camera.main", object: ctx.camera },
|
|
518
|
+
...Object.keys(ctx.entities.snapshot()).flatMap((id) => {
|
|
519
|
+
const object = entityObject(ctx.entities.get(id));
|
|
520
|
+
return object === void 0 ? [] : [{ id, object }];
|
|
521
|
+
})
|
|
522
|
+
];
|
|
523
|
+
}
|
|
524
|
+
function entityObject(entity) {
|
|
525
|
+
if (entity instanceof Object3D) return entity;
|
|
526
|
+
const candidate = entity?.mesh ?? entity?.object;
|
|
527
|
+
return candidate instanceof Object3D ? candidate : void 0;
|
|
528
|
+
}
|
|
529
|
+
function gameplayObservations(ctx, contactHistory, seed, replayRuntime) {
|
|
530
|
+
const animation = {};
|
|
531
|
+
const states = {};
|
|
532
|
+
const snapshot = ctx.entities.snapshot();
|
|
533
|
+
for (const id of Object.keys(snapshot)) {
|
|
534
|
+
const entity = ctx.entities.get(id);
|
|
535
|
+
if (typeof entity?.animation?.current === "string" && typeof entity.animation.advancedFrames === "number") {
|
|
536
|
+
animation[id] = {
|
|
537
|
+
advancedFrames: entity.animation.advancedFrames,
|
|
538
|
+
clip: entity.animation.current
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
if (typeof entity?.state === "string") states[id] = entity.state;
|
|
542
|
+
}
|
|
543
|
+
const channels = gameplayChannels();
|
|
544
|
+
const contacts = channels.includes("runtime.contacts") ? drainContacts(ctx, contactHistory) : void 0;
|
|
545
|
+
const tags = channels.includes("runtime.tags") ? tagCounts(snapshot) : void 0;
|
|
546
|
+
return {
|
|
547
|
+
animation,
|
|
548
|
+
...contacts === void 0 ? {} : { contacts },
|
|
549
|
+
states,
|
|
550
|
+
...tags === void 0 ? {} : { tags },
|
|
551
|
+
...runtimeObservation(seed, replayRuntime)
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function gameplayChannels(_ctx) {
|
|
555
|
+
return ["runtime.contacts", "runtime.tags"];
|
|
556
|
+
}
|
|
557
|
+
function drainContacts(ctx, history) {
|
|
558
|
+
for (const id of Object.keys(ctx.entities.snapshot())) {
|
|
559
|
+
for (const source of entitySources(ctx.entities.get(id))) {
|
|
560
|
+
for (const event of source.drainContacts()) {
|
|
561
|
+
const entity = findEntityId(ctx, event.body);
|
|
562
|
+
if (entity === void 0 || event.entity === void 0) continue;
|
|
563
|
+
history.push({
|
|
564
|
+
entity,
|
|
565
|
+
kind: event.started ? "trigger" : "trigger.exit",
|
|
566
|
+
with: event.entity
|
|
567
|
+
});
|
|
568
|
+
if (history.length > PLAYTEST_PROTOCOL_LIMITS.maxEventsPerDrain) history.shift();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return [...history];
|
|
573
|
+
}
|
|
574
|
+
function entitySources(entity) {
|
|
575
|
+
if (entity === void 0) return [];
|
|
576
|
+
return Object.values(entity).filter(
|
|
577
|
+
(value) => typeof value === "object" && value !== null && "drainContacts" in value && typeof value.drainContacts === "function"
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
function findEntityId(ctx, target) {
|
|
581
|
+
return Object.keys(ctx.entities.snapshot()).find((id) => {
|
|
582
|
+
const entity = ctx.entities.get(id);
|
|
583
|
+
return entity !== void 0 && Object.values(entity).some((value) => value === target);
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function tagCounts(snapshot) {
|
|
587
|
+
const counts = {};
|
|
588
|
+
for (const fields of Object.values(snapshot)) {
|
|
589
|
+
for (const tag of fields.tags ?? []) counts[tag] = { count: (counts[tag]?.count ?? 0) + 1 };
|
|
590
|
+
}
|
|
591
|
+
return counts;
|
|
592
|
+
}
|
|
593
|
+
function componentObservations(snapshot) {
|
|
594
|
+
const components = {};
|
|
595
|
+
for (const [id, fields] of Object.entries(snapshot)) {
|
|
596
|
+
if (Object.keys(fields).length === 0) continue;
|
|
597
|
+
assertJsonSafe(fields, `$.components.${id}`);
|
|
598
|
+
components[id] = fields;
|
|
599
|
+
}
|
|
600
|
+
return components;
|
|
601
|
+
}
|
|
602
|
+
function stateResources(ctx) {
|
|
603
|
+
return {
|
|
604
|
+
read: () => {
|
|
605
|
+
ctx.state.flush();
|
|
606
|
+
const state = ctx.state.getState();
|
|
607
|
+
assertJsonSafe(state);
|
|
608
|
+
const value = state;
|
|
609
|
+
return { GameState: value, state: value };
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export { playtest };
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@threenative/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The small vanilla Three.js game runtime foundation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./playtest": {
|
|
18
|
+
"types": "./dist/playtest.d.ts",
|
|
19
|
+
"import": "./dist/playtest.js"
|
|
20
|
+
},
|
|
21
|
+
"./hot": {
|
|
22
|
+
"types": "./dist/hot.d.ts",
|
|
23
|
+
"import": "./dist/hot.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"three": "0.185.1",
|
|
33
|
+
"zustand": "5.x"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/three": "0.185.3",
|
|
37
|
+
"publint": "0.3.22",
|
|
38
|
+
"tsup": "8.5.1",
|
|
39
|
+
"typescript": "5.9.3",
|
|
40
|
+
"@threenative/playtest": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup --config tsup.config.ts && publint",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "pnpm run build"
|
|
46
|
+
}
|
|
47
|
+
}
|