lecodes-sdk 2.0.3 → 2.0.4
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/global.d.ts +1 -0
- package/dist/types/inject.d.ts +1 -1
- package/dist/types/net/codec.d.ts +3 -2
- package/dist/types/net/core.d.ts +10 -1
- package/dist/types/net/index.d.ts +22 -5
- package/dist/types/net/replication.d.ts +23 -2
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -1
- package/src/animate/tween/read.ts +146 -0
- package/src/canvas/gen/cssColor.ts +1 -1
- package/src/canvas/gen/recorder.ts +1 -1
- package/src/canvas/gen/spec.ts +1 -1
- package/src/inject.ts +1 -1
- package/src/net/codec.ts +19 -10
- package/src/net/core.ts +18 -5
- package/src/net/index.ts +30 -9
- package/src/net/replication.ts +63 -28
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./bridges/*": "./src/bridges/*.d.ts",
|
|
21
21
|
"./ui/tree": "./src/ui/tree.ts",
|
|
22
22
|
"./tween/easing": "./src/animate/tween/easing.ts",
|
|
23
|
+
"./tween/read": "./src/animate/tween/read.ts",
|
|
23
24
|
"./testing": "./tests/helpers/index.ts",
|
|
24
25
|
"./testing/preload": "./tests/helpers/preload.ts",
|
|
25
26
|
"./version": "./src/version.ts"
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
"typescript": "~5.8.3",
|
|
43
44
|
"gl-matrix": "^3.4.4"
|
|
44
45
|
},
|
|
45
|
-
"version": "2.0.
|
|
46
|
+
"version": "2.0.4",
|
|
46
47
|
"files": [
|
|
47
48
|
"src",
|
|
48
49
|
"dist",
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The READER of the tween blob: `buildBlob`'s layout (./spec.ts) back into tracks and keys. For a
|
|
2
|
+
// host that plays an animation with no creator-tween in it (Web Lite hands the tracks to the
|
|
3
|
+
// browser's animations) — creator-tween reads the same layout in C (blob.h).
|
|
4
|
+
//
|
|
5
|
+
// The reader is the builder's twin and lives beside it: tests/animate/blob.test.ts builds a spec,
|
|
6
|
+
// reads it back and holds the two against each other, so a change of the layout that reaches one
|
|
7
|
+
// side only fails there.
|
|
8
|
+
|
|
9
|
+
import { EASE_TABLE, EASE_TABLE_SIZE, evalEasing, type EasingSpec } from "./easing"
|
|
10
|
+
|
|
11
|
+
/** The version of the layout this reader knows (./spec.ts BLOB_VERSION). */
|
|
12
|
+
const VERSION = 1
|
|
13
|
+
// The kinds whose one lane is an index into the string table (./spec.ts KIND_*).
|
|
14
|
+
const KIND_TRANSFORM_MATRIX = 4, KIND_DISCRETE = 8, KIND_STRING = 9
|
|
15
|
+
|
|
16
|
+
export type BlobKey = {
|
|
17
|
+
/** 0..1 within the track. */
|
|
18
|
+
t: number
|
|
19
|
+
easing: EasingSpec
|
|
20
|
+
/** Absent = the target's value when the animation first plays. */
|
|
21
|
+
value?: { lanes: number[] } | { str: string }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type BlobTrack = {
|
|
25
|
+
domain: number
|
|
26
|
+
/** An index into the `targets` handed over next to the blob (a role, in a transition's blob). */
|
|
27
|
+
target: number
|
|
28
|
+
prop: string
|
|
29
|
+
kind: number
|
|
30
|
+
lanes: number
|
|
31
|
+
/** The unit of a FLOAT_UNIT, the function list of a TRANSFORM. */
|
|
32
|
+
sig?: string
|
|
33
|
+
atMs: number
|
|
34
|
+
durMs: number
|
|
35
|
+
commit: boolean
|
|
36
|
+
keys: BlobKey[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ReadBlob = {
|
|
40
|
+
clock: number
|
|
41
|
+
/** One iteration, ms. */
|
|
42
|
+
durationMs: number
|
|
43
|
+
delayMs: number
|
|
44
|
+
/** -1 = forever. */
|
|
45
|
+
iterations: number
|
|
46
|
+
pingPong: boolean
|
|
47
|
+
rate: number
|
|
48
|
+
/** Time-callbacks, ms; the index is what the host reports. */
|
|
49
|
+
calls: number[]
|
|
50
|
+
tracks: BlobTrack[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The blob as tracks, or null for data that is not a blob of this version (or is cut short). */
|
|
54
|
+
export const readBlob = (data: ArrayLike<number>, strings: readonly string[]): ReadBlob | null => {
|
|
55
|
+
if (data.length < 10 || data[0] !== VERSION) return null
|
|
56
|
+
const nTracks = data[7]!, nCalls = data[8]!, nTables = data[9]!
|
|
57
|
+
let at = 10
|
|
58
|
+
if (at + nCalls + nTables * EASE_TABLE_SIZE > data.length) return null
|
|
59
|
+
const calls: number[] = []
|
|
60
|
+
for (let i = 0; i < nCalls; i++) calls.push(data[at++]!)
|
|
61
|
+
const tables: Float32Array[] = []
|
|
62
|
+
for (let i = 0; i < nTables; i++) {
|
|
63
|
+
const table = new Float32Array(EASE_TABLE_SIZE)
|
|
64
|
+
for (let k = 0; k < EASE_TABLE_SIZE; k++) table[k] = data[at++]!
|
|
65
|
+
tables.push(table)
|
|
66
|
+
}
|
|
67
|
+
const str = (index: number): string | undefined => (index >= 0 ? strings[index] : undefined)
|
|
68
|
+
|
|
69
|
+
const tracks: BlobTrack[] = []
|
|
70
|
+
for (let t = 0; t < nTracks; t++) {
|
|
71
|
+
if (at + 10 > data.length) return null
|
|
72
|
+
const kind = data[at + 3]!, lanes = data[at + 4]!, nKeys = data[at + 8]!
|
|
73
|
+
const track: BlobTrack = {
|
|
74
|
+
domain: data[at]!, target: data[at + 1]!, prop: str(data[at + 2]!) ?? "", kind, lanes,
|
|
75
|
+
sig: str(data[at + 9]!), atMs: data[at + 5]!, durMs: data[at + 6]!, commit: data[at + 7]! !== 0, keys: [],
|
|
76
|
+
}
|
|
77
|
+
at += 10
|
|
78
|
+
if (at + nKeys * (7 + lanes) > data.length) return null
|
|
79
|
+
const stringLane = kind === KIND_TRANSFORM_MATRIX || kind === KIND_DISCRETE || kind === KIND_STRING
|
|
80
|
+
for (let k = 0; k < nKeys; k++) {
|
|
81
|
+
const easeKind = data[at + 1]!
|
|
82
|
+
const p: [number, number, number, number] = [data[at + 2]!, data[at + 3]!, data[at + 4]!, data[at + 5]!]
|
|
83
|
+
const easing: EasingSpec = easeKind === EASE_TABLE ? { kind: easeKind, p, table: tables[p[0]] } : { kind: easeKind, p }
|
|
84
|
+
const key: BlobKey = { t: data[at]!, easing }
|
|
85
|
+
if (data[at + 6]! !== 0) {
|
|
86
|
+
if (stringLane) key.value = { str: str(data[at + 7]!) ?? "" }
|
|
87
|
+
else {
|
|
88
|
+
const values: number[] = []
|
|
89
|
+
for (let l = 0; l < lanes; l++) values.push(data[at + 7 + l]!)
|
|
90
|
+
key.value = { lanes: values }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
at += 7 + lanes
|
|
94
|
+
track.keys.push(key)
|
|
95
|
+
}
|
|
96
|
+
tracks.push(track)
|
|
97
|
+
}
|
|
98
|
+
if (at !== data.length) return null
|
|
99
|
+
return { clock: data[1]!, durationMs: data[2]!, delayMs: data[3]!, iterations: data[4]!, pingPong: data[5]! !== 0, rate: data[6]!, calls, tracks }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- a track's value at a time -------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const KIND_QUAT = 6, KIND_MAT4 = 11
|
|
105
|
+
|
|
106
|
+
const slerp = (out: number[], o: number, a: number[], b: number[], s: number): void => {
|
|
107
|
+
let bx = b[o] ?? 0, by = b[o + 1] ?? 0, bz = b[o + 2] ?? 0, bw = b[o + 3] ?? 1
|
|
108
|
+
const ax = a[o] ?? 0, ay = a[o + 1] ?? 0, az = a[o + 2] ?? 0, aw = a[o + 3] ?? 1
|
|
109
|
+
let cosom = ax * bx + ay * by + az * bz + aw * bw
|
|
110
|
+
if (cosom < 0) { cosom = -cosom; bx = -bx; by = -by; bz = -bz; bw = -bw }
|
|
111
|
+
let k0 = 1 - s, k1 = s
|
|
112
|
+
if (1 - cosom > 1e-6) {
|
|
113
|
+
const omega = Math.acos(Math.min(1, cosom)), sinom = Math.sin(omega)
|
|
114
|
+
k0 = Math.sin((1 - s) * omega) / sinom
|
|
115
|
+
k1 = Math.sin(s * omega) / sinom
|
|
116
|
+
}
|
|
117
|
+
out[o] = k0 * ax + k1 * bx; out[o + 1] = k0 * ay + k1 * by; out[o + 2] = k0 * az + k1 * bz; out[o + 3] = k0 * aw + k1 * bw
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The two keys around the local time `ms` of a track and how far between them the track is,
|
|
121
|
+
* eased: the first key holds before the track, the last one after it. */
|
|
122
|
+
export const segmentAt = (track: BlobTrack, ms: number): { from: BlobKey, to: BlobKey, s: number } | null => {
|
|
123
|
+
const keys = track.keys
|
|
124
|
+
if (keys.length === 0) return null
|
|
125
|
+
const u = track.durMs <= 0 ? (ms >= track.atMs ? 1 : 0) : Math.min(1, Math.max(0, (ms - track.atMs) / track.durMs))
|
|
126
|
+
const first = keys[0]!, last = keys[keys.length - 1]!
|
|
127
|
+
if (keys.length === 1 || u <= first.t) return { from: first, to: first, s: 0 }
|
|
128
|
+
if (u >= last.t) return { from: last, to: last, s: 0 }
|
|
129
|
+
let i = 0
|
|
130
|
+
while (i + 1 < keys.length && keys[i + 1]!.t <= u) i++
|
|
131
|
+
const from = keys[i]!, to = keys[i + 1]!
|
|
132
|
+
const span = to.t - from.t
|
|
133
|
+
return { from, to, s: evalEasing(from.easing, span > 0 ? (u - from.t) / span : 1) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The lanes of a numeric track at the local time `ms`, into `out` (a key with no value reads as zeros). */
|
|
137
|
+
export const sampleTrack = (track: BlobTrack, ms: number, out: number[]): void => {
|
|
138
|
+
const at = segmentAt(track, ms)
|
|
139
|
+
if (!at) return
|
|
140
|
+
const lanesOf = (key: BlobKey): number[] => (key.value && "lanes" in key.value ? key.value.lanes : [])
|
|
141
|
+
const a = lanesOf(at.from), b = lanesOf(at.to), s = at.s
|
|
142
|
+
const lerp = (from: number, to: number) => { for (let k = from; k < to; k++) out[k] = (a[k] ?? 0) + ((b[k] ?? 0) - (a[k] ?? 0)) * s }
|
|
143
|
+
if (track.kind === KIND_QUAT) { slerp(out, 0, a, b, s); return }
|
|
144
|
+
if (track.kind === KIND_MAT4) { lerp(0, 3); slerp(out, 3, a, b, s); lerp(7, 10); return }
|
|
145
|
+
lerp(0, track.lanes)
|
|
146
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/cssColor.ts @
|
|
1
|
+
// GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/cssColor.ts @ 72e545dab1f5 — do not edit here.
|
|
2
2
|
// Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
|
|
3
3
|
// The CSS color parser — the TypeScript twin of core/include/anycanvas/css_color.h: the same grammar,
|
|
4
4
|
// the same name table, float32 emulated op for op (Math.fround, in the header's order), so both give
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/recorder.ts @
|
|
1
|
+
// GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/recorder.ts @ 72e545dab1f5 — do not edit here.
|
|
2
2
|
// Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
|
|
3
3
|
// The TypeScript recorder: a Canvas2D-shaped API whose calls WRITE the opcode stream of spec/ops.h.
|
|
4
4
|
// Nothing is drawn here — the stream goes to the core interpreter (native, or wasm on the web), which
|
package/src/canvas/gen/spec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// GENERATED by scripts/vendor-canvas.ts from engines/canvas/spec/gen/spec.ts @
|
|
1
|
+
// GENERATED by scripts/vendor-canvas.ts from engines/canvas/spec/gen/spec.ts @ 72e545dab1f5 — do not edit here.
|
|
2
2
|
// Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
|
|
3
3
|
// GENERATED by spec/generate.ts from spec/*.h — do not edit.
|
|
4
4
|
|
package/src/inject.ts
CHANGED
|
@@ -39,7 +39,7 @@ export { device, type HapticStyle, type MotionOptions } from "./runtime/device"
|
|
|
39
39
|
export { Input, InputChannel, type InputKeyEvent, type InputGamepadEvent, type InputEventName, type GamepadState, type GamepadAxisName } from "./runtime/input"
|
|
40
40
|
export { WebSocket } from "./runtime/net"
|
|
41
41
|
// Multiplayer (docs/multiplayer-plan.md): roles, players, JSON messages over the _creatorNet transport.
|
|
42
|
-
export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform } from "./net"
|
|
42
|
+
export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform, type NetCorrection } from "./net"
|
|
43
43
|
// App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
|
|
44
44
|
// only ever imports its server functions (docs/backend-plan.md §4).
|
|
45
45
|
export { __rpc, __channel, __serverOnly, RpcError, type ClientChannel } from "./runtime/rpc"
|
package/src/net/codec.ts
CHANGED
|
@@ -6,23 +6,31 @@
|
|
|
6
6
|
// Nothing else: no strings, no nesting, no optionals — snapshot fields are flat and fixed-size by
|
|
7
7
|
// design. Both sides run the same bundle, so the layout is derived, never transmitted; the `sig`
|
|
8
8
|
// feeds the schema hash the handshake compares.
|
|
9
|
+
//
|
|
10
|
+
// A numeric field is INTERPOLATED between two snapshots unless the kind names it `discrete` (an
|
|
11
|
+
// index, a count, an id): a discrete field takes the later snapshot's value whole, as a boolean does.
|
|
12
|
+
// That is a rule of the reader — the wire is the same f32.
|
|
9
13
|
|
|
10
14
|
export type FieldKind = 0 | 1 | 2 // f32 · bool · f32[]
|
|
11
|
-
export type Field = { key: string; kind: FieldKind; len: number }
|
|
15
|
+
export type Field = { key: string; kind: FieldKind; len: number; step: boolean }
|
|
12
16
|
export type Layout = { fields: Field[]; floats: number; bools: number; bytes: number; sig: string }
|
|
13
17
|
|
|
14
18
|
const describe = (v: unknown): string => Array.isArray(v) ? `array of ${v.length}` : v === null ? "null" : typeof v
|
|
15
19
|
|
|
16
|
-
export const makeLayout = (defaults: Record<string, unknown>, what: string): Layout => {
|
|
20
|
+
export const makeLayout = (defaults: Record<string, unknown>, what: string, discrete: readonly string[] = []): Layout => {
|
|
17
21
|
const fields: Field[] = []
|
|
18
22
|
let floats = 0
|
|
19
23
|
let bools = 0
|
|
24
|
+
for (const key of discrete) {
|
|
25
|
+
if (!(key in defaults)) throw new Error(`${what}: discrete field '${key}' is not in the defaults`)
|
|
26
|
+
}
|
|
20
27
|
for (const key of Object.keys(defaults)) {
|
|
21
28
|
const v = defaults[key]
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
const step = discrete.includes(key)
|
|
30
|
+
if (typeof v === "number") { fields.push({ key, kind: 0, len: 1, step }); floats++ }
|
|
31
|
+
else if (typeof v === "boolean") { fields.push({ key, kind: 1, len: 1, step: true }); bools++ }
|
|
24
32
|
else if (Array.isArray(v) && v.length > 0 && v.length <= 64 && v.every((n) => typeof n === "number")) {
|
|
25
|
-
fields.push({ key, kind: 2, len: v.length }); floats += v.length
|
|
33
|
+
fields.push({ key, kind: 2, len: v.length, step }); floats += v.length
|
|
26
34
|
} else {
|
|
27
35
|
throw new Error(`${what}: field '${key}' must be a number, a boolean or a fixed-length number[] (got ${describe(v)})`)
|
|
28
36
|
}
|
|
@@ -97,14 +105,15 @@ export const unpackFrom = (view: DataView, offset: number, layout: Layout, into:
|
|
|
97
105
|
return o
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
/** `out = a + (b − a) · t` for the numeric fields; booleans take `b`'s. */
|
|
108
|
+
/** `out = a + (b − a) · t` for the numeric fields; booleans and the discrete ones take `b`'s. */
|
|
101
109
|
export const lerpState = (layout: Layout, a: any, b: any, t: number, out: any): void => {
|
|
102
110
|
for (const f of layout.fields) {
|
|
103
|
-
if (f.kind ===
|
|
104
|
-
else if (f.kind === 2) {
|
|
111
|
+
if (f.kind === 2) {
|
|
105
112
|
const pa = a[f.key] as number[], pb = b[f.key] as number[], po = out[f.key] as number[]
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
const k = f.step ? 1 : t
|
|
114
|
+
for (let i = 0; i < f.len; i++) po[i] = pa[i] + (pb[i] - pa[i]) * k
|
|
115
|
+
} else if (f.step) out[f.key] = b[f.key]
|
|
116
|
+
else out[f.key] = a[f.key] + (b[f.key] - a[f.key]) * t
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
|
package/src/net/core.ts
CHANGED
|
@@ -48,7 +48,7 @@ export type NetStats = { rtt: number; loss: number; sentKbps: number; receivedKb
|
|
|
48
48
|
|
|
49
49
|
export const CH_RELIABLE = 0
|
|
50
50
|
export const CH_UNRELIABLE = 1
|
|
51
|
-
export const PROTO =
|
|
51
|
+
export const PROTO = 3
|
|
52
52
|
export const LOCAL_SLOT = -1
|
|
53
53
|
|
|
54
54
|
// Record kinds from creator-net (creator-net.h CNET_RECORD_*).
|
|
@@ -67,6 +67,9 @@ export class NetPlayer {
|
|
|
67
67
|
/** A local bag for game facts (score, team, ready). NOT replicated — anything a client must see
|
|
68
68
|
* goes through a message or replicated state. */
|
|
69
69
|
data: any = {}
|
|
70
|
+
/** Server: what this player's `Net.connect(…, { hello })` carried (the game's own: a build, a
|
|
71
|
+
* token) — the value `Net.listen({ accept })` was asked about. Undefined everywhere else. */
|
|
72
|
+
hello: unknown = undefined
|
|
70
73
|
/** @internal transport slot on the server; LOCAL_SLOT for the in-process player */
|
|
71
74
|
_slot: number
|
|
72
75
|
/** @internal server side: this player's input ring (replication.ts) */
|
|
@@ -129,6 +132,10 @@ export const state = {
|
|
|
129
132
|
connectResolve: null as null | (() => void),
|
|
130
133
|
connectReject: null as null | ((e: Error) => void),
|
|
131
134
|
myName: "Player",
|
|
135
|
+
/** client: the game's own part of the `$hello` (`Net.connect(…, { hello })`), opaque to the SDK */
|
|
136
|
+
hello: undefined as unknown,
|
|
137
|
+
/** server: the game's say in the handshake (`Net.listen({ accept })`) — a reason refuses */
|
|
138
|
+
accept: null as null | ((hello: unknown, who: { name: string }) => string | null | void),
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
// Replication's seams into the roster / pump (set by replication.ts when a session starts — from a
|
|
@@ -255,6 +262,8 @@ export const resetToOffline = (): void => {
|
|
|
255
262
|
state.nextId = 1
|
|
256
263
|
state.connectResolve = null
|
|
257
264
|
state.connectReject = null
|
|
265
|
+
state.hello = undefined
|
|
266
|
+
state.accept = null
|
|
258
267
|
}
|
|
259
268
|
|
|
260
269
|
// ---- server side ------------------------------------------------------------------------------------
|
|
@@ -283,13 +292,17 @@ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknow
|
|
|
283
292
|
const [name, data] = parsed
|
|
284
293
|
if (name === "$hello") {
|
|
285
294
|
if (playerBySlot(slot)) return
|
|
286
|
-
const
|
|
295
|
+
const name = String(data?.name ?? `Player ${state.nextId}`)
|
|
296
|
+
// the SDK's own checks first, then the game's say (it never sees a client of another protocol)
|
|
297
|
+
const reason = !data || data.proto !== PROTO ? "version"
|
|
298
|
+
: hooks.helloCheck?.(data) ?? state.accept?.(data.hello, { name }) ?? null
|
|
287
299
|
if (reason) {
|
|
288
|
-
sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason }]))
|
|
300
|
+
sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason: String(reason) }]))
|
|
289
301
|
b.kick(slot)
|
|
290
302
|
return
|
|
291
303
|
}
|
|
292
|
-
const p = addPlayer(state.nextId++,
|
|
304
|
+
const p = addPlayer(state.nextId++, name, slot)
|
|
305
|
+
p.hello = data.hello
|
|
293
306
|
sendRaw(slot, CH_RELIABLE, JSON.stringify(["$welcome", {
|
|
294
307
|
id: p.id,
|
|
295
308
|
players: state.players.filter((q) => q !== p).map((q) => ({ id: q.id, name: q.name })),
|
|
@@ -310,7 +323,7 @@ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknow
|
|
|
310
323
|
const onClientRecord = (kind: number, slot: number, arg: number, payload: unknown): void => {
|
|
311
324
|
const b = bridge()!
|
|
312
325
|
if (kind === REC_CONNECTED) {
|
|
313
|
-
sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, ...(hooks.helloExtra?.() ?? {}) }]))
|
|
326
|
+
sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, hello: state.hello, ...(hooks.helloExtra?.() ?? {}) }]))
|
|
314
327
|
return
|
|
315
328
|
}
|
|
316
329
|
if (kind === REC_CONNECT_FAILED || kind === REC_DISCONNECTED) {
|
package/src/net/index.ts
CHANGED
|
@@ -10,12 +10,12 @@ import {
|
|
|
10
10
|
type Listener, type NetEvents, type NetLaunch, type NetMessage, type NetRole, type NetStatus,
|
|
11
11
|
} from "./core"
|
|
12
12
|
import {
|
|
13
|
-
NetEntity, Replicated, allEntities, currentTick, declareInput, declareKind, entityById, installReplication, setInput, stats,
|
|
14
|
-
type NetInput, type NetKind, type NetTransform,
|
|
13
|
+
NetEntity, Replicated, allEntities, currentTick, declareInput, declareKind, entityById, installReplication, setInput, stats, viewTick,
|
|
14
|
+
type NetCorrection, type NetInput, type NetKind, type NetTransform,
|
|
15
15
|
} from "./replication"
|
|
16
16
|
|
|
17
17
|
export { NetPlayer, NetEntity, Replicated }
|
|
18
|
-
export type { NetEvents, NetLaunch, NetMessage, NetRole, NetStatus, NetInput, NetKind, NetTransform }
|
|
18
|
+
export type { NetEvents, NetLaunch, NetMessage, NetRole, NetStatus, NetInput, NetKind, NetTransform, NetCorrection }
|
|
19
19
|
|
|
20
20
|
class NetImpl {
|
|
21
21
|
get role(): NetRole { return state.role }
|
|
@@ -34,12 +34,21 @@ class NetImpl {
|
|
|
34
34
|
/** The simulation tick — one per fixed step (`updateFixed`). The server's is THE tick; a client
|
|
35
35
|
* runs a few ticks ahead so its inputs arrive in time. */
|
|
36
36
|
get tick(): number { return currentTick() }
|
|
37
|
+
/** The server tick this machine is SHOWING the others at: on a client, its estimate of the server's
|
|
38
|
+
* clock less the interpolation delay (fractional); `tick` everywhere else, where nothing is
|
|
39
|
+
* interpolated. What a client names when it tells the server "as I saw it". */
|
|
40
|
+
get viewTick(): number { return viewTick() }
|
|
37
41
|
/** Live counters: snapshots, input lead / `ahead`, corrections, missed inputs, bytes out. */
|
|
38
42
|
get stats(): typeof stats { return stats }
|
|
39
43
|
|
|
40
44
|
/** Start serving on `port`. From offline this makes us the host (a player who is also the
|
|
41
|
-
* server); on the dedicated exe (`Net.launch.role === "server"`) there is no local player.
|
|
42
|
-
|
|
45
|
+
* server); on the dedicated exe (`Net.launch.role === "server"`) there is no local player.
|
|
46
|
+
* `accept` is the game's say in the handshake: it is given what the client's `connect` passed as
|
|
47
|
+
* `hello` and returns a reason to refuse (the client's `reject` event carries it), nothing to accept. */
|
|
48
|
+
listen(opts: {
|
|
49
|
+
port?: number; maxClients?: number; name?: string; dedicated?: boolean
|
|
50
|
+
accept?: (hello: unknown, who: { name: string }) => string | null | void
|
|
51
|
+
} = {}): boolean {
|
|
43
52
|
const b = bridge()
|
|
44
53
|
if (!b) { emit("error", "multiplayer is not supported on this host"); return false }
|
|
45
54
|
if (state.role !== "offline") this.disconnect()
|
|
@@ -56,6 +65,7 @@ class NetImpl {
|
|
|
56
65
|
state.isServer = true
|
|
57
66
|
state.isClient = !dedicated
|
|
58
67
|
state.status = "online"
|
|
68
|
+
state.accept = opts.accept ?? null
|
|
59
69
|
if (!dedicated) ensureMe()
|
|
60
70
|
installReplication()
|
|
61
71
|
startPhases()
|
|
@@ -63,12 +73,15 @@ class NetImpl {
|
|
|
63
73
|
return true
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
/** Connect to a host/server (`address` = "ip:port"). Resolves once the server welcomed us.
|
|
67
|
-
|
|
76
|
+
/** Connect to a host/server (`address` = "ip:port"). Resolves once the server welcomed us.
|
|
77
|
+
* `hello` = anything JSON the game wants the server to see before it lets us in (its
|
|
78
|
+
* `Net.listen({ accept })`, then `player.hello`); the SDK only carries it. */
|
|
79
|
+
connect(target: { address: string }, opts: { name?: string; hello?: unknown } = {}): Promise<void> {
|
|
68
80
|
const b = bridge()
|
|
69
81
|
if (!b) return Promise.reject(new Error("multiplayer is not supported on this host"))
|
|
70
82
|
if (state.role !== "offline") this.disconnect()
|
|
71
83
|
if (opts.name) state.myName = opts.name
|
|
84
|
+
state.hello = opts.hello
|
|
72
85
|
return new Promise<void>((resolve, reject) => {
|
|
73
86
|
if (!b.connect(target.address)) { reject(new Error(`bad address '${target.address}'`)); return }
|
|
74
87
|
state.role = "client"
|
|
@@ -163,8 +176,16 @@ class NetImpl {
|
|
|
163
176
|
|
|
164
177
|
/** A replicated entity type: the defaults are the snapshot layout (same field rules as `input`),
|
|
165
178
|
* `transform` tells the SDK which fields mirror the node's pose, `client` births the proxy on a
|
|
166
|
-
* client when the server spawns one (`new Node().aspect(Replicated, { kind, owner })`)
|
|
167
|
-
|
|
179
|
+
* client when the server spawns one (`new Node().aspect(Replicated, { kind, owner })`) — it may
|
|
180
|
+
* be set later, `Kind.client = …`, where the client's code lives. `discrete` names the numeric
|
|
181
|
+
* fields that are never interpolated (an index, a count); `correction` tunes how the owner's
|
|
182
|
+
* prediction is brought back to the server's state (all in the game's own units). */
|
|
183
|
+
kind<S extends object>(name: string, defaults: S, opts: {
|
|
184
|
+
transform?: NetTransform
|
|
185
|
+
client?: (rep: NetEntity<S>, scene: any) => any
|
|
186
|
+
discrete?: readonly (keyof S & string)[]
|
|
187
|
+
correction?: Partial<NetCorrection>
|
|
188
|
+
} = {}): NetKind<S> {
|
|
168
189
|
return declareKind(name, defaults, opts as any)
|
|
169
190
|
}
|
|
170
191
|
|
package/src/net/replication.ts
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// offset `rep.correction` absorbs the jump and decays.
|
|
17
17
|
//
|
|
18
18
|
// Wire (channel 1, unreliable, binary; a leading type byte):
|
|
19
|
-
// 1 inputs C→S u8 1 · u32 firstTick · u8 count · count × input record (
|
|
19
|
+
// 1 inputs C→S u8 1 · u32 firstTick · u8 count · count × input record (the ticks since the
|
|
20
|
+
// last send + 2 older ones, 3 at least; sent when the tick moved)
|
|
20
21
|
// 2 snapshot S→C u8 2 · u32 tick · u32 lastInputTick · u8 missed · u16 count ·
|
|
21
|
-
// count × {
|
|
22
|
+
// count × { u32 netId · u8 kind · state record } (every 3rd tick)
|
|
22
23
|
// Channel 0 (reliable JSON): `$spawn {netId, kind, owner, state}` · `$despawn {netId}`.
|
|
23
24
|
|
|
24
25
|
import { Aspect } from "../core/Aspect"
|
|
@@ -31,6 +32,19 @@ import { CH_UNRELIABLE, CH_RELIABLE, LOCAL_SLOT, NetPlayer, bridge, broadcastRaw
|
|
|
31
32
|
|
|
32
33
|
export type NetTransform = "pos" | "pos+rot" | "none"
|
|
33
34
|
|
|
35
|
+
/** How an owner's prediction is brought back to the server's state. The SDK knows nothing of what
|
|
36
|
+
* the entity is, so the defaults are neutral: every disagreement counts, none is a teleport. */
|
|
37
|
+
export type NetCorrection = {
|
|
38
|
+
/** A vertical disagreement below this is ignored (world units). 0 = none ignored. A body that
|
|
39
|
+
* snaps to a floor differs by a little between two machines, and lifting it by that feeds itself. */
|
|
40
|
+
deadbandY: number
|
|
41
|
+
/** A disagreement of this much or more is a teleport: the body jumps and `correction` stays zero
|
|
42
|
+
* (world units). Infinity = always smoothed. */
|
|
43
|
+
snap: number
|
|
44
|
+
/** The decay time constant of the visual offset `correction` (seconds). 0 = no smoothing. */
|
|
45
|
+
tau: number
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
/** The per-tick input layout: `const PlayerInput = Net.input({ moveX: 0, moveY: 0, jump: false })`. */
|
|
35
49
|
export type NetInput<T extends object = any> = { readonly layout: Layout; readonly defaults: T; readonly __t?: T }
|
|
36
50
|
|
|
@@ -44,8 +58,10 @@ export type NetKind<S extends object = any> = {
|
|
|
44
58
|
/** Which state fields the SDK samples on the server and applies to a client proxy: `pos` (a
|
|
45
59
|
* `pos: [x,y,z]` field), `pos+rot` (+ a `rot: [x,y,z,w]` quaternion field), or `none`. */
|
|
46
60
|
readonly transform: NetTransform
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
readonly correction: NetCorrection
|
|
62
|
+
/** Births the client-side proxy on spawn; the SDK attaches `Replicated` to what it returns.
|
|
63
|
+
* Assignable: a declaration both ends share may leave it to the client's own module. */
|
|
64
|
+
client?: (rep: NetEntity<S>, scene: Scene | null) => Node | void
|
|
49
65
|
readonly __t?: S
|
|
50
66
|
/** @internal scratch state for snapshot decoding */
|
|
51
67
|
readonly _scratch: S
|
|
@@ -56,10 +72,10 @@ const SNAPSHOT_EVERY = 3 // 20 Hz
|
|
|
56
72
|
const INTERP_DELAY = 6 // ticks behind the estimated server tick (100 ms)
|
|
57
73
|
const LEAD_MIN = 2, LEAD_MAX = 8
|
|
58
74
|
const SNAPSHOT_BUDGET = 1000 // bytes — one unreliable datagram (creator-net caps messages at 1024)
|
|
59
|
-
const CORRECTION_TAU = 0.1 // seconds — the visual offset's decay time constant
|
|
60
|
-
const SNAP_DISTANCE = 2 // metres — a bigger disagreement is a teleport: no smoothing
|
|
75
|
+
const CORRECTION_TAU = 0.1 // seconds — the visual offset's decay time constant, unless the kind says
|
|
61
76
|
const NO_INPUT = 0xffffffff
|
|
62
|
-
const
|
|
77
|
+
const INPUT_MAX = 8 // input records in one packet (the ring keeps as many ticks)
|
|
78
|
+
const REC_HEAD = 5 // u32 netId · u8 kind
|
|
63
79
|
|
|
64
80
|
// ---- registries ---------------------------------------------------------------------------------------
|
|
65
81
|
|
|
@@ -72,6 +88,8 @@ let nextNetId = 1
|
|
|
72
88
|
|
|
73
89
|
let tick = 0
|
|
74
90
|
let localInput: any = null // what Net.setInput set this tick (every role but `server`)
|
|
91
|
+
let localSet = false // Net.setInput was called at all this session
|
|
92
|
+
let sentTick = -1 // client: the tick of the newest input sent
|
|
75
93
|
const inputRing = new Map<number, any>() // client: tick → values, resent with the next 2 sends
|
|
76
94
|
let estimate = -1 // client: the server's tick, fractional, advanced per frame
|
|
77
95
|
let leadCooldown = 0
|
|
@@ -147,6 +165,12 @@ export class NetEntity<S extends object = any> {
|
|
|
147
165
|
const p = this.owner
|
|
148
166
|
return (p?._in?.cur ?? inp.defaults) as T
|
|
149
167
|
}
|
|
168
|
+
/** `input()` is an input the owner gave — false until the first one exists (the server before
|
|
169
|
+
* the owner's first packet, the owner before its first `Net.setInput`), when it is the defaults. */
|
|
170
|
+
get hasInput(): boolean {
|
|
171
|
+
if (!state.isServer || this.isMine) return localSet
|
|
172
|
+
return !!this.owner?._in?.cur
|
|
173
|
+
}
|
|
150
174
|
|
|
151
175
|
/** Client: `state` was refreshed (a snapshot landed / the interpolation moved). */
|
|
152
176
|
on(event: "state", cb: (s: S) => void): this { if (!this._listeners.includes(cb)) this._listeners.push(cb); return this }
|
|
@@ -157,10 +181,13 @@ export class NetEntity<S extends object = any> {
|
|
|
157
181
|
|
|
158
182
|
// ---- declarations (Net.kind / Net.input) --------------------------------------------------------------
|
|
159
183
|
|
|
160
|
-
export const declareKind = <S extends object>(name: string, defaults: S, opts: {
|
|
184
|
+
export const declareKind = <S extends object>(name: string, defaults: S, opts: {
|
|
185
|
+
transform?: NetTransform; client?: NetKind<S>["client"]; discrete?: readonly string[]; correction?: Partial<NetCorrection>
|
|
186
|
+
} = {}): NetKind<S> => {
|
|
161
187
|
if (kinds.has(name)) throw new Error(`Net.kind('${name}') is already declared`)
|
|
162
188
|
if (kindList.length >= 255) throw new Error("Net.kind: at most 255 kinds")
|
|
163
|
-
const layout = makeLayout(defaults as Record<string, unknown>, `Net.kind('${name}')
|
|
189
|
+
const layout = makeLayout(defaults as Record<string, unknown>, `Net.kind('${name}')`, opts.discrete)
|
|
190
|
+
const correction: NetCorrection = { deadbandY: 0, snap: Infinity, tau: CORRECTION_TAU, ...opts.correction }
|
|
164
191
|
const transform = opts.transform ?? "none"
|
|
165
192
|
const need = transform === "pos" ? ["pos"] : transform === "pos+rot" ? ["pos", "rot"] : []
|
|
166
193
|
for (const key of need) {
|
|
@@ -168,7 +195,7 @@ export const declareKind = <S extends object>(name: string, defaults: S, opts: {
|
|
|
168
195
|
const len = key === "pos" ? 3 : 4
|
|
169
196
|
if (!f || f.kind !== 2 || f.len !== len) throw new Error(`Net.kind('${name}'): transform '${transform}' needs a '${key}' field of ${len} numbers`)
|
|
170
197
|
}
|
|
171
|
-
const kind: NetKind<S> = { name, id: kindList.length, layout, defaults: cloneDefaults(defaults), transform, client: opts.client, _scratch: cloneDefaults(defaults) }
|
|
198
|
+
const kind: NetKind<S> = { name, id: kindList.length, layout, defaults: cloneDefaults(defaults), transform, correction, client: opts.client, _scratch: cloneDefaults(defaults) }
|
|
172
199
|
kinds.set(name, kind)
|
|
173
200
|
kindList.push(kind)
|
|
174
201
|
return kind
|
|
@@ -187,6 +214,7 @@ export const setInput = <T extends object>(inp: NetInput<T>, values: Partial<T>)
|
|
|
187
214
|
const v = cloneDefaults(inp.defaults)
|
|
188
215
|
assignState(inp.layout, v, values)
|
|
189
216
|
localInput = v
|
|
217
|
+
localSet = true
|
|
190
218
|
if (state.role === "client") {
|
|
191
219
|
inputRing.set(tick, v)
|
|
192
220
|
for (const t of inputRing.keys()) if (t < tick - 8) inputRing.delete(t)
|
|
@@ -194,6 +222,7 @@ export const setInput = <T extends object>(inp: NetInput<T>, values: Partial<T>)
|
|
|
194
222
|
}
|
|
195
223
|
|
|
196
224
|
export const currentTick = (): number => tick
|
|
225
|
+
export const viewTick = (): number => (state.role === "client" && estimate >= 0 ? estimate - INTERP_DELAY : tick)
|
|
197
226
|
export const allEntities = (): NetEntity[] => [...entities.values()]
|
|
198
227
|
export const entityById = (netId: number): NetEntity | undefined => entities.get(netId)
|
|
199
228
|
|
|
@@ -314,6 +343,7 @@ export class Replicated extends Aspect<"replicated", Node> {
|
|
|
314
343
|
get correction(): Vec3 { return this._rec?.correction ?? ZERO }
|
|
315
344
|
/** The owner's input for the tick being simulated (see `NetEntity.input`). */
|
|
316
345
|
input<T extends object>(inp: NetInput<T>): T { return this._rec ? this._rec.input(inp) : (localInput ?? inp.defaults) }
|
|
346
|
+
get hasInput(): boolean { return this._rec ? this._rec.hasInput : localSet }
|
|
317
347
|
// state-change listeners live on the entity: `node.replicated.entity.on('state', cb)` (the aspect's
|
|
318
348
|
// own on/off are its aspect events)
|
|
319
349
|
}
|
|
@@ -358,7 +388,7 @@ const buildSnapshot = (forTick: number): void => {
|
|
|
358
388
|
const list = [...entities.values()]
|
|
359
389
|
if (list.length === 0) { pendingSnapshot = null; return }
|
|
360
390
|
let size = 0
|
|
361
|
-
for (const e of list) size +=
|
|
391
|
+
for (const e of list) size += REC_HEAD + e.kind.layout.bytes
|
|
362
392
|
const buf = new ArrayBuffer(Math.min(size, SNAPSHOT_BUDGET))
|
|
363
393
|
const view = new DataView(buf)
|
|
364
394
|
let o = 0
|
|
@@ -370,10 +400,10 @@ const buildSnapshot = (forTick: number): void => {
|
|
|
370
400
|
}
|
|
371
401
|
for (let i = 0; i < n; i++) {
|
|
372
402
|
const e = list[(snapshotStart + i) % n]
|
|
373
|
-
const rec =
|
|
403
|
+
const rec = REC_HEAD + e.kind.layout.bytes
|
|
374
404
|
if (o + rec > buf.byteLength) break
|
|
375
405
|
samplePose(e)
|
|
376
|
-
view.
|
|
406
|
+
view.setUint32(o, e.netId, true); o += 4
|
|
377
407
|
view.setUint8(o, e.kind.id); o += 1
|
|
378
408
|
o = packInto(view, o, e.kind.layout, e.state)
|
|
379
409
|
written++
|
|
@@ -439,10 +469,10 @@ const onClientBinary = (channel: number, data: ArrayBuffer): void => {
|
|
|
439
469
|
|
|
440
470
|
let o = 12
|
|
441
471
|
for (let i = 0; i < count; i++) {
|
|
442
|
-
if (o +
|
|
443
|
-
const netId = view.
|
|
444
|
-
const kind = clientKinds[view.getUint8(o +
|
|
445
|
-
o +=
|
|
472
|
+
if (o + REC_HEAD > data.byteLength) break
|
|
473
|
+
const netId = view.getUint32(o, true)
|
|
474
|
+
const kind = clientKinds[view.getUint8(o + 4)]
|
|
475
|
+
o += REC_HEAD
|
|
446
476
|
if (!kind) {
|
|
447
477
|
if (!warnedKind) { warnedKind = true; console.warn("Net: snapshot with an unknown kind id — dropped") }
|
|
448
478
|
return
|
|
@@ -478,11 +508,9 @@ const reconcileOwner = (e: NetEntity, T: number): void => {
|
|
|
478
508
|
if (!pred) return
|
|
479
509
|
const s = e.state as any
|
|
480
510
|
const dx = s.pos[0] - pred[0], dz = s.pos[2] - pred[2]
|
|
481
|
-
//
|
|
482
|
-
// (landing phase, floor snap) and lifting the body by that much only makes it fall again — an
|
|
483
|
-
// oscillation the correction itself feeds. Only a real height difference (a ledge, a jump) counts.
|
|
511
|
+
// the kind's vertical deadband (NetCorrection.deadbandY): what it calls noise is not corrected
|
|
484
512
|
let dy = s.pos[1] - pred[1]
|
|
485
|
-
if (Math.abs(dy) <
|
|
513
|
+
if (Math.abs(dy) < e.kind.correction.deadbandY) dy = 0
|
|
486
514
|
const d = Math.hypot(dx, dy, dz)
|
|
487
515
|
if (d < 0.005) return
|
|
488
516
|
stats.corrections++
|
|
@@ -491,7 +519,7 @@ const reconcileOwner = (e: NetEntity, T: number): void => {
|
|
|
491
519
|
for (const q of e._pred.values()) { q[0] += dx; q[1] += dy; q[2] += dz }
|
|
492
520
|
const sh = e._shift ?? (e._shift = [0, 0, 0])
|
|
493
521
|
sh[0] += dx; sh[1] += dy; sh[2] += dz
|
|
494
|
-
if (d >=
|
|
522
|
+
if (d >= e.kind.correction.snap) e.correction.set(0, 0, 0)
|
|
495
523
|
}
|
|
496
524
|
|
|
497
525
|
const applyOwnerShift = (e: NetEntity): void => {
|
|
@@ -502,7 +530,7 @@ const applyOwnerShift = (e: NetEntity): void => {
|
|
|
502
530
|
e.node.position = [p.x + sh[0], p.y + sh[1], p.z + sh[2]]
|
|
503
531
|
const rec = e._pred.get(tick - 1)
|
|
504
532
|
if (rec) { rec[0] += sh[0]; rec[1] += sh[1]; rec[2] += sh[2] }
|
|
505
|
-
if (Math.hypot(sh[0], sh[1], sh[2]) <
|
|
533
|
+
if (Math.hypot(sh[0], sh[1], sh[2]) < e.kind.correction.snap) { e.correction.x -= sh[0]; e.correction.y -= sh[1]; e.correction.z -= sh[2] }
|
|
506
534
|
}
|
|
507
535
|
|
|
508
536
|
const clientFrame = (): void => {
|
|
@@ -510,11 +538,12 @@ const clientFrame = (): void => {
|
|
|
510
538
|
if (estimate >= 0) estimate += dt * 60
|
|
511
539
|
stats.lead = estimate >= 0 ? tick - estimate : 0
|
|
512
540
|
const rt = estimate - INTERP_DELAY
|
|
513
|
-
const k = Math.exp(-dt / CORRECTION_TAU)
|
|
514
541
|
for (const e of entities.values()) {
|
|
515
542
|
if (e.isMine) {
|
|
516
543
|
const c = e.correction
|
|
517
544
|
if (c.x !== 0 || c.y !== 0 || c.z !== 0) {
|
|
545
|
+
const tau = e.kind.correction.tau
|
|
546
|
+
const k = tau > 0 ? Math.exp(-dt / tau) : 0
|
|
518
547
|
c.x *= k; c.y *= k; c.z *= k
|
|
519
548
|
if (Math.hypot(c.x, c.y, c.z) < 1e-3) c.set(0, 0, 0)
|
|
520
549
|
}
|
|
@@ -538,13 +567,17 @@ const clientFrame = (): void => {
|
|
|
538
567
|
}
|
|
539
568
|
|
|
540
569
|
const sendInputs = (): void => {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
570
|
+
// nothing new since the last send: a frame without a fixed step
|
|
571
|
+
if (!inputDef || inputRing.size === 0 || tick === sentTick) return
|
|
572
|
+
// every tick since the last send (a long frame steps more than once) + 2 older ones against loss
|
|
573
|
+
const count = Math.min(INPUT_MAX, Math.max(3, tick - sentTick + 2))
|
|
574
|
+
sentTick = tick
|
|
575
|
+
const first = tick - count + 1
|
|
576
|
+
const buf = new ArrayBuffer(6 + count * inputDef.layout.bytes)
|
|
544
577
|
const view = new DataView(buf)
|
|
545
578
|
view.setUint8(0, 1)
|
|
546
579
|
view.setUint32(1, first, true)
|
|
547
|
-
view.setUint8(5,
|
|
580
|
+
view.setUint8(5, count)
|
|
548
581
|
let o = 6
|
|
549
582
|
let last: any = localInput ?? inputDef.defaults
|
|
550
583
|
for (let t = first; t <= tick; t++) {
|
|
@@ -615,6 +648,8 @@ export const installReplication = (): void => {
|
|
|
615
648
|
estimate = -1
|
|
616
649
|
}
|
|
617
650
|
inputRing.clear()
|
|
651
|
+
localSet = false
|
|
652
|
+
sentTick = -1
|
|
618
653
|
pendingSnapshot = null
|
|
619
654
|
leadCooldown = 0
|
|
620
655
|
for (const p of state.players) p._in = null
|