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.
@@ -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 (last 3 ticks)
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 × { u16 netId · u8 kind · state record } (every 3rd tick)
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
- /** Births the client-side proxy on spawn; the SDK attaches `Replicated` to what it returns. */
48
- readonly client?: (rep: NetEntity<S>, scene: Scene | null) => Node | void
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 VERTICAL_DEADBAND = 0.12 // metres — owner height disagreements below this are floor-snap noise
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: { transform?: NetTransform; client?: NetKind<S>["client"] } = {}): NetKind<S> => {
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 += 3 + e.kind.layout.bytes
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 = 3 + e.kind.layout.bytes
403
+ const rec = REC_HEAD + e.kind.layout.bytes
374
404
  if (o + rec > buf.byteLength) break
375
405
  samplePose(e)
376
- view.setUint16(o, e.netId, true); o += 2
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 + 3 > data.byteLength) break
443
- const netId = view.getUint16(o, true)
444
- const kind = clientKinds[view.getUint8(o + 2)]
445
- o += 3
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
- // Vertical deadband: a grounded character's height differs by a few cm between two machines
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) < VERTICAL_DEADBAND) dy = 0
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 >= SNAP_DISTANCE) e.correction.set(0, 0, 0)
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]) < SNAP_DISTANCE) { e.correction.x -= sh[0]; e.correction.y -= sh[1]; e.correction.z -= 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
- if (!inputDef || inputRing.size === 0) return
542
- const first = tick - 2
543
- const buf = new ArrayBuffer(6 + 3 * inputDef.layout.bytes)
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, 3)
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
@@ -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.2"
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). */