@lanmower/entrypoint 0.16.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/LICENSE +674 -0
- package/README.md +250 -0
- package/apps/environment/index.js +17 -0
- package/apps/interactive-door/index.js +33 -0
- package/apps/patrol-npc/index.js +37 -0
- package/apps/physics-crate/index.js +23 -0
- package/apps/power-crate/index.js +169 -0
- package/apps/tps-game/index.js +168 -0
- package/apps/tps-game/schwust.glb +0 -0
- package/apps/world/index.js +22 -0
- package/client/app.js +181 -0
- package/client/camera.js +116 -0
- package/client/index.html +24 -0
- package/client/style.css +11 -0
- package/package.json +52 -0
- package/server.js +3 -0
- package/src/apps/AppContext.js +172 -0
- package/src/apps/AppLoader.js +160 -0
- package/src/apps/AppRuntime.js +192 -0
- package/src/apps/EventBus.js +62 -0
- package/src/apps/HotReloadQueue.js +63 -0
- package/src/client/InputHandler.js +85 -0
- package/src/client/PhysicsNetworkClient.js +171 -0
- package/src/client/PredictionEngine.js +123 -0
- package/src/client/ReconciliationEngine.js +54 -0
- package/src/connection/ConnectionManager.js +133 -0
- package/src/connection/QualityMonitor.js +46 -0
- package/src/connection/SessionStore.js +67 -0
- package/src/debug/CliDebugger.js +93 -0
- package/src/debug/Inspector.js +52 -0
- package/src/debug/StateInspector.js +42 -0
- package/src/index.client.js +8 -0
- package/src/index.js +1 -0
- package/src/index.server.js +27 -0
- package/src/math.js +21 -0
- package/src/netcode/EventLog.js +74 -0
- package/src/netcode/LagCompensator.js +78 -0
- package/src/netcode/NetworkState.js +66 -0
- package/src/netcode/PhysicsIntegration.js +132 -0
- package/src/netcode/PlayerManager.js +109 -0
- package/src/netcode/SnapshotEncoder.js +49 -0
- package/src/netcode/TickSystem.js +66 -0
- package/src/physics/GLBLoader.js +29 -0
- package/src/physics/World.js +195 -0
- package/src/protocol/Codec.js +60 -0
- package/src/protocol/EventEmitter.js +60 -0
- package/src/protocol/MessageTypes.js +73 -0
- package/src/protocol/SequenceTracker.js +71 -0
- package/src/protocol/msgpack.js +119 -0
- package/src/sdk/ClientMessageHandler.js +80 -0
- package/src/sdk/ReloadHandlers.js +68 -0
- package/src/sdk/ReloadManager.js +126 -0
- package/src/sdk/ServerAPI.js +133 -0
- package/src/sdk/ServerHandlers.js +76 -0
- package/src/sdk/StaticHandler.js +32 -0
- package/src/sdk/TickHandler.js +84 -0
- package/src/sdk/client.js +122 -0
- package/src/sdk/server.js +184 -0
- package/src/shared/movement.js +69 -0
- package/src/spatial/Octree.js +91 -0
- package/src/stage/Stage.js +90 -0
- package/src/stage/StageLoader.js +95 -0
- package/src/storage/FSAdapter.js +56 -0
- package/src/storage/StorageAdapter.js +7 -0
- package/src/transport/TransportWrapper.js +25 -0
- package/src/transport/WebSocketTransport.js +55 -0
- package/src/transport/WebTransportServer.js +83 -0
- package/src/transport/WebTransportTransport.js +94 -0
- package/world/kaira.glb +0 -0
- package/world/schwust.glb +0 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { PredictionEngine } from './PredictionEngine.js'
|
|
2
|
+
import { pack, unpack } from '../protocol/msgpack.js'
|
|
3
|
+
import { MSG } from '../protocol/MessageTypes.js'
|
|
4
|
+
|
|
5
|
+
export class PhysicsNetworkClient {
|
|
6
|
+
constructor(config = {}) {
|
|
7
|
+
this.config = { url: config.url || 'ws://localhost:8080/ws', tickRate: config.tickRate || 128, predictionEnabled: config.predictionEnabled !== false, debug: config.debug || false, ...config }
|
|
8
|
+
this.ws = null
|
|
9
|
+
this.playerId = null
|
|
10
|
+
this.connected = false
|
|
11
|
+
this._predEngine = null
|
|
12
|
+
this._playerStates = new Map()
|
|
13
|
+
this._entityStates = new Map()
|
|
14
|
+
this.lastSnapshotTick = 0
|
|
15
|
+
this.currentTick = 0
|
|
16
|
+
this.state = { players: [], entities: [] }
|
|
17
|
+
this.heartbeatTimer = null
|
|
18
|
+
this.callbacks = {
|
|
19
|
+
onConnect: config.onConnect || (() => {}),
|
|
20
|
+
onDisconnect: config.onDisconnect || (() => {}),
|
|
21
|
+
onPlayerJoined: config.onPlayerJoined || (() => {}),
|
|
22
|
+
onPlayerLeft: config.onPlayerLeft || (() => {}),
|
|
23
|
+
onEntityAdded: config.onEntityAdded || (() => {}),
|
|
24
|
+
onEntityRemoved: config.onEntityRemoved || (() => {}),
|
|
25
|
+
onSnapshot: config.onSnapshot || (() => {}),
|
|
26
|
+
onRender: config.onRender || (() => {}),
|
|
27
|
+
onStateUpdate: config.onStateUpdate || (() => {}),
|
|
28
|
+
onWorldDef: config.onWorldDef || (() => {}),
|
|
29
|
+
onAppModule: config.onAppModule || (() => {}),
|
|
30
|
+
onAssetUpdate: config.onAssetUpdate || (() => {}),
|
|
31
|
+
onAppEvent: config.onAppEvent || (() => {})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async connect() {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
try {
|
|
38
|
+
this.ws = new WebSocket(this.config.url)
|
|
39
|
+
this.ws.binaryType = 'arraybuffer'
|
|
40
|
+
this.ws.onopen = () => this._onOpen(resolve)
|
|
41
|
+
this.ws.onmessage = (event) => this.onMessage(event.data)
|
|
42
|
+
this.ws.onclose = () => this._onClose()
|
|
43
|
+
this.ws.onerror = (error) => this._onError(error, reject)
|
|
44
|
+
} catch (error) { reject(error) }
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_onOpen(resolve) {
|
|
49
|
+
this.connected = true
|
|
50
|
+
this._startHeartbeat()
|
|
51
|
+
this.callbacks.onConnect()
|
|
52
|
+
resolve()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_onClose() {
|
|
56
|
+
this.connected = false
|
|
57
|
+
this._stopHeartbeat()
|
|
58
|
+
this.callbacks.onDisconnect()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_onError(error, reject) { reject(error) }
|
|
62
|
+
|
|
63
|
+
sendInput(input) {
|
|
64
|
+
if (!this._isOpen()) return
|
|
65
|
+
if (this.config.predictionEnabled && this._predEngine) this._predEngine.addInput(input)
|
|
66
|
+
this.ws.send(pack({ type: MSG.INPUT, payload: { input } }))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
sendFire(data) {
|
|
70
|
+
if (!this._isOpen()) return
|
|
71
|
+
this.ws.send(pack({ type: MSG.APP_EVENT, payload: { type: 'fire', shooterId: this.playerId, ...data } }))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_isOpen() { return this.ws && this.ws.readyState === WebSocket.OPEN }
|
|
75
|
+
|
|
76
|
+
onMessage(data) {
|
|
77
|
+
try {
|
|
78
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data
|
|
79
|
+
const msg = unpack(bytes)
|
|
80
|
+
this._handleMessage(msg.type, msg.payload || {})
|
|
81
|
+
} catch (e) { console.error('[client] parse error:', e) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_handleMessage(type, payload) {
|
|
85
|
+
if (type === MSG.HANDSHAKE_ACK) {
|
|
86
|
+
this.playerId = payload.playerId
|
|
87
|
+
this.currentTick = payload.tick
|
|
88
|
+
this._predEngine = new PredictionEngine(this.config.tickRate)
|
|
89
|
+
this._predEngine.init(this.playerId)
|
|
90
|
+
} else if (type === MSG.SNAPSHOT || type === MSG.STATE_CORRECTION) {
|
|
91
|
+
this._onSnapshot(payload)
|
|
92
|
+
} else if (type === MSG.PLAYER_LEAVE) {
|
|
93
|
+
this._playerStates.delete(payload.playerId)
|
|
94
|
+
this.callbacks.onPlayerLeft(payload.playerId)
|
|
95
|
+
} else if (type === MSG.WORLD_DEF) {
|
|
96
|
+
if (payload.movement && this._predEngine) this._predEngine.setMovement(payload.movement)
|
|
97
|
+
if (payload.gravity && this._predEngine) this._predEngine.setGravity(payload.gravity)
|
|
98
|
+
this.callbacks.onWorldDef?.(payload)
|
|
99
|
+
} else if (type === MSG.APP_EVENT) {
|
|
100
|
+
this.callbacks.onAppEvent?.(payload)
|
|
101
|
+
} else if (type === MSG.HOT_RELOAD || type === MSG.APP_MODULE || type === MSG.ASSET_UPDATE) {
|
|
102
|
+
const cb = { [MSG.HOT_RELOAD]: 'onHotReload', [MSG.APP_MODULE]: 'onAppModule', [MSG.ASSET_UPDATE]: 'onAssetUpdate' }[type]
|
|
103
|
+
this.callbacks[cb]?.(payload)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_onSnapshot(data) {
|
|
108
|
+
this.lastSnapshotTick = this.currentTick = data.tick || 0
|
|
109
|
+
for (const p of data.players || []) {
|
|
110
|
+
const { playerId, state } = this._parsePlayer(p)
|
|
111
|
+
if (!this._playerStates.has(playerId)) this.callbacks.onPlayerJoined(playerId, state)
|
|
112
|
+
this._playerStates.set(playerId, state)
|
|
113
|
+
if (playerId === this.playerId && this.config.predictionEnabled && this._predEngine) {
|
|
114
|
+
this._predEngine.onServerSnapshot({ players: [state] }, this.currentTick)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const e of data.entities || []) {
|
|
118
|
+
const { entityId, state } = this._parseEntity(e)
|
|
119
|
+
if (!this._entityStates.has(entityId)) this.callbacks.onEntityAdded(entityId, state)
|
|
120
|
+
this._entityStates.set(entityId, state)
|
|
121
|
+
}
|
|
122
|
+
this.state.players = Array.from(this._playerStates.values())
|
|
123
|
+
this.state.entities = Array.from(this._entityStates.values())
|
|
124
|
+
this.callbacks.onSnapshot(data)
|
|
125
|
+
this.callbacks.onStateUpdate(this.state)
|
|
126
|
+
this._render()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_parsePlayer(p) {
|
|
130
|
+
if (Array.isArray(p)) return { playerId: p[0], state: { id: p[0], position: [p[1], p[2], p[3]], rotation: [p[4], p[5], p[6], p[7]], velocity: [p[8], p[9], p[10]], onGround: p[11] === 1, health: p[12], inputSequence: p[13] } }
|
|
131
|
+
return { playerId: p.id || p.i, state: { id: p.id || p.i, position: p.position || [0, 0, 0], rotation: p.rotation || [0, 0, 0, 1], velocity: p.velocity || [0, 0, 0], onGround: p.onGround ?? false, health: p.health ?? 100 } }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_parseEntity(e) {
|
|
135
|
+
if (Array.isArray(e)) return { entityId: e[0], state: { id: e[0], model: e[1], position: [e[2], e[3], e[4]], rotation: [e[5], e[6], e[7], e[8]], bodyType: e[9], custom: e[10] } }
|
|
136
|
+
return { entityId: e.id, state: { id: e.id, model: e.model, position: e.position || [0, 0, 0], rotation: e.rotation || [0, 0, 0, 1], bodyType: e.bodyType || 'static', custom: e.custom || null } }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_render() {
|
|
140
|
+
const displayStates = new Map()
|
|
141
|
+
for (const [playerId, serverState] of this._playerStates) {
|
|
142
|
+
displayStates.set(playerId, playerId === this.playerId && this.config.predictionEnabled && this._predEngine ? this._predEngine.getDisplayState(this.currentTick, 0) : serverState)
|
|
143
|
+
}
|
|
144
|
+
this.callbacks.onRender(displayStates)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getLocalState() {
|
|
148
|
+
return this.config.predictionEnabled && this._predEngine ? this._predEngine.localState : this._playerStates.get(this.playerId)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getRemoteState(playerId) { return this._playerStates.get(playerId) }
|
|
152
|
+
getAllStates() { return new Map(this._playerStates) }
|
|
153
|
+
getEntity(entityId) { return this._entityStates.get(entityId) }
|
|
154
|
+
getAllEntities() { return new Map(this._entityStates) }
|
|
155
|
+
|
|
156
|
+
disconnect() {
|
|
157
|
+
this._stopHeartbeat()
|
|
158
|
+
if (this.ws) this.ws.close()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_startHeartbeat() {
|
|
162
|
+
this._stopHeartbeat()
|
|
163
|
+
this.heartbeatTimer = setInterval(() => {
|
|
164
|
+
if (this._isOpen()) this.ws.send(pack({ type: MSG.HEARTBEAT, payload: {} }))
|
|
165
|
+
}, 1000)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_stopHeartbeat() {
|
|
169
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ReconciliationEngine } from './ReconciliationEngine.js'
|
|
2
|
+
import { applyMovement, DEFAULT_MOVEMENT } from '../shared/movement.js'
|
|
3
|
+
|
|
4
|
+
export class PredictionEngine {
|
|
5
|
+
constructor(tickRate = 128) {
|
|
6
|
+
this.tickRate = tickRate
|
|
7
|
+
this.tickDuration = 1000 / tickRate
|
|
8
|
+
this.localPlayerId = null
|
|
9
|
+
this.localState = null
|
|
10
|
+
this.lastServerState = null
|
|
11
|
+
this.inputHistory = []
|
|
12
|
+
this.reconciliationEngine = new ReconciliationEngine()
|
|
13
|
+
this.movement = { ...DEFAULT_MOVEMENT }
|
|
14
|
+
this.gravityY = -9.81
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
setMovement(m) { Object.assign(this.movement, m) }
|
|
18
|
+
|
|
19
|
+
setGravity(g) { if (g && g[1] != null) this.gravityY = g[1] }
|
|
20
|
+
|
|
21
|
+
init(playerId, initialState = {}) {
|
|
22
|
+
this.localPlayerId = playerId
|
|
23
|
+
this.localState = {
|
|
24
|
+
id: playerId,
|
|
25
|
+
position: initialState.position || [0, 0, 0],
|
|
26
|
+
rotation: initialState.rotation || [0, 0, 0, 1],
|
|
27
|
+
velocity: initialState.velocity || [0, 0, 0],
|
|
28
|
+
onGround: true,
|
|
29
|
+
health: initialState.health || 100
|
|
30
|
+
}
|
|
31
|
+
this.lastServerState = JSON.parse(JSON.stringify(this.localState))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
addInput(input) {
|
|
35
|
+
this.inputHistory.push({
|
|
36
|
+
sequence: this.inputHistory.length,
|
|
37
|
+
data: input,
|
|
38
|
+
timestamp: Date.now()
|
|
39
|
+
})
|
|
40
|
+
if (this.inputHistory.length > 128) {
|
|
41
|
+
this.inputHistory.shift()
|
|
42
|
+
}
|
|
43
|
+
this.predict(input)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
predict(input) {
|
|
47
|
+
const dt = this.tickDuration / 1000
|
|
48
|
+
const state = this.localState
|
|
49
|
+
applyMovement(state, input, this.movement, dt)
|
|
50
|
+
state.velocity[1] += this.gravityY * dt
|
|
51
|
+
state.position[0] += state.velocity[0] * dt
|
|
52
|
+
state.position[1] += state.velocity[1] * dt
|
|
53
|
+
state.position[2] += state.velocity[2] * dt
|
|
54
|
+
if (state.position[1] < 0) {
|
|
55
|
+
state.position[1] = 0
|
|
56
|
+
state.velocity[1] = 0
|
|
57
|
+
state.onGround = true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interpolate(factor) {
|
|
62
|
+
if (!this.lastServerState || !this.localState) return this.localState
|
|
63
|
+
return {
|
|
64
|
+
id: this.localState.id,
|
|
65
|
+
position: [
|
|
66
|
+
this.lastServerState.position[0] + (this.localState.position[0] - this.lastServerState.position[0]) * factor,
|
|
67
|
+
this.lastServerState.position[1] + (this.localState.position[1] - this.lastServerState.position[1]) * factor,
|
|
68
|
+
this.lastServerState.position[2] + (this.localState.position[2] - this.lastServerState.position[2]) * factor
|
|
69
|
+
],
|
|
70
|
+
rotation: this.localState.rotation,
|
|
71
|
+
velocity: this.localState.velocity,
|
|
72
|
+
health: this.localState.health,
|
|
73
|
+
onGround: this.localState.onGround
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
extrapolate(ticksAhead = 1) {
|
|
78
|
+
const extrapolated = JSON.parse(JSON.stringify(this.localState))
|
|
79
|
+
const dt = (this.tickDuration / 1000) * ticksAhead
|
|
80
|
+
extrapolated.position[0] += this.localState.velocity[0] * dt
|
|
81
|
+
extrapolated.position[1] += this.localState.velocity[1] * dt
|
|
82
|
+
extrapolated.position[2] += this.localState.velocity[2] * dt
|
|
83
|
+
return extrapolated
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onServerSnapshot(snapshot, tick) {
|
|
87
|
+
for (const serverPlayer of snapshot.players) {
|
|
88
|
+
if (serverPlayer.id === this.localPlayerId) {
|
|
89
|
+
this.lastServerState = JSON.parse(JSON.stringify(serverPlayer))
|
|
90
|
+
const reconciliation = this.reconciliationEngine.reconcile(
|
|
91
|
+
this.lastServerState, this.localState, tick
|
|
92
|
+
)
|
|
93
|
+
if (reconciliation.needsCorrection) {
|
|
94
|
+
this.reconciliationEngine.applyCorrection(this.localState, reconciliation.correction)
|
|
95
|
+
this.resimulate()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
resimulate() {
|
|
102
|
+
const baseState = JSON.parse(JSON.stringify(this.lastServerState))
|
|
103
|
+
this.localState = baseState
|
|
104
|
+
for (const input of this.inputHistory) {
|
|
105
|
+
this.predict(input.data)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getDisplayState(tick, ticksSinceLastSnapshot) {
|
|
110
|
+
const alpha = (ticksSinceLastSnapshot % 1) / 1
|
|
111
|
+
return this.interpolate(alpha)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getInputHistory() { return this.inputHistory }
|
|
115
|
+
|
|
116
|
+
calculateDivergence() {
|
|
117
|
+
if (!this.lastServerState || !this.localState) return 0
|
|
118
|
+
const dx = this.localState.position[0] - this.lastServerState.position[0]
|
|
119
|
+
const dy = this.localState.position[1] - this.lastServerState.position[1]
|
|
120
|
+
const dz = this.localState.position[2] - this.lastServerState.position[2]
|
|
121
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class ReconciliationEngine {
|
|
2
|
+
constructor(config = {}) {
|
|
3
|
+
this.correctionThreshold = config.correctionThreshold || 0.01
|
|
4
|
+
this.correctionSpeed = config.correctionSpeed || 0.5
|
|
5
|
+
this.lastReconcileTime = 0
|
|
6
|
+
this.reconcileInterval = config.reconcileInterval || 100
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
reconcile(serverState, localState, tick) {
|
|
10
|
+
const now = Date.now()
|
|
11
|
+
if (now - this.lastReconcileTime < this.reconcileInterval) {
|
|
12
|
+
return { needsCorrection: false, correction: null }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
this.lastReconcileTime = now
|
|
16
|
+
|
|
17
|
+
const divergence = this.calculateDivergence(serverState, localState)
|
|
18
|
+
|
|
19
|
+
if (divergence < this.correctionThreshold) {
|
|
20
|
+
return { needsCorrection: false, divergence }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const correction = this.generateCorrection(serverState, localState)
|
|
24
|
+
return { needsCorrection: true, correction, divergence }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
calculateDivergence(serverState, localState) {
|
|
28
|
+
if (!serverState || !localState) return 0
|
|
29
|
+
|
|
30
|
+
const dx = serverState.position[0] - localState.position[0]
|
|
31
|
+
const dy = serverState.position[1] - localState.position[1]
|
|
32
|
+
const dz = serverState.position[2] - localState.position[2]
|
|
33
|
+
|
|
34
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
generateCorrection(serverState, localState) {
|
|
38
|
+
return {
|
|
39
|
+
position: [
|
|
40
|
+
serverState.position[0] * this.correctionSpeed + localState.position[0] * (1 - this.correctionSpeed),
|
|
41
|
+
serverState.position[1] * this.correctionSpeed + localState.position[1] * (1 - this.correctionSpeed),
|
|
42
|
+
serverState.position[2] * this.correctionSpeed + localState.position[2] * (1 - this.correctionSpeed)
|
|
43
|
+
],
|
|
44
|
+
velocity: [...serverState.velocity],
|
|
45
|
+
onGround: serverState.onGround
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
applyCorrection(localState, correction) {
|
|
50
|
+
localState.position = correction.position
|
|
51
|
+
localState.velocity = correction.velocity
|
|
52
|
+
localState.onGround = correction.onGround
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { pack, unpack } from '../protocol/msgpack.js'
|
|
2
|
+
import { isUnreliable } from '../protocol/MessageTypes.js'
|
|
3
|
+
import { EventEmitter } from '../protocol/EventEmitter.js'
|
|
4
|
+
|
|
5
|
+
export class ConnectionManager extends EventEmitter {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
super()
|
|
8
|
+
this.clients = new Map()
|
|
9
|
+
this.heartbeatInterval = options.heartbeatInterval || 1000
|
|
10
|
+
this.heartbeatTimeout = options.heartbeatTimeout || 3000
|
|
11
|
+
this.timers = new Map()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
addClient(clientId, transport) {
|
|
15
|
+
const client = {
|
|
16
|
+
id: clientId,
|
|
17
|
+
transport,
|
|
18
|
+
lastHeartbeat: Date.now(),
|
|
19
|
+
sessionToken: null,
|
|
20
|
+
transportType: transport.type || 'websocket'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
transport.on('message', (data) => {
|
|
24
|
+
try {
|
|
25
|
+
client.lastHeartbeat = Date.now()
|
|
26
|
+
const msg = unpack(data)
|
|
27
|
+
this.emit('message', clientId, msg)
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error(`[connection] decode error for ${clientId}:`, err.message)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
transport.on('close', () => {
|
|
34
|
+
this.removeClient(clientId)
|
|
35
|
+
this.emit('disconnect', clientId, 'closed')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
transport.on('error', (err) => {
|
|
39
|
+
console.error(`[connection] transport error for ${clientId}:`, err.message)
|
|
40
|
+
this.removeClient(clientId)
|
|
41
|
+
this.emit('disconnect', clientId, 'error')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
this.clients.set(clientId, client)
|
|
45
|
+
this._setupHeartbeat(clientId)
|
|
46
|
+
return client
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_setupHeartbeat(clientId) {
|
|
50
|
+
const check = () => {
|
|
51
|
+
const client = this.clients.get(clientId)
|
|
52
|
+
if (!client) return
|
|
53
|
+
const age = Date.now() - client.lastHeartbeat
|
|
54
|
+
if (age > this.heartbeatTimeout) {
|
|
55
|
+
this.removeClient(clientId)
|
|
56
|
+
this.emit('disconnect', clientId, 'timeout')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const timer = setTimeout(check, this.heartbeatInterval)
|
|
60
|
+
this.timers.set(`hb-${clientId}`, timer)
|
|
61
|
+
}
|
|
62
|
+
const timer = setTimeout(check, this.heartbeatInterval)
|
|
63
|
+
this.timers.set(`hb-${clientId}`, timer)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
removeClient(clientId) {
|
|
67
|
+
const client = this.clients.get(clientId)
|
|
68
|
+
if (!client) return
|
|
69
|
+
if (client.transport && client.transport.isOpen) {
|
|
70
|
+
client.transport.close()
|
|
71
|
+
}
|
|
72
|
+
this.clients.delete(clientId)
|
|
73
|
+
const timer = this.timers.get(`hb-${clientId}`)
|
|
74
|
+
if (timer) clearTimeout(timer)
|
|
75
|
+
this.timers.delete(`hb-${clientId}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getClient(clientId) {
|
|
79
|
+
return this.clients.get(clientId)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
send(clientId, type, payload = {}) {
|
|
83
|
+
const client = this.clients.get(clientId)
|
|
84
|
+
if (!client || !client.transport.isOpen) return false
|
|
85
|
+
try {
|
|
86
|
+
const data = pack({ type, payload })
|
|
87
|
+
if (isUnreliable(type)) return client.transport.sendUnreliable(data)
|
|
88
|
+
return client.transport.send(data)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(`[connection] send error to ${clientId}:`, err.message)
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
broadcast(type, payload = {}) {
|
|
96
|
+
const data = pack({ type, payload })
|
|
97
|
+
const unreliable = isUnreliable(type)
|
|
98
|
+
let count = 0
|
|
99
|
+
for (const client of this.clients.values()) {
|
|
100
|
+
if (!client.transport.isOpen) continue
|
|
101
|
+
try {
|
|
102
|
+
if (unreliable) client.transport.sendUnreliable(data)
|
|
103
|
+
else client.transport.send(data)
|
|
104
|
+
count++
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(`[connection] broadcast error to ${client.id}:`, err.message)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return count
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getAllStats() {
|
|
113
|
+
return {
|
|
114
|
+
activeConnections: this.clients.size,
|
|
115
|
+
clients: Array.from(this.clients.entries()).map(([id, c]) => ({
|
|
116
|
+
id,
|
|
117
|
+
transport: c.transportType,
|
|
118
|
+
sessionToken: c.sessionToken ? '***' : null
|
|
119
|
+
}))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
destroy() {
|
|
124
|
+
for (const clientId of this.clients.keys()) {
|
|
125
|
+
this.removeClient(clientId)
|
|
126
|
+
}
|
|
127
|
+
for (const timer of this.timers.values()) {
|
|
128
|
+
clearTimeout(timer)
|
|
129
|
+
}
|
|
130
|
+
this.clients.clear()
|
|
131
|
+
this.timers.clear()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export class QualityMonitor {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.bytesIn = 0
|
|
4
|
+
this.bytesOut = 0
|
|
5
|
+
this.heartbeatsSent = 0
|
|
6
|
+
this.heartbeatsReceived = 0
|
|
7
|
+
this.rttSamples = []
|
|
8
|
+
this.startTime = Date.now()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
recordBytesIn(count) {
|
|
12
|
+
this.bytesIn += count
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
recordBytesOut(count) {
|
|
16
|
+
this.bytesOut += count
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
recordHeartbeatSent() {
|
|
20
|
+
this.heartbeatsSent++
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
recordHeartbeatReceived() {
|
|
24
|
+
this.heartbeatsReceived++
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
recordRtt(rtt) {
|
|
28
|
+
this.rttSamples.push(rtt)
|
|
29
|
+
if (this.rttSamples.length > 100) this.rttSamples.shift()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getStats() {
|
|
33
|
+
const elapsed = Date.now() - this.startTime
|
|
34
|
+
const avgRtt = this.rttSamples.length > 0
|
|
35
|
+
? this.rttSamples.reduce((a, b) => a + b, 0) / this.rttSamples.length
|
|
36
|
+
: 0
|
|
37
|
+
return {
|
|
38
|
+
bytesIn: this.bytesIn,
|
|
39
|
+
bytesOut: this.bytesOut,
|
|
40
|
+
heartbeatsSent: this.heartbeatsSent,
|
|
41
|
+
heartbeatsReceived: this.heartbeatsReceived,
|
|
42
|
+
avgRtt,
|
|
43
|
+
uptime: elapsed
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export class SessionStore {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.ttl = options.ttl || 30000
|
|
6
|
+
this.sessions = new Map()
|
|
7
|
+
this.timers = new Map()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
create(playerId, state) {
|
|
11
|
+
const token = randomBytes(16).toString('hex')
|
|
12
|
+
const session = {
|
|
13
|
+
token,
|
|
14
|
+
playerId,
|
|
15
|
+
state: state ? { ...state } : {},
|
|
16
|
+
createdAt: Date.now()
|
|
17
|
+
}
|
|
18
|
+
this.sessions.set(token, session)
|
|
19
|
+
this._setupExpire(token)
|
|
20
|
+
return token
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
update(token, data) {
|
|
24
|
+
const session = this.sessions.get(token)
|
|
25
|
+
if (!session) return false
|
|
26
|
+
if (data.state) Object.assign(session.state, data.state)
|
|
27
|
+
session.lastUpdated = Date.now()
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get(token) {
|
|
32
|
+
const session = this.sessions.get(token)
|
|
33
|
+
if (!session) return null
|
|
34
|
+
if (Date.now() - session.createdAt > this.ttl) {
|
|
35
|
+
this.destroy(token)
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
return session
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_setupExpire(token) {
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
this.sessions.delete(token)
|
|
44
|
+
this.timers.delete(token)
|
|
45
|
+
}, this.ttl)
|
|
46
|
+
this.timers.set(token, timer)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
destroy(token) {
|
|
50
|
+
const timer = this.timers.get(token)
|
|
51
|
+
if (timer) clearTimeout(timer)
|
|
52
|
+
this.timers.delete(token)
|
|
53
|
+
this.sessions.delete(token)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getActiveCount() {
|
|
57
|
+
return this.sessions.size
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
destroyAll() {
|
|
61
|
+
for (const timer of this.timers.values()) {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
}
|
|
64
|
+
this.timers.clear()
|
|
65
|
+
this.sessions.clear()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export class CliDebugger {
|
|
2
|
+
constructor(prefix = '[spawnpoint]') {
|
|
3
|
+
this.prefix = prefix
|
|
4
|
+
this.startTime = Date.now()
|
|
5
|
+
this.history = []
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
_timestamp() {
|
|
9
|
+
const elapsed = Date.now() - this.startTime
|
|
10
|
+
const s = (elapsed / 1000).toFixed(2)
|
|
11
|
+
return `${s}s`.padStart(8)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
_formatVec3(v) {
|
|
15
|
+
if (!v) return '(null)'
|
|
16
|
+
return `(${v[0].toFixed(2)}, ${v[1].toFixed(2)}, ${v[2].toFixed(2)})`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
spawn(entity, position) {
|
|
20
|
+
const msg = `${this.prefix} SPAWN ${String(entity).padEnd(15)} @ ${this._formatVec3(position)}`
|
|
21
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
22
|
+
this.history.push(msg)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
collision(a, b, position) {
|
|
26
|
+
const msg = `${this.prefix} COLLISION ${String(a).padEnd(8)} <-> ${String(b).padEnd(8)} @ ${this._formatVec3(position)}`
|
|
27
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
28
|
+
this.history.push(msg)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
hit(shooter, target, damage) {
|
|
32
|
+
const msg = `${this.prefix} HIT ${String(shooter).padEnd(10)} -> ${String(target).padEnd(10)} [${damage}hp]`
|
|
33
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
34
|
+
this.history.push(msg)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
death(entity, damage) {
|
|
38
|
+
const msg = `${this.prefix} DEATH ${String(entity).padEnd(15)} from ${damage}hp`
|
|
39
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
40
|
+
this.history.push(msg)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
respawn(entity, position) {
|
|
44
|
+
const msg = `${this.prefix} RESPAWN ${String(entity).padEnd(10)} @ ${this._formatVec3(position)}`
|
|
45
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
46
|
+
this.history.push(msg)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
state(entity, key, value) {
|
|
50
|
+
const v = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
51
|
+
const msg = `${this.prefix} STATE ${String(entity).padEnd(15)} ${key}=${v}`
|
|
52
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
perf(label, ms) {
|
|
56
|
+
const status = ms < 10 ? '✓' : ms < 20 ? '⚠' : '✗'
|
|
57
|
+
const msg = `${this.prefix} ${status} ${label.padEnd(20)} ${ms.toFixed(1)}ms`
|
|
58
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
physics(body, pos, vel, health) {
|
|
62
|
+
const speed = Math.sqrt(vel[0]*vel[0] + vel[1]*vel[1] + vel[2]*vel[2]).toFixed(1)
|
|
63
|
+
const msg = `${this.prefix} PHY ${String(body).padEnd(10)} pos=${this._formatVec3(pos)} vel=${speed}m/s hp=${health}`
|
|
64
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
error(category, message) {
|
|
68
|
+
const msg = `${this.prefix} ERROR [${category}] ${message}`
|
|
69
|
+
console.error(`${this._timestamp()} ${msg}`)
|
|
70
|
+
this.history.push(msg)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
section(title) {
|
|
74
|
+
console.log(`\n${this.prefix} ${'='.repeat(50)}`)
|
|
75
|
+
console.log(`${this.prefix} ${title}`)
|
|
76
|
+
console.log(`${this.prefix} ${'='.repeat(50)}\n`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
summary(stats = {}) {
|
|
80
|
+
console.log(`\n${this.prefix} SESSION SUMMARY`)
|
|
81
|
+
for (const [key, value] of Object.entries(stats)) {
|
|
82
|
+
console.log(`${this.prefix} ${key}: ${value}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
log(message) {
|
|
87
|
+
const msg = `${this.prefix} ${message}`
|
|
88
|
+
console.log(`${this._timestamp()} ${msg}`)
|
|
89
|
+
this.history.push(msg)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const cliDebugger = new CliDebugger()
|