lecodes-cli 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/index.js +1240 -170
  2. package/package.json +3 -2
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/aspectMacro.ts +86 -0
  5. package/runtime/sdk/compile/assetIconMacro.ts +384 -0
  6. package/runtime/sdk/compile/assetMacro.ts +146 -0
  7. package/runtime/sdk/compile/assetName.ts +50 -0
  8. package/runtime/sdk/compile/bundler.ts +287 -0
  9. package/runtime/sdk/compile/compileProject.ts +129 -0
  10. package/runtime/sdk/compile/detectEntry.ts +128 -0
  11. package/runtime/sdk/compile/fontMacro.ts +459 -0
  12. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  13. package/runtime/sdk/compile/header.ts +67 -0
  14. package/runtime/sdk/compile/index.ts +100 -0
  15. package/runtime/sdk/compile/libraryImports.ts +52 -0
  16. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +88 -0
  18. package/runtime/sdk/compile/serverSplit.ts +233 -0
  19. package/runtime/sdk/compile/serverTypes.ts +227 -0
  20. package/runtime/sdk/compile/sfnt.ts +98 -0
  21. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  22. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  23. package/runtime/sdk/compile/sourcemap.ts +25 -0
  24. package/runtime/sdk/core/Aspect.ts +65 -9
  25. package/runtime/sdk/core/StateMachine.ts +308 -0
  26. package/runtime/sdk/core/time.ts +3 -0
  27. package/runtime/sdk/gl/CameraPlace.ts +52 -52
  28. package/runtime/sdk/gl/Light.ts +12 -4
  29. package/runtime/sdk/gl/Lightmap.ts +87 -10
  30. package/runtime/sdk/gl/Material.ts +15 -0
  31. package/runtime/sdk/gl/Model.ts +68 -8
  32. package/runtime/sdk/gl/NavAgent.ts +337 -0
  33. package/runtime/sdk/gl/NavMesh.ts +397 -0
  34. package/runtime/sdk/gl/Ragdoll.ts +270 -0
  35. package/runtime/sdk/gl/Scene.ts +7 -0
  36. package/runtime/sdk/gl/Shape.ts +37 -5
  37. package/runtime/sdk/gl/Terrain.ts +1092 -0
  38. package/runtime/sdk/gl/Texture.ts +17 -0
  39. package/runtime/sdk/gl/Trigger.ts +45 -45
  40. package/runtime/sdk/gl/animation/Animator.ts +35 -2
  41. package/runtime/sdk/gl/animation/Layer.ts +3 -1
  42. package/runtime/sdk/gl/animation/Loop.ts +5 -0
  43. package/runtime/sdk/gl/animation/Playback.ts +4 -3
  44. package/runtime/sdk/gl/animation/core.ts +52 -10
  45. package/runtime/sdk/gl/scenarios.ts +291 -291
  46. package/runtime/sdk/gl/state.ts +6 -6
  47. package/runtime/sdk/gl/terrainMesh.ts +219 -0
  48. package/runtime/sdk/inject.ts +12 -1
  49. package/runtime/sdk/net/codec.ts +119 -0
  50. package/runtime/sdk/net/core.ts +384 -0
  51. package/runtime/sdk/net/index.ts +181 -0
  52. package/runtime/sdk/net/replication.ts +622 -0
  53. package/runtime/sdk/scene/defineScene.ts +140 -14
  54. package/runtime/sdk/scene/editorPlugins.ts +18 -0
  55. package/runtime/sdk/scene/gizmos.ts +148 -148
  56. package/runtime/sdk/ui/UINode.ts +4 -0
  57. package/runtime/sdk-types.json +1 -1
@@ -0,0 +1,384 @@
1
+ // Net core — the transport half of LeCodes multiplayer (docs/multiplayer-plan.md §4–5): roles,
2
+ // connect / listen, players + roster, JSON messages, the poll pump. Replication (inputs, snapshots,
3
+ // `Replicated`) lives in ./replication.ts and plugs into this file through `hooks`; the public `Net`
4
+ // object is ./index.ts.
5
+ //
6
+ // Roles (`Net.role`):
7
+ // offline the default — both sides run in this process, `Net.send` delivers in-process
8
+ // host offline + `Net.listen()`: a player who is also the server (listen server)
9
+ // server the dedicated exe (`--server`): `isServer` true, `isClient` false, no local player
10
+ // client connected to a host or a server
11
+ // Game code is written once: `Net.isServer` guards the rules, `Net.isClient` the presentation,
12
+ // and offline satisfies both.
13
+ //
14
+ // Transport: the `_creatorNet` bridge (creator-pkg over creator-net / yojimbo) — a byte pipe with
15
+ // two channels (0 reliable-ordered, 1 unreliable) that knows nothing about players. Everything on
16
+ // the reliable channel here is JSON text: the roster protocol (`$`-prefixed, reserved) and the
17
+ // game's own `Net.message`s (`[name, data]`). Binary payloads (inputs, snapshots) go to
18
+ // ./replication.ts. Pumped from the aspect dispatcher's phase hooks (core/Aspect.ts) — poll FIRST in
19
+ // the early phase, flush LAST in the late one — claimed on the first listen/connect, never at
20
+ // module scope (chisel drops top-level side effects, and a host without the bridge has no loop).
21
+
22
+ import { _ensureNetDispatch, _phaseHooks } from "../core/Aspect"
23
+
24
+ export type Listener = (...args: any[]) => void
25
+
26
+ export type NetRole = "offline" | "host" | "client" | "server"
27
+ export type NetStatus = "idle" | "connecting" | "online" | "error"
28
+
29
+ /** A typed message key: `const Fire = Net.message<{ dir: number[] }>("fire")`. The type lives in
30
+ * TypeScript only; the payload is JSON. */
31
+ export type NetMessage<T = unknown> = { readonly name: string; readonly unreliable: boolean; readonly __t?: T }
32
+
33
+ export type NetEvents = {
34
+ /** Client: welcomed by the server. Host/server: listening. */
35
+ connect: () => void
36
+ /** The connection ended (`reason` from the transport, e.g. "timed out", "kicked"). */
37
+ disconnect: (reason: string) => void
38
+ /** The server refused us (`reason`: "full" | "version" | "schema" | a kick reason). */
39
+ reject: (reason: string) => void
40
+ join: (player: NetPlayer) => void
41
+ leave: (player: NetPlayer, reason: string) => void
42
+ error: (message: string) => void
43
+ }
44
+
45
+ export type NetLaunch = { role: "server" | "client"; address: string; port: number; maxClients: number }
46
+
47
+ export const CH_RELIABLE = 0
48
+ export const CH_UNRELIABLE = 1
49
+ export const PROTO = 2
50
+ export const LOCAL_SLOT = -1
51
+
52
+ // Record kinds from creator-net (creator-net.h CNET_RECORD_*).
53
+ const REC_CONNECTED = 1
54
+ const REC_DISCONNECTED = 2
55
+ const REC_MESSAGE = 3
56
+ const REC_CONNECT_FAILED = 4
57
+
58
+ export const bridge = (): typeof _creatorNet | null => (typeof _creatorNet !== "undefined" ? _creatorNet : null)
59
+
60
+ export class NetPlayer {
61
+ /** Session-unique id assigned by the server (1 = the host's own player when hosting). */
62
+ readonly id: number
63
+ name: string
64
+ readonly connectedAt: number
65
+ /** A local bag for game facts (score, team, ready). NOT replicated — anything a client must see
66
+ * goes through a message or replicated state. */
67
+ data: any = {}
68
+ /** @internal transport slot on the server; LOCAL_SLOT for the in-process player */
69
+ _slot: number
70
+ /** @internal server side: this player's input ring (replication.ts) */
71
+ _in: any = null
72
+ private _leave: Listener[] = []
73
+
74
+ constructor(id: number, name: string, slot: number) {
75
+ this.id = id
76
+ this.name = name
77
+ this._slot = slot
78
+ this.connectedAt = Date.now()
79
+ }
80
+
81
+ get isMe(): boolean { return state.me === this }
82
+ /** Round trip to this player in ms (server side) or to the server (our own player on a client). */
83
+ get rtt(): number { return infoOf(this)[1] }
84
+ get loss(): number { return infoOf(this)[2] }
85
+
86
+ /** Server → this player. */
87
+ send<T>(msg: NetMessage<T>, data: T): void {
88
+ if (!state.isServer) throw new Error("player.send is server-side; a client uses Net.send")
89
+ deliverTo(this, msg, data)
90
+ }
91
+
92
+ kick(reason = "kicked"): void {
93
+ if (!state.isServer || this._slot === LOCAL_SLOT) return
94
+ sendRaw(this._slot, CH_RELIABLE, JSON.stringify(["$reject", { reason }]))
95
+ bridge()?.kick(this._slot)
96
+ }
97
+
98
+ on(event: "leave", cb: (player: NetPlayer, reason: string) => void): this {
99
+ if (!this._leave.includes(cb)) this._leave.push(cb)
100
+ return this
101
+ }
102
+ off(event: "leave", cb: (player: NetPlayer, reason: string) => void): this {
103
+ const i = this._leave.indexOf(cb)
104
+ if (i >= 0) this._leave.splice(i, 1)
105
+ return this
106
+ }
107
+ /** @internal */
108
+ _left(reason: string): void {
109
+ for (const fn of this._leave.slice()) fn(this, reason)
110
+ }
111
+ }
112
+
113
+ // ---- state ---------------------------------------------------------------------------------------
114
+
115
+ export const state = {
116
+ role: "offline" as NetRole,
117
+ status: "idle" as NetStatus,
118
+ isServer: true,
119
+ isClient: true,
120
+ me: null as NetPlayer | null,
121
+ players: [] as NetPlayer[],
122
+ nextId: 1,
123
+ /** a session is up: the phase hooks are installed and pump every frame */
124
+ active: false,
125
+ events: new Map<string, Listener[]>(),
126
+ handlers: new Map<string, Listener[]>(),
127
+ connectResolve: null as null | (() => void),
128
+ connectReject: null as null | ((e: Error) => void),
129
+ myName: "Player",
130
+ }
131
+
132
+ // Replication's seams into the roster / pump (set by replication.ts when a session starts — from a
133
+ // function, never at module scope). Each is optional so the core runs alone.
134
+ export const hooks = {
135
+ frameEarly: null as null | ((dt: number) => void),
136
+ frameFixed: null as null | ((dt: number) => void),
137
+ frameLate: null as null | ((dt: number) => void),
138
+ /** client: extra `$hello` fields (schema hash) */
139
+ helloExtra: null as null | (() => Record<string, unknown>),
140
+ /** server: a reject reason for this `$hello`, or null to accept */
141
+ helloCheck: null as null | ((data: any) => string | null),
142
+ /** server: extra `$welcome` fields (tick, kind table) */
143
+ welcomeExtra: null as null | (() => Record<string, unknown>),
144
+ /** server: right after `$welcome` + `$join` went out — send the live entities to the newcomer */
145
+ afterWelcome: null as null | ((p: NetPlayer) => void),
146
+ /** client: the `$welcome` arrived (before the connect event) */
147
+ onWelcome: null as null | ((data: any) => void),
148
+ /** client: a reserved `$` message the roster does not know (spawn / despawn) */
149
+ clientReserved: null as null | ((name: string, data: any) => void),
150
+ /** binary payloads (channel 1): inputs on the server, snapshots on the client */
151
+ serverBinary: null as null | ((p: NetPlayer, channel: number, data: ArrayBuffer) => void),
152
+ clientBinary: null as null | ((channel: number, data: ArrayBuffer) => void),
153
+ /** server: a player is gone (after the leave event) */
154
+ playerLeft: null as null | ((p: NetPlayer) => void),
155
+ /** the session ends (before the state resets); `role` = the role we are leaving */
156
+ reset: null as null | ((role: NetRole) => void),
157
+ }
158
+
159
+ export const emit = (event: keyof NetEvents, ...args: any[]): void => {
160
+ const list = state.events.get(event)
161
+ if (!list) return
162
+ for (const fn of list.slice()) fn(...args)
163
+ }
164
+
165
+ const EMPTY_INFO = new Float32Array(5)
166
+ const infoOf = (p: NetPlayer): Float32Array => {
167
+ const b = bridge()
168
+ if (!b || p._slot === LOCAL_SLOT && state.role !== "client") return EMPTY_INFO
169
+ return b.clientInfo(p._slot)
170
+ }
171
+
172
+ export const playerBySlot = (slot: number): NetPlayer | undefined => state.players.find((p) => p._slot === slot)
173
+
174
+ export const addPlayer = (id: number, name: string, slot: number): NetPlayer => {
175
+ const p = new NetPlayer(id, name, slot)
176
+ state.players.push(p)
177
+ return p
178
+ }
179
+
180
+ export const removePlayer = (p: NetPlayer, reason: string): void => {
181
+ const i = state.players.indexOf(p)
182
+ if (i >= 0) state.players.splice(i, 1)
183
+ p._left(reason)
184
+ emit("leave", p, reason)
185
+ if (state.isServer) hooks.playerLeft?.(p)
186
+ }
187
+
188
+ // The local player exists in every role but `server`.
189
+ export const ensureMe = (): NetPlayer => {
190
+ if (state.me) return state.me
191
+ state.me = addPlayer(state.nextId++, state.myName, LOCAL_SLOT)
192
+ return state.me
193
+ }
194
+
195
+ // ---- transport --------------------------------------------------------------------------------------
196
+
197
+ export const sendRaw = (slot: number, channel: number, payload: string | ArrayBuffer): boolean => {
198
+ const b = bridge()
199
+ return b ? b.send(slot, channel, payload) : false
200
+ }
201
+
202
+ // Handler dispatch is in-process for the local player (offline / host's own), async so a message
203
+ // sent from inside a handler behaves like one that crossed the wire (ordered, never re-entrant).
204
+ export const queueLocal = (fn: () => void): void => { Promise.resolve().then(fn) }
205
+
206
+ export const dispatchMessage = (name: string, data: unknown, from: NetPlayer | null): void => {
207
+ const list = state.handlers.get(name)
208
+ if (!list) return
209
+ for (const fn of list.slice()) fn(data, from)
210
+ }
211
+
212
+ export const deliverTo = (p: NetPlayer, msg: NetMessage<any>, data: unknown): void => {
213
+ if (p._slot === LOCAL_SLOT) {
214
+ queueLocal(() => dispatchMessage(msg.name, data, null))
215
+ return
216
+ }
217
+ sendRaw(p._slot, msg.unreliable ? CH_UNRELIABLE : CH_RELIABLE, JSON.stringify([msg.name, data]))
218
+ }
219
+
220
+ export const broadcastRaw = (payload: string | ArrayBuffer, except: NetPlayer | null, channel = CH_RELIABLE): void => {
221
+ for (const p of state.players) {
222
+ if (p === except || p._slot === LOCAL_SLOT) continue
223
+ sendRaw(p._slot, channel, payload)
224
+ }
225
+ }
226
+
227
+ // The session's frame plumbing: poll first thing in the early phase, replication's own work around
228
+ // it, sends flushed at the end of the late phase. The dispatcher (and its fixed phase — Net.tick is
229
+ // a fixed-step count) is forced on, so an app with no aspects at all still pumps.
230
+ export const startPhases = (): void => {
231
+ if (state.active) return
232
+ state.active = true
233
+ _ensureNetDispatch()
234
+ _phaseHooks.early = (dt) => { pump(); if (state.active) hooks.frameEarly?.(dt) }
235
+ _phaseHooks.fixed = (dt) => { if (state.active) hooks.frameFixed?.(dt) }
236
+ _phaseHooks.late = (dt) => { if (state.active) hooks.frameLate?.(dt) }
237
+ }
238
+
239
+ export const stopPhases = (): void => {
240
+ if (!state.active) return
241
+ state.active = false
242
+ _phaseHooks.early = _phaseHooks.fixed = _phaseHooks.late = null
243
+ }
244
+
245
+ export const resetToOffline = (): void => {
246
+ hooks.reset?.(state.role)
247
+ stopPhases()
248
+ state.role = "offline"
249
+ state.status = "idle"
250
+ state.isServer = true
251
+ state.isClient = true
252
+ state.players = []
253
+ state.me = null
254
+ state.nextId = 1
255
+ state.connectResolve = null
256
+ state.connectReject = null
257
+ }
258
+
259
+ // ---- server side ------------------------------------------------------------------------------------
260
+
261
+ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknown): void => {
262
+ const b = bridge()!
263
+ if (kind === REC_CONNECTED) return // the roster starts with the client's $hello
264
+ if (kind === REC_DISCONNECTED) {
265
+ const p = playerBySlot(slot)
266
+ if (!p) return
267
+ const reason = b.reasonString(arg, true)
268
+ removePlayer(p, reason)
269
+ broadcastRaw(JSON.stringify(["$leave", { id: p.id, reason }]), null)
270
+ return
271
+ }
272
+ if (kind !== REC_MESSAGE) return
273
+ if (payload instanceof ArrayBuffer) {
274
+ const from = playerBySlot(slot)
275
+ if (from) hooks.serverBinary?.(from, arg, payload)
276
+ return
277
+ }
278
+ if (typeof payload !== "string") return
279
+ let parsed: any
280
+ try { parsed = JSON.parse(payload) } catch { return }
281
+ if (!Array.isArray(parsed) || typeof parsed[0] !== "string") return
282
+ const [name, data] = parsed
283
+ if (name === "$hello") {
284
+ if (playerBySlot(slot)) return
285
+ const reason = !data || data.proto !== PROTO ? "version" : hooks.helloCheck?.(data) ?? null
286
+ if (reason) {
287
+ sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason }]))
288
+ b.kick(slot)
289
+ return
290
+ }
291
+ const p = addPlayer(state.nextId++, String(data.name ?? `Player ${state.nextId - 1}`), slot)
292
+ sendRaw(slot, CH_RELIABLE, JSON.stringify(["$welcome", {
293
+ id: p.id,
294
+ players: state.players.filter((q) => q !== p).map((q) => ({ id: q.id, name: q.name })),
295
+ ...(hooks.welcomeExtra?.() ?? {}),
296
+ }]))
297
+ broadcastRaw(JSON.stringify(["$join", { id: p.id, name: p.name }]), p)
298
+ hooks.afterWelcome?.(p)
299
+ emit("join", p)
300
+ return
301
+ }
302
+ if (name.startsWith("$")) return
303
+ const from = playerBySlot(slot)
304
+ if (from) dispatchMessage(name, data, from)
305
+ }
306
+
307
+ // ---- client side ------------------------------------------------------------------------------------
308
+
309
+ const onClientRecord = (kind: number, slot: number, arg: number, payload: unknown): void => {
310
+ const b = bridge()!
311
+ if (kind === REC_CONNECTED) {
312
+ sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, ...(hooks.helloExtra?.() ?? {}) }]))
313
+ return
314
+ }
315
+ if (kind === REC_CONNECT_FAILED || kind === REC_DISCONNECTED) {
316
+ const reason = b.reasonString(arg, false)
317
+ const wasOnline = state.status === "online"
318
+ const reject = state.connectReject
319
+ resetToOffline()
320
+ if (kind === REC_CONNECT_FAILED) {
321
+ state.status = "error"
322
+ reject?.(new Error(reason))
323
+ emit("error", reason)
324
+ } else {
325
+ reject?.(new Error(reason))
326
+ if (wasOnline) emit("disconnect", reason)
327
+ }
328
+ return
329
+ }
330
+ if (kind !== REC_MESSAGE) return
331
+ if (payload instanceof ArrayBuffer) { hooks.clientBinary?.(arg, payload); return }
332
+ if (typeof payload !== "string") return
333
+ let parsed: any
334
+ try { parsed = JSON.parse(payload) } catch { return }
335
+ if (!Array.isArray(parsed) || typeof parsed[0] !== "string") return
336
+ const [name, data] = parsed
337
+ switch (name) {
338
+ case "$welcome": {
339
+ state.me = addPlayer(data.id, state.myName, LOCAL_SLOT)
340
+ for (const q of data.players ?? []) addPlayer(q.id, q.name, 0)
341
+ state.status = "online"
342
+ hooks.onWelcome?.(data)
343
+ const resolve = state.connectResolve
344
+ state.connectResolve = null
345
+ state.connectReject = null
346
+ resolve?.()
347
+ emit("connect")
348
+ return
349
+ }
350
+ case "$reject": {
351
+ const reason = String(data?.reason ?? "rejected")
352
+ const reject = state.connectReject
353
+ state.connectReject = null
354
+ state.connectResolve = null
355
+ reject?.(new Error(reason))
356
+ emit("reject", reason)
357
+ return
358
+ }
359
+ case "$join": {
360
+ const p = addPlayer(data.id, data.name, 0)
361
+ emit("join", p)
362
+ return
363
+ }
364
+ case "$leave": {
365
+ const p = state.players.find((q) => q.id === data.id)
366
+ if (p) removePlayer(p, String(data.reason ?? ""))
367
+ return
368
+ }
369
+ }
370
+ if (name.startsWith("$")) { hooks.clientReserved?.(name, data); return }
371
+ dispatchMessage(name, data, null)
372
+ }
373
+
374
+ export const pump = (): void => {
375
+ const b = bridge()
376
+ if (!b) return
377
+ const batch = b.poll()
378
+ if (!batch) return
379
+ const onRecord = state.isServer ? onServerRecord : onClientRecord
380
+ for (let i = 0; i + 3 < batch.length; i += 4) {
381
+ onRecord(batch[i], batch[i + 1], batch[i + 2], batch[i + 3])
382
+ if (!state.active) return // a record tore the session down
383
+ }
384
+ }
@@ -0,0 +1,181 @@
1
+ // Net — LeCodes multiplayer, the public object (docs/multiplayer-plan.md §4). The transport, roster
2
+ // and JSON messages are ./core.ts; kinds, inputs, snapshots and the `Replicated` aspect are
3
+ // ./replication.ts. Principles: the schema object is the key and the verbs live on the addressee
4
+ // (`Net.send(Msg, d)` / `player.send(Msg, d)` / `Net.broadcast(Msg, d)` / `Net.on(Msg, cb)`); the
5
+ // player is the unit of addressing; offline / host / client / server run the same game code.
6
+
7
+ import {
8
+ CH_RELIABLE, CH_UNRELIABLE, LOCAL_SLOT, NetPlayer, bridge, dispatchMessage, emit, ensureMe, queueLocal,
9
+ removePlayer, resetToOffline, sendRaw, startPhases, state,
10
+ type Listener, type NetEvents, type NetLaunch, type NetMessage, type NetRole, type NetStatus,
11
+ } from "./core"
12
+ import {
13
+ NetEntity, Replicated, allEntities, currentTick, declareInput, declareKind, entityById, installReplication, setInput, stats,
14
+ type NetInput, type NetKind, type NetTransform,
15
+ } from "./replication"
16
+
17
+ export { NetPlayer, NetEntity, Replicated }
18
+ export type { NetEvents, NetLaunch, NetMessage, NetRole, NetStatus, NetInput, NetKind, NetTransform }
19
+
20
+ class NetImpl {
21
+ get role(): NetRole { return state.role }
22
+ get status(): NetStatus { return state.status }
23
+ get isServer(): boolean { return state.isServer }
24
+ get isClient(): boolean { return state.isClient }
25
+ /** True on hosts that ship the transport (desktop, the 3d/full Android variants, iOS). Web: false. */
26
+ get supported(): boolean { return bridge() !== null }
27
+ /** What the exe was launched as (`--server --port N` / `--connect ADDR`), or null. */
28
+ get launch(): NetLaunch | null { return (bridge()?.launch() as NetLaunch | null) ?? null }
29
+ get me(): NetPlayer { return state.role === "server" ? (null as unknown as NetPlayer) : ensureMe() }
30
+ get players(): readonly NetPlayer[] { if (state.role !== "server") ensureMe(); return state.players }
31
+ player(id: number): NetPlayer | undefined { return state.players.find((p) => p.id === id) }
32
+ /** Our round trip to the server (client), 0 otherwise. */
33
+ get rtt(): number { return state.role === "client" ? (bridge()?.clientInfo(0)[1] ?? 0) : 0 }
34
+ /** The simulation tick — one per fixed step (`updateFixed`). The server's is THE tick; a client
35
+ * runs a few ticks ahead so its inputs arrive in time. */
36
+ get tick(): number { return currentTick() }
37
+ /** Live counters: snapshots, input lead / `ahead`, corrections, missed inputs, bytes out. */
38
+ get stats(): typeof stats { return stats }
39
+
40
+ /** 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
+ listen(opts: { port?: number; maxClients?: number; name?: string; dedicated?: boolean } = {}): boolean {
43
+ const b = bridge()
44
+ if (!b) { emit("error", "multiplayer is not supported on this host"); return false }
45
+ if (state.role !== "offline") this.disconnect()
46
+ if (opts.name) state.myName = opts.name
47
+ // `dedicated` (no local player) defaults to how the exe was launched; a windowed --server that
48
+ // still wants its own player passes `dedicated: false`.
49
+ const dedicated = opts.dedicated ?? this.launch?.role === "server"
50
+ if (!b.listen(opts.port ?? 40000, opts.maxClients ?? 16)) {
51
+ state.status = "error"
52
+ emit("error", `could not listen on port ${opts.port ?? 40000}`)
53
+ return false
54
+ }
55
+ state.role = dedicated ? "server" : "host"
56
+ state.isServer = true
57
+ state.isClient = !dedicated
58
+ state.status = "online"
59
+ if (!dedicated) ensureMe()
60
+ installReplication()
61
+ startPhases()
62
+ emit("connect")
63
+ return true
64
+ }
65
+
66
+ /** Connect to a host/server (`address` = "ip:port"). Resolves once the server welcomed us. */
67
+ connect(target: { address: string }, opts: { name?: string } = {}): Promise<void> {
68
+ const b = bridge()
69
+ if (!b) return Promise.reject(new Error("multiplayer is not supported on this host"))
70
+ if (state.role !== "offline") this.disconnect()
71
+ if (opts.name) state.myName = opts.name
72
+ return new Promise<void>((resolve, reject) => {
73
+ if (!b.connect(target.address)) { reject(new Error(`bad address '${target.address}'`)); return }
74
+ state.role = "client"
75
+ state.isServer = false
76
+ state.isClient = true
77
+ state.status = "connecting"
78
+ state.connectResolve = resolve
79
+ state.connectReject = reject
80
+ installReplication()
81
+ startPhases()
82
+ })
83
+ }
84
+
85
+ /** Client → offline. Host/server → everyone is dropped, back to offline. */
86
+ disconnect(): void {
87
+ const b = bridge()
88
+ if (state.role === "offline") return
89
+ const wasClient = state.role === "client"
90
+ const players = state.players.slice()
91
+ b?.disconnect()
92
+ resetToOffline()
93
+ if (wasClient) emit("disconnect", "disconnected")
94
+ else for (const p of players) if (p._slot !== LOCAL_SLOT) removePlayer(p, "server closed")
95
+ }
96
+
97
+ // ---- messages: JSON, typed in TS only -------------------------------------------------------------
98
+
99
+ /** Declare a message type once; the name is the wire id. `unreliable` = channel 1 (fire-and-forget). */
100
+ message<T = void>(name: string, opts: { unreliable?: boolean } = {}): NetMessage<T> {
101
+ if (name.startsWith("$")) throw new Error("message names starting with '$' are reserved")
102
+ return { name, unreliable: !!opts.unreliable }
103
+ }
104
+
105
+ /** Client → server (offline / host: delivered in-process, `from` = our own player). */
106
+ send<T>(msg: NetMessage<T>, data: T): void {
107
+ if (state.role === "client") {
108
+ sendRaw(0, msg.unreliable ? CH_UNRELIABLE : CH_RELIABLE, JSON.stringify([msg.name, data]))
109
+ return
110
+ }
111
+ if (state.role === "server") throw new Error("Net.send is client-side; the server uses player.send / Net.broadcast")
112
+ const me = ensureMe()
113
+ queueLocal(() => dispatchMessage(msg.name, data, me))
114
+ }
115
+
116
+ /** Server → every player (incl. the host's own), minus `except`. */
117
+ broadcast<T>(msg: NetMessage<T>, data: T, opts: { except?: NetPlayer | null } = {}): void {
118
+ if (!state.isServer) throw new Error("Net.broadcast is server-side")
119
+ const payload = JSON.stringify([msg.name, data])
120
+ const channel = msg.unreliable ? CH_UNRELIABLE : CH_RELIABLE
121
+ for (const p of state.players) {
122
+ if (p === opts.except) continue
123
+ if (p._slot === LOCAL_SLOT) queueLocal(() => dispatchMessage(msg.name, data, null))
124
+ else sendRaw(p._slot, channel, payload)
125
+ }
126
+ }
127
+
128
+ /** Subscribe to a built-in event or to a message (`from` = the sending player on the server,
129
+ * null on a client — the server sent it). Returns `Net` for chaining. */
130
+ on<K extends keyof NetEvents>(event: K, cb: NetEvents[K]): this
131
+ on<T>(msg: NetMessage<T>, cb: (data: T, from: NetPlayer | null) => void): this
132
+ on(key: any, cb: Listener): this {
133
+ const map = typeof key === "string" ? state.events : state.handlers
134
+ const name = typeof key === "string" ? key : key.name
135
+ const list = map.get(name) ?? []
136
+ if (!list.includes(cb)) list.push(cb)
137
+ map.set(name, list)
138
+ return this
139
+ }
140
+
141
+ off<K extends keyof NetEvents>(event: K, cb: NetEvents[K]): this
142
+ off<T>(msg: NetMessage<T>, cb: (data: T, from: NetPlayer | null) => void): this
143
+ off(key: any, cb: Listener): this {
144
+ const map = typeof key === "string" ? state.events : state.handlers
145
+ const name = typeof key === "string" ? key : key.name
146
+ const list = map.get(name)
147
+ if (!list) return this
148
+ const i = list.indexOf(cb)
149
+ if (i >= 0) list.splice(i, 1)
150
+ return this
151
+ }
152
+
153
+ // ---- replication: defaults object = layout --------------------------------------------------------
154
+
155
+ /** The per-tick input layout (one per game): `const PlayerInput = Net.input({ moveX: 0, moveY: 0, jump: false })`.
156
+ * Fields: number → f32, boolean → 1 bit, number[] → f32 × length. */
157
+ input<T extends object>(defaults: T): NetInput<T> { return declareInput(defaults) }
158
+
159
+ /** Set this tick's input — from `updateFixed`, once per tick, on the machine that owns the player
160
+ * (client / host / offline). The owner's sim reads it back through `rep.input(PlayerInput)`; a
161
+ * client also ships it to the server for the same tick. */
162
+ setInput<T extends object>(inp: NetInput<T>, values: Partial<T>): void { setInput(inp, values) }
163
+
164
+ /** A replicated entity type: the defaults are the snapshot layout (same field rules as `input`),
165
+ * `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
+ kind<S extends object>(name: string, defaults: S, opts: { transform?: NetTransform; client?: (rep: NetEntity<S>, scene: any) => any } = {}): NetKind<S> {
168
+ return declareKind(name, defaults, opts as any)
169
+ }
170
+
171
+ /** Every replicated entity this machine knows. */
172
+ get entities(): readonly NetEntity[] { return allEntities() }
173
+ entity(netId: number): NetEntity | undefined { return entityById(netId) }
174
+
175
+ /** Dev only: yojimbo's network simulator on our side of the wire (latency/jitter in ms, loss in %). */
176
+ simulate(opts: { latency?: number; jitter?: number; loss?: number }): void {
177
+ bridge()?.simulate(opts.latency ?? 0, opts.jitter ?? 0, opts.loss ?? 0)
178
+ }
179
+ }
180
+
181
+ export const Net = new NetImpl()