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/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
|
package/src/ui/UIScreen.ts
CHANGED
|
@@ -37,6 +37,23 @@ export interface UIScreen extends UIContainerBase<UIScreenStyle, DrawableStyle &
|
|
|
37
37
|
*/
|
|
38
38
|
keepAlive(enabled?: boolean): this
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The screen's ADDRESS on the web: while the screen is the one shown, the browser's address
|
|
42
|
+
* bar shows it, and a link copied from there opens the app with it (`app.launchUrl`). A route,
|
|
43
|
+
* not a page's address: `"/product/42"`, `"/search?q=shoes"` — the app never sees where the
|
|
44
|
+
* page is served from. Call it again to change the address of a screen that is shown (a
|
|
45
|
+
* filter, a search): the entry of the browser's history is replaced, not added to. `null`
|
|
46
|
+
* takes the address away; a screen with none keeps the address of the one under it.
|
|
47
|
+
*
|
|
48
|
+
* The way in is the app's: read `app.launchUrl` at start and listen to the `"url"` event, as
|
|
49
|
+
* for a deep link on a phone. No-op on hosts without an address bar.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* const ProductScreen = (id: string) => UIScreen(...).url(`/product/${id}`)
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
url(route: string | null): this
|
|
56
|
+
|
|
40
57
|
/** Touch began on the screen; `ev.track(...)` takes over the rest of the gesture. */
|
|
41
58
|
onTouchStart(callback: (ev: TouchStartEvent<UIScreen>) => void): this,
|
|
42
59
|
/** @internal The destination tuple (Presentable). */
|
|
@@ -80,6 +97,12 @@ export class ScreenElement extends ContainerElement<"screen"> {
|
|
|
80
97
|
return this
|
|
81
98
|
}
|
|
82
99
|
|
|
100
|
+
url(route: string | null): this {
|
|
101
|
+
// an optional method of the bridge: a host without an address bar has none
|
|
102
|
+
if (this._h.id !== 0) tree().setDestUrl?.(TREE_DEST_SCREEN, this._h.id, route)
|
|
103
|
+
return this
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
readonly touchStartListeners: any[] = []
|
|
84
107
|
onTouchStart (callback: any): this {
|
|
85
108
|
this.touchStartListeners.push(callback)
|
package/src/version.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
|
|
5
5
|
// and a host's embedded version must agree on the major for the host to run the bundle; minor
|
|
6
6
|
// and patch never gate anything (features are detected, never versioned).
|
|
7
|
-
export const SDK_VERSION = "2.0.
|
|
7
|
+
export const SDK_VERSION = "2.0.4"
|
|
8
8
|
|
|
9
9
|
/** The major of a semver string, or null when it is not one. */
|
|
10
10
|
export const sdkMajor = (version: string | null | undefined): number | null => {
|
|
@@ -192,6 +192,8 @@ export class FakeTree {
|
|
|
192
192
|
}
|
|
193
193
|
routerHide = (transition: number): void => { this.rec("routerHide", transition); this.closeCurrent() }
|
|
194
194
|
routerRestore = (transition: number): void => { this.rec("routerRestore", transition); const top = this.stack[this.stack.length - 1]; if (top) this.present(top, transition) }
|
|
195
|
+
/** A host with an address bar has it (Web Lite); a test of a host without one deletes it. */
|
|
196
|
+
setDestUrl?: (kind: number, id: number, url: string | null) => void = (kind, id, url) => { this.rec("setDestUrl", kind, id, url) }
|
|
195
197
|
isViewSupported = (name: string): boolean => { this.rec("isViewSupported", name); return this.supportedViews.has(name) }
|
|
196
198
|
supportedViews = new Set<string>()
|
|
197
199
|
/** The contract version of a supported view (1 unless a test says otherwise). */
|