lecodes-sdk 2.0.2 → 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/ui/UIScreen.d.ts +17 -0
- 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/bridges/tree.d.ts +4 -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/chisel.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/ui/UIScreen.ts +23 -0
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +2 -0
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
|
+
}
|
package/src/bridges/tree.d.ts
CHANGED
|
@@ -95,6 +95,10 @@ declare global {
|
|
|
95
95
|
routerPop(index: i32, transition: i32): void
|
|
96
96
|
routerHide(transition: i32): void
|
|
97
97
|
routerRestore(transition: i32): void
|
|
98
|
+
// The ADDRESS of a destination (`UIScreen.url`): a route (`/product/42`) a host with an address
|
|
99
|
+
// bar shows while the destination is the one on screen; null takes it away. OPTIONAL: a host
|
|
100
|
+
// without an address bar has no such method (the SDK feature-detects) — Web Lite has it.
|
|
101
|
+
setDestUrl?(kind: u8, id: f64, url: string | null): void
|
|
98
102
|
// Does the host registerView(name)? / the contract version its half was generated from (0 = no
|
|
99
103
|
// such view; on a host that does not say, 1 for a view it has) / the NativeView method channel:
|
|
100
104
|
// `args` is the array of the call's arguments, the callbacks settle exactly once through the
|
|
@@ -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/chisel.ts
CHANGED
|
@@ -128,7 +128,7 @@ export const chiselVocab: ChiselVocab = {
|
|
|
128
128
|
"onScroll", "onScrollRelease", "onOverscroll", "onRefresh", "onSubmit",
|
|
129
129
|
"onSelect", "onChange", "onFocus", "onBlur", "onOverlayTap", "onDetent",
|
|
130
130
|
"onEndReached", "onStartReached",
|
|
131
|
-
"append", "insert", "remove", "setContent", "keepAlive",
|
|
131
|
+
"append", "insert", "remove", "setContent", "keepAlive", "url",
|
|
132
132
|
"attachTo", "detents", "dismissible", "transition", "setSourceRect",
|
|
133
133
|
"push", "pop", "popToRoot", "replace", "select",
|
|
134
134
|
// UITabs / UIVirtualizedList / the native views (NativeView, CameraView, MapView, QRScanner)
|
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
|
|