incanto 0.51.0 → 0.52.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/2d.d.ts +37 -2
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +3 -3
- package/dist/3d.js +4 -4
- package/dist/{behavior-rZNfzVbH.d.ts → behavior-uPEuZrUB.d.ts} +10 -0
- package/dist/{create-game-CkzKZ4v5.js → create-game-B_e9hJf7.js} +92 -10
- package/dist/{create-game-BNaOC3Px.js → create-game-CGnoypjL.js} +6 -6
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +1 -1
- package/dist/{duplicate-EybyYeyL.js → duplicate-B-OtSRFL.js} +1 -1
- package/dist/editor.js +1917 -1579
- package/dist/{environment-presets-C08pOC6H.js → environment-presets-DSZwsKPs.js} +3 -3
- package/dist/{gameplay-bStgtZBV.js → gameplay-B6jqvYeM.js} +6 -4
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +65 -5
- package/dist/index.js +7 -7
- package/dist/{loader-B2asghWa.js → loader-B-Gft32x.js} +32 -32
- package/dist/{loader-Dkbn56KC.d.ts → loader-B9iTqs27.d.ts} +1 -1
- package/dist/net.d.ts +15 -410
- package/dist/net.js +1 -822
- package/dist/{pathfinding-Bz34pvQD.d.ts → pathfinding-CAR9DjQQ.d.ts} +1 -1
- package/dist/{physics-2d-Ccq2L9R0.js → physics-2d-Dns-oZlE.js} +2 -2
- package/dist/{physics-3d-B2c5KNYh.js → physics-3d-BhI0ehpe.js} +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-CGq-Hee8.js → register-DVwlnZAZ.js} +2 -2
- package/dist/{register-B_BaaGx-.js → register-Trx7WHnD.js} +3 -3
- package/dist/{registry-IyWCGe4q.js → registry-C7u42TID.js} +23 -1
- package/dist/{replay-DIP2_as4.d.ts → replay-BU1CCM15.d.ts} +1 -1
- package/dist/{replay-BgKxGcXH.js → replay-BicPOMX0.js} +195 -2
- package/dist/split-screen-CZ9ccBBQ.js +1267 -0
- package/dist/split-screen-paxkQs_q.d.ts +441 -0
- package/dist/{src-C3UwzYXl.js → src-5gbZO47I.js} +1 -1
- package/dist/{teardown-BKTCzLek.js → teardown-ks3d5W9n.js} +2 -1
- package/dist/{test-D2gRpk5V.js → test-DAojuFdb.js} +119 -22
- package/dist/test.d.ts +60 -5
- package/dist/test.js +3 -3
- package/dist/vite.js +2 -2
- package/editor/assets/{agent8-DbX_msaO.js → agent8-CCvckvbw.js} +1 -1
- package/editor/assets/{debug-C-kMxdl1.js → debug-CLNCOnbc.js} +1 -1
- package/editor/assets/{index-CUc1U7wm.js → index-Cb5Brupb.js} +99 -92
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/skills/incanto-assets.md +4 -1
- package/skills/incanto-editor.md +48 -11
- package/skills/incanto-gameplay-behaviors.md +14 -5
- package/skills/incanto-multiplayer.md +39 -3
- package/skills/incanto-verifying-your-game.md +56 -0
- package/templates/agent8-server.ts +79 -2
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/dist/register-CLVhzWcI.js +0 -374
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
import { C as diagnose, M as Signal, f as Node, n as loadScene, t as buildNodeJson } from "./loader-B-Gft32x.js";
|
|
2
|
+
import { h as Engine, t as registerCoreNodes } from "./register-DVwlnZAZ.js";
|
|
3
|
+
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
4
|
+
import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
|
|
5
|
+
import { l as registerNode } from "./registry-C7u42TID.js";
|
|
6
|
+
//#region src/net/loopback.ts
|
|
7
|
+
/**
|
|
8
|
+
* In-memory multiplayer hub implementing the SAME kernel contract as
|
|
9
|
+
* `templates/agent8-server.js` — local split-screen demos, offline development,
|
|
10
|
+
* and headless tests run real multi-client flows without any infrastructure.
|
|
11
|
+
*/
|
|
12
|
+
var LoopbackHub = class {
|
|
13
|
+
rooms = /* @__PURE__ */ new Map();
|
|
14
|
+
accountSeq = 0;
|
|
15
|
+
roomSeq = 0;
|
|
16
|
+
entitySeq = 0;
|
|
17
|
+
/** Per-room listener registries, keyed by roomId. */
|
|
18
|
+
listeners = {
|
|
19
|
+
roomState: /* @__PURE__ */ new Map(),
|
|
20
|
+
allUsers: /* @__PURE__ */ new Map(),
|
|
21
|
+
myState: /* @__PURE__ */ new Map(),
|
|
22
|
+
collection: /* @__PURE__ */ new Map(),
|
|
23
|
+
message: /* @__PURE__ */ new Map(),
|
|
24
|
+
join: /* @__PURE__ */ new Map(),
|
|
25
|
+
leave: /* @__PURE__ */ new Map()
|
|
26
|
+
};
|
|
27
|
+
createClient(account) {
|
|
28
|
+
this.accountSeq += 1;
|
|
29
|
+
return new LoopbackTransport(this, account ?? `user${this.accountSeq}`);
|
|
30
|
+
}
|
|
31
|
+
/** @internal */
|
|
32
|
+
_room(roomId) {
|
|
33
|
+
let room = this.rooms.get(roomId);
|
|
34
|
+
if (!room) {
|
|
35
|
+
room = {
|
|
36
|
+
users: /* @__PURE__ */ new Map(),
|
|
37
|
+
state: {},
|
|
38
|
+
collections: /* @__PURE__ */ new Map()
|
|
39
|
+
};
|
|
40
|
+
this.rooms.set(roomId, room);
|
|
41
|
+
}
|
|
42
|
+
return room;
|
|
43
|
+
}
|
|
44
|
+
/** @internal The server.js kernel, in memory. */
|
|
45
|
+
_call(account, fn, args) {
|
|
46
|
+
switch (fn) {
|
|
47
|
+
case "joinRoom": {
|
|
48
|
+
const roomId = args[0] ?? `room${++this.roomSeq}`;
|
|
49
|
+
this._room(roomId).users.set(account, {});
|
|
50
|
+
this.fire(this.listeners.join.get(roomId), account);
|
|
51
|
+
this.fireAllUsers(roomId);
|
|
52
|
+
return roomId;
|
|
53
|
+
}
|
|
54
|
+
case "leaveRoom": {
|
|
55
|
+
const roomId = args[0];
|
|
56
|
+
this._room(roomId).users.delete(account);
|
|
57
|
+
this.fire(this.listeners.leave.get(roomId), account);
|
|
58
|
+
this.fireAllUsers(roomId);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
case "setMyState": {
|
|
62
|
+
const [roomId, patch] = args;
|
|
63
|
+
const room = this._room(roomId);
|
|
64
|
+
const prev = room.users.get(account) ?? {};
|
|
65
|
+
room.users.set(account, {
|
|
66
|
+
...prev,
|
|
67
|
+
...jsonClone(patch)
|
|
68
|
+
});
|
|
69
|
+
for (const entry of this.listeners.myState.get(roomId) ?? []) if (entry.account === account) entry.cb(jsonClone(room.users.get(account) ?? {}));
|
|
70
|
+
this.fireAllUsers(roomId);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
case "patchRoomState": {
|
|
74
|
+
const [roomId, patch] = args;
|
|
75
|
+
const room = this._room(roomId);
|
|
76
|
+
room.state = {
|
|
77
|
+
...room.state,
|
|
78
|
+
...jsonClone(patch)
|
|
79
|
+
};
|
|
80
|
+
this.fire(this.listeners.roomState.get(roomId), jsonClone(room.state));
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "addEntity": {
|
|
84
|
+
const [roomId, collectionId, entity] = args;
|
|
85
|
+
const room = this._room(roomId);
|
|
86
|
+
let coll = room.collections.get(collectionId);
|
|
87
|
+
if (!coll) {
|
|
88
|
+
coll = /* @__PURE__ */ new Map();
|
|
89
|
+
room.collections.set(collectionId, coll);
|
|
90
|
+
}
|
|
91
|
+
const id = entity.__id ?? `e${++this.entitySeq}`;
|
|
92
|
+
coll.set(id, {
|
|
93
|
+
...jsonClone(entity),
|
|
94
|
+
__id: id
|
|
95
|
+
});
|
|
96
|
+
this.fireCollection(roomId, collectionId);
|
|
97
|
+
return id;
|
|
98
|
+
}
|
|
99
|
+
case "updateEntity": {
|
|
100
|
+
const [roomId, collectionId, id, patch] = args;
|
|
101
|
+
const coll = this._room(roomId).collections.get(collectionId);
|
|
102
|
+
const prev = coll?.get(id);
|
|
103
|
+
if (coll && prev) {
|
|
104
|
+
coll.set(id, {
|
|
105
|
+
...prev,
|
|
106
|
+
...jsonClone(patch),
|
|
107
|
+
__id: id
|
|
108
|
+
});
|
|
109
|
+
this.fireCollection(roomId, collectionId);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
case "removeEntity": {
|
|
114
|
+
const [roomId, collectionId, id] = args;
|
|
115
|
+
this._room(roomId).collections.get(collectionId)?.delete(id);
|
|
116
|
+
this.fireCollection(roomId, collectionId);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
case "sendEvent": {
|
|
120
|
+
const [roomId, type, payload] = args;
|
|
121
|
+
for (const entry of this.listeners.message.get(roomId) ?? []) if (entry.type === type) entry.cb(jsonClone(payload));
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
default: throw new IncantoError("BAD_FORMAT", `Loopback kernel has no remote function '${fn}'. Available: joinRoom, leaveRoom, setMyState, patchRoomState, addEntity, updateEntity, removeEntity, sendEvent.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** @internal */
|
|
128
|
+
_subscribe(kind, roomId, entry) {
|
|
129
|
+
const registry = this.listeners[kind];
|
|
130
|
+
let set = registry.get(roomId);
|
|
131
|
+
if (!set) {
|
|
132
|
+
set = /* @__PURE__ */ new Set();
|
|
133
|
+
registry.set(roomId, set);
|
|
134
|
+
}
|
|
135
|
+
set.add(entry);
|
|
136
|
+
return () => set?.delete(entry);
|
|
137
|
+
}
|
|
138
|
+
/** @internal */
|
|
139
|
+
_snapshotUsers(roomId) {
|
|
140
|
+
return Object.fromEntries([...this._room(roomId).users.entries()].map(([a, s]) => [a, jsonClone(s)]));
|
|
141
|
+
}
|
|
142
|
+
/** @internal */
|
|
143
|
+
_snapshotCollection(roomId, collectionId) {
|
|
144
|
+
const coll = this._room(roomId).collections.get(collectionId);
|
|
145
|
+
return coll ? Object.fromEntries([...coll.entries()].map(([i, e]) => [i, jsonClone(e)])) : {};
|
|
146
|
+
}
|
|
147
|
+
/** @internal */
|
|
148
|
+
_snapshotRoomState(roomId) {
|
|
149
|
+
return jsonClone(this._room(roomId).state);
|
|
150
|
+
}
|
|
151
|
+
fire(set, value) {
|
|
152
|
+
for (const cb of set ?? []) cb(value);
|
|
153
|
+
}
|
|
154
|
+
fireAllUsers(roomId) {
|
|
155
|
+
const snapshot = this._snapshotUsers(roomId);
|
|
156
|
+
for (const cb of this.listeners.allUsers.get(roomId) ?? []) cb(snapshot);
|
|
157
|
+
}
|
|
158
|
+
fireCollection(roomId, collectionId) {
|
|
159
|
+
const snapshot = this._snapshotCollection(roomId, collectionId);
|
|
160
|
+
for (const entry of this.listeners.collection.get(roomId) ?? []) if (entry.id === collectionId) entry.cb(snapshot);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
/** A client on a LoopbackHub. Subscriptions fire immediately with a snapshot. */
|
|
164
|
+
var LoopbackTransport = class {
|
|
165
|
+
hub;
|
|
166
|
+
account;
|
|
167
|
+
connected = false;
|
|
168
|
+
constructor(hub, account) {
|
|
169
|
+
this.hub = hub;
|
|
170
|
+
this.account = account;
|
|
171
|
+
}
|
|
172
|
+
connect() {
|
|
173
|
+
this.connected = true;
|
|
174
|
+
return Promise.resolve(true);
|
|
175
|
+
}
|
|
176
|
+
disconnect() {
|
|
177
|
+
this.connected = false;
|
|
178
|
+
return Promise.resolve();
|
|
179
|
+
}
|
|
180
|
+
remoteFunction(fn, args = []) {
|
|
181
|
+
return Promise.resolve(this.hub._call(this.account, fn, args));
|
|
182
|
+
}
|
|
183
|
+
subscribeRoomState(roomId, cb) {
|
|
184
|
+
const off = this.hub._subscribe("roomState", roomId, cb);
|
|
185
|
+
cb(this.hub._snapshotRoomState(roomId));
|
|
186
|
+
return off;
|
|
187
|
+
}
|
|
188
|
+
subscribeRoomMyState(roomId, cb) {
|
|
189
|
+
const off = this.hub._subscribe("myState", roomId, {
|
|
190
|
+
account: this.account,
|
|
191
|
+
cb
|
|
192
|
+
});
|
|
193
|
+
cb(jsonClone(this.hub._room(roomId).users.get(this.account) ?? {}));
|
|
194
|
+
return off;
|
|
195
|
+
}
|
|
196
|
+
subscribeRoomAllUserStates(roomId, cb) {
|
|
197
|
+
const off = this.hub._subscribe("allUsers", roomId, cb);
|
|
198
|
+
cb(this.hub._snapshotUsers(roomId));
|
|
199
|
+
return off;
|
|
200
|
+
}
|
|
201
|
+
subscribeRoomCollection(roomId, collectionId, cb) {
|
|
202
|
+
const off = this.hub._subscribe("collection", roomId, {
|
|
203
|
+
id: collectionId,
|
|
204
|
+
cb
|
|
205
|
+
});
|
|
206
|
+
cb(this.hub._snapshotCollection(roomId, collectionId));
|
|
207
|
+
return off;
|
|
208
|
+
}
|
|
209
|
+
onRoomMessage(roomId, type, cb) {
|
|
210
|
+
return this.hub._subscribe("message", roomId, {
|
|
211
|
+
type,
|
|
212
|
+
cb
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
onRoomUserJoin(roomId, cb) {
|
|
216
|
+
return this.hub._subscribe("join", roomId, cb);
|
|
217
|
+
}
|
|
218
|
+
onRoomUserLeave(roomId, cb) {
|
|
219
|
+
return this.hub._subscribe("leave", roomId, cb);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/net/local-game-server.ts
|
|
224
|
+
/**
|
|
225
|
+
* Run a multiplayer game's REAL agent8-sdk-v2 `Server` class **locally**, in
|
|
226
|
+
* memory, with NO cloud and NO auth — the preview/dev path.
|
|
227
|
+
*
|
|
228
|
+
* `LoopbackHub` answers only the fixed built-in room protocol (joinRoom,
|
|
229
|
+
* setMyState, collections, events). Real games add server-AUTHORITATIVE rules
|
|
230
|
+
* (`awardPoint`, `castSpell`, `$roomTick` match clocks) in `server/src/server.ts`
|
|
231
|
+
* against the v2 contexts `$sender`/`$global`/`$room`/`$lock`. LocalGameServer
|
|
232
|
+
* runs that exact class body so the whole game — client + server logic — is
|
|
233
|
+
* playable in dev before it ever touches the Agent8 platform. Swap
|
|
234
|
+
* `local.createClient(account)` for `await createAgent8Server()` to go live;
|
|
235
|
+
* nothing else changes.
|
|
236
|
+
*
|
|
237
|
+
* How it stays faithful to the isolated-vm platform:
|
|
238
|
+
* - A FRESH `Server` instance runs per request, so `this.*` never persists.
|
|
239
|
+
* - The v2 globals are injected on `globalThis` for the duration of each call
|
|
240
|
+
* (the SAME `server/src/server.ts` body that reads bare `$room`/`$sender`
|
|
241
|
+
* runs unmodified).
|
|
242
|
+
* - Calls are SERIALIZED through one queue, so a global binding can't leak across
|
|
243
|
+
* another request's `await`s (the platform isolates per request; we serialize).
|
|
244
|
+
* - `$roomTick(deltaMS, roomId)` runs only while a room has users — drive it from
|
|
245
|
+
* the engine: `engine.updated.connect((dt) => local.tick(dt * 1000))`.
|
|
246
|
+
*
|
|
247
|
+
* NOT a substitute for the cloud: no isolated-vm sandboxing, no persistence, no
|
|
248
|
+
* rate limits — a faithful FUNCTIONAL emulator for local play and tests.
|
|
249
|
+
*/
|
|
250
|
+
var LocalGameServer = class {
|
|
251
|
+
hub = new LoopbackHub();
|
|
252
|
+
serverClass;
|
|
253
|
+
/** Each client's current room — binds `$sender.roomId` on later calls. */
|
|
254
|
+
clientRooms = /* @__PURE__ */ new Map();
|
|
255
|
+
/** All remoteFunction/tick calls run one-at-a-time so injected globals never overlap. */
|
|
256
|
+
queue = Promise.resolve();
|
|
257
|
+
/** Per-key mutex backing `$lock`. */
|
|
258
|
+
locks = /* @__PURE__ */ new Map();
|
|
259
|
+
/** Persistent (process-lifetime) store backing the `$global` + `$asset` contexts. */
|
|
260
|
+
g_state = {};
|
|
261
|
+
g_userStates = /* @__PURE__ */ new Map();
|
|
262
|
+
g_collections = /* @__PURE__ */ new Map();
|
|
263
|
+
g_assets = /* @__PURE__ */ new Map();
|
|
264
|
+
g_seq = 0;
|
|
265
|
+
/** Client-side global/asset subscription registries (fanned out on mutation). */
|
|
266
|
+
subState = /* @__PURE__ */ new Set();
|
|
267
|
+
subUser = /* @__PURE__ */ new Map();
|
|
268
|
+
subColl = /* @__PURE__ */ new Map();
|
|
269
|
+
subAsset = /* @__PURE__ */ new Map();
|
|
270
|
+
subMsg = /* @__PURE__ */ new Map();
|
|
271
|
+
constructor(serverClass) {
|
|
272
|
+
this.serverClass = serverClass;
|
|
273
|
+
}
|
|
274
|
+
/** A client on this server. Distinct `account` per player (default auto-assigned). */
|
|
275
|
+
createClient(account) {
|
|
276
|
+
return new LocalGameServerTransport(this, this.hub.createClient(account));
|
|
277
|
+
}
|
|
278
|
+
collectionSnapshot(id) {
|
|
279
|
+
const c = this.g_collections.get(id);
|
|
280
|
+
return c ? Object.fromEntries([...c.entries()].map(([k, v]) => [k, jsonClone(v)])) : {};
|
|
281
|
+
}
|
|
282
|
+
assetSnapshot(acct) {
|
|
283
|
+
return Object.fromEntries(this.g_assets.get(acct) ?? []);
|
|
284
|
+
}
|
|
285
|
+
/** @internal */
|
|
286
|
+
subscribeGlobalState(cb) {
|
|
287
|
+
this.subState.add(cb);
|
|
288
|
+
cb(jsonClone(this.g_state));
|
|
289
|
+
return () => this.subState.delete(cb);
|
|
290
|
+
}
|
|
291
|
+
/** @internal */
|
|
292
|
+
subscribeGlobalUserState(account, cb) {
|
|
293
|
+
const off = addToSetMap(this.subUser, account, cb);
|
|
294
|
+
cb(jsonClone(this.g_userStates.get(account) ?? {}));
|
|
295
|
+
return off;
|
|
296
|
+
}
|
|
297
|
+
/** @internal */
|
|
298
|
+
subscribeGlobalCollection(id, cb) {
|
|
299
|
+
const off = addToSetMap(this.subColl, id, cb);
|
|
300
|
+
cb(this.collectionSnapshot(id));
|
|
301
|
+
return off;
|
|
302
|
+
}
|
|
303
|
+
/** @internal */
|
|
304
|
+
subscribeAsset(account, cb) {
|
|
305
|
+
const off = addToSetMap(this.subAsset, account, cb);
|
|
306
|
+
cb(this.assetSnapshot(account));
|
|
307
|
+
return off;
|
|
308
|
+
}
|
|
309
|
+
/** @internal Messages are events — no immediate snapshot. */
|
|
310
|
+
onGlobalMessage(account, type, cb) {
|
|
311
|
+
const entry = {
|
|
312
|
+
account,
|
|
313
|
+
cb
|
|
314
|
+
};
|
|
315
|
+
return addToSetMap(this.subMsg, type, entry);
|
|
316
|
+
}
|
|
317
|
+
fireState() {
|
|
318
|
+
const s = jsonClone(this.g_state);
|
|
319
|
+
for (const cb of [...this.subState]) cb(s);
|
|
320
|
+
}
|
|
321
|
+
fireUser(account) {
|
|
322
|
+
const s = jsonClone(this.g_userStates.get(account) ?? {});
|
|
323
|
+
for (const cb of [...this.subUser.get(account) ?? []]) cb(s);
|
|
324
|
+
}
|
|
325
|
+
fireColl(id) {
|
|
326
|
+
const r = this.collectionSnapshot(id);
|
|
327
|
+
for (const cb of [...this.subColl.get(id) ?? []]) cb(r);
|
|
328
|
+
}
|
|
329
|
+
fireAsset(account) {
|
|
330
|
+
const w = this.assetSnapshot(account);
|
|
331
|
+
for (const cb of [...this.subAsset.get(account) ?? []]) cb(w);
|
|
332
|
+
}
|
|
333
|
+
fireMsg(type, message, target) {
|
|
334
|
+
for (const e of [...this.subMsg.get(type) ?? []]) if (!target || e.account === target) e.cb(jsonClone(message));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Advance server-driven periodic logic. Calls the `Server`'s `$roomTick(deltaMS,
|
|
338
|
+
* roomId)` once per room that currently has users. No-op without a `$roomTick`.
|
|
339
|
+
*/
|
|
340
|
+
tick(deltaMS) {
|
|
341
|
+
return this.enqueue(() => this.runTick(deltaMS));
|
|
342
|
+
}
|
|
343
|
+
/** @internal Dispatch a remoteFunction (serialized). */
|
|
344
|
+
call(account, fn, args) {
|
|
345
|
+
return this.enqueue(() => this.dispatch(account, fn, args));
|
|
346
|
+
}
|
|
347
|
+
enqueue(run) {
|
|
348
|
+
const result = this.queue.then(run, run);
|
|
349
|
+
this.queue = result.then(() => void 0, () => void 0);
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
async dispatch(account, fn, args) {
|
|
353
|
+
const server = this.serverClass ? new this.serverClass() : null;
|
|
354
|
+
const method = server && typeof server[fn] === "function" ? server[fn] : null;
|
|
355
|
+
const roomId = fn === "joinRoom" ? void 0 : this.clientRooms.get(account) ?? args[0];
|
|
356
|
+
let out;
|
|
357
|
+
if (method && server) {
|
|
358
|
+
const restore = this.bindGlobals(account, roomId);
|
|
359
|
+
try {
|
|
360
|
+
out = await method.apply(server, args);
|
|
361
|
+
} finally {
|
|
362
|
+
restore();
|
|
363
|
+
}
|
|
364
|
+
} else out = this.hub._call(account, fn, args);
|
|
365
|
+
if (fn === "joinRoom" && typeof out === "string") this.clientRooms.set(account, out);
|
|
366
|
+
else if (fn === "leaveRoom") this.clientRooms.delete(account);
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
async runTick(deltaMS) {
|
|
370
|
+
if (!this.serverClass) return;
|
|
371
|
+
if (typeof new this.serverClass().$roomTick !== "function") return;
|
|
372
|
+
for (const roomId of new Set(this.clientRooms.values())) {
|
|
373
|
+
const server = new this.serverClass();
|
|
374
|
+
const restore = this.bindGlobals("", roomId);
|
|
375
|
+
try {
|
|
376
|
+
await server.$roomTick.call(server, deltaMS, roomId);
|
|
377
|
+
} finally {
|
|
378
|
+
restore();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/** Install the v2 globals for one dispatch; returns a restorer. */
|
|
383
|
+
bindGlobals(account, roomId) {
|
|
384
|
+
const g = globalThis;
|
|
385
|
+
const prev = {
|
|
386
|
+
$sender: g.$sender,
|
|
387
|
+
$global: g.$global,
|
|
388
|
+
$room: g.$room,
|
|
389
|
+
$lock: g.$lock,
|
|
390
|
+
$asset: g.$asset
|
|
391
|
+
};
|
|
392
|
+
g.$sender = {
|
|
393
|
+
account,
|
|
394
|
+
roomId: roomId ?? ""
|
|
395
|
+
};
|
|
396
|
+
g.$global = this.globalContext(account);
|
|
397
|
+
g.$room = this.roomContext(account, roomId);
|
|
398
|
+
g.$asset = this.assetContext(account);
|
|
399
|
+
g.$lock = (key, fn) => this.withLock(key, fn);
|
|
400
|
+
return () => {
|
|
401
|
+
g.$sender = prev.$sender;
|
|
402
|
+
g.$global = prev.$global;
|
|
403
|
+
g.$room = prev.$room;
|
|
404
|
+
g.$lock = prev.$lock;
|
|
405
|
+
g.$asset = prev.$asset;
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The `$global` context — room membership, persistent GLOBAL state/user-state/
|
|
410
|
+
* collections, room management, and global messaging. Backed by an in-memory
|
|
411
|
+
* store that survives between requests (unlike rooms, which the hub clears when
|
|
412
|
+
* empty) — matching the platform's persistent-global / ephemeral-room split.
|
|
413
|
+
*/
|
|
414
|
+
globalContext(account) {
|
|
415
|
+
const hub = this.hub;
|
|
416
|
+
const coll = (id) => {
|
|
417
|
+
let c = this.g_collections.get(id);
|
|
418
|
+
if (!c) {
|
|
419
|
+
c = /* @__PURE__ */ new Map();
|
|
420
|
+
this.g_collections.set(id, c);
|
|
421
|
+
}
|
|
422
|
+
return c;
|
|
423
|
+
};
|
|
424
|
+
const userState = (acct) => this.g_userStates.get(acct) ?? {};
|
|
425
|
+
return {
|
|
426
|
+
joinRoom: (rid) => Promise.resolve(hub._call(account, "joinRoom", [rid])),
|
|
427
|
+
leaveRoom: () => {
|
|
428
|
+
const left = this.clientRooms.get(account) ?? "";
|
|
429
|
+
hub._call(account, "leaveRoom", [left]);
|
|
430
|
+
return Promise.resolve(left);
|
|
431
|
+
},
|
|
432
|
+
getGlobalState: () => Promise.resolve(jsonClone(this.g_state)),
|
|
433
|
+
updateGlobalState: (patch) => {
|
|
434
|
+
Object.assign(this.g_state, jsonClone(patch));
|
|
435
|
+
this.fireState();
|
|
436
|
+
return Promise.resolve(jsonClone(this.g_state));
|
|
437
|
+
},
|
|
438
|
+
getUserState: (acct) => Promise.resolve(jsonClone(userState(acct))),
|
|
439
|
+
updateUserState: (acct, patch) => {
|
|
440
|
+
const next = {
|
|
441
|
+
...userState(acct),
|
|
442
|
+
...jsonClone(patch)
|
|
443
|
+
};
|
|
444
|
+
this.g_userStates.set(acct, next);
|
|
445
|
+
this.fireUser(acct);
|
|
446
|
+
return Promise.resolve(jsonClone(next));
|
|
447
|
+
},
|
|
448
|
+
getMyState: () => Promise.resolve(jsonClone(userState(account))),
|
|
449
|
+
updateMyState: (patch) => {
|
|
450
|
+
const next = {
|
|
451
|
+
...userState(account),
|
|
452
|
+
...jsonClone(patch)
|
|
453
|
+
};
|
|
454
|
+
this.g_userStates.set(account, next);
|
|
455
|
+
this.fireUser(account);
|
|
456
|
+
return Promise.resolve(jsonClone(next));
|
|
457
|
+
},
|
|
458
|
+
addCollectionItem: (id, item) => {
|
|
459
|
+
const __id = item.__id ?? `g${++this.g_seq}`;
|
|
460
|
+
const stored = {
|
|
461
|
+
...jsonClone(item),
|
|
462
|
+
__id
|
|
463
|
+
};
|
|
464
|
+
coll(id).set(__id, stored);
|
|
465
|
+
this.fireColl(id);
|
|
466
|
+
return Promise.resolve(jsonClone(stored));
|
|
467
|
+
},
|
|
468
|
+
updateCollectionItem: (id, item) => {
|
|
469
|
+
const { __id, ...patch } = item;
|
|
470
|
+
const c = coll(id);
|
|
471
|
+
const next = {
|
|
472
|
+
...c.get(__id) ?? {},
|
|
473
|
+
...jsonClone(patch),
|
|
474
|
+
__id
|
|
475
|
+
};
|
|
476
|
+
c.set(__id, next);
|
|
477
|
+
this.fireColl(id);
|
|
478
|
+
return Promise.resolve(jsonClone(next));
|
|
479
|
+
},
|
|
480
|
+
deleteCollectionItem: (id, itemId) => {
|
|
481
|
+
coll(id).delete(itemId);
|
|
482
|
+
this.fireColl(id);
|
|
483
|
+
return Promise.resolve({ __id: itemId });
|
|
484
|
+
},
|
|
485
|
+
deleteCollection: (id) => {
|
|
486
|
+
this.g_collections.delete(id);
|
|
487
|
+
this.fireColl(id);
|
|
488
|
+
return Promise.resolve(id);
|
|
489
|
+
},
|
|
490
|
+
getCollectionItem: (id, itemId) => Promise.resolve(jsonClone(coll(id).get(itemId) ?? {})),
|
|
491
|
+
getCollectionItems: (id, options) => Promise.resolve(queryItems([...coll(id).values()].map(jsonClone), options)),
|
|
492
|
+
countCollectionItems: (id, options) => Promise.resolve(queryItems([...coll(id).values()].map(jsonClone), options).length),
|
|
493
|
+
countRooms: () => Promise.resolve(new Set(this.clientRooms.values()).size),
|
|
494
|
+
getAllRoomIds: () => Promise.resolve([...new Set(this.clientRooms.values())]),
|
|
495
|
+
getAllRoomStates: () => Promise.resolve([...new Set(this.clientRooms.values())].map((r) => roomStateWithDefaults(hub, r))),
|
|
496
|
+
getRoomUserAccounts: (rid) => Promise.resolve(Object.keys(hub._snapshotUsers(rid))),
|
|
497
|
+
countRoomUsers: (rid) => Promise.resolve(Object.keys(hub._snapshotUsers(rid)).length),
|
|
498
|
+
getRoomState: (rid) => Promise.resolve(roomStateWithDefaults(hub, rid)),
|
|
499
|
+
updateRoomState: (rid, patch) => {
|
|
500
|
+
hub._call(account, "patchRoomState", [rid, patch]);
|
|
501
|
+
return Promise.resolve(roomStateWithDefaults(hub, rid));
|
|
502
|
+
},
|
|
503
|
+
getRoomUserState: (rid, acct) => Promise.resolve(hub._snapshotUsers(rid)[acct] ?? {}),
|
|
504
|
+
updateRoomUserState: (rid, acct, patch) => {
|
|
505
|
+
hub._call(acct, "setMyState", [rid, patch]);
|
|
506
|
+
return Promise.resolve(hub._snapshotUsers(rid)[acct] ?? {});
|
|
507
|
+
},
|
|
508
|
+
broadcastToAll: (type, message) => {
|
|
509
|
+
this.fireMsg(type, message);
|
|
510
|
+
},
|
|
511
|
+
sendMessageToUser: (type, acct, message) => {
|
|
512
|
+
this.fireMsg(type, message, acct);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The `$asset` context — a per-account currency ledger. `burn`/`transfer` throw
|
|
518
|
+
* on an insufficient balance (the documented pattern always checks `has` first),
|
|
519
|
+
* surfacing economy bugs in preview just as the platform would.
|
|
520
|
+
*/
|
|
521
|
+
assetContext(account) {
|
|
522
|
+
const wallet = (acct) => {
|
|
523
|
+
let w = this.g_assets.get(acct);
|
|
524
|
+
if (!w) {
|
|
525
|
+
w = /* @__PURE__ */ new Map();
|
|
526
|
+
this.g_assets.set(acct, w);
|
|
527
|
+
}
|
|
528
|
+
return w;
|
|
529
|
+
};
|
|
530
|
+
const all = (acct) => Object.fromEntries(wallet(acct));
|
|
531
|
+
return {
|
|
532
|
+
get: (assetId) => Promise.resolve(wallet(account).get(assetId) ?? 0),
|
|
533
|
+
getAll: () => Promise.resolve(all(account)),
|
|
534
|
+
has: (assetId, amount) => Promise.resolve((wallet(account).get(assetId) ?? 0) >= amount),
|
|
535
|
+
mint: (assetId, amount) => {
|
|
536
|
+
requireAmount("mint", amount);
|
|
537
|
+
wallet(account).set(assetId, (wallet(account).get(assetId) ?? 0) + amount);
|
|
538
|
+
this.fireAsset(account);
|
|
539
|
+
return Promise.resolve(all(account));
|
|
540
|
+
},
|
|
541
|
+
burn: (assetId, amount) => {
|
|
542
|
+
requireAmount("burn", amount);
|
|
543
|
+
const have = wallet(account).get(assetId) ?? 0;
|
|
544
|
+
if (have < amount) throw new IncantoError("BAD_FORMAT", `$asset.burn: insufficient '${assetId}' for ${account} (have ${have}, need ${amount}). Guard with $asset.has first.`);
|
|
545
|
+
wallet(account).set(assetId, have - amount);
|
|
546
|
+
this.fireAsset(account);
|
|
547
|
+
return Promise.resolve(all(account));
|
|
548
|
+
},
|
|
549
|
+
transfer: (toAccount, assetId, amount) => {
|
|
550
|
+
requireAmount("transfer", amount);
|
|
551
|
+
const have = wallet(account).get(assetId) ?? 0;
|
|
552
|
+
if (have < amount) throw new IncantoError("BAD_FORMAT", `$asset.transfer: insufficient '${assetId}' for ${account} (have ${have}, need ${amount}).`);
|
|
553
|
+
wallet(account).set(assetId, have - amount);
|
|
554
|
+
wallet(toAccount).set(assetId, (wallet(toAccount).get(assetId) ?? 0) + amount);
|
|
555
|
+
this.fireAsset(account);
|
|
556
|
+
this.fireAsset(toAccount);
|
|
557
|
+
return Promise.resolve(all(account));
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/** The `$room` context, bound to one room — maps onto the hub's kernel primitives. */
|
|
562
|
+
roomContext(account, roomId) {
|
|
563
|
+
const rid = () => {
|
|
564
|
+
if (!roomId) throw new IncantoError("BAD_FORMAT", "$room used outside a room — joinRoom must resolve a room before $room is touched.");
|
|
565
|
+
return roomId;
|
|
566
|
+
};
|
|
567
|
+
const hub = this.hub;
|
|
568
|
+
return {
|
|
569
|
+
updateMyState: (patch) => Promise.resolve(hub._call(account, "setMyState", [rid(), patch])).then(() => hub._snapshotUsers(rid())[account] ?? {}),
|
|
570
|
+
updateRoomState: (patch) => Promise.resolve(hub._call(account, "patchRoomState", [rid(), patch])).then(() => hub._snapshotRoomState(rid())),
|
|
571
|
+
updateUserState: (acct, patch) => Promise.resolve(hub._call(acct, "setMyState", [rid(), patch])).then(() => hub._snapshotUsers(rid())[acct] ?? {}),
|
|
572
|
+
getUserState: (acct) => Promise.resolve(hub._snapshotUsers(rid())[acct] ?? {}),
|
|
573
|
+
getMyState: () => Promise.resolve(hub._snapshotUsers(rid())[account] ?? {}),
|
|
574
|
+
getRoomState: () => Promise.resolve(roomStateWithDefaults(hub, rid())),
|
|
575
|
+
getAllUserStates: () => Promise.resolve(Object.entries(hub._snapshotUsers(rid())).map(([acct, state]) => ({
|
|
576
|
+
...state,
|
|
577
|
+
account: acct
|
|
578
|
+
}))),
|
|
579
|
+
countUsers: () => Promise.resolve(Object.keys(hub._snapshotUsers(rid())).length),
|
|
580
|
+
addCollectionItem: (coll, item) => {
|
|
581
|
+
const id = hub._call(account, "addEntity", [
|
|
582
|
+
rid(),
|
|
583
|
+
coll,
|
|
584
|
+
item
|
|
585
|
+
]);
|
|
586
|
+
return Promise.resolve(hub._snapshotCollection(rid(), coll)[id] ?? { __id: id });
|
|
587
|
+
},
|
|
588
|
+
updateCollectionItem: (coll, item) => {
|
|
589
|
+
const { __id, ...patch } = item;
|
|
590
|
+
hub._call(account, "updateEntity", [
|
|
591
|
+
rid(),
|
|
592
|
+
coll,
|
|
593
|
+
__id,
|
|
594
|
+
patch
|
|
595
|
+
]);
|
|
596
|
+
return Promise.resolve(hub._snapshotCollection(rid(), coll)[__id] ?? { __id });
|
|
597
|
+
},
|
|
598
|
+
deleteCollectionItem: (coll, itemId) => {
|
|
599
|
+
hub._call(account, "removeEntity", [
|
|
600
|
+
rid(),
|
|
601
|
+
coll,
|
|
602
|
+
itemId
|
|
603
|
+
]);
|
|
604
|
+
return Promise.resolve({ __id: itemId });
|
|
605
|
+
},
|
|
606
|
+
deleteCollection: (coll) => {
|
|
607
|
+
for (const id of Object.keys(hub._snapshotCollection(rid(), coll))) hub._call(account, "removeEntity", [
|
|
608
|
+
rid(),
|
|
609
|
+
coll,
|
|
610
|
+
id
|
|
611
|
+
]);
|
|
612
|
+
return Promise.resolve(coll);
|
|
613
|
+
},
|
|
614
|
+
getCollectionItem: (coll, itemId) => Promise.resolve(hub._snapshotCollection(rid(), coll)[itemId] ?? {}),
|
|
615
|
+
getCollectionItems: (coll, options) => Promise.resolve(queryItems(Object.values(hub._snapshotCollection(rid(), coll)), options)),
|
|
616
|
+
countCollectionItems: (coll, options) => Promise.resolve(queryItems(Object.values(hub._snapshotCollection(rid(), coll)), options).length),
|
|
617
|
+
broadcastToRoom: (type, message) => {
|
|
618
|
+
hub._call(account, "sendEvent", [
|
|
619
|
+
rid(),
|
|
620
|
+
type,
|
|
621
|
+
message
|
|
622
|
+
]);
|
|
623
|
+
},
|
|
624
|
+
sendMessageToUser: (_type, _acct, _message) => {}
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
withLock(key, fn) {
|
|
628
|
+
const run = (this.locks.get(key) ?? Promise.resolve()).then(() => fn());
|
|
629
|
+
this.locks.set(key, run.then(() => void 0, () => void 0));
|
|
630
|
+
return run;
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
/** Add a value to a Map-of-Sets registry; returns an unsubscribe that removes it. */
|
|
634
|
+
function addToSetMap(map, key, value) {
|
|
635
|
+
let set = map.get(key);
|
|
636
|
+
if (!set) {
|
|
637
|
+
set = /* @__PURE__ */ new Set();
|
|
638
|
+
map.set(key, set);
|
|
639
|
+
}
|
|
640
|
+
set.add(value);
|
|
641
|
+
return () => {
|
|
642
|
+
set?.delete(value);
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/** A room state snapshot with the platform-maintained `roomId` + `$users` defaults. */
|
|
646
|
+
function roomStateWithDefaults(hub, roomId) {
|
|
647
|
+
return {
|
|
648
|
+
...hub._snapshotRoomState(roomId),
|
|
649
|
+
roomId,
|
|
650
|
+
$users: Object.keys(hub._snapshotUsers(roomId))
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
/** Throw unless `amount` is a non-negative finite number (matches the platform; the
|
|
654
|
+
* preview must NOT be more lenient — a negative burn would otherwise ADD currency). */
|
|
655
|
+
function requireAmount(op, amount) {
|
|
656
|
+
if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) throw new IncantoError("BAD_FORMAT", `$asset.${op}: amount must be a non-negative finite number (got ${amount}).`);
|
|
657
|
+
}
|
|
658
|
+
/** Compare two JSON-ish scalars for ordering. Numbers numerically, else by string. */
|
|
659
|
+
function cmp(a, b) {
|
|
660
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
661
|
+
const sa = String(a);
|
|
662
|
+
const sb = String(b);
|
|
663
|
+
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
664
|
+
}
|
|
665
|
+
function jsonEq(a, b) {
|
|
666
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
667
|
+
}
|
|
668
|
+
function matchFilter(fieldVal, op, value) {
|
|
669
|
+
switch (op) {
|
|
670
|
+
case "<": return cmp(fieldVal, value) < 0;
|
|
671
|
+
case "<=": return cmp(fieldVal, value) <= 0;
|
|
672
|
+
case ">": return cmp(fieldVal, value) > 0;
|
|
673
|
+
case ">=": return cmp(fieldVal, value) >= 0;
|
|
674
|
+
case "==": return jsonEq(fieldVal, value);
|
|
675
|
+
case "!=": return !jsonEq(fieldVal, value);
|
|
676
|
+
case "array-contains": return Array.isArray(fieldVal) && fieldVal.some((v) => jsonEq(v, value));
|
|
677
|
+
case "in": return Array.isArray(value) && value.some((v) => jsonEq(v, fieldVal));
|
|
678
|
+
case "not-in": return Array.isArray(value) && !value.some((v) => jsonEq(v, fieldVal));
|
|
679
|
+
case "array-contains-any": return Array.isArray(fieldVal) && Array.isArray(value) && fieldVal.some((v) => value.some((w) => jsonEq(v, w)));
|
|
680
|
+
default: throw new IncantoError("BAD_FORMAT", `Unknown collection filter operator '${op}'.`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/** Apply CollectionOptions to a list of items (preview emulation of the platform query). */
|
|
684
|
+
function queryItems(items, options) {
|
|
685
|
+
if (!options) return items;
|
|
686
|
+
const { filters, orderBy, limit, startAfter, endBefore } = options;
|
|
687
|
+
if (filters?.length && orderBy?.length) throw new IncantoError("BAD_FORMAT", "CollectionOptions cannot combine filters and orderBy (the platform requires a composite index). Use one or the other.");
|
|
688
|
+
let out = items;
|
|
689
|
+
if (filters?.length) out = out.filter((it) => filters.every((f) => matchFilter(it[f.field], f.operator, f.value)));
|
|
690
|
+
const ob = orderBy?.[0];
|
|
691
|
+
if (ob) {
|
|
692
|
+
const { field } = ob;
|
|
693
|
+
const dir = ob.direction === "desc" ? -1 : 1;
|
|
694
|
+
out = [...out].sort((a, b) => cmp(a[field], b[field]) * dir);
|
|
695
|
+
if (startAfter !== void 0) out = out.filter((it) => cmp(it[field], startAfter) * dir > 0);
|
|
696
|
+
if (endBefore !== void 0) out = out.filter((it) => cmp(it[field], endBefore) * dir < 0);
|
|
697
|
+
}
|
|
698
|
+
if (typeof limit === "number") out = out.slice(0, limit);
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
/** Construct a local preview server. With no `server`, it equals a raw LoopbackHub. */
|
|
702
|
+
function createLocalGameServer(opts = {}) {
|
|
703
|
+
return new LocalGameServer(opts.server);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* A client of a {@link LocalGameServer}. Subscriptions read the shared in-memory
|
|
707
|
+
* store directly (same fan-out as Loopback); `remoteFunction` routes through the
|
|
708
|
+
* server's serialized dispatch so the game's `Server` class actually runs.
|
|
709
|
+
*/
|
|
710
|
+
var LocalGameServerTransport = class {
|
|
711
|
+
server;
|
|
712
|
+
loop;
|
|
713
|
+
account;
|
|
714
|
+
connected = false;
|
|
715
|
+
constructor(server, loop) {
|
|
716
|
+
this.server = server;
|
|
717
|
+
this.loop = loop;
|
|
718
|
+
this.account = loop.account;
|
|
719
|
+
}
|
|
720
|
+
async connect() {
|
|
721
|
+
this.connected = true;
|
|
722
|
+
await this.loop.connect();
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
async disconnect() {
|
|
726
|
+
this.connected = false;
|
|
727
|
+
await this.loop.disconnect();
|
|
728
|
+
}
|
|
729
|
+
remoteFunction(fn, args = []) {
|
|
730
|
+
return this.server.call(this.account, fn, args);
|
|
731
|
+
}
|
|
732
|
+
subscribeRoomState(roomId, cb) {
|
|
733
|
+
return this.loop.subscribeRoomState(roomId, cb);
|
|
734
|
+
}
|
|
735
|
+
subscribeRoomMyState(roomId, cb) {
|
|
736
|
+
return this.loop.subscribeRoomMyState(roomId, cb);
|
|
737
|
+
}
|
|
738
|
+
subscribeRoomAllUserStates(roomId, cb) {
|
|
739
|
+
return this.loop.subscribeRoomAllUserStates(roomId, cb);
|
|
740
|
+
}
|
|
741
|
+
subscribeRoomCollection(roomId, collectionId, cb) {
|
|
742
|
+
return this.loop.subscribeRoomCollection(roomId, collectionId, cb);
|
|
743
|
+
}
|
|
744
|
+
onRoomMessage(roomId, type, cb) {
|
|
745
|
+
return this.loop.onRoomMessage(roomId, type, cb);
|
|
746
|
+
}
|
|
747
|
+
onRoomUserJoin(roomId, cb) {
|
|
748
|
+
return this.loop.onRoomUserJoin(roomId, cb);
|
|
749
|
+
}
|
|
750
|
+
onRoomUserLeave(roomId, cb) {
|
|
751
|
+
return this.loop.onRoomUserLeave(roomId, cb);
|
|
752
|
+
}
|
|
753
|
+
subscribeGlobalState(cb) {
|
|
754
|
+
return this.server.subscribeGlobalState(cb);
|
|
755
|
+
}
|
|
756
|
+
subscribeGlobalMyState(cb) {
|
|
757
|
+
return this.server.subscribeGlobalUserState(this.account, cb);
|
|
758
|
+
}
|
|
759
|
+
subscribeGlobalUserState(account, cb) {
|
|
760
|
+
return this.server.subscribeGlobalUserState(account, cb);
|
|
761
|
+
}
|
|
762
|
+
subscribeGlobalCollection(collectionId, cb) {
|
|
763
|
+
return this.server.subscribeGlobalCollection(collectionId, cb);
|
|
764
|
+
}
|
|
765
|
+
subscribeAsset(account, cb) {
|
|
766
|
+
return this.server.subscribeAsset(account, cb);
|
|
767
|
+
}
|
|
768
|
+
onGlobalMessage(type, cb) {
|
|
769
|
+
return this.server.onGlobalMessage(this.account, type, cb);
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/net/network-manager.ts
|
|
774
|
+
const managers = /* @__PURE__ */ new WeakMap();
|
|
775
|
+
/**
|
|
776
|
+
* Per-engine multiplayer hub: joins a room, exposes the room channels as core
|
|
777
|
+
* Signals, replicates `network: {mode:'owner'}` node props on a throttle
|
|
778
|
+
* window, and resolves registered scenes for NetworkSpawner.
|
|
779
|
+
*/
|
|
780
|
+
var NetworkManager = class NetworkManager {
|
|
781
|
+
account;
|
|
782
|
+
roomId;
|
|
783
|
+
roomState = new Signal();
|
|
784
|
+
allUserStates = new Signal();
|
|
785
|
+
userJoined = new Signal();
|
|
786
|
+
userLeft = new Signal();
|
|
787
|
+
/**
|
|
788
|
+
* GLOBAL (cross-room, persistent) channels — populated only when the transport
|
|
789
|
+
* supports them (`LocalGameServer`, the agent8 adapter). On a transport without a
|
|
790
|
+
* global tier they stay empty / never fire.
|
|
791
|
+
*/
|
|
792
|
+
globalState = new Signal();
|
|
793
|
+
globalMyState = new Signal();
|
|
794
|
+
/** The local account's `$asset` ledger. */
|
|
795
|
+
asset = new Signal();
|
|
796
|
+
/** Latest snapshots for polling consumers (NetworkSpawner). */
|
|
797
|
+
latestUserStates = {};
|
|
798
|
+
latestRoomState = {};
|
|
799
|
+
latestGlobalState = {};
|
|
800
|
+
latestGlobalMyState = {};
|
|
801
|
+
latestAsset = {};
|
|
802
|
+
server;
|
|
803
|
+
engine;
|
|
804
|
+
throttleMs;
|
|
805
|
+
subs = [];
|
|
806
|
+
messageSignals = /* @__PURE__ */ new Map();
|
|
807
|
+
collectionSignals = /* @__PURE__ */ new Map();
|
|
808
|
+
latestCollections = /* @__PURE__ */ new Map();
|
|
809
|
+
globalMessageSignals = /* @__PURE__ */ new Map();
|
|
810
|
+
globalCollectionSignals = /* @__PURE__ */ new Map();
|
|
811
|
+
latestGlobalCollections = /* @__PURE__ */ new Map();
|
|
812
|
+
scenes = /* @__PURE__ */ new Map();
|
|
813
|
+
lastSent = /* @__PURE__ */ new Map();
|
|
814
|
+
sendAccumulator = 0;
|
|
815
|
+
detachReplication = null;
|
|
816
|
+
lastOwner = null;
|
|
817
|
+
boundScene = null;
|
|
818
|
+
static get(engine) {
|
|
819
|
+
return managers.get(engine) ?? null;
|
|
820
|
+
}
|
|
821
|
+
static async create(engine, opts = {}) {
|
|
822
|
+
await managers.get(engine)?.dispose();
|
|
823
|
+
const server = opts.transport ?? await createDefaultTransport(opts.config);
|
|
824
|
+
await server.connect();
|
|
825
|
+
const requested = opts.room ?? engine.scene?.multiplayer?.room ?? "auto";
|
|
826
|
+
const manager = new NetworkManager(engine, server, await server.remoteFunction("joinRoom", [requested === "auto" ? void 0 : requested], { needResponse: true }), opts.throttleMs ?? 50);
|
|
827
|
+
managers.set(engine, manager);
|
|
828
|
+
return manager;
|
|
829
|
+
}
|
|
830
|
+
constructor(engine, server, roomId, throttleMs) {
|
|
831
|
+
this.engine = engine;
|
|
832
|
+
this.server = server;
|
|
833
|
+
this.roomId = roomId;
|
|
834
|
+
this.account = server.account;
|
|
835
|
+
this.throttleMs = Math.max(30, throttleMs);
|
|
836
|
+
this.boundScene = engine.scene;
|
|
837
|
+
this.subs.push(server.subscribeRoomState(roomId, (state) => {
|
|
838
|
+
this.latestRoomState = state;
|
|
839
|
+
this.roomState.emit(state);
|
|
840
|
+
}), server.subscribeRoomAllUserStates(roomId, (states) => {
|
|
841
|
+
this.latestUserStates = states;
|
|
842
|
+
this.allUserStates.emit(states);
|
|
843
|
+
}), server.onRoomUserJoin(roomId, (account) => this.userJoined.emit(account)), server.onRoomUserLeave(roomId, (account) => this.userLeft.emit(account)));
|
|
844
|
+
if (server.subscribeGlobalState) this.subs.push(server.subscribeGlobalState((state) => {
|
|
845
|
+
this.latestGlobalState = state;
|
|
846
|
+
this.globalState.emit(state);
|
|
847
|
+
}));
|
|
848
|
+
if (server.subscribeGlobalMyState) this.subs.push(server.subscribeGlobalMyState((state) => {
|
|
849
|
+
this.latestGlobalMyState = state;
|
|
850
|
+
this.globalMyState.emit(state);
|
|
851
|
+
}));
|
|
852
|
+
if (server.subscribeAsset) this.subs.push(server.subscribeAsset(this.account, (assets) => {
|
|
853
|
+
this.latestAsset = assets;
|
|
854
|
+
this.asset.emit(assets);
|
|
855
|
+
}));
|
|
856
|
+
this.detachReplication = engine.fixedUpdated.connect((dt) => this.replicate(dt));
|
|
857
|
+
}
|
|
858
|
+
/** Signal for a typed room message (`sendEvent` on any client). */
|
|
859
|
+
message(type) {
|
|
860
|
+
let sig = this.messageSignals.get(type);
|
|
861
|
+
if (!sig) {
|
|
862
|
+
sig = new Signal();
|
|
863
|
+
this.messageSignals.set(type, sig);
|
|
864
|
+
this.subs.push(this.server.onRoomMessage(this.roomId, type, (m) => sig?.emit(m)));
|
|
865
|
+
}
|
|
866
|
+
return sig;
|
|
867
|
+
}
|
|
868
|
+
/** Signal + snapshot for a room collection (spawned entities). */
|
|
869
|
+
collection(id) {
|
|
870
|
+
let sig = this.collectionSignals.get(id);
|
|
871
|
+
if (!sig) {
|
|
872
|
+
sig = new Signal();
|
|
873
|
+
this.collectionSignals.set(id, sig);
|
|
874
|
+
this.subs.push(this.server.subscribeRoomCollection(this.roomId, id, (entities) => {
|
|
875
|
+
this.latestCollections.set(id, entities);
|
|
876
|
+
sig?.emit(entities);
|
|
877
|
+
}));
|
|
878
|
+
}
|
|
879
|
+
return sig;
|
|
880
|
+
}
|
|
881
|
+
latestCollection(id) {
|
|
882
|
+
this.collection(id);
|
|
883
|
+
return this.latestCollections.get(id) ?? {};
|
|
884
|
+
}
|
|
885
|
+
/** Signal for a typed GLOBAL message (`$global.broadcastToAll`/`sendMessageToUser`). */
|
|
886
|
+
globalMessage(type) {
|
|
887
|
+
let sig = this.globalMessageSignals.get(type);
|
|
888
|
+
if (!sig) {
|
|
889
|
+
sig = new Signal();
|
|
890
|
+
this.globalMessageSignals.set(type, sig);
|
|
891
|
+
const sub = this.server.onGlobalMessage?.(type, (m) => sig?.emit(m));
|
|
892
|
+
if (sub) this.subs.push(sub);
|
|
893
|
+
}
|
|
894
|
+
return sig;
|
|
895
|
+
}
|
|
896
|
+
/** Signal + snapshot for a GLOBAL collection (persistent, cross-room). */
|
|
897
|
+
globalCollection(id) {
|
|
898
|
+
let sig = this.globalCollectionSignals.get(id);
|
|
899
|
+
if (!sig) {
|
|
900
|
+
sig = new Signal();
|
|
901
|
+
this.globalCollectionSignals.set(id, sig);
|
|
902
|
+
const sub = this.server.subscribeGlobalCollection?.(id, (entities) => {
|
|
903
|
+
this.latestGlobalCollections.set(id, entities);
|
|
904
|
+
sig?.emit(entities);
|
|
905
|
+
});
|
|
906
|
+
if (sub) this.subs.push(sub);
|
|
907
|
+
}
|
|
908
|
+
return sig;
|
|
909
|
+
}
|
|
910
|
+
latestGlobalCollection(id) {
|
|
911
|
+
this.globalCollection(id);
|
|
912
|
+
return this.latestGlobalCollections.get(id) ?? {};
|
|
913
|
+
}
|
|
914
|
+
setMyState(patch) {
|
|
915
|
+
return this.server.remoteFunction("setMyState", [this.roomId, patch], {
|
|
916
|
+
throttle: this.throttleMs,
|
|
917
|
+
throttleKey: "incanto:myState"
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
patchRoomState(patch) {
|
|
921
|
+
return this.server.remoteFunction("patchRoomState", [this.roomId, patch]);
|
|
922
|
+
}
|
|
923
|
+
addEntity(collectionId, entity) {
|
|
924
|
+
return this.server.remoteFunction("addEntity", [
|
|
925
|
+
this.roomId,
|
|
926
|
+
collectionId,
|
|
927
|
+
entity
|
|
928
|
+
]);
|
|
929
|
+
}
|
|
930
|
+
updateEntity(collectionId, id, patch) {
|
|
931
|
+
return this.server.remoteFunction("updateEntity", [
|
|
932
|
+
this.roomId,
|
|
933
|
+
collectionId,
|
|
934
|
+
id,
|
|
935
|
+
patch
|
|
936
|
+
]);
|
|
937
|
+
}
|
|
938
|
+
removeEntity(collectionId, id) {
|
|
939
|
+
return this.server.remoteFunction("removeEntity", [
|
|
940
|
+
this.roomId,
|
|
941
|
+
collectionId,
|
|
942
|
+
id
|
|
943
|
+
]);
|
|
944
|
+
}
|
|
945
|
+
sendEvent(type, payload) {
|
|
946
|
+
return this.server.remoteFunction("sendEvent", [
|
|
947
|
+
this.roomId,
|
|
948
|
+
type,
|
|
949
|
+
payload
|
|
950
|
+
]);
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Invoke a CUSTOM server-authoritative function — a method you added to your
|
|
954
|
+
* `server/src/server.ts` `Server` class (run live on the Agent8 platform, or
|
|
955
|
+
* locally via `createLocalGameServer`). The room id is prepended automatically,
|
|
956
|
+
* matching the `(roomId, …)` shape every server method takes:
|
|
957
|
+
*
|
|
958
|
+
* `manager.call('claimCoin', coinId)` → server `claimCoin(roomId, coinId)`
|
|
959
|
+
*
|
|
960
|
+
* Returns the function's result (use it for server-validated outcomes — a
|
|
961
|
+
* rejected claim, a rolled value). This is THE entry point for game rules the
|
|
962
|
+
* client must not be trusted to compute.
|
|
963
|
+
*/
|
|
964
|
+
call(fn, ...args) {
|
|
965
|
+
return this.server.remoteFunction(fn, [this.roomId, ...args], { needResponse: true });
|
|
966
|
+
}
|
|
967
|
+
/** Register a scene for NetworkSpawner (`"scene": "<key>"`). */
|
|
968
|
+
/**
|
|
969
|
+
* `json` is `unknown` for the reason `createGame2D.scene` is: an imported
|
|
970
|
+
* `.scene.json` does not satisfy `SceneJson` (`format: number` vs `1`), and
|
|
971
|
+
* the loader validates it properly anyway.
|
|
972
|
+
*/
|
|
973
|
+
registerScene(key, json) {
|
|
974
|
+
this.scenes.set(key, json);
|
|
975
|
+
}
|
|
976
|
+
resolveScene(key) {
|
|
977
|
+
const json = this.scenes.get(key);
|
|
978
|
+
if (!json) throw new IncantoError("UNRESOLVED_INSTANCE", `No scene registered for '${key}'. Registered: [${[...this.scenes.keys()].join(", ")}]. Call manager.registerScene('${key}', sceneJson).`);
|
|
979
|
+
return json;
|
|
980
|
+
}
|
|
981
|
+
async dispose() {
|
|
982
|
+
this.detachReplication?.();
|
|
983
|
+
for (const off of this.subs) off();
|
|
984
|
+
await this.server.remoteFunction("leaveRoom", [this.roomId]);
|
|
985
|
+
managers.delete(this.engine);
|
|
986
|
+
}
|
|
987
|
+
replicate(dt) {
|
|
988
|
+
const scene = this.engine.scene;
|
|
989
|
+
if (!scene || scene !== this.boundScene) return;
|
|
990
|
+
this.sendAccumulator += dt * 1e3;
|
|
991
|
+
const owner = findOwnerNode(scene.root, true);
|
|
992
|
+
if (!owner) return;
|
|
993
|
+
if (owner !== this.lastOwner) {
|
|
994
|
+
this.lastOwner = owner;
|
|
995
|
+
this.lastSent.clear();
|
|
996
|
+
}
|
|
997
|
+
const net = owner.network;
|
|
998
|
+
const window = Math.max(30, typeof net.throttleMs === "number" ? net.throttleMs : this.throttleMs);
|
|
999
|
+
const syncKeys = Array.isArray(net.sync) ? net.sync : [];
|
|
1000
|
+
const patch = {};
|
|
1001
|
+
for (const key of syncKeys) {
|
|
1002
|
+
const value = readSyncKey(owner, key);
|
|
1003
|
+
if (value === void 0) continue;
|
|
1004
|
+
if (!jsonEquals(this.lastSent.get(key) ?? null, value)) patch[key] = jsonClone(value);
|
|
1005
|
+
}
|
|
1006
|
+
if (this.sendAccumulator < window) return;
|
|
1007
|
+
if (Object.keys(patch).length === 0) return;
|
|
1008
|
+
for (const [key, value] of Object.entries(patch)) this.lastSent.set(key, jsonClone(value));
|
|
1009
|
+
this.sendAccumulator = 0;
|
|
1010
|
+
this.setMyState({ sync: patch });
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
function findOwnerNode(node, enforceSingle = false) {
|
|
1014
|
+
const owners = [];
|
|
1015
|
+
collectOwners(node, owners);
|
|
1016
|
+
if (enforceSingle && owners.length > 1) throw new IncantoError("BAD_FORMAT", `Multiple network owner nodes found (${owners.map((o) => o.getPath()).join(", ")}). Exactly ONE node per player may declare network.mode 'owner' — replicate spawned entities through collections instead.`);
|
|
1017
|
+
return owners[0] ?? null;
|
|
1018
|
+
}
|
|
1019
|
+
function collectOwners(node, out) {
|
|
1020
|
+
if (node.network?.mode === "owner") out.push(node);
|
|
1021
|
+
for (const c of node.children) collectOwners(c, out);
|
|
1022
|
+
}
|
|
1023
|
+
/** `'position'` → own prop; `'Sprite.animation'` → child path + prop (last dot splits). */
|
|
1024
|
+
function readSyncKey(node, key) {
|
|
1025
|
+
const lastDot = key.lastIndexOf(".");
|
|
1026
|
+
const target = lastDot === -1 ? node : node.getNodeOrNull(key.slice(0, lastDot)) ?? void 0;
|
|
1027
|
+
const prop = lastDot === -1 ? key : key.slice(lastDot + 1);
|
|
1028
|
+
if (!target) return void 0;
|
|
1029
|
+
return target[prop];
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* @internal Applies a replicated flat sync patch (`{'position': …,
|
|
1033
|
+
* 'Sprite.animation': …}`) onto a spawned subtree (NetworkSpawner).
|
|
1034
|
+
*/
|
|
1035
|
+
function applySyncPatch(root, patch, setProp, onMissingNode) {
|
|
1036
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
1037
|
+
const lastDot = key.lastIndexOf(".");
|
|
1038
|
+
const target = lastDot === -1 ? root : root.getNodeOrNull(key.slice(0, lastDot));
|
|
1039
|
+
const prop = lastDot === -1 ? key : key.slice(lastDot + 1);
|
|
1040
|
+
if (target) setProp(target, prop, value);
|
|
1041
|
+
else onMissingNode?.(key);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
async function createDefaultTransport(config) {
|
|
1045
|
+
const { createAgent8Server } = await import("./agent8-CvsfVskX.js").then((n) => n.t);
|
|
1046
|
+
return createAgent8Server(config);
|
|
1047
|
+
}
|
|
1048
|
+
//#endregion
|
|
1049
|
+
//#region src/net/nodes/network-spawner.ts
|
|
1050
|
+
/** Platform bookkeeping the game never replicates. */
|
|
1051
|
+
const RESERVED_KEYS = new Set([
|
|
1052
|
+
"__id",
|
|
1053
|
+
"__roomId",
|
|
1054
|
+
"__updated",
|
|
1055
|
+
"__leaved",
|
|
1056
|
+
"account",
|
|
1057
|
+
"roomId"
|
|
1058
|
+
]);
|
|
1059
|
+
/**
|
|
1060
|
+
* Materializes a registered scene per remote player or collection entity:
|
|
1061
|
+
*
|
|
1062
|
+
* - `source: 'users'` — one instance per OTHER account in the room
|
|
1063
|
+
* (self is skipped; the local player is your own owned node)
|
|
1064
|
+
* - `source: 'collection:<id>'` — one instance per entity (bullets, pickups…)
|
|
1065
|
+
*
|
|
1066
|
+
* Replicated flat `{sync: {...}}` patches apply onto each instance's root;
|
|
1067
|
+
* `position` lerps toward its target when `interpolate` is on. Emits
|
|
1068
|
+
* `spawned(node, key)` / `despawned(node, key)`.
|
|
1069
|
+
*/
|
|
1070
|
+
var NetworkSpawner = class extends Node {
|
|
1071
|
+
static typeName = "NetworkSpawner";
|
|
1072
|
+
static signals = ["spawned", "despawned"];
|
|
1073
|
+
static props = {
|
|
1074
|
+
source: { default: "users" },
|
|
1075
|
+
scene: { default: "" },
|
|
1076
|
+
interpolate: { default: true }
|
|
1077
|
+
};
|
|
1078
|
+
source = "users";
|
|
1079
|
+
/** Key registered via `manager.registerScene(key, json)`. */
|
|
1080
|
+
scene = "";
|
|
1081
|
+
interpolate = true;
|
|
1082
|
+
spawned = /* @__PURE__ */ new Map();
|
|
1083
|
+
positionTargets = /* @__PURE__ */ new Map();
|
|
1084
|
+
failedKeys = /* @__PURE__ */ new Set();
|
|
1085
|
+
reported = /* @__PURE__ */ new Set();
|
|
1086
|
+
update(dt) {
|
|
1087
|
+
const engine = this.tree?.engine;
|
|
1088
|
+
if (!engine) return;
|
|
1089
|
+
const manager = NetworkManager.get(engine);
|
|
1090
|
+
if (!manager || this.scene === "") return;
|
|
1091
|
+
const entries = this.currentEntries(manager);
|
|
1092
|
+
for (const [key, state] of Object.entries(entries)) {
|
|
1093
|
+
let instance = this.spawned.get(key);
|
|
1094
|
+
if (!instance) {
|
|
1095
|
+
if (this.failedKeys.has(key)) continue;
|
|
1096
|
+
try {
|
|
1097
|
+
instance = buildNodeJson(manager.resolveScene(this.scene).root);
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
this.failedKeys.add(key);
|
|
1100
|
+
throw error;
|
|
1101
|
+
}
|
|
1102
|
+
instance.name = sanitizeName(key);
|
|
1103
|
+
this.addChild(instance);
|
|
1104
|
+
this.spawned.set(key, instance);
|
|
1105
|
+
this.emit("spawned", instance, key);
|
|
1106
|
+
}
|
|
1107
|
+
const patch = this.patchOf(state);
|
|
1108
|
+
if (patch) applySyncPatch(instance, patch, (target, prop, value) => this.setProp(target, prop, value), (missing) => this.report(`'${missing}' names a node this scene does not have`));
|
|
1109
|
+
}
|
|
1110
|
+
for (const [key, instance] of [...this.spawned.entries()]) if (!(key in entries)) {
|
|
1111
|
+
this.spawned.delete(key);
|
|
1112
|
+
this.positionTargets.delete(instance);
|
|
1113
|
+
this.emit("despawned", instance, key);
|
|
1114
|
+
instance.free();
|
|
1115
|
+
}
|
|
1116
|
+
if (this.interpolate) this.stepInterpolation(dt);
|
|
1117
|
+
}
|
|
1118
|
+
currentEntries(manager) {
|
|
1119
|
+
if (this.source === "users") {
|
|
1120
|
+
const out = {};
|
|
1121
|
+
for (const [account, state] of Object.entries(manager.latestUserStates)) if (account !== manager.account) out[account] = state;
|
|
1122
|
+
return out;
|
|
1123
|
+
}
|
|
1124
|
+
if (this.source.startsWith("collection:")) return manager.latestCollection(this.source.slice(11));
|
|
1125
|
+
return {};
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* The replicated fields of one entry.
|
|
1129
|
+
*
|
|
1130
|
+
* A USER state carries them under `sync` (that is what the owner node sends).
|
|
1131
|
+
* A COLLECTION entity is written by your SERVER, which has no reason to know
|
|
1132
|
+
* that convention — `addCollectionItem('coins', { position: [x, y] })` is the
|
|
1133
|
+
* obvious thing to write, and it used to apply nothing at all. Both work:
|
|
1134
|
+
* `sync` when present, otherwise the entity minus the platform's own keys.
|
|
1135
|
+
*/
|
|
1136
|
+
patchOf(state) {
|
|
1137
|
+
if (state.sync !== void 0) return state.sync;
|
|
1138
|
+
if (this.source === "users") return void 0;
|
|
1139
|
+
const out = {};
|
|
1140
|
+
for (const [key, value] of Object.entries(state)) {
|
|
1141
|
+
if (RESERVED_KEYS.has(key) || key.startsWith("$")) continue;
|
|
1142
|
+
out[key] = value;
|
|
1143
|
+
}
|
|
1144
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
1145
|
+
}
|
|
1146
|
+
/** Say it ONCE per message — a per-frame repeat would drown the log. */
|
|
1147
|
+
report(message) {
|
|
1148
|
+
if (this.reported.has(message)) return;
|
|
1149
|
+
this.reported.add(message);
|
|
1150
|
+
diagnose(this.tree?.engine ?? null, "error", `[incanto] NetworkSpawner '${this.name}': ${message} — that value replicates to nothing.`);
|
|
1151
|
+
}
|
|
1152
|
+
setProp(target, prop, value) {
|
|
1153
|
+
if (this.interpolate && prop === "position" && Array.isArray(value)) {
|
|
1154
|
+
this.positionTargets.set(target, value);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
target[prop] = value;
|
|
1158
|
+
}
|
|
1159
|
+
stepInterpolation(dt) {
|
|
1160
|
+
const f = Math.min(1, dt * 8);
|
|
1161
|
+
for (const [target, goal] of this.positionTargets) {
|
|
1162
|
+
const pos = target.position;
|
|
1163
|
+
if (!Array.isArray(pos)) continue;
|
|
1164
|
+
target.position = pos.map((v, i) => v + ((goal[i] ?? v) - v) * f);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
function sanitizeName(key) {
|
|
1169
|
+
return key.replace(/[/%]/g, "_") || "remote";
|
|
1170
|
+
}
|
|
1171
|
+
//#endregion
|
|
1172
|
+
//#region src/net/register.ts
|
|
1173
|
+
/**
|
|
1174
|
+
* Register the networking node types (and the core nodes). Call once in your
|
|
1175
|
+
* game entry before loading a multiplayer scene. Explicit — never a side effect.
|
|
1176
|
+
*/
|
|
1177
|
+
function registerNodesNet() {
|
|
1178
|
+
registerCoreNodes();
|
|
1179
|
+
registerNode(NetworkSpawner);
|
|
1180
|
+
}
|
|
1181
|
+
//#endregion
|
|
1182
|
+
//#region src/net/split-screen.ts
|
|
1183
|
+
/**
|
|
1184
|
+
* N local clients on ONE page sharing ONE in-memory game server — the
|
|
1185
|
+
* split-screen preview harness three examples were hand-wiring (~40 lines
|
|
1186
|
+
* each). Wires: LocalGameServer + per-player Engine/scene/NetworkManager +
|
|
1187
|
+
* the server tick driven from the first panel's clock.
|
|
1188
|
+
*
|
|
1189
|
+
* const { players, server } = await createSplitScreen({
|
|
1190
|
+
* scene: gameJson,
|
|
1191
|
+
* server: MyServerClass,
|
|
1192
|
+
* scenes: { 'remote-player': remoteJson },
|
|
1193
|
+
* setup: ({ engine }, i) => {
|
|
1194
|
+
* new Renderer2D({ canvas: canvases[i], engine });
|
|
1195
|
+
* engine.input.attachKeyboard(window);
|
|
1196
|
+
* engine.start();
|
|
1197
|
+
* },
|
|
1198
|
+
* });
|
|
1199
|
+
*
|
|
1200
|
+
* Going live stays the one-line story: replace the transport with
|
|
1201
|
+
* `createAgent8Server()` and boot ONE client per browser.
|
|
1202
|
+
*/
|
|
1203
|
+
async function createSplitScreen(opts) {
|
|
1204
|
+
const server = createLocalGameServer(opts);
|
|
1205
|
+
const accounts = opts.accounts ?? ["p1", "p2"];
|
|
1206
|
+
const players = [];
|
|
1207
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
1208
|
+
const account = accounts[i];
|
|
1209
|
+
const engine = new Engine(opts.seed !== void 0 ? { seed: opts.seed + i } : {});
|
|
1210
|
+
engine.setScene(loadScene(jsonClone(opts.scene)));
|
|
1211
|
+
const manager = await NetworkManager.create(engine, {
|
|
1212
|
+
transport: server.createClient(account),
|
|
1213
|
+
...opts.room !== void 0 || i > 0 ? { room: opts.room ?? players[0].manager.roomId } : {}
|
|
1214
|
+
});
|
|
1215
|
+
for (const [name, json] of Object.entries(opts.scenes ?? {})) manager.registerScene(name, json);
|
|
1216
|
+
await enablePhysicsFor(engine, opts.physics);
|
|
1217
|
+
const player = {
|
|
1218
|
+
account,
|
|
1219
|
+
engine,
|
|
1220
|
+
manager
|
|
1221
|
+
};
|
|
1222
|
+
players.push(player);
|
|
1223
|
+
await opts.setup(player, i);
|
|
1224
|
+
}
|
|
1225
|
+
const disconnect = (players[0]?.engine)?.updated.connect((dt) => void server.tick(Math.min(dt, .05) * 1e3));
|
|
1226
|
+
return {
|
|
1227
|
+
server,
|
|
1228
|
+
players,
|
|
1229
|
+
dispose() {
|
|
1230
|
+
disconnect?.();
|
|
1231
|
+
for (const p of players) p.engine.dispose?.();
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Enable physics the way the createGame helpers do, without dragging three.js
|
|
1237
|
+
* into the net layer's static graph: the adapter is imported only when the
|
|
1238
|
+
* scene actually needs it.
|
|
1239
|
+
*/
|
|
1240
|
+
async function enablePhysicsFor(engine, want) {
|
|
1241
|
+
if (want === false) return;
|
|
1242
|
+
const root = engine.scene?.tree.root;
|
|
1243
|
+
if (!root) return;
|
|
1244
|
+
const dim = engine.scene?.dimension === "3d" ? "3d" : "2d";
|
|
1245
|
+
if (want !== true) {
|
|
1246
|
+
if (!hasBodyNamed(root, dim === "3d" ? "PhysicsBody3D" : "PhysicsBody2D")) return;
|
|
1247
|
+
}
|
|
1248
|
+
if (dim === "3d") {
|
|
1249
|
+
const { enablePhysics3D } = await import("./3d.js");
|
|
1250
|
+
await enablePhysics3D(engine);
|
|
1251
|
+
} else {
|
|
1252
|
+
const { enablePhysics2D } = await import("./2d.js");
|
|
1253
|
+
await enablePhysics2D(engine);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
/** Any node whose prototype chain carries the adapter's body base class. */
|
|
1257
|
+
function hasBodyNamed(node, base) {
|
|
1258
|
+
let ctor = node.constructor;
|
|
1259
|
+
while (typeof ctor === "function") {
|
|
1260
|
+
if (ctor.name === base) return true;
|
|
1261
|
+
ctor = Object.getPrototypeOf(ctor);
|
|
1262
|
+
}
|
|
1263
|
+
for (const child of node.children) if (hasBodyNamed(child, base)) return true;
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
//#endregion
|
|
1267
|
+
export { applySyncPatch as a, createLocalGameServer as c, NetworkManager as i, LoopbackHub as l, registerNodesNet as n, LocalGameServer as o, NetworkSpawner as r, LocalGameServerTransport as s, createSplitScreen as t, LoopbackTransport as u };
|