@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,80 @@
|
|
|
1
|
+
import { MSG } from '../protocol/MessageTypes.js'
|
|
2
|
+
import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
|
|
3
|
+
import { unpack } from '../protocol/msgpack.js'
|
|
4
|
+
|
|
5
|
+
export function createMessageRouter(deps) {
|
|
6
|
+
const { emitter, quality, stateInspector, send, getState, setState } = deps
|
|
7
|
+
|
|
8
|
+
function handleSnapshot(data) {
|
|
9
|
+
try {
|
|
10
|
+
const decoded = SnapshotEncoder.decode(data)
|
|
11
|
+
if (decoded.tick) {
|
|
12
|
+
stateInspector.recordSnapshotDelay(Date.now() - (decoded.timestamp || 0))
|
|
13
|
+
setState('tick', decoded.tick)
|
|
14
|
+
}
|
|
15
|
+
for (const p of decoded.players || []) getState('players').set(p.id, p)
|
|
16
|
+
for (const e of decoded.entities || []) getState('entities').set(e.id, e)
|
|
17
|
+
emitter.emit('snapshot', decoded)
|
|
18
|
+
} catch (e) {
|
|
19
|
+
emitter.emit('error', e)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return function onMessage(raw) {
|
|
24
|
+
const buf = raw instanceof ArrayBuffer ? new Uint8Array(raw) : (raw instanceof Uint8Array ? raw : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength))
|
|
25
|
+
let msg
|
|
26
|
+
try { msg = unpack(buf) } catch (e) { return }
|
|
27
|
+
if (!msg || typeof msg !== 'object') return
|
|
28
|
+
const byteLen = buf.length || buf.byteLength || 0
|
|
29
|
+
quality.recordBytesIn(byteLen)
|
|
30
|
+
|
|
31
|
+
if (msg.type === MSG.HEARTBEAT) {
|
|
32
|
+
send(MSG.HEARTBEAT_ACK, { ts: msg.payload?.ts })
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (msg.type === MSG.HEARTBEAT_ACK && msg.payload?.ts) {
|
|
36
|
+
quality.recordRtt(Date.now() - msg.payload.ts)
|
|
37
|
+
quality.recordHeartbeatReceived()
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
if (msg.type === MSG.HANDSHAKE_ACK) {
|
|
41
|
+
setState('playerId', msg.payload.playerId)
|
|
42
|
+
setState('sessionToken', msg.payload.sessionToken)
|
|
43
|
+
setState('tick', msg.payload.tick)
|
|
44
|
+
emitter.emit('connect', { playerId: msg.payload.playerId, tick: msg.payload.tick })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (msg.type === MSG.SNAPSHOT) { handleSnapshot(msg.payload); return }
|
|
48
|
+
if (msg.type === MSG.PLAYER_LEAVE) {
|
|
49
|
+
getState('players').delete(msg.payload.playerId)
|
|
50
|
+
emitter.emit('playerLeave', msg.payload.playerId)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (msg.type === MSG.RECONNECT_ACK) {
|
|
54
|
+
setState('playerId', msg.payload.playerId)
|
|
55
|
+
setState('sessionToken', msg.payload.sessionToken)
|
|
56
|
+
setState('tick', msg.payload.tick)
|
|
57
|
+
setState('reconnecting', false)
|
|
58
|
+
setState('reconnectAttempts', 0)
|
|
59
|
+
emitter.emit('reconnect', msg.payload)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (msg.type === MSG.STATE_RECOVERY) {
|
|
63
|
+
const decoded = SnapshotEncoder.decode(msg.payload.snapshot)
|
|
64
|
+
for (const p of decoded.players || []) getState('players').set(p.id, p)
|
|
65
|
+
for (const e of decoded.entities || []) getState('entities').set(e.id, e)
|
|
66
|
+
emitter.emit('stateRecovery', decoded)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (msg.type === MSG.STATE_CORRECTION) {
|
|
70
|
+
stateInspector.recordCorrection(getState('playerId'), null, msg.payload, 0)
|
|
71
|
+
emitter.emit('correction', msg.payload)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (msg.type === MSG.DISCONNECT_REASON) {
|
|
75
|
+
emitter.emit('disconnectReason', msg.payload)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
emitter.emit('message', msg)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function swapInstance(target, NewClass, constructArgs, stateKeys) {
|
|
2
|
+
const oldProto = Object.getPrototypeOf(target)
|
|
3
|
+
const oldOwnDescriptors = Object.getOwnPropertyDescriptors(target)
|
|
4
|
+
try {
|
|
5
|
+
const fresh = new NewClass(...constructArgs)
|
|
6
|
+
Object.setPrototypeOf(target, NewClass.prototype)
|
|
7
|
+
const freshDescriptors = Object.getOwnPropertyDescriptors(fresh)
|
|
8
|
+
for (const key of Object.keys(freshDescriptors)) {
|
|
9
|
+
if (!stateKeys.includes(key)) {
|
|
10
|
+
Object.defineProperty(target, key, freshDescriptors[key])
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
for (const key of Object.keys(oldOwnDescriptors)) {
|
|
14
|
+
if (!(key in freshDescriptors) && !stateKeys.includes(key)) {
|
|
15
|
+
delete target[key]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
} catch (e) {
|
|
19
|
+
Object.setPrototypeOf(target, oldProto)
|
|
20
|
+
for (const [key, desc] of Object.entries(oldOwnDescriptors)) {
|
|
21
|
+
Object.defineProperty(target, key, desc)
|
|
22
|
+
}
|
|
23
|
+
for (const key of Object.keys(target)) {
|
|
24
|
+
if (!(key in oldOwnDescriptors)) delete target[key]
|
|
25
|
+
}
|
|
26
|
+
throw e
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createReloadHandlers(deps) {
|
|
31
|
+
const {
|
|
32
|
+
networkState, playerManager, physicsIntegration,
|
|
33
|
+
lagCompensator, physics, appRuntime, connections
|
|
34
|
+
} = deps
|
|
35
|
+
|
|
36
|
+
const reloadTickHandler = async () => {
|
|
37
|
+
const { createTickHandler: refreshHandler } = await import('./TickHandler.js?' + Date.now())
|
|
38
|
+
return refreshHandler(deps)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const reloadPhysicsIntegration = async () => {
|
|
42
|
+
const { PhysicsIntegration: New } = await import('../netcode/PhysicsIntegration.js?' + Date.now())
|
|
43
|
+
swapInstance(physicsIntegration, New, [{ ...physicsIntegration.config, physicsWorld: physics }], ['playerBodies'])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const reloadLagCompensator = async () => {
|
|
47
|
+
const { LagCompensator: New } = await import('../netcode/LagCompensator.js?' + Date.now())
|
|
48
|
+
swapInstance(lagCompensator, New, [lagCompensator.historyWindow], ['playerHistory'])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const reloadPlayerManager = async () => {
|
|
52
|
+
const { PlayerManager: New } = await import('../netcode/PlayerManager.js?' + Date.now())
|
|
53
|
+
swapInstance(playerManager, New, [], ['players', 'inputBuffers', 'nextPlayerId'])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const reloadNetworkState = async () => {
|
|
57
|
+
const { NetworkState: New } = await import('../netcode/NetworkState.js?' + Date.now())
|
|
58
|
+
swapInstance(networkState, New, [], ['players', 'tick', 'timestamp'])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
reloadTickHandler,
|
|
63
|
+
reloadPhysicsIntegration,
|
|
64
|
+
reloadLagCompensator,
|
|
65
|
+
reloadPlayerManager,
|
|
66
|
+
reloadNetworkState
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { watch } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export class ReloadManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this._watchers = new Map()
|
|
7
|
+
this._moduleCache = new Map()
|
|
8
|
+
this._reloadState = new Map()
|
|
9
|
+
this._debounceTimers = new Map()
|
|
10
|
+
this._failureCounters = new Map()
|
|
11
|
+
this._validators = new Map()
|
|
12
|
+
this._MAX_FAILURES = 3
|
|
13
|
+
this._MAX_BACKOFF = 400
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
addWatcher(moduleId, filePath, onReload, validator) {
|
|
17
|
+
const absPath = resolve(filePath)
|
|
18
|
+
if (this._watchers.has(moduleId)) return
|
|
19
|
+
this._reloadState.set(moduleId, { inProgress: false, lastSuccess: null, failureCount: 0 })
|
|
20
|
+
this._failureCounters.set(moduleId, 0)
|
|
21
|
+
if (validator) this._validators.set(moduleId, validator)
|
|
22
|
+
const startWatch = async () => {
|
|
23
|
+
try {
|
|
24
|
+
const ac = new AbortController()
|
|
25
|
+
this._watchers.set(moduleId, ac)
|
|
26
|
+
const watcher = watch(absPath, { signal: ac.signal })
|
|
27
|
+
;(async () => {
|
|
28
|
+
try {
|
|
29
|
+
for await (const event of watcher) {
|
|
30
|
+
this._debounce(moduleId, () => this._handleReload(moduleId, onReload))
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
if (e.name !== 'AbortError') {
|
|
34
|
+
console.error(`[ReloadManager] watch error for ${moduleId}:`, e.message)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
})()
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error(`[ReloadManager] failed to start watcher for ${moduleId}:`, e.message)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
startWatch()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_debounce(moduleId, fn) {
|
|
46
|
+
if (this._debounceTimers.has(moduleId)) {
|
|
47
|
+
clearTimeout(this._debounceTimers.get(moduleId))
|
|
48
|
+
}
|
|
49
|
+
this._failureCounters.set(moduleId, 0)
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
fn()
|
|
52
|
+
this._debounceTimers.delete(moduleId)
|
|
53
|
+
}, 100)
|
|
54
|
+
this._debounceTimers.set(moduleId, timer)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async _handleReload(moduleId, onReload) {
|
|
58
|
+
const state = this._reloadState.get(moduleId)
|
|
59
|
+
if (!state) return
|
|
60
|
+
if (state.inProgress) return
|
|
61
|
+
state.inProgress = true
|
|
62
|
+
const failureCount = this._failureCounters.get(moduleId) || 0
|
|
63
|
+
if (failureCount >= this._MAX_FAILURES) {
|
|
64
|
+
console.error(`[ReloadManager] ${moduleId} exceeded max failures, stopping auto-reload`)
|
|
65
|
+
state.inProgress = false
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const validator = this._validators.get(moduleId)
|
|
69
|
+
if (validator) {
|
|
70
|
+
try {
|
|
71
|
+
const valid = await validator()
|
|
72
|
+
if (!valid) {
|
|
73
|
+
console.warn(`[ReloadManager] ${moduleId} failed pre-reload validation, skipping`)
|
|
74
|
+
state.inProgress = false
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.warn(`[ReloadManager] ${moduleId} validation error, skipping:`, e.message)
|
|
79
|
+
state.inProgress = false
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
await onReload()
|
|
85
|
+
this._failureCounters.set(moduleId, 0)
|
|
86
|
+
state.lastSuccess = Date.now()
|
|
87
|
+
console.log(`[ReloadManager] successfully reloaded ${moduleId}`)
|
|
88
|
+
} catch (e) {
|
|
89
|
+
const newFailureCount = failureCount + 1
|
|
90
|
+
this._failureCounters.set(moduleId, newFailureCount)
|
|
91
|
+
const backoff = Math.min(100 * Math.pow(2, failureCount - 1), this._MAX_BACKOFF)
|
|
92
|
+
console.error(`[ReloadManager] reload failed for ${moduleId} (${newFailureCount}/${this._MAX_FAILURES}):`, e.message)
|
|
93
|
+
if (newFailureCount < this._MAX_FAILURES) {
|
|
94
|
+
console.log(`[ReloadManager] retrying ${moduleId} in ${backoff}ms`)
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, backoff))
|
|
96
|
+
state.inProgress = false
|
|
97
|
+
await this._handleReload(moduleId, onReload)
|
|
98
|
+
return
|
|
99
|
+
} else {
|
|
100
|
+
console.error(`[ReloadManager] ${moduleId} gave up after ${newFailureCount} failures`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
state.inProgress = false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stopWatcher(moduleId) {
|
|
107
|
+
const ac = this._watchers.get(moduleId)
|
|
108
|
+
if (ac) ac.abort()
|
|
109
|
+
this._watchers.delete(moduleId)
|
|
110
|
+
this._debounceTimers.delete(moduleId)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
stopAllWatchers() {
|
|
114
|
+
for (const ac of this._watchers.values()) ac.abort()
|
|
115
|
+
this._watchers.clear()
|
|
116
|
+
for (const timer of this._debounceTimers.values()) clearTimeout(timer)
|
|
117
|
+
this._debounceTimers.clear()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
cacheModule(moduleId, module) { this._moduleCache.set(moduleId, module) }
|
|
121
|
+
getModule(moduleId) { return this._moduleCache.get(moduleId) }
|
|
122
|
+
getState(moduleId) { return this._reloadState.get(moduleId) }
|
|
123
|
+
getStats() {
|
|
124
|
+
return { watchers: this._watchers.size, modules: this._moduleCache.size, failures: Object.fromEntries(this._failureCounters) }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from 'node:http'
|
|
2
|
+
import { WebSocketServer as WSServer } from 'ws'
|
|
3
|
+
import { MSG } from '../protocol/MessageTypes.js'
|
|
4
|
+
import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
|
|
5
|
+
import { createStaticHandler } from './StaticHandler.js'
|
|
6
|
+
import { WebSocketTransport } from '../transport/WebSocketTransport.js'
|
|
7
|
+
import { WebTransportServer } from '../transport/WebTransportServer.js'
|
|
8
|
+
|
|
9
|
+
export function createServerAPI(ctx) {
|
|
10
|
+
const { config, port, tickRate, staticDirs, appLoader, appRuntime, physics, stageLoader } = ctx
|
|
11
|
+
const { tickSystem, playerManager, networkState, lagCompensator, connections, sessions, inspector, emitter, reloadManager, eventBus, eventLog, storage } = ctx
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
physics,
|
|
15
|
+
runtime: appRuntime,
|
|
16
|
+
loader: appLoader,
|
|
17
|
+
tickSystem,
|
|
18
|
+
playerManager,
|
|
19
|
+
networkState,
|
|
20
|
+
lagCompensator,
|
|
21
|
+
connections,
|
|
22
|
+
sessions,
|
|
23
|
+
inspector,
|
|
24
|
+
emitter,
|
|
25
|
+
reloadManager,
|
|
26
|
+
eventBus,
|
|
27
|
+
eventLog,
|
|
28
|
+
storage,
|
|
29
|
+
on: emitter.on.bind(emitter),
|
|
30
|
+
off: emitter.off.bind(emitter),
|
|
31
|
+
|
|
32
|
+
stageLoader,
|
|
33
|
+
|
|
34
|
+
async loadWorld(worldDef) {
|
|
35
|
+
ctx.currentWorldDef = worldDef
|
|
36
|
+
if (worldDef.spawnPoint) ctx.worldSpawnPoint = [...worldDef.spawnPoint]
|
|
37
|
+
await appLoader.loadAll()
|
|
38
|
+
const stage = stageLoader.loadFromDefinition('main', worldDef)
|
|
39
|
+
return { entities: new Map(), apps: new Map(), count: stage.entityCount }
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async start() {
|
|
43
|
+
await appLoader.loadAll()
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
if (staticDirs.length > 0) {
|
|
46
|
+
ctx.httpServer = createHttpServer(createStaticHandler(staticDirs))
|
|
47
|
+
ctx.wss = new WSServer({ server: ctx.httpServer, path: '/ws' })
|
|
48
|
+
ctx.httpServer.on('error', reject)
|
|
49
|
+
ctx.httpServer.listen(port, () => {
|
|
50
|
+
attachWSHandlers(ctx)
|
|
51
|
+
resolve({ port: ctx.port, tickRate: ctx.tickRate })
|
|
52
|
+
})
|
|
53
|
+
} else {
|
|
54
|
+
ctx.wss = new WSServer({ port }, () => {
|
|
55
|
+
attachWSHandlers(ctx)
|
|
56
|
+
resolve({ port: ctx.port, tickRate: ctx.tickRate })
|
|
57
|
+
})
|
|
58
|
+
ctx.wss.on('error', reject)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
stop() {
|
|
64
|
+
tickSystem.stop()
|
|
65
|
+
appLoader.stopWatching()
|
|
66
|
+
reloadManager.stopAllWatchers()
|
|
67
|
+
connections.destroy()
|
|
68
|
+
sessions.destroyAll()
|
|
69
|
+
if (ctx.wtServer) ctx.wtServer.stop()
|
|
70
|
+
if (ctx.wss) ctx.wss.close()
|
|
71
|
+
if (ctx.httpServer) ctx.httpServer.close()
|
|
72
|
+
physics.destroy()
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
send(id, type, p) {
|
|
76
|
+
return connections.send(id, type, p)
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
broadcast(type, p) {
|
|
80
|
+
connections.broadcast(type, p)
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
getPlayerCount() {
|
|
84
|
+
return playerManager.getPlayerCount()
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
getEntityCount() {
|
|
88
|
+
return appRuntime.entities.size
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
getSnapshot() {
|
|
92
|
+
return appRuntime.getSnapshot()
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
reloadTickHandler: async () => {
|
|
96
|
+
ctx.setTickHandler(await ctx.reloadHandlers.reloadTickHandler())
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
getReloadStats() {
|
|
100
|
+
return reloadManager.getStats()
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
getAllStats() {
|
|
104
|
+
return {
|
|
105
|
+
connections: connections.getAllStats(),
|
|
106
|
+
inspector: inspector.getAllClients(connections),
|
|
107
|
+
sessions: sessions.getActiveCount(),
|
|
108
|
+
tick: tickSystem.currentTick,
|
|
109
|
+
players: playerManager.getPlayerCount()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function attachWSHandlers(ctx) {
|
|
116
|
+
ctx.wss.on('connection', (socket) => {
|
|
117
|
+
ctx.onClientConnect(new WebSocketTransport(socket))
|
|
118
|
+
})
|
|
119
|
+
if (ctx.config.webTransport) {
|
|
120
|
+
const wtp = ctx.config.webTransport.port || 4433
|
|
121
|
+
ctx.wtServer = new WebTransportServer({
|
|
122
|
+
port: wtp,
|
|
123
|
+
cert: ctx.config.webTransport.cert,
|
|
124
|
+
key: ctx.config.webTransport.key
|
|
125
|
+
})
|
|
126
|
+
ctx.wtServer.on('session', ctx.onClientConnect)
|
|
127
|
+
if (ctx.wtServer.start()) console.log()
|
|
128
|
+
}
|
|
129
|
+
ctx.tickSystem.onTick(ctx.onTick)
|
|
130
|
+
ctx.tickSystem.start()
|
|
131
|
+
ctx.appLoader.watchAll()
|
|
132
|
+
ctx.setupSDKWatchers()
|
|
133
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { MSG, DISCONNECT_REASONS } from '../protocol/MessageTypes.js'
|
|
2
|
+
import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
|
|
3
|
+
|
|
4
|
+
export function createConnectionHandlers(ctx) {
|
|
5
|
+
const { tickSystem, playerManager, networkState, lagCompensator, physicsIntegration, connections, sessions, appLoader, appRuntime, emitter, inspector } = ctx
|
|
6
|
+
|
|
7
|
+
function onClientConnect(transport) {
|
|
8
|
+
const sp = [...ctx.worldSpawnPoint]
|
|
9
|
+
const playerId = playerManager.addPlayer(transport, { position: sp })
|
|
10
|
+
networkState.addPlayer(playerId, { position: sp })
|
|
11
|
+
physicsIntegration.addPlayerCollider(playerId, 0.4)
|
|
12
|
+
physicsIntegration.setPlayerPosition(playerId, sp)
|
|
13
|
+
const playerState = playerManager.getPlayer(playerId).state
|
|
14
|
+
lagCompensator.recordPlayerPosition(playerId, playerState.position, playerState.rotation, playerState.velocity, tickSystem.currentTick)
|
|
15
|
+
const client = connections.addClient(playerId, transport)
|
|
16
|
+
client.sessionToken = sessions.create(playerId, playerManager.getPlayer(playerId).state)
|
|
17
|
+
connections.send(playerId, MSG.HANDSHAKE_ACK, { playerId, tick: tickSystem.currentTick, sessionToken: client.sessionToken, tickRate: ctx.tickRate })
|
|
18
|
+
if (ctx.currentWorldDef) {
|
|
19
|
+
connections.send(playerId, MSG.WORLD_DEF, ctx.currentWorldDef)
|
|
20
|
+
}
|
|
21
|
+
const clientModules = appLoader.getClientModules()
|
|
22
|
+
for (const [appName, code] of Object.entries(clientModules)) {
|
|
23
|
+
connections.send(playerId, MSG.APP_MODULE, { app: appName, code })
|
|
24
|
+
}
|
|
25
|
+
const snap = appRuntime.getSnapshot()
|
|
26
|
+
connections.send(playerId, MSG.SNAPSHOT, { seq: ++ctx.snapshotSeq, ...SnapshotEncoder.encode(snap) })
|
|
27
|
+
appRuntime.fireMessage('game', { type: 'player_join', playerId })
|
|
28
|
+
emitter.emit('playerJoin', { id: playerId })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
connections.on('message', (clientId, msg) => {
|
|
32
|
+
if (inspector.handleMessage(clientId, msg)) return
|
|
33
|
+
if (msg.type === MSG.INPUT || msg.type === MSG.PLAYER_INPUT) {
|
|
34
|
+
playerManager.addInput(clientId, msg.payload?.input || msg.payload)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (msg.type === MSG.APP_EVENT) {
|
|
38
|
+
if (msg.payload?.entityId) appRuntime.fireInteract(msg.payload.entityId, { id: clientId })
|
|
39
|
+
if (msg.payload?.type === 'fire') {
|
|
40
|
+
const shooter = playerManager.getPlayer(clientId)
|
|
41
|
+
const pos = shooter?.state?.position || [0, 0, 0]
|
|
42
|
+
const origin = [pos[0], pos[1] + 0.9, pos[2]]
|
|
43
|
+
appRuntime.fireMessage('game', { ...msg.payload, shooterId: clientId, origin })
|
|
44
|
+
}
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (msg.type === MSG.RECONNECT) {
|
|
48
|
+
const session = sessions.get(msg.payload?.sessionToken)
|
|
49
|
+
if (!session) {
|
|
50
|
+
connections.send(clientId, MSG.DISCONNECT_REASON, { code: DISCONNECT_REASONS.INVALID_SESSION })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const snap = networkState.getSnapshot()
|
|
54
|
+
const ents = appRuntime.getSnapshot()
|
|
55
|
+
connections.send(clientId, MSG.RECONNECT_ACK, { playerId: session.playerId, tick: tickSystem.currentTick, sessionToken: msg.payload.sessionToken })
|
|
56
|
+
connections.send(clientId, MSG.STATE_RECOVERY, { snapshot: SnapshotEncoder.encode({ tick: snap.tick, timestamp: snap.timestamp, players: snap.players, entities: ents.entities }), tick: tickSystem.currentTick })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
emitter.emit('message', clientId, msg)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
connections.on('disconnect', (clientId, reason) => {
|
|
63
|
+
const client = connections.getClient(clientId)
|
|
64
|
+
if (client?.sessionToken) { const p = playerManager.getPlayer(clientId); if (p) sessions.update(client.sessionToken, { state: p.state }) }
|
|
65
|
+
appRuntime.fireMessage('game', { type: 'player_leave', playerId: clientId })
|
|
66
|
+
physicsIntegration.removePlayerCollider(clientId)
|
|
67
|
+
lagCompensator.clearPlayerHistory(clientId)
|
|
68
|
+
inspector.removeClient(clientId)
|
|
69
|
+
playerManager.removePlayer(clientId)
|
|
70
|
+
networkState.removePlayer(clientId)
|
|
71
|
+
connections.broadcast(MSG.PLAYER_LEAVE, { playerId: clientId })
|
|
72
|
+
emitter.emit('playerLeave', { id: clientId, reason })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return { onClientConnect }
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join, extname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MIME_TYPES = {
|
|
5
|
+
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
|
6
|
+
'.json': 'application/json', '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json',
|
|
7
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp',
|
|
8
|
+
'.svg': 'image/svg+xml', '.wasm': 'application/wasm'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createStaticHandler(dirs) {
|
|
12
|
+
return (req, res) => {
|
|
13
|
+
const url = req.url.split('?')[0]
|
|
14
|
+
for (const { prefix, dir } of dirs) {
|
|
15
|
+
if (!url.startsWith(prefix)) continue
|
|
16
|
+
const relative = url === prefix ? '/index.html' : url.slice(prefix.length)
|
|
17
|
+
const fp = join(dir, relative)
|
|
18
|
+
if (existsSync(fp)) {
|
|
19
|
+
const ext = extname(fp)
|
|
20
|
+
const headers = { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' }
|
|
21
|
+
if (ext === '.js' || ext === '.html' || ext === '.css') {
|
|
22
|
+
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
23
|
+
}
|
|
24
|
+
res.writeHead(200, headers)
|
|
25
|
+
res.end(readFileSync(fp))
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
res.writeHead(404)
|
|
30
|
+
res.end('not found')
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { MSG } from '../protocol/MessageTypes.js'
|
|
2
|
+
import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
|
|
3
|
+
import { applyMovement, DEFAULT_MOVEMENT } from '../shared/movement.js'
|
|
4
|
+
|
|
5
|
+
export function createTickHandler(deps) {
|
|
6
|
+
const {
|
|
7
|
+
networkState, playerManager, physicsIntegration,
|
|
8
|
+
lagCompensator, physics, appRuntime, connections,
|
|
9
|
+
movement: m = {}, stageLoader, eventLog
|
|
10
|
+
} = deps
|
|
11
|
+
const movement = { ...DEFAULT_MOVEMENT, ...m }
|
|
12
|
+
const collisionRestitution = m.collisionRestitution || 0.2
|
|
13
|
+
const collisionDamping = m.collisionDamping || 0.25
|
|
14
|
+
let snapshotSeq = 0
|
|
15
|
+
|
|
16
|
+
return function onTick(tick, dt) {
|
|
17
|
+
networkState.setTick(tick, Date.now())
|
|
18
|
+
for (const player of playerManager.getConnectedPlayers()) {
|
|
19
|
+
const inputs = playerManager.getInputs(player.id)
|
|
20
|
+
const st = player.state
|
|
21
|
+
let inp = null
|
|
22
|
+
|
|
23
|
+
if (inputs.length > 0) {
|
|
24
|
+
inp = inputs[inputs.length - 1].data
|
|
25
|
+
if (inp) {
|
|
26
|
+
const yaw = inp.yaw || 0
|
|
27
|
+
st.rotation = [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)]
|
|
28
|
+
}
|
|
29
|
+
playerManager.clearInputs(player.id)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
applyMovement(st, inp, movement, dt)
|
|
33
|
+
const updated = physicsIntegration.updatePlayerPhysics(player.id, st, dt)
|
|
34
|
+
st.position = updated.position
|
|
35
|
+
st.velocity = updated.velocity
|
|
36
|
+
st.onGround = updated.onGround
|
|
37
|
+
lagCompensator.recordPlayerPosition(player.id, st.position, st.rotation, st.velocity, tick)
|
|
38
|
+
networkState.updatePlayer(player.id, {
|
|
39
|
+
position: st.position, rotation: st.rotation,
|
|
40
|
+
velocity: st.velocity, onGround: st.onGround,
|
|
41
|
+
health: st.health, inputSequence: player.inputSequence
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
const players = playerManager.getConnectedPlayers()
|
|
45
|
+
for (const player of players) {
|
|
46
|
+
const collisions = physicsIntegration.checkCollisionWithOthers(player.id, players)
|
|
47
|
+
for (const collision of collisions) {
|
|
48
|
+
const other = playerManager.getPlayer(collision.playerId)
|
|
49
|
+
if (!other) continue
|
|
50
|
+
const dx = collision.normal[0], dy = collision.normal[1], dz = collision.normal[2]
|
|
51
|
+
const relVx = other.state.velocity[0] - player.state.velocity[0]
|
|
52
|
+
const relVz = other.state.velocity[2] - player.state.velocity[2]
|
|
53
|
+
const relDotNorm = relVx * dx + relVz * dz
|
|
54
|
+
if (relDotNorm >= 0) continue
|
|
55
|
+
const impulse = (1 + collisionRestitution) * relDotNorm * 0.5
|
|
56
|
+
player.state.velocity[0] -= impulse * dx * collisionDamping
|
|
57
|
+
player.state.velocity[2] -= impulse * dz * collisionDamping
|
|
58
|
+
other.state.velocity[0] += impulse * dx * collisionDamping
|
|
59
|
+
other.state.velocity[2] += impulse * dz * collisionDamping
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
physics.step(dt)
|
|
63
|
+
appRuntime.tick(tick, dt)
|
|
64
|
+
const playerSnap = networkState.getSnapshot()
|
|
65
|
+
snapshotSeq++
|
|
66
|
+
if (stageLoader && stageLoader.getActiveStage()) {
|
|
67
|
+
for (const player of players) {
|
|
68
|
+
const pos = player.state.position
|
|
69
|
+
const entitySnap = appRuntime.getSnapshotForPlayer(pos, stageLoader.getActiveStage().spatial.relevanceRadius)
|
|
70
|
+
const combined = { tick: playerSnap.tick, timestamp: playerSnap.timestamp, players: playerSnap.players, entities: entitySnap.entities }
|
|
71
|
+
connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...SnapshotEncoder.encode(combined) })
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
const entitySnap = appRuntime.getSnapshot()
|
|
75
|
+
const combined = { tick: playerSnap.tick, timestamp: playerSnap.timestamp, players: playerSnap.players, entities: entitySnap.entities }
|
|
76
|
+
connections.broadcast(MSG.SNAPSHOT, { seq: snapshotSeq, ...SnapshotEncoder.encode(combined) })
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
appRuntime._drainReloadQueue()
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.error('[TickHandler] reload queue error:', e.message)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|