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.
- package/dist/index.js +1240 -170
- package/package.json +3 -2
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +86 -0
- package/runtime/sdk/compile/assetIconMacro.ts +384 -0
- package/runtime/sdk/compile/assetMacro.ts +146 -0
- package/runtime/sdk/compile/assetName.ts +50 -0
- package/runtime/sdk/compile/bundler.ts +287 -0
- package/runtime/sdk/compile/compileProject.ts +129 -0
- package/runtime/sdk/compile/detectEntry.ts +128 -0
- package/runtime/sdk/compile/fontMacro.ts +459 -0
- package/runtime/sdk/compile/fontRegistry.ts +78 -0
- package/runtime/sdk/compile/header.ts +67 -0
- package/runtime/sdk/compile/index.ts +100 -0
- package/runtime/sdk/compile/libraryImports.ts +52 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/sceneEditor.ts +88 -0
- package/runtime/sdk/compile/serverSplit.ts +233 -0
- package/runtime/sdk/compile/serverTypes.ts +227 -0
- package/runtime/sdk/compile/sfnt.ts +98 -0
- package/runtime/sdk/compile/shaderSchema.ts +202 -0
- package/runtime/sdk/compile/shaderTargets.ts +81 -0
- package/runtime/sdk/compile/sourcemap.ts +25 -0
- package/runtime/sdk/core/Aspect.ts +65 -9
- package/runtime/sdk/core/StateMachine.ts +308 -0
- package/runtime/sdk/core/time.ts +3 -0
- package/runtime/sdk/gl/CameraPlace.ts +52 -52
- package/runtime/sdk/gl/Light.ts +12 -4
- package/runtime/sdk/gl/Lightmap.ts +87 -10
- package/runtime/sdk/gl/Material.ts +15 -0
- package/runtime/sdk/gl/Model.ts +68 -8
- package/runtime/sdk/gl/NavAgent.ts +337 -0
- package/runtime/sdk/gl/NavMesh.ts +397 -0
- package/runtime/sdk/gl/Ragdoll.ts +270 -0
- package/runtime/sdk/gl/Scene.ts +7 -0
- package/runtime/sdk/gl/Shape.ts +37 -5
- package/runtime/sdk/gl/Terrain.ts +1092 -0
- package/runtime/sdk/gl/Texture.ts +17 -0
- package/runtime/sdk/gl/Trigger.ts +45 -45
- package/runtime/sdk/gl/animation/Animator.ts +35 -2
- package/runtime/sdk/gl/animation/Layer.ts +3 -1
- package/runtime/sdk/gl/animation/Loop.ts +5 -0
- package/runtime/sdk/gl/animation/Playback.ts +4 -3
- package/runtime/sdk/gl/animation/core.ts +52 -10
- package/runtime/sdk/gl/scenarios.ts +291 -291
- package/runtime/sdk/gl/state.ts +6 -6
- package/runtime/sdk/gl/terrainMesh.ts +219 -0
- package/runtime/sdk/inject.ts +12 -1
- package/runtime/sdk/net/codec.ts +119 -0
- package/runtime/sdk/net/core.ts +384 -0
- package/runtime/sdk/net/index.ts +181 -0
- package/runtime/sdk/net/replication.ts +622 -0
- package/runtime/sdk/scene/defineScene.ts +140 -14
- package/runtime/sdk/scene/editorPlugins.ts +18 -0
- package/runtime/sdk/scene/gizmos.ts +148 -148
- package/runtime/sdk/ui/UINode.ts +4 -0
- 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()
|