@net-mesh/browser 0.37.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/CHANGELOG.md +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- package/package.json +72 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hostNetcode` — the authoritative side of netcode model 2: a fixed-rate
|
|
3
|
+
* tick loop that applies each player's inputs, broadcasts snapshots on the
|
|
4
|
+
* lossy carrier, answers clock pings, and keeps a short history for lag
|
|
5
|
+
* compensation.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const net = hostNetcode({
|
|
9
|
+
* transport: node, // or meshStoreTransport(mesh) on a dedicated host
|
|
10
|
+
* label: 'my-game.movement',
|
|
11
|
+
* tickRate: 30,
|
|
12
|
+
* step: ({ inputs }) => { // apply this tick's inputs to your world
|
|
13
|
+
* for (const [peer, list] of inputs) for (const { data } of list) move(world, peer, data);
|
|
14
|
+
* },
|
|
15
|
+
* snapshot: () => world.ships, // entity id → state, sent to every player
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { type Frame, type NetcodeTransport, type Now } from './wire.js';
|
|
20
|
+
/** One input as `step` receives it. */
|
|
21
|
+
export interface TickInput<I> {
|
|
22
|
+
readonly seq: number;
|
|
23
|
+
/** The host time the player was rendering when it issued the input. */
|
|
24
|
+
readonly seen: number;
|
|
25
|
+
readonly data: I;
|
|
26
|
+
}
|
|
27
|
+
/** What `step` gets each tick. */
|
|
28
|
+
export interface TickContext<I> {
|
|
29
|
+
readonly tick: number;
|
|
30
|
+
/** Host clock, ms, at this tick. */
|
|
31
|
+
readonly time: number;
|
|
32
|
+
/** ms since the previous tick (1000 / tickRate, nominally). */
|
|
33
|
+
readonly dtMs: number;
|
|
34
|
+
/** Each player's inputs since the last tick, oldest first, each applied
|
|
35
|
+
* exactly once however many times the lossy carrier repeated it. */
|
|
36
|
+
readonly inputs: ReadonlyMap<string, readonly TickInput<I>[]>;
|
|
37
|
+
}
|
|
38
|
+
/** What {@link HostNetcode.rewind} returns. */
|
|
39
|
+
export interface Rewound<E> {
|
|
40
|
+
/** The entities as of the returned `time`. */
|
|
41
|
+
readonly entities: Readonly<Record<string, E>>;
|
|
42
|
+
/** The host time rewound to — `seen`, unless the cap clamped it. */
|
|
43
|
+
readonly time: number;
|
|
44
|
+
/** `true` when `seen` was further back than `maxRewindMs` allows. */
|
|
45
|
+
readonly clamped: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** {@link hostNetcode}'s argument. */
|
|
48
|
+
export interface HostNetcodeOptions<E, I> {
|
|
49
|
+
readonly transport: NetcodeTransport;
|
|
50
|
+
/** Names this netcode instance; players join the same label. */
|
|
51
|
+
readonly label: string;
|
|
52
|
+
/** Ticks per second. Default 30. */
|
|
53
|
+
readonly tickRate?: number;
|
|
54
|
+
/** Advance the world one tick. */
|
|
55
|
+
step(context: TickContext<I>): void;
|
|
56
|
+
/** The world as players should see it: entity id → state. */
|
|
57
|
+
snapshot(tick: number): Readonly<Record<string, E>>;
|
|
58
|
+
/** Which entities `peer` receives. Default: all. */
|
|
59
|
+
visible?(peer: string, id: string, entity: E): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Interest management: the key an entity is found under (a grid cell, a
|
|
62
|
+
* room; `null`: always delivered). A player that states interest keys
|
|
63
|
+
* (`joinNetcode({ interest })` / `setInterest`) then receives only the
|
|
64
|
+
* entities whose key it named — after `visible`, which stays the
|
|
65
|
+
* permission; interest is only a filter. A player that states none
|
|
66
|
+
* receives everything `visible` allows.
|
|
67
|
+
*/
|
|
68
|
+
interest?(id: string, entity: E): string | null;
|
|
69
|
+
/** Admit `peer` as a player. Default: everyone. */
|
|
70
|
+
authorize?(peer: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* How far back {@link HostNetcode.rewind} may go, in ms. Default 200 —
|
|
73
|
+
* lag compensation that trusts any claimed latency lets a player buy an
|
|
74
|
+
* advantage by faking lag.
|
|
75
|
+
*/
|
|
76
|
+
readonly maxRewindMs?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Largest snapshot frame, bytes. Default 8000 — under one event, so a
|
|
79
|
+
* snapshot never rides as fragments (on the lossy carrier one lost
|
|
80
|
+
* fragment loses them all). A larger snapshot is sent as independent
|
|
81
|
+
* chunks; a lost chunk costs the entities it carried, for one tick.
|
|
82
|
+
*/
|
|
83
|
+
readonly maxFrameBytes?: number;
|
|
84
|
+
/** Drop a player silent this long, ms. Default 5000. */
|
|
85
|
+
readonly playerTimeoutMs?: number;
|
|
86
|
+
/** `false` to drive ticks yourself with {@link HostNetcode.tick}. Default `true`. */
|
|
87
|
+
readonly autoTick?: boolean;
|
|
88
|
+
readonly now?: Now;
|
|
89
|
+
}
|
|
90
|
+
/** The running host. */
|
|
91
|
+
export interface HostNetcode<E> {
|
|
92
|
+
/** Players currently connected, 16-hex peer ids. */
|
|
93
|
+
players(): readonly string[];
|
|
94
|
+
/** The tick about to run. */
|
|
95
|
+
readonly currentTick: number;
|
|
96
|
+
/** Run one tick now (what the auto loop calls). */
|
|
97
|
+
tick(): void;
|
|
98
|
+
/**
|
|
99
|
+
* Lag compensation: the entities as a player saw them at host time
|
|
100
|
+
* `seen` (from {@link TickInput.seen}), from this host's own history —
|
|
101
|
+
* never further back than `maxRewindMs` before now.
|
|
102
|
+
*/
|
|
103
|
+
rewind(seen: number): Rewound<E>;
|
|
104
|
+
/** Frames dropped and why. */
|
|
105
|
+
readonly dropped: Readonly<Record<string, number>>;
|
|
106
|
+
/** The last error a send or stream open raised, for diagnosis; `null` if none. */
|
|
107
|
+
readonly lastSendError: string | null;
|
|
108
|
+
close(): void;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* One snapshot as frames: one, or — over `maxBytes` — chunks by
|
|
112
|
+
* {@link chunkOf}, as many as it takes for each to fit (up to
|
|
113
|
+
* {@link MAX_SNAPSHOT_CHUNKS}; past that the chunks are sent over-size and
|
|
114
|
+
* ride as fragments).
|
|
115
|
+
*/
|
|
116
|
+
export declare function snapshotFrames<E>(label: string, tick: number, time: number, ack: number, entities: Readonly<Record<string, E>>, maxBytes: number, interestVersion?: number): Frame<E, never>[];
|
|
117
|
+
/** Start the authoritative netcode host. */
|
|
118
|
+
export declare function hostNetcode<E, I>(options: HostNetcodeOptions<E, I>): HostNetcode<E>;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hostNetcode` — the authoritative side of netcode model 2: a fixed-rate
|
|
3
|
+
* tick loop that applies each player's inputs, broadcasts snapshots on the
|
|
4
|
+
* lossy carrier, answers clock pings, and keeps a short history for lag
|
|
5
|
+
* compensation.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const net = hostNetcode({
|
|
9
|
+
* transport: node, // or meshStoreTransport(mesh) on a dedicated host
|
|
10
|
+
* label: 'my-game.movement',
|
|
11
|
+
* tickRate: 30,
|
|
12
|
+
* step: ({ inputs }) => { // apply this tick's inputs to your world
|
|
13
|
+
* for (const [peer, list] of inputs) for (const { data } of list) move(world, peer, data);
|
|
14
|
+
* },
|
|
15
|
+
* snapshot: () => world.ships, // entity id → state, sent to every player
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { MAX_SNAPSHOT_CHUNKS, chunkOf, defaultNow, decodeFrame, encodeFrame, eventPeer, } from './wire.js';
|
|
20
|
+
/** History kept: enough ticks to cover the rewind cap with room. */
|
|
21
|
+
function historySize(tickRate, maxRewindMs) {
|
|
22
|
+
return Math.max(8, Math.ceil((maxRewindMs / 1000) * tickRate) + 4);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One snapshot as frames: one, or — over `maxBytes` — chunks by
|
|
26
|
+
* {@link chunkOf}, as many as it takes for each to fit (up to
|
|
27
|
+
* {@link MAX_SNAPSHOT_CHUNKS}; past that the chunks are sent over-size and
|
|
28
|
+
* ride as fragments).
|
|
29
|
+
*/
|
|
30
|
+
export function snapshotFrames(label, tick, time, ack, entities, maxBytes, interestVersion = 0) {
|
|
31
|
+
// The interest version the view was filtered by, echoed so the player
|
|
32
|
+
// stops repeating its `w` frame.
|
|
33
|
+
const iv = interestVersion > 0 ? { iv: interestVersion } : {};
|
|
34
|
+
const whole = { n: label, k: 's', tick, t: time, ack, e: entities, ...iv };
|
|
35
|
+
const size = encodeFrame(whole).length;
|
|
36
|
+
if (size <= maxBytes)
|
|
37
|
+
return [whole];
|
|
38
|
+
const ids = Object.keys(entities);
|
|
39
|
+
let of = Math.min(MAX_SNAPSHOT_CHUNKS, Math.max(2, Math.ceil(size / (maxBytes * 0.75))));
|
|
40
|
+
for (;;) {
|
|
41
|
+
const parts = Array.from({ length: of }, () => ({}));
|
|
42
|
+
for (const id of ids)
|
|
43
|
+
parts[chunkOf(id, of)][id] = entities[id];
|
|
44
|
+
const frames = parts.map((e, c) => ({ n: label, k: 's', tick, t: time, ack, e, c, of, ...iv }));
|
|
45
|
+
if (of >= MAX_SNAPSHOT_CHUNKS || frames.every(frame => encodeFrame(frame).length <= maxBytes)) {
|
|
46
|
+
return frames;
|
|
47
|
+
}
|
|
48
|
+
of = Math.min(MAX_SNAPSHOT_CHUNKS, of * 2);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Start the authoritative netcode host. */
|
|
52
|
+
export function hostNetcode(options) {
|
|
53
|
+
const now = options.now ?? defaultNow;
|
|
54
|
+
const tickRate = options.tickRate ?? 30;
|
|
55
|
+
if (!(tickRate > 0 && tickRate <= 240))
|
|
56
|
+
throw new RangeError('tickRate must be in (0, 240]');
|
|
57
|
+
const tickMs = 1000 / tickRate;
|
|
58
|
+
const maxRewindMs = options.maxRewindMs ?? 200;
|
|
59
|
+
const maxFrameBytes = Math.max(256, options.maxFrameBytes ?? 8000);
|
|
60
|
+
const playerTimeoutMs = options.playerTimeoutMs ?? 5000;
|
|
61
|
+
const players = new Map();
|
|
62
|
+
const history = [];
|
|
63
|
+
const keep = historySize(tickRate, maxRewindMs);
|
|
64
|
+
const dropped = {};
|
|
65
|
+
const drop = (why) => {
|
|
66
|
+
dropped[why] = (dropped[why] ?? 0) + 1;
|
|
67
|
+
};
|
|
68
|
+
let tick = 0;
|
|
69
|
+
let lastError = null;
|
|
70
|
+
let lastTickAt = now();
|
|
71
|
+
let closed = false;
|
|
72
|
+
const send = (peer, player, frame) => {
|
|
73
|
+
if (player.stream === null) {
|
|
74
|
+
if (!player.opening) {
|
|
75
|
+
player.opening = true;
|
|
76
|
+
Promise.resolve(options.transport.openStream({ reliability: 'fireAndForget', peer, label: options.label, lossy: true }))
|
|
77
|
+
.then(stream => {
|
|
78
|
+
if (closed || !players.has(peer)) {
|
|
79
|
+
stream.close();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
player.stream = stream;
|
|
83
|
+
})
|
|
84
|
+
.catch(error => {
|
|
85
|
+
lastError = String(error?.message ?? error);
|
|
86
|
+
drop('stream-open-failed');
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
89
|
+
player.opening = false;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
drop('stream-not-open');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// A failed send retires the stream: the session under it may have
|
|
96
|
+
// been replaced (a reconnect, a relayed → direct upgrade), and a stream
|
|
97
|
+
// handle is fenced to the session it was opened on — every later send
|
|
98
|
+
// on it would fail too. The next send reopens on the current session.
|
|
99
|
+
const stream = player.stream;
|
|
100
|
+
const retire = (error) => {
|
|
101
|
+
lastError = String(error?.message ?? error);
|
|
102
|
+
drop('send-failed');
|
|
103
|
+
if (player.stream === stream) {
|
|
104
|
+
player.stream = null;
|
|
105
|
+
try {
|
|
106
|
+
stream.close();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Already unusable.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
void Promise.resolve(stream.send(encodeFrame(frame))).catch(retire);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
retire(error);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const playerFor = (peer) => {
|
|
121
|
+
const existing = players.get(peer);
|
|
122
|
+
if (existing)
|
|
123
|
+
return existing;
|
|
124
|
+
if (options.authorize && !options.authorize(peer)) {
|
|
125
|
+
drop('unauthorized');
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const player = {
|
|
129
|
+
stream: null,
|
|
130
|
+
opening: false,
|
|
131
|
+
lastApplied: 0,
|
|
132
|
+
pending: new Map(),
|
|
133
|
+
lastHeard: now(),
|
|
134
|
+
interest: null,
|
|
135
|
+
interestVersion: 0,
|
|
136
|
+
};
|
|
137
|
+
players.set(peer, player);
|
|
138
|
+
return player;
|
|
139
|
+
};
|
|
140
|
+
const stopEvents = options.transport.onEvent(event => {
|
|
141
|
+
if (closed || event.type !== 'stream_data')
|
|
142
|
+
return;
|
|
143
|
+
const frame = decodeFrame(event.payload, options.label);
|
|
144
|
+
if (frame === null)
|
|
145
|
+
return;
|
|
146
|
+
const peer = eventPeer(event.peerNode);
|
|
147
|
+
if (peer === null) {
|
|
148
|
+
drop('no-authenticated-peer');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const player = playerFor(peer);
|
|
152
|
+
if (player === null)
|
|
153
|
+
return;
|
|
154
|
+
const t1 = now();
|
|
155
|
+
player.lastHeard = t1;
|
|
156
|
+
switch (frame.k) {
|
|
157
|
+
case 'h':
|
|
158
|
+
return;
|
|
159
|
+
case 'p':
|
|
160
|
+
send(peer, player, { n: options.label, k: 'q', t0: frame.t0, t1, t2: now() });
|
|
161
|
+
return;
|
|
162
|
+
case 'w':
|
|
163
|
+
// The lossy carrier reorders and repeats: only a newer version counts.
|
|
164
|
+
if (frame.v > player.interestVersion) {
|
|
165
|
+
player.interestVersion = frame.v;
|
|
166
|
+
player.interest = new Set(frame.w);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
case 'i':
|
|
170
|
+
for (const input of frame.i) {
|
|
171
|
+
if (!Number.isInteger(input.seq) || input.seq <= player.lastApplied)
|
|
172
|
+
continue;
|
|
173
|
+
if (!player.pending.has(input.seq)) {
|
|
174
|
+
player.pending.set(input.seq, { seq: input.seq, seen: Number(input.seen) || 0, data: input.data });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
default:
|
|
179
|
+
drop('unexpected-frame');
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const runTick = () => {
|
|
183
|
+
if (closed)
|
|
184
|
+
return;
|
|
185
|
+
const time = now();
|
|
186
|
+
const dtMs = tick === 0 ? tickMs : time - lastTickAt;
|
|
187
|
+
lastTickAt = time;
|
|
188
|
+
for (const [peer, player] of players) {
|
|
189
|
+
if (time - player.lastHeard > playerTimeoutMs) {
|
|
190
|
+
player.stream?.close();
|
|
191
|
+
players.delete(peer);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const inputs = new Map();
|
|
195
|
+
for (const [peer, player] of players) {
|
|
196
|
+
if (player.pending.size === 0)
|
|
197
|
+
continue;
|
|
198
|
+
const ordered = [...player.pending.values()].sort((a, b) => a.seq - b.seq);
|
|
199
|
+
player.pending.clear();
|
|
200
|
+
// A gap (inputs lost despite redundancy) is skipped, not waited on:
|
|
201
|
+
// a tick cannot stall for one player's packet.
|
|
202
|
+
player.lastApplied = ordered.at(-1).seq;
|
|
203
|
+
inputs.set(peer, ordered);
|
|
204
|
+
}
|
|
205
|
+
options.step({ tick, time, dtMs, inputs });
|
|
206
|
+
const entities = options.snapshot(tick);
|
|
207
|
+
history.push({ tick, time, entities });
|
|
208
|
+
if (history.length > keep)
|
|
209
|
+
history.shift();
|
|
210
|
+
for (const [peer, player] of players) {
|
|
211
|
+
let view = entities;
|
|
212
|
+
const wanted = options.interest && player.interest;
|
|
213
|
+
if (options.visible || wanted) {
|
|
214
|
+
const filtered = {};
|
|
215
|
+
for (const [id, entity] of Object.entries(entities)) {
|
|
216
|
+
if (options.visible && !options.visible(peer, id, entity))
|
|
217
|
+
continue;
|
|
218
|
+
if (wanted) {
|
|
219
|
+
let key;
|
|
220
|
+
try {
|
|
221
|
+
key = options.interest(id, entity);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
key = null;
|
|
225
|
+
}
|
|
226
|
+
if (typeof key === 'string' && !wanted.has(key))
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
filtered[id] = entity;
|
|
230
|
+
}
|
|
231
|
+
view = filtered;
|
|
232
|
+
}
|
|
233
|
+
const frames = snapshotFrames(options.label, tick, time, player.lastApplied, view, maxFrameBytes, player.interestVersion);
|
|
234
|
+
for (const frame of frames)
|
|
235
|
+
send(peer, player, frame);
|
|
236
|
+
}
|
|
237
|
+
tick += 1;
|
|
238
|
+
};
|
|
239
|
+
const timer = options.autoTick === false ? null : setInterval(runTick, tickMs);
|
|
240
|
+
return {
|
|
241
|
+
players: () => [...players.keys()],
|
|
242
|
+
get currentTick() {
|
|
243
|
+
return tick;
|
|
244
|
+
},
|
|
245
|
+
tick: runTick,
|
|
246
|
+
rewind(seen) {
|
|
247
|
+
const floor = now() - maxRewindMs;
|
|
248
|
+
const clamped = seen < floor;
|
|
249
|
+
const time = clamped ? floor : seen;
|
|
250
|
+
// The newest recorded state at or before `time`.
|
|
251
|
+
let chosen = history[0];
|
|
252
|
+
for (const entry of history) {
|
|
253
|
+
if (entry.time <= time)
|
|
254
|
+
chosen = entry;
|
|
255
|
+
else
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
return { entities: chosen?.entities ?? {}, time, clamped };
|
|
259
|
+
},
|
|
260
|
+
dropped,
|
|
261
|
+
get lastSendError() {
|
|
262
|
+
return lastError;
|
|
263
|
+
},
|
|
264
|
+
close() {
|
|
265
|
+
if (closed)
|
|
266
|
+
return;
|
|
267
|
+
closed = true;
|
|
268
|
+
if (timer !== null)
|
|
269
|
+
clearInterval(timer);
|
|
270
|
+
stopEvents();
|
|
271
|
+
for (const player of players.values())
|
|
272
|
+
player.stream?.close();
|
|
273
|
+
players.clear();
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@net-mesh/browser/netcode` — responsive movement over the lossy carrier
|
|
3
|
+
* (netcode model 2): an authoritative host tick loop with lag compensation,
|
|
4
|
+
* and on each player snapshot interpolation for others plus prediction and
|
|
5
|
+
* reconciliation for yourself.
|
|
6
|
+
*
|
|
7
|
+
* Use it beside the store, not instead of it: the store holds durable,
|
|
8
|
+
* validated state (inventory, score, doors); netcode carries high-rate
|
|
9
|
+
* transforms that are superseded many times a second.
|
|
10
|
+
*/
|
|
11
|
+
export { hostNetcode } from './host.js';
|
|
12
|
+
export type { HostNetcode, HostNetcodeOptions, Rewound, TickContext, TickInput } from './host.js';
|
|
13
|
+
export { joinNetcode } from './client.js';
|
|
14
|
+
export type { JoinNetcodeOptions, LocalEntity, NetcodeClient, NetcodeStats } from './client.js';
|
|
15
|
+
export { ClockEstimator, CLOCK_WINDOW } from './clock.js';
|
|
16
|
+
export type { ClockEstimate, Millis } from './clock.js';
|
|
17
|
+
export { SnapshotBuffer, lerpNumbers, SNAPSHOT_BUFFER } from './interpolate.js';
|
|
18
|
+
export type { Interpolator, TimedSnapshot } from './interpolate.js';
|
|
19
|
+
export type { NetcodeStream, NetcodeTransport } from './wire.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@net-mesh/browser/netcode` — responsive movement over the lossy carrier
|
|
3
|
+
* (netcode model 2): an authoritative host tick loop with lag compensation,
|
|
4
|
+
* and on each player snapshot interpolation for others plus prediction and
|
|
5
|
+
* reconciliation for yourself.
|
|
6
|
+
*
|
|
7
|
+
* Use it beside the store, not instead of it: the store holds durable,
|
|
8
|
+
* validated state (inventory, score, doors); netcode carries high-rate
|
|
9
|
+
* transforms that are superseded many times a second.
|
|
10
|
+
*/
|
|
11
|
+
export { hostNetcode } from './host.js';
|
|
12
|
+
export { joinNetcode } from './client.js';
|
|
13
|
+
export { ClockEstimator, CLOCK_WINDOW } from './clock.js';
|
|
14
|
+
export { SnapshotBuffer, lerpNumbers, SNAPSHOT_BUFFER } from './interpolate.js';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot interpolation: render remote entities a little in the past,
|
|
3
|
+
* between two snapshots the host already sent, so lost and late packets
|
|
4
|
+
* do not make them stutter.
|
|
5
|
+
*/
|
|
6
|
+
/** A snapshot as the client keeps it. */
|
|
7
|
+
export interface TimedSnapshot<E> {
|
|
8
|
+
/** Host clock, ms, when the host produced it. */
|
|
9
|
+
readonly time: number;
|
|
10
|
+
readonly tick: number;
|
|
11
|
+
readonly entities: Readonly<Record<string, E>>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Blend two states of one entity; `alpha` is in `[0, 1]` — or above 1 when
|
|
15
|
+
* extrapolating past the newest snapshot (`extrapolateMs`), where `to` is
|
|
16
|
+
* carried on along the line from `from`.
|
|
17
|
+
*/
|
|
18
|
+
export type Interpolator<E> = (from: E, to: E, alpha: number) => E;
|
|
19
|
+
/**
|
|
20
|
+
* The default blend: every numeric field is linearly interpolated, every
|
|
21
|
+
* other field is taken from `to`. Nested objects are blended the same way,
|
|
22
|
+
* one level at a time. Angles are not special-cased: a game with wrapping
|
|
23
|
+
* angles supplies its own.
|
|
24
|
+
*/
|
|
25
|
+
export declare function lerpNumbers<E>(from: E, to: E, alpha: number): E;
|
|
26
|
+
/** Snapshots kept, at most. */
|
|
27
|
+
export declare const SNAPSHOT_BUFFER = 64;
|
|
28
|
+
/** A time-ordered buffer of snapshots with interpolated reads. */
|
|
29
|
+
export declare class SnapshotBuffer<E> {
|
|
30
|
+
#private;
|
|
31
|
+
/**
|
|
32
|
+
* Add a snapshot. One older than the newest already held is still
|
|
33
|
+
* inserted in order (an unordered carrier reorders); one for a tick
|
|
34
|
+
* already held is ignored.
|
|
35
|
+
*/
|
|
36
|
+
add(snapshot: TimedSnapshot<E>): void;
|
|
37
|
+
/** Snapshots that arrived after a newer one (reordered by the carrier). */
|
|
38
|
+
get late(): number;
|
|
39
|
+
/** The newest snapshot, or `null`. */
|
|
40
|
+
latest(): TimedSnapshot<E> | null;
|
|
41
|
+
/**
|
|
42
|
+
* The entities as of host time `at`: interpolated between the two
|
|
43
|
+
* snapshots around it. Before the oldest held, the oldest. Past the
|
|
44
|
+
* newest: held there, or — with `extrapolateMs` — carried on along the
|
|
45
|
+
* last two snapshots' line for at most that long, then held. An entity
|
|
46
|
+
* present only in the later snapshot appears as it is there; one gone
|
|
47
|
+
* from the later snapshot is gone.
|
|
48
|
+
*/
|
|
49
|
+
at(time: number, blend: Interpolator<E>, extrapolateMs?: number): Readonly<Record<string, E>>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot interpolation: render remote entities a little in the past,
|
|
3
|
+
* between two snapshots the host already sent, so lost and late packets
|
|
4
|
+
* do not make them stutter.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The default blend: every numeric field is linearly interpolated, every
|
|
8
|
+
* other field is taken from `to`. Nested objects are blended the same way,
|
|
9
|
+
* one level at a time. Angles are not special-cased: a game with wrapping
|
|
10
|
+
* angles supplies its own.
|
|
11
|
+
*/
|
|
12
|
+
export function lerpNumbers(from, to, alpha) {
|
|
13
|
+
if (typeof from === 'number' && typeof to === 'number') {
|
|
14
|
+
return (from + (to - from) * alpha);
|
|
15
|
+
}
|
|
16
|
+
if (from === null || to === null || typeof from !== 'object' || typeof to !== 'object' || Array.isArray(to)) {
|
|
17
|
+
return to;
|
|
18
|
+
}
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const [key, value] of Object.entries(to)) {
|
|
21
|
+
const previous = from[key];
|
|
22
|
+
out[key] = previous === undefined ? value : lerpNumbers(previous, value, alpha);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/** Snapshots kept, at most. */
|
|
27
|
+
export const SNAPSHOT_BUFFER = 64;
|
|
28
|
+
/** A time-ordered buffer of snapshots with interpolated reads. */
|
|
29
|
+
export class SnapshotBuffer {
|
|
30
|
+
#snapshots = [];
|
|
31
|
+
#late = 0;
|
|
32
|
+
/**
|
|
33
|
+
* Add a snapshot. One older than the newest already held is still
|
|
34
|
+
* inserted in order (an unordered carrier reorders); one for a tick
|
|
35
|
+
* already held is ignored.
|
|
36
|
+
*/
|
|
37
|
+
add(snapshot) {
|
|
38
|
+
const list = this.#snapshots;
|
|
39
|
+
if (list.some(s => s.tick === snapshot.tick))
|
|
40
|
+
return;
|
|
41
|
+
let at = list.length;
|
|
42
|
+
while (at > 0 && list[at - 1].time > snapshot.time)
|
|
43
|
+
at -= 1;
|
|
44
|
+
if (at < list.length)
|
|
45
|
+
this.#late += 1;
|
|
46
|
+
list.splice(at, 0, snapshot);
|
|
47
|
+
if (list.length > SNAPSHOT_BUFFER)
|
|
48
|
+
list.shift();
|
|
49
|
+
}
|
|
50
|
+
/** Snapshots that arrived after a newer one (reordered by the carrier). */
|
|
51
|
+
get late() {
|
|
52
|
+
return this.#late;
|
|
53
|
+
}
|
|
54
|
+
/** The newest snapshot, or `null`. */
|
|
55
|
+
latest() {
|
|
56
|
+
return this.#snapshots.at(-1) ?? null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The entities as of host time `at`: interpolated between the two
|
|
60
|
+
* snapshots around it. Before the oldest held, the oldest. Past the
|
|
61
|
+
* newest: held there, or — with `extrapolateMs` — carried on along the
|
|
62
|
+
* last two snapshots' line for at most that long, then held. An entity
|
|
63
|
+
* present only in the later snapshot appears as it is there; one gone
|
|
64
|
+
* from the later snapshot is gone.
|
|
65
|
+
*/
|
|
66
|
+
at(time, blend, extrapolateMs = 0) {
|
|
67
|
+
const list = this.#snapshots;
|
|
68
|
+
if (list.length === 0)
|
|
69
|
+
return {};
|
|
70
|
+
if (time <= list[0].time)
|
|
71
|
+
return list[0].entities;
|
|
72
|
+
const last = list.at(-1);
|
|
73
|
+
if (time >= last.time) {
|
|
74
|
+
const before = list.at(-2);
|
|
75
|
+
if (extrapolateMs <= 0 || before === undefined || last.time === before.time || time === last.time) {
|
|
76
|
+
return last.entities;
|
|
77
|
+
}
|
|
78
|
+
const ahead = Math.min(time - last.time, extrapolateMs);
|
|
79
|
+
const alpha = 1 + ahead / (last.time - before.time);
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const [id, to] of Object.entries(last.entities)) {
|
|
82
|
+
const from = before.entities[id];
|
|
83
|
+
out[id] = from === undefined ? to : blend(from, to, alpha);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
let i = 1;
|
|
88
|
+
while (list[i].time < time)
|
|
89
|
+
i += 1;
|
|
90
|
+
const a = list[i - 1];
|
|
91
|
+
const b = list[i];
|
|
92
|
+
const alpha = b.time === a.time ? 1 : (time - a.time) / (b.time - a.time);
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [id, to] of Object.entries(b.entities)) {
|
|
95
|
+
const from = a.entities[id];
|
|
96
|
+
out[id] = from === undefined ? to : blend(from, to, alpha);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
}
|