spoint 0.1.80 → 0.1.82

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -64,7 +64,7 @@ export class AppRuntime {
64
64
  }
65
65
  if (config.autoTrimesh && entity.model && this._physics) {
66
66
  entity.collider = { type: 'trimesh', model: entity.model }
67
- this._physics.addStaticTrimeshAsync(this.resolveAssetPath(entity.model), 0)
67
+ this._physics.addStaticTrimeshAsync(this.resolveAssetPath(entity.model), 0, entity.position || [0,0,0])
68
68
  .then(id => { entity._physicsBodyId = id })
69
69
  .catch(e => console.error(`[AppRuntime] Failed to create trimesh for ${entity.model}:`, e.message))
70
70
  }
@@ -127,6 +127,18 @@ export class ConnectionManager extends EventEmitter {
127
127
  return count
128
128
  }
129
129
 
130
+ sendPacked(clientId, data, unreliable) {
131
+ const client = this.clients.get(clientId)
132
+ if (!client || !client.transport.isOpen) return false
133
+ try {
134
+ if (unreliable) return client.transport.sendUnreliable(data)
135
+ return client.transport.send(data)
136
+ } catch (err) {
137
+ console.error(`[connection] sendPacked error to ${clientId}:`, err.message)
138
+ return false
139
+ }
140
+ }
141
+
130
142
  getAllStats() {
131
143
  return {
132
144
  activeConnections: this.clients.size,
@@ -28,21 +28,7 @@ function encodeEntity(e) {
28
28
  ]
29
29
  }
30
30
 
31
- function entityKey(encoded) {
32
- let k = encoded[1]
33
- for (let i = 2; i < 10; i++) k += '|' + encoded[i]
34
- k += '|' + encoded[9]
35
- if (encoded[10] !== null && encoded[10] !== undefined) k += '|' + JSON.stringify(encoded[10])
36
- return k
37
- }
38
-
39
31
  export class SnapshotEncoder {
40
- static encode(snapshot) {
41
- const players = (snapshot.players || []).map(encodePlayer)
42
- const entities = (snapshot.entities || []).map(encodeEntity)
43
- return { tick: snapshot.tick || 0, timestamp: snapshot.timestamp || 0, players, entities }
44
- }
45
-
46
32
  static encodeDelta(snapshot, prevEntityMap) {
47
33
  const players = (snapshot.players || []).map(encodePlayer)
48
34
  const currentIds = new Set()
@@ -50,11 +36,16 @@ export class SnapshotEncoder {
50
36
  const nextMap = new Map()
51
37
  for (const e of snapshot.entities || []) {
52
38
  const encoded = encodeEntity(e)
53
- const key = entityKey(encoded)
54
39
  currentIds.add(e.id)
55
- nextMap.set(e.id, key)
56
40
  const prev = prevEntityMap.get(e.id)
57
- if (prev !== key) entities.push(encoded)
41
+ let k = encoded[1]
42
+ for (let i = 2; i < 10; i++) k += '|' + encoded[i]
43
+ k += '|' + encoded[9]
44
+ const cust = encoded[10]
45
+ const custStr = (prev && prev[1] === cust) ? prev[2] : (cust != null ? JSON.stringify(cust) : '')
46
+ k += '|' + custStr
47
+ nextMap.set(e.id, [k, cust, custStr])
48
+ if (!prev || prev[0] !== k) entities.push(encoded)
58
49
  }
59
50
  const removed = []
60
51
  for (const id of prevEntityMap.keys()) {
@@ -66,6 +57,12 @@ export class SnapshotEncoder {
66
57
  }
67
58
  }
68
59
 
60
+ static encode(snapshot) {
61
+ const players = (snapshot.players || []).map(encodePlayer)
62
+ const entities = (snapshot.entities || []).map(encodeEntity)
63
+ return { tick: snapshot.tick || 0, timestamp: snapshot.timestamp || 0, players, entities }
64
+ }
65
+
69
66
  static decode(data) {
70
67
  if (data.players && Array.isArray(data.players)) {
71
68
  const players = data.players.map(p => {
@@ -364,6 +364,164 @@ async function extractMeshWithMeshopt(buf, json, prim, binOffset, meshName) {
364
364
  }
365
365
  }
366
366
 
367
+ /**
368
+ * Extract ALL meshes and ALL primitives from a GLB file and combine them into
369
+ * a single flat vertex/index buffer suitable for a trimesh collider.
370
+ * Handles Draco-compressed primitives. Used for map collision where the GLB
371
+ * may have dozens of meshes each with many primitives.
372
+ *
373
+ * @param {string} filepath - Path to GLB file
374
+ * @returns {Promise<Object>} {vertices: Float32Array, indices: Uint32Array, vertexCount, triangleCount}
375
+ */
376
+ export async function extractAllMeshesFromGLBAsync(filepath) {
377
+ console.log(`[GLBLoader] Extracting ALL meshes from: ${filepath}`)
378
+ const buf = readFileSync(filepath)
379
+ if (buf.toString('ascii', 0, 4) !== 'glTF') throw new Error('Not a GLB file')
380
+
381
+ const jsonLen = buf.readUInt32LE(12)
382
+ const json = JSON.parse(buf.toString('utf-8', 20, 20 + jsonLen))
383
+ const binOffset = 20 + jsonLen + 8
384
+
385
+ const allVertices = []
386
+ const allIndices = []
387
+ let vertexOffset = 0
388
+ let totalTriangles = 0
389
+
390
+ // Build a node->transform map for node hierarchy
391
+ const nodeTransforms = buildNodeTransforms(json)
392
+
393
+ for (let meshIdx = 0; meshIdx < (json.meshes || []).length; meshIdx++) {
394
+ const mesh = json.meshes[meshIdx]
395
+ // Find node referencing this mesh to get its world transform
396
+ const nodeIdx = (json.nodes || []).findIndex(n => n.mesh === meshIdx)
397
+ const worldTransform = nodeIdx >= 0 ? nodeTransforms[nodeIdx] : null
398
+
399
+ for (let primIdx = 0; primIdx < mesh.primitives.length; primIdx++) {
400
+ const prim = mesh.primitives[primIdx]
401
+ let result
402
+ try {
403
+ if (prim.extensions?.KHR_draco_mesh_compression) {
404
+ result = await decompressDracoMesh(buf, json, prim, binOffset, mesh.name)
405
+ } else {
406
+ result = extractStandardMesh(buf, json, prim, binOffset, mesh.name)
407
+ }
408
+ } catch (e) {
409
+ console.warn(`[GLBLoader] Skipping mesh[${meshIdx}] prim[${primIdx}]: ${e.message}`)
410
+ continue
411
+ }
412
+
413
+ if (!result.indices || result.triangleCount === 0) continue
414
+
415
+ // Apply world transform to vertices if present
416
+ let verts = result.vertices
417
+ if (worldTransform) {
418
+ verts = applyTransformMatrix(result.vertices, worldTransform)
419
+ }
420
+
421
+ allVertices.push(verts)
422
+ // Remap indices by current vertex offset
423
+ const remapped = new Uint32Array(result.indices.length)
424
+ for (let i = 0; i < result.indices.length; i++) {
425
+ remapped[i] = result.indices[i] + vertexOffset
426
+ }
427
+ allIndices.push(remapped)
428
+ vertexOffset += result.vertexCount
429
+ totalTriangles += result.triangleCount
430
+ }
431
+ }
432
+
433
+ if (allVertices.length === 0) throw new Error('No valid mesh primitives found in GLB')
434
+
435
+ // Concatenate all vertex and index arrays
436
+ const totalVerts = vertexOffset
437
+ const combinedVertices = new Float32Array(totalVerts * 3)
438
+ let vOff = 0
439
+ for (const v of allVertices) { combinedVertices.set(v, vOff); vOff += v.length }
440
+
441
+ const combinedIndices = new Uint32Array(totalTriangles * 3)
442
+ let iOff = 0
443
+ for (const idx of allIndices) { combinedIndices.set(idx, iOff); iOff += idx.length }
444
+
445
+ console.log(`[GLBLoader] Combined: ${totalVerts} vertices, ${totalTriangles} triangles from ${allIndices.length} primitives`)
446
+ return { vertices: combinedVertices, indices: combinedIndices, vertexCount: totalVerts, triangleCount: totalTriangles }
447
+ }
448
+
449
+ /**
450
+ * Build world-space 4x4 transform matrices for every node in the scene graph.
451
+ * Returns an array indexed by node index.
452
+ */
453
+ function buildNodeTransforms(json) {
454
+ const nodes = json.nodes || []
455
+ const matrices = new Array(nodes.length).fill(null)
456
+
457
+ function getMatrix(nodeIdx) {
458
+ if (matrices[nodeIdx] !== null) return matrices[nodeIdx]
459
+ const node = nodes[nodeIdx]
460
+ let local = mat4Identity()
461
+ if (node.matrix) {
462
+ local = node.matrix.slice()
463
+ } else {
464
+ const t = node.translation || [0, 0, 0]
465
+ const r = node.rotation || [0, 0, 0, 1]
466
+ const s = node.scale || [1, 1, 1]
467
+ local = mat4TRS(t, r, s)
468
+ }
469
+ // Find parent
470
+ const parentIdx = nodes.findIndex((n, i) => i !== nodeIdx && (n.children || []).includes(nodeIdx))
471
+ if (parentIdx >= 0) {
472
+ local = mat4Mul(getMatrix(parentIdx), local)
473
+ }
474
+ matrices[nodeIdx] = local
475
+ return local
476
+ }
477
+
478
+ for (let i = 0; i < nodes.length; i++) getMatrix(i)
479
+ return matrices
480
+ }
481
+
482
+ function mat4Identity() {
483
+ return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]
484
+ }
485
+
486
+ function mat4TRS(t, r, s) {
487
+ const [qx, qy, qz, qw] = r
488
+ const [sx, sy, sz] = s
489
+ const x2=qx+qx, y2=qy+qy, z2=qz+qz
490
+ const xx=qx*x2, xy=qx*y2, xz=qx*z2
491
+ const yy=qy*y2, yz=qy*z2, zz=qz*z2
492
+ const wx=qw*x2, wy=qw*y2, wz=qw*z2
493
+ return [
494
+ (1-(yy+zz))*sx, (xy+wz)*sx, (xz-wy)*sx, 0,
495
+ (xy-wz)*sy, (1-(xx+zz))*sy,(yz+wx)*sy, 0,
496
+ (xz+wy)*sz, (yz-wx)*sz, (1-(xx+yy))*sz,0,
497
+ t[0], t[1], t[2], 1
498
+ ]
499
+ }
500
+
501
+ function mat4Mul(a, b) {
502
+ const out = new Array(16)
503
+ for (let row = 0; row < 4; row++) {
504
+ for (let col = 0; col < 4; col++) {
505
+ let sum = 0
506
+ for (let k = 0; k < 4; k++) sum += a[row + k*4] * b[k + col*4]
507
+ out[row + col*4] = sum
508
+ }
509
+ }
510
+ return out
511
+ }
512
+
513
+ function applyTransformMatrix(vertices, m) {
514
+ const count = vertices.length / 3
515
+ const out = new Float32Array(vertices.length)
516
+ for (let i = 0; i < count; i++) {
517
+ const x = vertices[i*3], y = vertices[i*3+1], z = vertices[i*3+2]
518
+ out[i*3] = m[0]*x + m[4]*y + m[8]*z + m[12]
519
+ out[i*3+1] = m[1]*x + m[5]*y + m[9]*z + m[13]
520
+ out[i*3+2] = m[2]*x + m[6]*y + m[10]*z + m[14]
521
+ }
522
+ return out
523
+ }
524
+
367
525
  /**
368
526
  * Check if a GLB file has Draco-compressed meshes without attempting extraction.
369
527
  * Useful for validation and error reporting.
@@ -1,5 +1,5 @@
1
- import initJolt from 'jolt-physics/wasm-compat'
2
- import { extractMeshFromGLB, extractMeshFromGLBAsync } from './GLBLoader.js'
1
+ import initJolt from 'jolt-physics/wasm-compat'
2
+ import { extractMeshFromGLB, extractMeshFromGLBAsync, extractAllMeshesFromGLBAsync } from './GLBLoader.js'
3
3
  const LAYER_STATIC = 0, LAYER_DYNAMIC = 1, NUM_LAYERS = 2
4
4
  let joltInstance = null
5
5
  async function getJolt() { if (!joltInstance) joltInstance = await initJolt(); return joltInstance }
@@ -71,110 +71,92 @@ export class PhysicsWorld {
71
71
  layer = motionType === 'static' ? LAYER_STATIC : LAYER_DYNAMIC
72
72
  return this._addBody(shape, position, mt, layer, { ...opts, meta: { type: motionType, shape: shapeType } })
73
73
  }
74
- addStaticTrimesh(glbPath, meshIndex = 0) {
75
- const J = this.Jolt
76
- const mesh = extractMeshFromGLB(glbPath, meshIndex)
77
-
78
- // Apply node transform if present (scale, rotation, translation)
79
- let vertices = mesh.vertices
80
- const nodeT = mesh.nodeTransform
81
- if (nodeT) {
82
- const numVerts = mesh.vertexCount
83
- vertices = new Float32Array(numVerts * 3)
84
-
85
- const scale = nodeT.scale || [1, 1, 1]
86
- const translation = nodeT.translation || [0, 0, 0]
87
- const rotation = nodeT.rotation
88
-
89
- for (let i = 0; i < numVerts; i++) {
90
- let x = mesh.vertices[i * 3] * scale[0]
91
- let y = mesh.vertices[i * 3 + 1] * scale[1]
92
- let z = mesh.vertices[i * 3 + 2] * scale[2]
93
-
94
- if (rotation) {
95
- const [qx, qy, qz, qw] = rotation
96
- const ix = qw * x + qy * z - qz * y
97
- const iy = qw * y + qz * x - qx * z
98
- const iz = qw * z + qx * y - qy * x
99
- const iw = -qx * x - qy * y - qz * z
100
- x = ix * qw - iw * qx - iy * qz + iz * qy
101
- y = iy * qw - iw * qy - iz * qx + ix * qz
102
- z = iz * qw - iw * qz - ix * qy + iy * qx
103
- }
104
-
105
- vertices[i * 3] = x + translation[0]
106
- vertices[i * 3 + 1] = y + translation[1]
107
- vertices[i * 3 + 2] = z + translation[2]
108
- }
109
- }
110
-
111
- const triangles = new J.TriangleList(); triangles.resize(mesh.triangleCount)
112
- for (let t = 0; t < mesh.triangleCount; t++) {
113
- const tri = triangles.at(t)
114
- for (let v = 0; v < 3; v++) {
115
- const idx = mesh.indices[t * 3 + v]
116
- tri.set_mV(v, new J.Float3(vertices[idx * 3], vertices[idx * 3 + 1], vertices[idx * 3 + 2]))
117
- }
118
- }
119
- const shape = new J.MeshShapeSettings(triangles).Create().Get()
120
- return this._addBody(shape, [0, 0, 0], J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', mesh: mesh.name, triangles: mesh.triangleCount } })
121
- }
74
+ addStaticTrimesh(glbPath, meshIndex = 0) {
75
+ const J = this.Jolt
76
+ const mesh = extractMeshFromGLB(glbPath, meshIndex)
77
+
78
+ // Apply node transform if present (scale, rotation, translation)
79
+ let vertices = mesh.vertices
80
+ const nodeT = mesh.nodeTransform
81
+ if (nodeT) {
82
+ const numVerts = mesh.vertexCount
83
+ vertices = new Float32Array(numVerts * 3)
84
+
85
+ const scale = nodeT.scale || [1, 1, 1]
86
+ const translation = nodeT.translation || [0, 0, 0]
87
+ const rotation = nodeT.rotation
88
+
89
+ for (let i = 0; i < numVerts; i++) {
90
+ let x = mesh.vertices[i * 3] * scale[0]
91
+ let y = mesh.vertices[i * 3 + 1] * scale[1]
92
+ let z = mesh.vertices[i * 3 + 2] * scale[2]
93
+
94
+ if (rotation) {
95
+ const [qx, qy, qz, qw] = rotation
96
+ const ix = qw * x + qy * z - qz * y
97
+ const iy = qw * y + qz * x - qx * z
98
+ const iz = qw * z + qx * y - qy * x
99
+ const iw = -qx * x - qy * y - qz * z
100
+ x = ix * qw - iw * qx - iy * qz + iz * qy
101
+ y = iy * qw - iw * qy - iz * qx + ix * qz
102
+ z = iz * qw - iw * qz - ix * qy + iy * qx
103
+ }
104
+
105
+ vertices[i * 3] = x + translation[0]
106
+ vertices[i * 3 + 1] = y + translation[1]
107
+ vertices[i * 3 + 2] = z + translation[2]
108
+ }
109
+ }
110
+
111
+ const triangles = new J.TriangleList(); triangles.resize(mesh.triangleCount)
112
+ const f3 = new J.Float3(0, 0, 0)
113
+ for (let t = 0; t < mesh.triangleCount; t++) {
114
+ const tri = triangles.at(t)
115
+ for (let v = 0; v < 3; v++) {
116
+ const idx = mesh.indices[t * 3 + v]
117
+ f3.x = vertices[idx * 3]; f3.y = vertices[idx * 3 + 1]; f3.z = vertices[idx * 3 + 2]
118
+ tri.set_mV(v, f3)
119
+ }
120
+ }
121
+ const settings = new J.MeshShapeSettings(triangles)
122
+ const shape = settings.Create().Get()
123
+ J.destroy(f3)
124
+ J.destroy(triangles)
125
+ J.destroy(settings)
126
+ return this._addBody(shape, [0, 0, 0], J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', mesh: mesh.name, triangles: mesh.triangleCount } })
127
+ }
122
128
 
123
- addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0]) {
124
- return new Promise(async (resolve, reject) => {
125
- try {
126
- const J = this.Jolt
127
- const mesh = await extractMeshFromGLBAsync(glbPath, meshIndex)
128
-
129
- // Apply node transform if present (scale, rotation, translation)
130
- let vertices = mesh.vertices
131
- const nodeT = mesh.nodeTransform
132
- if (nodeT) {
133
- const numVerts = mesh.vertexCount
134
- vertices = new Float32Array(numVerts * 3)
135
-
136
- const scale = nodeT.scale || [1, 1, 1]
137
- const translation = nodeT.translation || [0, 0, 0]
138
- const rotation = nodeT.rotation
139
-
140
- for (let i = 0; i < numVerts; i++) {
141
- let x = mesh.vertices[i * 3] * scale[0]
142
- let y = mesh.vertices[i * 3 + 1] * scale[1]
143
- let z = mesh.vertices[i * 3 + 2] * scale[2]
144
-
145
- if (rotation) {
146
- const [qx, qy, qz, qw] = rotation
147
- const ix = qw * x + qy * z - qz * y
148
- const iy = qw * y + qz * x - qx * z
149
- const iz = qw * z + qx * y - qy * x
150
- const iw = -qx * x - qy * y - qz * z
151
- x = ix * qw - iw * qx - iy * qz + iz * qy
152
- y = iy * qw - iw * qy - iz * qx + ix * qz
153
- z = iz * qw - iw * qz - ix * qy + iy * qx
154
- }
155
-
156
- vertices[i * 3] = x + translation[0]
157
- vertices[i * 3 + 1] = y + translation[1]
158
- vertices[i * 3 + 2] = z + translation[2]
159
- }
160
- }
161
-
162
- const triangles = new J.TriangleList(); triangles.resize(mesh.triangleCount)
163
- for (let t = 0; t < mesh.triangleCount; t++) {
164
- const tri = triangles.at(t)
165
- for (let v = 0; v < 3; v++) {
166
- const idx = mesh.indices[t * 3 + v]
167
- tri.set_mV(v, new J.Float3(vertices[idx * 3], vertices[idx * 3 + 1], vertices[idx * 3 + 2]))
168
- }
169
- }
170
- const shape = new J.MeshShapeSettings(triangles).Create().Get()
171
- const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', mesh: mesh.name, triangles: mesh.triangleCount } })
172
- resolve(id)
173
- } catch (e) {
174
- reject(e)
175
- }
176
- })
177
- }
129
+ addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0]) {
130
+ return new Promise(async (resolve, reject) => {
131
+ try {
132
+ const J = this.Jolt
133
+ // Use combined extraction: all meshes + all primitives (handles Draco, multi-mesh maps)
134
+ const mesh = await extractAllMeshesFromGLBAsync(glbPath)
135
+ const { vertices, indices, triangleCount } = mesh
136
+
137
+ const triangles = new J.TriangleList(); triangles.resize(triangleCount)
138
+ // Reuse a single Float3 to avoid WASM heap growth from per-vertex allocations
139
+ const f3 = new J.Float3(0, 0, 0)
140
+ for (let t = 0; t < triangleCount; t++) {
141
+ const tri = triangles.at(t)
142
+ for (let v = 0; v < 3; v++) {
143
+ const idx = indices[t * 3 + v]
144
+ f3.x = vertices[idx * 3]; f3.y = vertices[idx * 3 + 1]; f3.z = vertices[idx * 3 + 2]
145
+ tri.set_mV(v, f3)
146
+ }
147
+ }
148
+ const settings = new J.MeshShapeSettings(triangles)
149
+ const shape = settings.Create().Get()
150
+ J.destroy(f3)
151
+ J.destroy(triangles)
152
+ J.destroy(settings)
153
+ const id = this._addBody(shape, position, J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', triangles: triangleCount } })
154
+ resolve(id)
155
+ } catch (e) {
156
+ reject(e)
157
+ }
158
+ })
159
+ }
178
160
  addPlayerCharacter(radius, halfHeight, position, mass) {
179
161
  const J = this.Jolt
180
162
  const cvs = new J.CharacterVirtualSettings()
@@ -232,9 +214,7 @@ export class PhysicsWorld {
232
214
  getCharacterPosition(charId) {
233
215
  const ch = this.characters?.get(charId); if (!ch) return [0, 0, 0]
234
216
  const p = ch.GetPosition()
235
- const r = [p.GetX(), p.GetY(), p.GetZ()]
236
- this.Jolt.destroy(p)
237
- return r
217
+ return [p.GetX(), p.GetY(), p.GetZ()]
238
218
  }
239
219
  getCharacterVelocity(charId) {
240
220
  const ch = this.characters?.get(charId); if (!ch) return [0, 0, 0]
@@ -320,8 +300,8 @@ export class PhysicsWorld {
320
300
  const dir = len > 0 ? [direction[0] / len, direction[1] / len, direction[2] / len] : direction
321
301
  const ray = new J.RRayCast(new J.RVec3(origin[0], origin[1], origin[2]), new J.Vec3(dir[0] * maxDistance, dir[1] * maxDistance, dir[2] * maxDistance))
322
302
  const rs = new J.RayCastSettings(), col = new J.CastRayClosestHitCollisionCollector()
323
- const bp = new J.DefaultBroadPhaseLayerFilter(this._ovbp)
324
- const ol = new J.DefaultObjectLayerFilter(this._objFilter)
303
+ const bp = new J.DefaultBroadPhaseLayerFilter(this.jolt.GetObjectVsBroadPhaseLayerFilter(), LAYER_DYNAMIC)
304
+ const ol = new J.DefaultObjectLayerFilter(this.jolt.GetObjectLayerPairFilter(), LAYER_DYNAMIC)
325
305
  const eb = excludeBodyId != null ? this._getBody(excludeBodyId) : null
326
306
  const bf = eb ? new J.IgnoreSingleBodyFilter(eb.GetID()) : new J.BodyFilter()
327
307
  const sf = new J.ShapeFilter()
@@ -335,13 +315,25 @@ export class PhysicsWorld {
335
315
  return result
336
316
  }
337
317
  destroy() {
318
+ if (!this.Jolt) return
319
+ const J = this.Jolt
338
320
  if (this.characters) {
339
- for (const [id, ch] of this.characters) {
340
- this.Jolt.destroy(ch)
341
- }
321
+ for (const ch of this.characters.values()) J.destroy(ch)
342
322
  this.characters.clear()
343
323
  }
324
+ if (this._charFilters) {
325
+ J.destroy(this._charFilters.bp)
326
+ J.destroy(this._charFilters.ol)
327
+ J.destroy(this._charFilters.body)
328
+ J.destroy(this._charFilters.shape)
329
+ this._charFilters = null
330
+ }
331
+ if (this._charUpdateSettings) { J.destroy(this._charUpdateSettings); this._charUpdateSettings = null }
332
+ if (this._charGravity) { J.destroy(this._charGravity); this._charGravity = null }
344
333
  for (const [id] of this.bodies) this.removeBody(id)
345
- if (this.jolt) { this.Jolt.destroy(this.jolt); this.jolt = null }
334
+ if (this._tmpVec3) { J.destroy(this._tmpVec3); this._tmpVec3 = null }
335
+ if (this._tmpRVec3) { J.destroy(this._tmpRVec3); this._tmpRVec3 = null }
336
+ if (this.jolt) { J.destroy(this.jolt); this.jolt = null }
337
+ this.physicsSystem = null; this.bodyInterface = null
346
338
  }
347
339
  }
@@ -0,0 +1,87 @@
1
+ import { WebSocket } from 'ws'
2
+ import { pack, unpack } from '../protocol/msgpack.js'
3
+
4
+ const CONFIG = {
5
+ botCount: 100,
6
+ durationMs: 60000,
7
+ inputHz: 60,
8
+ serverUrl: process.env.BOT_URL || 'ws://localhost:3001/ws',
9
+ batchSize: 10,
10
+ batchDelayMs: 200
11
+ }
12
+
13
+ const MSG_INPUT = 0x11
14
+ const MSG_SNAPSHOT = 0x10
15
+
16
+ function makeInput(botId, tick) {
17
+ const phase = (tick / 80 + botId * 0.37) % 1
18
+ return {
19
+ forward: phase < 0.7,
20
+ backward: phase > 0.85,
21
+ left: phase > 0.72 && phase < 0.82,
22
+ right: phase > 0.82 && phase < 0.85,
23
+ jump: tick % 200 === botId % 200,
24
+ sprint: tick % 400 < 300,
25
+ yaw: (botId / CONFIG.botCount) * Math.PI * 2 + Math.sin(tick / 180) * 0.8,
26
+ pitch: 0,
27
+ crouch: false,
28
+ interact: false
29
+ }
30
+ }
31
+
32
+ const stats = { connected: 0, snapshots: 0, errors: 0 }
33
+
34
+ function createBot(botId) {
35
+ let tick = 0
36
+ let interval = null
37
+ const ws = new WebSocket(CONFIG.serverUrl)
38
+ ws.binaryType = 'arraybuffer'
39
+ ws.on('open', () => {
40
+ stats.connected++
41
+ interval = setInterval(() => {
42
+ if (ws.readyState !== WebSocket.OPEN) return
43
+ ws.send(pack({ type: MSG_INPUT, payload: makeInput(botId, ++tick) }))
44
+ }, 1000 / CONFIG.inputHz)
45
+ })
46
+ ws.on('message', data => {
47
+ try {
48
+ const msg = unpack(data instanceof ArrayBuffer ? new Uint8Array(data) : data)
49
+ if (msg?.type === MSG_SNAPSHOT) stats.snapshots++
50
+ } catch {}
51
+ })
52
+ ws.on('error', () => { stats.errors++ })
53
+ ws.on('close', () => { stats.connected--; if (interval) clearInterval(interval) })
54
+ return ws
55
+ }
56
+
57
+ async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
58
+
59
+ async function main() {
60
+ const start = Date.now()
61
+ console.log(`[BotHarness] Connecting ${CONFIG.botCount} bots → ${CONFIG.serverUrl}`)
62
+ const bots = []
63
+ for (let i = 0; i < CONFIG.botCount; i += CONFIG.batchSize) {
64
+ const end = Math.min(i + CONFIG.batchSize, CONFIG.botCount)
65
+ for (let j = i; j < end; j++) bots.push(createBot(j))
66
+ await sleep(CONFIG.batchDelayMs)
67
+ }
68
+ await sleep(2000)
69
+ console.log(`[BotHarness] ${stats.connected}/${CONFIG.botCount} connected, running ${CONFIG.durationMs / 1000}s`)
70
+ const reportInterval = setInterval(() => {
71
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1)
72
+ const rate = (stats.snapshots / parseFloat(elapsed)).toFixed(0)
73
+ console.log(`[BotHarness] t=${elapsed}s conn=${stats.connected} snaps=${stats.snapshots} (${rate}/s) err=${stats.errors}`)
74
+ }, 5000)
75
+ await sleep(CONFIG.durationMs)
76
+ clearInterval(reportInterval)
77
+ const elapsed = (Date.now() - start) / 1000
78
+ const perBotPerSec = (stats.snapshots / CONFIG.botCount / elapsed).toFixed(2)
79
+ console.log(`\n[BotHarness] ── FINAL ──`)
80
+ console.log(`[BotHarness] Duration: ${elapsed.toFixed(1)}s | Connected: ${stats.connected}/${CONFIG.botCount}`)
81
+ console.log(`[BotHarness] Snapshots: ${stats.snapshots} total | ${perBotPerSec}/bot/sec | Errors: ${stats.errors}`)
82
+ for (const ws of bots) if (ws.readyState === WebSocket.OPEN) ws.close()
83
+ await sleep(500)
84
+ process.exit(0)
85
+ }
86
+
87
+ main().catch(e => { console.error('[BotHarness] fatal:', e); process.exit(1) })
@@ -1,8 +1,11 @@
1
1
  import { MSG } from '../protocol/MessageTypes.js'
2
2
  import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
3
+ import { pack } from '../protocol/msgpack.js'
4
+ import { isUnreliable } from '../protocol/MessageTypes.js'
3
5
  import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js'
4
6
 
5
7
  const KEYFRAME_INTERVAL = 128
8
+ const SNAP_GROUPS = 4
6
9
 
7
10
  export function createTickHandler(deps) {
8
11
  const {
@@ -13,23 +16,20 @@ export function createTickHandler(deps) {
13
16
  const applyMovement = _movement?.applyMovement || _applyMovement
14
17
  const DEFAULT_MOVEMENT = _movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT
15
18
  const movement = { ...DEFAULT_MOVEMENT, ...m }
16
- const collisionRestitution = movement.collisionRestitution || 0.2
17
- const collisionDamping = movement.collisionDamping || 0.25
18
19
  let snapshotSeq = 0
19
-
20
20
  const playerEntityMaps = new Map()
21
21
  let broadcastEntityMap = new Map()
22
-
23
22
  let profileLog = 0
24
- const separated = new Set()
23
+ const snapUnreliable = isUnreliable(MSG.SNAPSHOT)
24
+
25
25
  return function onTick(tick, dt) {
26
26
  const t0 = performance.now()
27
27
  networkState.setTick(tick, Date.now())
28
28
  const players = playerManager.getConnectedPlayers()
29
+
29
30
  for (const player of players) {
30
31
  const inputs = playerManager.getInputs(player.id)
31
32
  const st = player.state
32
-
33
33
  if (inputs.length > 0) {
34
34
  player.lastInput = inputs[inputs.length - 1].data
35
35
  playerManager.clearInputs(player.id)
@@ -37,12 +37,14 @@ export function createTickHandler(deps) {
37
37
  const inp = player.lastInput || null
38
38
  if (inp) {
39
39
  const yaw = inp.yaw || 0
40
- st.rotation = [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)]
40
+ st.rotation[0] = 0
41
+ st.rotation[1] = Math.sin(yaw / 2)
42
+ st.rotation[2] = 0
43
+ st.rotation[3] = Math.cos(yaw / 2)
41
44
  st.crouch = inp.crouch ? 1 : 0
42
45
  st.lookPitch = inp.pitch || 0
43
46
  st.lookYaw = yaw
44
47
  }
45
-
46
48
  applyMovement(st, inp, movement, dt)
47
49
  if (inp) physicsIntegration.setCrouch(player.id, !!inp.crouch)
48
50
  const wishedVx = st.velocity[0], wishedVz = st.velocity[2]
@@ -60,83 +62,95 @@ export function createTickHandler(deps) {
60
62
  crouch: st.crouch || 0, lookPitch: st.lookPitch || 0, lookYaw: st.lookYaw || 0
61
63
  })
62
64
  }
65
+
63
66
  const t1 = performance.now()
64
- separated.clear()
67
+ const cellSz = physicsIntegration.config.capsuleRadius * 8
68
+ const minDist = physicsIntegration.config.capsuleRadius * 2
69
+ const minDist2 = minDist * minDist
70
+ const grid = new Map()
71
+ for (const p of players) {
72
+ const cx = Math.floor(p.state.position[0] / cellSz)
73
+ const cz = Math.floor(p.state.position[2] / cellSz)
74
+ const ck = cx * 65536 + cz
75
+ let cell = grid.get(ck)
76
+ if (!cell) { cell = []; grid.set(ck, cell) }
77
+ cell.push(p)
78
+ }
65
79
  for (const player of players) {
66
- const collisions = physicsIntegration.checkCollisionWithOthers(player.id, players)
67
- for (const collision of collisions) {
68
- const pairKey = player.id < collision.playerId ? `${player.id}-${collision.playerId}` : `${collision.playerId}-${player.id}`
69
- if (separated.has(pairKey)) continue
70
- separated.add(pairKey)
71
- const other = playerManager.getPlayer(collision.playerId)
72
- if (!other) continue
73
- const nx = collision.normal[0], nz = collision.normal[2]
74
- const minDist = physicsIntegration.config.capsuleRadius * 2
75
- const overlap = minDist - collision.distance
76
- const halfPush = overlap * 0.5
77
- const pushVel = Math.min(halfPush / dt, 3.0)
78
- player.state.position[0] -= nx * halfPush
79
- player.state.position[2] -= nz * halfPush
80
- player.state.velocity[0] -= nx * pushVel
81
- player.state.velocity[2] -= nz * pushVel
82
- other.state.position[0] += nx * halfPush
83
- other.state.position[2] += nz * halfPush
84
- other.state.velocity[0] += nx * pushVel
85
- other.state.velocity[2] += nz * pushVel
86
- physicsIntegration.setPlayerPosition(player.id, player.state.position)
87
- physicsIntegration.setPlayerPosition(other.id, other.state.position)
80
+ const px = player.state.position[0], py = player.state.position[1], pz = player.state.position[2]
81
+ const cx = Math.floor(px / cellSz), cz = Math.floor(pz / cellSz)
82
+ for (let ddx = -1; ddx <= 1; ddx++) {
83
+ for (let ddz = -1; ddz <= 1; ddz++) {
84
+ const neighbors = grid.get((cx + ddx) * 65536 + (cz + ddz))
85
+ if (!neighbors) continue
86
+ for (const other of neighbors) {
87
+ if (other.id <= player.id) continue
88
+ const ox = other.state.position[0], oy = other.state.position[1], oz = other.state.position[2]
89
+ const dx = ox - px, dy = oy - py, dz = oz - pz
90
+ const dist2 = dx * dx + dy * dy + dz * dz
91
+ if (dist2 >= minDist2 || dist2 === 0) continue
92
+ const distance = Math.sqrt(dist2)
93
+ const nx = dx / distance, nz = dz / distance
94
+ const overlap = minDist - distance
95
+ const halfPush = overlap * 0.5
96
+ const pushVel = Math.min(halfPush / dt, 3.0)
97
+ player.state.position[0] -= nx * halfPush
98
+ player.state.position[2] -= nz * halfPush
99
+ player.state.velocity[0] -= nx * pushVel
100
+ player.state.velocity[2] -= nz * pushVel
101
+ other.state.position[0] += nx * halfPush
102
+ other.state.position[2] += nz * halfPush
103
+ other.state.velocity[0] += nx * pushVel
104
+ other.state.velocity[2] += nz * pushVel
105
+ physicsIntegration.setPlayerPosition(player.id, player.state.position)
106
+ physicsIntegration.setPlayerPosition(other.id, other.state.position)
107
+ }
108
+ }
88
109
  }
89
110
  }
111
+
90
112
  const t2 = performance.now()
91
113
  physics.step(dt)
92
114
  const t3 = performance.now()
93
115
  appRuntime.tick(tick, dt)
94
116
  const t4 = performance.now()
117
+
95
118
  if (players.length > 0) {
96
119
  const playerSnap = networkState.getSnapshot()
97
120
  snapshotSeq++
98
121
  const isKeyframe = snapshotSeq % KEYFRAME_INTERVAL === 0
122
+ const curGroup = tick % SNAP_GROUPS
123
+
99
124
  if (stageLoader && stageLoader.getActiveStage()) {
125
+ const relevanceRadius = stageLoader.getActiveStage().spatial.relevanceRadius
100
126
  for (const player of players) {
101
- const pos = player.state.position
102
- const entitySnap = appRuntime.getSnapshotForPlayer(pos, stageLoader.getActiveStage().spatial.relevanceRadius)
127
+ if (!isKeyframe && player.id % SNAP_GROUPS !== curGroup) continue
128
+ const entitySnap = appRuntime.getSnapshotForPlayer(player.state.position, relevanceRadius)
103
129
  const combined = { tick: playerSnap.tick, timestamp: playerSnap.timestamp, players: playerSnap.players, entities: entitySnap.entities }
104
- if (isKeyframe || !playerEntityMaps.has(player.id)) {
105
- const encoded = SnapshotEncoder.encode(combined)
106
- const { entityMap } = SnapshotEncoder.encodeDelta(combined, new Map())
107
- playerEntityMaps.set(player.id, entityMap)
108
- connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
109
- } else {
110
- const prevMap = playerEntityMaps.get(player.id)
111
- const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
112
- playerEntityMaps.set(player.id, entityMap)
113
- connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
114
- }
130
+ const prevMap = (isKeyframe || !playerEntityMaps.has(player.id)) ? new Map() : playerEntityMaps.get(player.id)
131
+ const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
132
+ playerEntityMaps.set(player.id, entityMap)
133
+ connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
115
134
  }
116
135
  } else {
117
136
  const entitySnap = appRuntime.getSnapshot()
118
137
  const combined = { tick: playerSnap.tick, timestamp: playerSnap.timestamp, players: playerSnap.players, entities: entitySnap.entities }
119
- if (isKeyframe || broadcastEntityMap.size === 0) {
120
- const encoded = SnapshotEncoder.encode(combined)
121
- const { entityMap } = SnapshotEncoder.encodeDelta(combined, new Map())
122
- broadcastEntityMap = entityMap
123
- connections.broadcast(MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
124
- } else {
125
- const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, broadcastEntityMap)
126
- broadcastEntityMap = entityMap
127
- connections.broadcast(MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
138
+ const prevMap = (isKeyframe || broadcastEntityMap.size === 0) ? new Map() : broadcastEntityMap
139
+ const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
140
+ broadcastEntityMap = entityMap
141
+ const data = pack({ type: MSG.SNAPSHOT, payload: { seq: snapshotSeq, ...encoded } })
142
+ for (const player of players) {
143
+ if (!isKeyframe && player.id % SNAP_GROUPS !== curGroup) continue
144
+ connections.sendPacked(player.id, data, snapUnreliable)
128
145
  }
129
146
  }
130
147
  }
148
+
131
149
  for (const id of playerEntityMaps.keys()) {
132
150
  if (!playerManager.getPlayer(id)) playerEntityMaps.delete(id)
133
151
  }
134
152
  const t5 = performance.now()
135
- try {
136
- appRuntime._drainReloadQueue()
137
- } catch (e) {
138
- console.error('[TickHandler] reload queue error:', e.message)
139
- }
153
+ try { appRuntime._drainReloadQueue() } catch (e) { console.error('[TickHandler] reload queue error:', e.message) }
140
154
  profileLog++
141
155
  if (profileLog % 1280 === 0) {
142
156
  const total = t5 - t0
@@ -145,7 +159,7 @@ export function createTickHandler(deps) {
145
159
  const rss = (mem.rss / 1048576).toFixed(1)
146
160
  const ext = (mem.external / 1048576).toFixed(1)
147
161
  const ab = (mem.arrayBuffers / 1048576).toFixed(1)
148
- try { console.log(`[tick-profile] tick:${tick} players:${players.length} total:${total.toFixed(2)}ms | mv:${(t1-t0).toFixed(2)} col:${(t2-t1).toFixed(2)} phys:${(t3-t2).toFixed(2)} app:${(t4-t3).toFixed(2)} snap:${(t5-t4).toFixed(2)} | heap:${heap}MB rss:${rss}MB ext:${ext}MB ab:${ab}MB`) } catch (_) {}
162
+ try { console.log(`[tick-profile] tick:${tick} players:${players.length} total:${total.toFixed(2)}ms | mv:${(t1 - t0).toFixed(2)} col:${(t2 - t1).toFixed(2)} phys:${(t3 - t2).toFixed(2)} app:${(t4 - t3).toFixed(2)} snap:${(t5 - t4).toFixed(2)} | heap:${heap}MB rss:${rss}MB ext:${ext}MB ab:${ab}MB`) } catch (_) {}
149
163
  }
150
164
  }
151
165
  }