archetype-ecs-net 0.1.1
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/README.md +287 -0
- package/dist/ComponentRegistry.d.ts +14 -0
- package/dist/ComponentRegistry.js +90 -0
- package/dist/DirtyTracker.d.ts +47 -0
- package/dist/DirtyTracker.js +348 -0
- package/dist/InterestManager.d.ts +25 -0
- package/dist/InterestManager.js +71 -0
- package/dist/NetClient.d.ts +14 -0
- package/dist/NetClient.js +123 -0
- package/dist/NetServer.d.ts +35 -0
- package/dist/NetServer.js +166 -0
- package/dist/Protocol.d.ts +58 -0
- package/dist/Protocol.js +356 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.js +3 -0
- package/package.json +43 -0
- package/src/ComponentRegistry.ts +115 -0
- package/src/DirtyTracker.ts +459 -0
- package/src/InterestManager.ts +104 -0
- package/src/NetClient.ts +156 -0
- package/src/NetServer.ts +240 -0
- package/src/Protocol.ts +410 -0
- package/src/index.ts +18 -0
- package/src/types.ts +55 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { component } from 'archetype-ecs';
|
|
2
|
+
import { MSG_DELTA } from './types.js';
|
|
3
|
+
/** Tag component — add to any entity that should be synced over the network */
|
|
4
|
+
export const Networked = component('Networked');
|
|
5
|
+
export function createSnapshotDiffer(em, registry) {
|
|
6
|
+
// Enable core-level change tracking + double-buffered snapshots
|
|
7
|
+
em.enableTracking(Networked);
|
|
8
|
+
// Precompute field refs per registered component
|
|
9
|
+
// Each field also carries its index within the component for bitmask building
|
|
10
|
+
const compFields = [];
|
|
11
|
+
for (const reg of registry.components) {
|
|
12
|
+
const refs = [];
|
|
13
|
+
for (let fi = 0; fi < reg.fields.length; fi++) {
|
|
14
|
+
const field = reg.fields[fi];
|
|
15
|
+
refs.push({ name: field.name, ref: reg.component[field.name], fieldIdx: fi });
|
|
16
|
+
}
|
|
17
|
+
compFields.push({ wireId: reg.wireId, refs });
|
|
18
|
+
}
|
|
19
|
+
// Max wireId for pre-allocating sparse bitmask array
|
|
20
|
+
const maxWireId = compFields.length > 0 ? compFields[compFields.length - 1].wireId : 0;
|
|
21
|
+
let firstDiff = true;
|
|
22
|
+
let nextNetId = 1;
|
|
23
|
+
const entityToNetId = new Map();
|
|
24
|
+
const netIdToEntityMap = new Map();
|
|
25
|
+
// ── Shared helper: encode all components of an entity from live ECS ──
|
|
26
|
+
function writeEntityComponents(encoder, eid) {
|
|
27
|
+
const compCountOff = encoder.reserveU8();
|
|
28
|
+
let compCount = 0;
|
|
29
|
+
for (const cf of compFields) {
|
|
30
|
+
const firstVal = em.get(eid, cf.refs[0].ref);
|
|
31
|
+
if (firstVal === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
compCount++;
|
|
34
|
+
encoder.writeU8(cf.wireId);
|
|
35
|
+
for (const f of cf.refs) {
|
|
36
|
+
const regField = registry.components[cf.wireId].fields[f.fieldIdx];
|
|
37
|
+
encoder.writeField(regField.type, em.get(eid, f.ref));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
encoder.patchU8(compCountOff, compCount);
|
|
41
|
+
}
|
|
42
|
+
// ── Shared helper: flush ECS changes + handle first-diff ──
|
|
43
|
+
function flushAndPrepare() {
|
|
44
|
+
const changes = em.flushChanges();
|
|
45
|
+
const coreCreated = changes.created;
|
|
46
|
+
const coreDestroyed = changes.destroyed;
|
|
47
|
+
if (firstDiff) {
|
|
48
|
+
firstDiff = false;
|
|
49
|
+
const existing = em.query([Networked]);
|
|
50
|
+
for (const eid of existing) {
|
|
51
|
+
if (!coreCreated.has(eid))
|
|
52
|
+
coreCreated.add(eid);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { coreCreated, coreDestroyed };
|
|
56
|
+
}
|
|
57
|
+
// ── Shared helper: process creates + destroys, update netId maps ──
|
|
58
|
+
function processCreateDestroy(coreCreated, coreDestroyed) {
|
|
59
|
+
// Collect destroyed netIds before removing from map
|
|
60
|
+
const destroyed = [];
|
|
61
|
+
for (const eid of coreDestroyed) {
|
|
62
|
+
const netId = entityToNetId.get(eid);
|
|
63
|
+
if (netId !== undefined) {
|
|
64
|
+
destroyed.push(netId);
|
|
65
|
+
entityToNetId.delete(eid);
|
|
66
|
+
netIdToEntityMap.delete(netId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Assign netIds to created entities (excluding immediately destroyed)
|
|
70
|
+
const created = [];
|
|
71
|
+
for (const eid of coreCreated) {
|
|
72
|
+
if (coreDestroyed.has(eid))
|
|
73
|
+
continue;
|
|
74
|
+
const netId = nextNetId++;
|
|
75
|
+
entityToNetId.set(eid, netId);
|
|
76
|
+
netIdToEntityMap.set(netId, eid);
|
|
77
|
+
created.push({ netId, entityId: eid });
|
|
78
|
+
}
|
|
79
|
+
return { created, destroyed };
|
|
80
|
+
}
|
|
81
|
+
// ── Shared helper: collect dirty entries from archetype iteration ──
|
|
82
|
+
function collectDirty(coreCreated, coreDestroyed) {
|
|
83
|
+
const dirty = [];
|
|
84
|
+
em.forEach([Networked], (a) => {
|
|
85
|
+
const count = a.count;
|
|
86
|
+
const entityIds = a.entityIds;
|
|
87
|
+
const snapCount = a.snapshotCount;
|
|
88
|
+
const snapEids = a.snapshotEntityIds;
|
|
89
|
+
if (!snapEids || snapCount === 0)
|
|
90
|
+
return;
|
|
91
|
+
const minCount = count < snapCount ? count : snapCount;
|
|
92
|
+
const fieldArrs = [];
|
|
93
|
+
for (const cf of compFields) {
|
|
94
|
+
for (const f of cf.refs) {
|
|
95
|
+
const front = a.field(f.ref);
|
|
96
|
+
const back = a.snapshot(f.ref);
|
|
97
|
+
if (front && back) {
|
|
98
|
+
const regField = registry.components[cf.wireId].fields[f.fieldIdx];
|
|
99
|
+
fieldArrs.push({ wireId: cf.wireId, fieldIdx: f.fieldIdx, front, back, type: regField.type });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (fieldArrs.length === 0)
|
|
104
|
+
return;
|
|
105
|
+
for (let i = 0; i < minCount; i++) {
|
|
106
|
+
const eid = entityIds[i];
|
|
107
|
+
if (eid !== snapEids[i])
|
|
108
|
+
continue;
|
|
109
|
+
if (coreCreated.has(eid) || coreDestroyed.has(eid))
|
|
110
|
+
continue;
|
|
111
|
+
const netId = entityToNetId.get(eid);
|
|
112
|
+
if (netId === undefined)
|
|
113
|
+
continue;
|
|
114
|
+
let hasDirty = false;
|
|
115
|
+
const masks = new Uint16Array(maxWireId + 1);
|
|
116
|
+
for (let f = 0; f < fieldArrs.length; f++) {
|
|
117
|
+
const fa = fieldArrs[f];
|
|
118
|
+
if (fa.front[i] !== fa.back[i]) {
|
|
119
|
+
masks[fa.wireId] |= (1 << fa.fieldIdx);
|
|
120
|
+
hasDirty = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (hasDirty) {
|
|
124
|
+
dirty.push({ netId, entityId: eid, dirtyMasks: masks });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return dirty;
|
|
129
|
+
}
|
|
130
|
+
// ── Shared helper: encode a dirty entry's updated fields ──
|
|
131
|
+
function encodeDirtyEntity(encoder, entry) {
|
|
132
|
+
encoder.writeVarint(entry.netId);
|
|
133
|
+
const compCountOff = encoder.reserveU8();
|
|
134
|
+
let compCount = 0;
|
|
135
|
+
for (let w = 0; w <= maxWireId; w++) {
|
|
136
|
+
const mask = entry.dirtyMasks[w];
|
|
137
|
+
if (mask === 0)
|
|
138
|
+
continue;
|
|
139
|
+
compCount++;
|
|
140
|
+
encoder.writeU8(w);
|
|
141
|
+
encoder.writeU16(mask);
|
|
142
|
+
// Write dirty field values from live ECS
|
|
143
|
+
for (const f of compFields[w].refs) {
|
|
144
|
+
if (mask & (1 << f.fieldIdx)) {
|
|
145
|
+
const regField = registry.components[w].fields[f.fieldIdx];
|
|
146
|
+
encoder.writeField(regField.type, em.get(entry.entityId, f.ref));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
encoder.patchU8(compCountOff, compCount);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
get entityNetIds() {
|
|
154
|
+
return entityToNetId;
|
|
155
|
+
},
|
|
156
|
+
get netIdToEntity() {
|
|
157
|
+
return netIdToEntityMap;
|
|
158
|
+
},
|
|
159
|
+
// ── Broadcast mode: original fused diff+encode (unchanged behavior) ──
|
|
160
|
+
diffAndEncode(encoder) {
|
|
161
|
+
const changes = em.flushChanges();
|
|
162
|
+
const coreCreated = changes.created;
|
|
163
|
+
const coreDestroyed = changes.destroyed;
|
|
164
|
+
// First diff: treat all existing Networked entities as created
|
|
165
|
+
if (firstDiff) {
|
|
166
|
+
firstDiff = false;
|
|
167
|
+
const existing = em.query([Networked]);
|
|
168
|
+
for (const eid of existing) {
|
|
169
|
+
if (!coreCreated.has(eid))
|
|
170
|
+
coreCreated.add(eid);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Collect destroyed netIds before removing from map
|
|
174
|
+
const destroyed = [];
|
|
175
|
+
for (const eid of coreDestroyed) {
|
|
176
|
+
const netId = entityToNetId.get(eid);
|
|
177
|
+
if (netId !== undefined) {
|
|
178
|
+
destroyed.push(netId);
|
|
179
|
+
entityToNetId.delete(eid);
|
|
180
|
+
netIdToEntityMap.delete(netId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ── Start encoding ────────────────────────────────
|
|
184
|
+
encoder.reset();
|
|
185
|
+
encoder.writeU8(MSG_DELTA);
|
|
186
|
+
// Created: assign netIds, encode component data directly from ECS
|
|
187
|
+
const createdEntities = [];
|
|
188
|
+
for (const eid of coreCreated) {
|
|
189
|
+
if (!coreDestroyed.has(eid))
|
|
190
|
+
createdEntities.push(eid);
|
|
191
|
+
}
|
|
192
|
+
encoder.writeU16(createdEntities.length);
|
|
193
|
+
for (const eid of createdEntities) {
|
|
194
|
+
const netId = nextNetId++;
|
|
195
|
+
entityToNetId.set(eid, netId);
|
|
196
|
+
netIdToEntityMap.set(netId, eid);
|
|
197
|
+
encoder.writeVarint(netId);
|
|
198
|
+
writeEntityComponents(encoder, eid);
|
|
199
|
+
}
|
|
200
|
+
// Destroyed
|
|
201
|
+
encoder.writeU16(destroyed.length);
|
|
202
|
+
for (const netId of destroyed)
|
|
203
|
+
encoder.writeVarint(netId);
|
|
204
|
+
// Updated — backpatch count, write directly from front buffers
|
|
205
|
+
const updateCountOff = encoder.reserveU16();
|
|
206
|
+
let updateCount = 0;
|
|
207
|
+
em.forEach([Networked], (a) => {
|
|
208
|
+
const count = a.count;
|
|
209
|
+
const entityIds = a.entityIds;
|
|
210
|
+
const snapCount = a.snapshotCount;
|
|
211
|
+
const snapEids = a.snapshotEntityIds;
|
|
212
|
+
if (!snapEids || snapCount === 0)
|
|
213
|
+
return;
|
|
214
|
+
const minCount = count < snapCount ? count : snapCount;
|
|
215
|
+
const fieldArrs = [];
|
|
216
|
+
for (const cf of compFields) {
|
|
217
|
+
for (const f of cf.refs) {
|
|
218
|
+
const front = a.field(f.ref);
|
|
219
|
+
const back = a.snapshot(f.ref);
|
|
220
|
+
if (front && back) {
|
|
221
|
+
const regField = registry.components[cf.wireId].fields[f.fieldIdx];
|
|
222
|
+
fieldArrs.push({ wireId: cf.wireId, fieldIdx: f.fieldIdx, front, back, type: regField.type });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (fieldArrs.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
const dirtyMasks = new Uint16Array(maxWireId + 1);
|
|
229
|
+
for (let i = 0; i < minCount; i++) {
|
|
230
|
+
const eid = entityIds[i];
|
|
231
|
+
if (eid !== snapEids[i])
|
|
232
|
+
continue;
|
|
233
|
+
if (coreCreated.has(eid) || coreDestroyed.has(eid))
|
|
234
|
+
continue;
|
|
235
|
+
const netId = entityToNetId.get(eid);
|
|
236
|
+
if (netId === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
let hasDirty = false;
|
|
239
|
+
for (let f = 0; f < fieldArrs.length; f++) {
|
|
240
|
+
const fa = fieldArrs[f];
|
|
241
|
+
if (fa.front[i] !== fa.back[i]) {
|
|
242
|
+
dirtyMasks[fa.wireId] |= (1 << fa.fieldIdx);
|
|
243
|
+
hasDirty = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (hasDirty) {
|
|
247
|
+
updateCount++;
|
|
248
|
+
encoder.writeVarint(netId);
|
|
249
|
+
const compCountOff = encoder.reserveU8();
|
|
250
|
+
let compCount = 0;
|
|
251
|
+
for (let w = 0; w <= maxWireId; w++) {
|
|
252
|
+
const mask = dirtyMasks[w];
|
|
253
|
+
if (mask === 0)
|
|
254
|
+
continue;
|
|
255
|
+
dirtyMasks[w] = 0;
|
|
256
|
+
compCount++;
|
|
257
|
+
encoder.writeU8(w);
|
|
258
|
+
encoder.writeU16(mask);
|
|
259
|
+
for (let f = 0; f < fieldArrs.length; f++) {
|
|
260
|
+
const fa = fieldArrs[f];
|
|
261
|
+
if (fa.wireId === w && (mask & (1 << fa.fieldIdx))) {
|
|
262
|
+
encoder.writeField(fa.type, fa.front[i]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
encoder.patchU8(compCountOff, compCount);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
for (let w = 0; w <= maxWireId; w++)
|
|
270
|
+
dirtyMasks[w] = 0;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
encoder.patchU16(updateCountOff, updateCount);
|
|
275
|
+
em.flushSnapshots();
|
|
276
|
+
return encoder.finish();
|
|
277
|
+
},
|
|
278
|
+
// ── Interest mode: Phase 1 — compute changeset ──
|
|
279
|
+
computeChangeset() {
|
|
280
|
+
const { coreCreated, coreDestroyed } = flushAndPrepare();
|
|
281
|
+
const { created, destroyed } = processCreateDestroy(coreCreated, coreDestroyed);
|
|
282
|
+
const dirty = collectDirty(coreCreated, coreDestroyed);
|
|
283
|
+
const createdSet = new Set();
|
|
284
|
+
for (const c of created)
|
|
285
|
+
createdSet.add(c.netId);
|
|
286
|
+
const destroyedSet = new Set();
|
|
287
|
+
for (const d of destroyed)
|
|
288
|
+
destroyedSet.add(d);
|
|
289
|
+
return { created, destroyed, dirty, createdSet, destroyedSet };
|
|
290
|
+
},
|
|
291
|
+
preEncodeChangeset(encoder, changeset, extraEnterNetIds) {
|
|
292
|
+
const enterSlices = new Map();
|
|
293
|
+
const updateSlices = new Map();
|
|
294
|
+
// Pre-encode created entities (global creates that may enter views)
|
|
295
|
+
for (const entry of changeset.created) {
|
|
296
|
+
encoder.reset();
|
|
297
|
+
encoder.writeVarint(entry.netId);
|
|
298
|
+
writeEntityComponents(encoder, entry.entityId);
|
|
299
|
+
enterSlices.set(entry.netId, new Uint8Array(encoder.finish()));
|
|
300
|
+
}
|
|
301
|
+
// Pre-encode extra enters (existing entities entering a client's view)
|
|
302
|
+
for (const netId of extraEnterNetIds) {
|
|
303
|
+
if (enterSlices.has(netId))
|
|
304
|
+
continue; // already encoded as created
|
|
305
|
+
const eid = netIdToEntityMap.get(netId);
|
|
306
|
+
if (eid === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
encoder.reset();
|
|
309
|
+
encoder.writeVarint(netId);
|
|
310
|
+
writeEntityComponents(encoder, eid);
|
|
311
|
+
enterSlices.set(netId, new Uint8Array(encoder.finish()));
|
|
312
|
+
}
|
|
313
|
+
// Pre-encode dirty entity updates
|
|
314
|
+
for (const entry of changeset.dirty) {
|
|
315
|
+
encoder.reset();
|
|
316
|
+
encodeDirtyEntity(encoder, entry);
|
|
317
|
+
updateSlices.set(entry.netId, new Uint8Array(encoder.finish()));
|
|
318
|
+
}
|
|
319
|
+
return { enterSlices, updateSlices };
|
|
320
|
+
},
|
|
321
|
+
composeFromCache(encoder, cache, clientDelta) {
|
|
322
|
+
encoder.reset();
|
|
323
|
+
encoder.writeU8(MSG_DELTA);
|
|
324
|
+
// Created section
|
|
325
|
+
encoder.writeU16(clientDelta.enters.length);
|
|
326
|
+
for (const netId of clientDelta.enters) {
|
|
327
|
+
const slice = cache.enterSlices.get(netId);
|
|
328
|
+
if (slice)
|
|
329
|
+
encoder.writeBytes(slice);
|
|
330
|
+
}
|
|
331
|
+
// Destroyed section
|
|
332
|
+
encoder.writeU16(clientDelta.leaves.length);
|
|
333
|
+
for (const netId of clientDelta.leaves)
|
|
334
|
+
encoder.writeVarint(netId);
|
|
335
|
+
// Updated section
|
|
336
|
+
encoder.writeU16(clientDelta.updates.length);
|
|
337
|
+
for (const netId of clientDelta.updates) {
|
|
338
|
+
const slice = cache.updateSlices.get(netId);
|
|
339
|
+
if (slice)
|
|
340
|
+
encoder.writeBytes(slice);
|
|
341
|
+
}
|
|
342
|
+
return encoder.finish();
|
|
343
|
+
},
|
|
344
|
+
flushSnapshots() {
|
|
345
|
+
em.flushSnapshots();
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ClientId } from './types.js';
|
|
2
|
+
import type { Changeset } from './DirtyTracker.js';
|
|
3
|
+
/**
|
|
4
|
+
* Per-client interest filter callback.
|
|
5
|
+
* Return the set of netIds that should be visible to this client.
|
|
6
|
+
*/
|
|
7
|
+
export type InterestFilter = (clientId: ClientId) => ReadonlySet<number>;
|
|
8
|
+
/** Describes what changed for a specific client this tick */
|
|
9
|
+
export interface ClientDelta {
|
|
10
|
+
/** NetIds to send as "created" (entered view or globally new + in view) */
|
|
11
|
+
readonly enters: number[];
|
|
12
|
+
/** NetIds to send as "destroyed" (left view or globally destroyed + was known) */
|
|
13
|
+
readonly leaves: number[];
|
|
14
|
+
/** NetIds to send field updates for (dirty + in view + already known) */
|
|
15
|
+
readonly updates: number[];
|
|
16
|
+
}
|
|
17
|
+
export interface ClientView {
|
|
18
|
+
/** The set of netIds this client currently knows about */
|
|
19
|
+
readonly knownEntities: ReadonlySet<number>;
|
|
20
|
+
/** Pre-populate known set (e.g., after sending filtered full state on connect) */
|
|
21
|
+
initKnown(netIds: ReadonlySet<number>): void;
|
|
22
|
+
/** Compute enter/leave/update delta for this tick */
|
|
23
|
+
update(currentInterest: ReadonlySet<number>, changeset: Changeset): ClientDelta;
|
|
24
|
+
}
|
|
25
|
+
export declare function createClientView(): ClientView;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function createClientView() {
|
|
2
|
+
const known = new Set();
|
|
3
|
+
// Reusable arrays to avoid GC pressure
|
|
4
|
+
const enters = [];
|
|
5
|
+
const leaves = [];
|
|
6
|
+
const updates = [];
|
|
7
|
+
return {
|
|
8
|
+
get knownEntities() {
|
|
9
|
+
return known;
|
|
10
|
+
},
|
|
11
|
+
initKnown(netIds) {
|
|
12
|
+
known.clear();
|
|
13
|
+
for (const id of netIds)
|
|
14
|
+
known.add(id);
|
|
15
|
+
},
|
|
16
|
+
update(interest, changeset) {
|
|
17
|
+
enters.length = 0;
|
|
18
|
+
leaves.length = 0;
|
|
19
|
+
updates.length = 0;
|
|
20
|
+
const destroyedSet = changeset.destroyedSet;
|
|
21
|
+
const createdSet = changeset.createdSet;
|
|
22
|
+
// 1. Globally destroyed entities that this client knew about → leave
|
|
23
|
+
for (const netId of changeset.destroyed) {
|
|
24
|
+
if (known.has(netId)) {
|
|
25
|
+
leaves.push(netId);
|
|
26
|
+
known.delete(netId);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 2. Globally created entities that are in interest → enter
|
|
30
|
+
for (const entry of changeset.created) {
|
|
31
|
+
if (interest.has(entry.netId)) {
|
|
32
|
+
enters.push(entry.netId);
|
|
33
|
+
known.add(entry.netId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 3. Interest transitions for existing entities
|
|
37
|
+
// - In known but NOT in interest → leave (entity left view)
|
|
38
|
+
// - In interest but NOT in known → enter (entity entered view)
|
|
39
|
+
for (const netId of known) {
|
|
40
|
+
if (!interest.has(netId) && !destroyedSet.has(netId)) {
|
|
41
|
+
leaves.push(netId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Remove leaves from known
|
|
45
|
+
for (const netId of leaves)
|
|
46
|
+
known.delete(netId);
|
|
47
|
+
for (const netId of interest) {
|
|
48
|
+
if (!known.has(netId) && !createdSet.has(netId) && !destroyedSet.has(netId)) {
|
|
49
|
+
enters.push(netId);
|
|
50
|
+
known.add(netId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// 4. Dirty entities that are in known (and not just entered) → update
|
|
54
|
+
for (const entry of changeset.dirty) {
|
|
55
|
+
if (known.has(entry.netId) && !createdSet.has(entry.netId)) {
|
|
56
|
+
// Check it's not a fresh enter (enters already get full data)
|
|
57
|
+
let isEnter = false;
|
|
58
|
+
for (let i = 0; i < enters.length; i++) {
|
|
59
|
+
if (enters[i] === entry.netId) {
|
|
60
|
+
isEnter = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!isEnter)
|
|
65
|
+
updates.push(entry.netId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { enters, leaves, updates };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EntityManager } from 'archetype-ecs';
|
|
2
|
+
import type { ComponentRegistry } from './ComponentRegistry.js';
|
|
3
|
+
export interface NetClient {
|
|
4
|
+
/** Connect to server */
|
|
5
|
+
connect(url: string): void;
|
|
6
|
+
/** Disconnect from server */
|
|
7
|
+
disconnect(): void;
|
|
8
|
+
/** Whether currently connected */
|
|
9
|
+
readonly connected: boolean;
|
|
10
|
+
/** Callbacks */
|
|
11
|
+
onConnected: (() => void) | null;
|
|
12
|
+
onDisconnected: (() => void) | null;
|
|
13
|
+
}
|
|
14
|
+
export declare function createNetClient(em: EntityManager, registry: ComponentRegistry): NetClient;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ProtocolDecoder } from './Protocol.js';
|
|
2
|
+
import { MSG_DELTA, MSG_FULL } from './types.js';
|
|
3
|
+
export function createNetClient(em, registry) {
|
|
4
|
+
const decoder = new ProtocolDecoder();
|
|
5
|
+
let ws = null;
|
|
6
|
+
// Map wireId → component def for fast lookup during apply
|
|
7
|
+
const wireToComponent = new Map();
|
|
8
|
+
for (const reg of registry.components) {
|
|
9
|
+
wireToComponent.set(reg.wireId, reg);
|
|
10
|
+
}
|
|
11
|
+
// netId → local entity ID mapping
|
|
12
|
+
const netToEntity = new Map();
|
|
13
|
+
function applyFullState(msg) {
|
|
14
|
+
// Clear all existing entities and mappings
|
|
15
|
+
for (const id of em.getAllEntities()) {
|
|
16
|
+
em.destroyEntity(id);
|
|
17
|
+
}
|
|
18
|
+
netToEntity.clear();
|
|
19
|
+
// Recreate entities with their components
|
|
20
|
+
for (const [netId, compMap] of msg.entities) {
|
|
21
|
+
const args = [];
|
|
22
|
+
for (const [wireId, data] of compMap) {
|
|
23
|
+
const reg = wireToComponent.get(wireId);
|
|
24
|
+
if (reg) {
|
|
25
|
+
args.push(reg.component, data);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const localId = args.length > 0
|
|
29
|
+
? em.createEntityWith(...args)
|
|
30
|
+
: em.createEntity();
|
|
31
|
+
netToEntity.set(netId, localId);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function applyDelta(msg) {
|
|
35
|
+
// Apply creates
|
|
36
|
+
for (const [netId, compMap] of msg.created) {
|
|
37
|
+
const args = [];
|
|
38
|
+
for (const [wireId, data] of compMap) {
|
|
39
|
+
const reg = wireToComponent.get(wireId);
|
|
40
|
+
if (reg)
|
|
41
|
+
args.push(reg.component, data);
|
|
42
|
+
}
|
|
43
|
+
const localId = args.length > 0
|
|
44
|
+
? em.createEntityWith(...args)
|
|
45
|
+
: em.createEntity();
|
|
46
|
+
netToEntity.set(netId, localId);
|
|
47
|
+
}
|
|
48
|
+
// Apply destroys
|
|
49
|
+
for (const netId of msg.destroyed) {
|
|
50
|
+
const localId = netToEntity.get(netId);
|
|
51
|
+
if (localId !== undefined) {
|
|
52
|
+
em.destroyEntity(localId);
|
|
53
|
+
netToEntity.delete(netId);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Apply updates
|
|
57
|
+
for (const update of msg.updated) {
|
|
58
|
+
const localId = netToEntity.get(update.netId);
|
|
59
|
+
if (localId === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
const reg = wireToComponent.get(update.componentWireId);
|
|
62
|
+
if (!reg)
|
|
63
|
+
continue;
|
|
64
|
+
for (const [fieldName, value] of Object.entries(update.data)) {
|
|
65
|
+
const fieldRef = reg.component[fieldName];
|
|
66
|
+
if (fieldRef) {
|
|
67
|
+
em.set(localId, fieldRef, value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const client = {
|
|
73
|
+
onConnected: null,
|
|
74
|
+
onDisconnected: null,
|
|
75
|
+
get connected() {
|
|
76
|
+
return ws !== null && ws.readyState === WebSocket.OPEN;
|
|
77
|
+
},
|
|
78
|
+
connect(url) {
|
|
79
|
+
if (ws) {
|
|
80
|
+
ws.close();
|
|
81
|
+
}
|
|
82
|
+
ws = new WebSocket(url);
|
|
83
|
+
ws.binaryType = 'arraybuffer';
|
|
84
|
+
ws.onopen = () => {
|
|
85
|
+
client.onConnected?.();
|
|
86
|
+
};
|
|
87
|
+
ws.onmessage = (event) => {
|
|
88
|
+
const buffer = event.data;
|
|
89
|
+
const msg = decoder.decode(buffer, registry);
|
|
90
|
+
if (msg.type === MSG_FULL) {
|
|
91
|
+
applyFullState(msg);
|
|
92
|
+
}
|
|
93
|
+
else if (msg.type === MSG_DELTA) {
|
|
94
|
+
applyDelta(msg);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
ws.onclose = () => {
|
|
98
|
+
if (ws === null)
|
|
99
|
+
return; // already handled by onerror
|
|
100
|
+
ws = null;
|
|
101
|
+
client.onDisconnected?.();
|
|
102
|
+
};
|
|
103
|
+
ws.onerror = () => {
|
|
104
|
+
if (ws === null)
|
|
105
|
+
return;
|
|
106
|
+
const sock = ws;
|
|
107
|
+
ws = null;
|
|
108
|
+
client.onDisconnected?.();
|
|
109
|
+
try {
|
|
110
|
+
sock.close();
|
|
111
|
+
}
|
|
112
|
+
catch { /* ignore */ }
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
disconnect() {
|
|
116
|
+
if (ws) {
|
|
117
|
+
ws.close();
|
|
118
|
+
ws = null;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return client;
|
|
123
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EntityId, EntityManager } from 'archetype-ecs';
|
|
2
|
+
import type { ComponentRegistry } from './ComponentRegistry.js';
|
|
3
|
+
import type { InterestFilter } from './InterestManager.js';
|
|
4
|
+
import type { ClientId, NetworkConfig } from './types.js';
|
|
5
|
+
export interface ServerTransport {
|
|
6
|
+
start(port: number, handlers: TransportHandlers): Promise<void>;
|
|
7
|
+
stop(): Promise<void>;
|
|
8
|
+
send(clientId: ClientId, data: ArrayBuffer): void;
|
|
9
|
+
broadcast(data: ArrayBuffer): void;
|
|
10
|
+
}
|
|
11
|
+
export interface TransportHandlers {
|
|
12
|
+
onOpen(clientId: ClientId): void;
|
|
13
|
+
onClose(clientId: ClientId): void;
|
|
14
|
+
onMessage(clientId: ClientId, data: ArrayBuffer): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function createWsTransport(): ServerTransport;
|
|
17
|
+
export interface NetServer {
|
|
18
|
+
/** Start listening */
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
/** Stop server and disconnect all clients */
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
/** Diff, encode, send. Without filter: broadcast to all. With filter: per-client interest. */
|
|
23
|
+
tick(filter?: InterestFilter): void;
|
|
24
|
+
/** Send a custom message to a specific client */
|
|
25
|
+
send(clientId: ClientId, data: ArrayBuffer): void;
|
|
26
|
+
/** Number of connected clients */
|
|
27
|
+
readonly clientCount: number;
|
|
28
|
+
/** Entity → netId mapping (assigned during tick) */
|
|
29
|
+
readonly entityNetIds: ReadonlyMap<EntityId, number>;
|
|
30
|
+
/** Callbacks */
|
|
31
|
+
onConnect: ((clientId: ClientId) => void) | null;
|
|
32
|
+
onDisconnect: ((clientId: ClientId) => void) | null;
|
|
33
|
+
onMessage: ((clientId: ClientId, data: ArrayBuffer) => void) | null;
|
|
34
|
+
}
|
|
35
|
+
export declare function createNetServer(em: EntityManager, registry: ComponentRegistry, config: NetworkConfig, transport?: ServerTransport): NetServer;
|