spoint 0.1.79 → 0.1.81

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/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: spoint
3
- description: "Build multiplayer physics games with the Spawnpoint engine. Use when asked to: create a game, add physics objects, spawn entities, build an arena, handle player interaction, add weapons, respawn, scoring, create moving platforms, manage world config, load 3D models, add HUD/UI, work with the EventBus, or develop any app inside an apps/ directory."
3
+ description: "Build multiplayer physics games with the Spawnpoint engine. Use when asked to: create a game, add physics objects, spawn entities, build a map/level, handle player interaction, add weapons, respawn, scoring, create moving platforms, manage world config, load 3D models, add HUD/UI, work with the EventBus, or develop any app inside an apps/ directory."
4
4
  ---
5
5
 
6
6
  # Spawnpoint App Development Reference
@@ -26,36 +26,30 @@ export default {
26
26
  movement: { maxSpeed: 4.0, groundAccel: 10.0, airAccel: 1.0, friction: 6.0, stopSpeed: 2.0, jumpImpulse: 4.0 },
27
27
  player: { health: 100, capsuleRadius: 0.4, capsuleHalfHeight: 0.9, modelScale: 1.323, feetOffset: 0.212 },
28
28
  scene: { skyColor: 0x87ceeb, fogColor: 0x87ceeb, fogNear: 80, fogFar: 200, sunIntensity: 1.5, sunPosition: [20, 40, 20] },
29
- entities: [{ id: 'arena', position: [0,0,0], app: 'arena' }],
29
+ entities: [{ id: 'environment', model: './apps/game/map.glb', position: [0,0,0], app: 'environment' }],
30
30
  spawnPoint: [0, 2, 0]
31
31
  }
32
32
  ```
33
33
 
34
- ### `apps/arena/index.js`
34
+ ### `apps/environment/index.js` — static environment with trimesh physics
35
35
  ```js
36
- const HALF = 12, WH = 3, WT = 0.5
37
- const WALLS = [
38
- { id:'wn', x:0, y:WH/2, z:-HALF, hx:HALF, hy:WH/2, hz:WT/2 },
39
- { id:'ws', x:0, y:WH/2, z: HALF, hx:HALF, hy:WH/2, hz:WT/2 },
40
- { id:'we', x: HALF, y:WH/2, z:0, hx:WT/2, hy:WH/2, hz:HALF },
41
- { id:'ww', x:-HALF, y:WH/2, z:0, hx:WT/2, hy:WH/2, hz:HALF },
42
- ]
43
36
  export default {
44
37
  server: {
45
- setup(ctx) {
46
- ctx.state.ids = ctx.state.ids || []
47
- if (ctx.state.ids.length > 0) return // hot-reload guard
48
- ctx.entity.custom = { mesh:'box', color:0x5a7a4a, sx:HALF*2, sy:0.5, sz:HALF*2 }
38
+ async setup(ctx) {
49
39
  ctx.physics.setStatic(true)
50
- ctx.physics.addBoxCollider([HALF, 0.25, HALF])
51
- for (const w of WALLS) {
52
- const e = ctx.world.spawn(w.id, { position:[w.x,w.y,w.z], app:'box-static', config:{ hx:w.hx, hy:w.hy, hz:w.hz, color:0x7a6a5a } })
53
- if (e) ctx.state.ids.push(w.id)
40
+ try {
41
+ await ctx.physics.addTrimeshCollider()
42
+ } catch (e) {
43
+ console.log(\`Trimesh failed: \${e.message}, using box fallback\`)
44
+ ctx.physics.addBoxCollider([100, 25, 100])
54
45
  }
55
- },
56
- teardown(ctx) { for (const id of ctx.state.ids||[]) ctx.world.destroy(id); ctx.state.ids = [] }
46
+ }
57
47
  },
58
- client: { render(ctx) { return { position:ctx.entity.position, rotation:ctx.entity.rotation, custom:ctx.entity.custom } } }
48
+ client: {
49
+ render(ctx) {
50
+ return { model: ctx.entity.model, position: ctx.entity.position, rotation: ctx.entity.rotation }
51
+ }
52
+ }
59
53
  }
60
54
  ```
61
55
 
@@ -23,11 +23,16 @@ function discoverModels() {
23
23
 
24
24
  export default {
25
25
  server: {
26
- setup(ctx) {
26
+ async setup(ctx) {
27
27
  ctx.physics.setStatic(true)
28
- // Use simple box collider for environment - trimesh creation is too slow for large meshes
29
- // The environment is primarily visual; players collide with box bounds instead
30
- ctx.physics.addBoxCollider([50, 15, 50])
28
+ // Use trimesh collider for accurate environment collision
29
+ // Deferred to background so server startup isn't blocked
30
+ try {
31
+ await ctx.physics.addTrimeshCollider()
32
+ } catch (e) {
33
+ console.log(`[Environment] Trimesh collider failed: ${e.message}, using box collider`)
34
+ ctx.physics.addBoxCollider([100, 25, 100])
35
+ }
31
36
 
32
37
  ctx.state.smartObjects = new Map()
33
38
  ctx.state.editorMode = false
@@ -1,70 +1,65 @@
1
- export default {
2
- port: 3001,
3
- tickRate: 128,
4
- gravity: [0, -9.81, 0],
5
- movement: {
6
- maxSpeed: 4.0,
7
- groundAccel: 10.0,
8
- airAccel: 1.0,
9
- friction: 6.0,
10
- stopSpeed: 2.0,
11
- jumpImpulse: 4.0,
12
- collisionRestitution: 0.2,
13
- collisionDamping: 0.25
14
- },
15
- player: {
16
- health: 100,
17
- capsuleRadius: 0.4,
18
- capsuleHalfHeight: 0.9,
19
- crouchHalfHeight: 0.45,
20
- mass: 120,
21
- modelScale: 1.323,
22
- feetOffset: 0.212
23
- },
24
- scene: {
25
- skyColor: 0x87ceeb,
26
- fogColor: 0x87ceeb,
27
- fogNear: 80,
28
- fogFar: 200,
29
- ambientColor: 0xfff4d6,
30
- ambientIntensity: 0.3,
31
- sunColor: 0xffffff,
32
- sunIntensity: 1.5,
33
- sunPosition: [21, 50, 20],
34
- fillColor: 0x4488ff,
35
- fillIntensity: 0.4,
36
- fillPosition: [-20, 30, -10],
37
- shadowMapSize: 1024,
38
- shadowBias: 0.0038,
39
- shadowNormalBias: 0.6,
40
- shadowRadius: 12,
41
- shadowBlurSamples: 8
42
- },
43
- camera: {
44
- fov: 70,
45
- shoulderOffset: 0.35,
46
- headHeight: 0.4,
47
- zoomStages: [0, 1.5, 3, 5, 8],
48
- defaultZoomIndex: 2,
49
- followSpeed: 12.0,
50
- snapSpeed: 30.0,
51
- mouseSensitivity: 0.002,
52
- pitchRange: [-1.4, 1.4]
53
- },
54
- animation: {
55
- mixerTimeScale: 1.3,
56
- walkTimeScale: 2.0,
57
- sprintTimeScale: 0.56,
58
- fadeTime: 0.15
59
- },
60
- entities: [
61
- { id: 'environment', model: './apps/tps-game/schwust.glb', position: [0, -10, 0], app: 'environment' },
62
- { id: 'game', position: [0, 0, 0], app: 'tps-game' },
63
- { id: 'power-crates', position: [0, 0, 0], app: 'power-crate' },
64
- { id: 'interact-box', position: [-100, 3, -100], app: 'interactable' }
65
- // To use the primitive arena instead of schwust.glb, replace above with:
66
- // { id: 'arena', position: [0, 0, 0], app: 'arena' }
67
- ],
68
- playerModel: './apps/tps-game/cleetus.vrm',
69
- spawnPoint: [-35, 3, -65]
70
- }
1
+ export default {
2
+ port: 3001,
3
+ tickRate: 128,
4
+ gravity: [0, -9.81, 0],
5
+ movement: {
6
+ maxSpeed: 4.0,
7
+ groundAccel: 10.0,
8
+ airAccel: 1.0,
9
+ friction: 6.0,
10
+ stopSpeed: 2.0,
11
+ jumpImpulse: 4.0,
12
+ collisionRestitution: 0.2,
13
+ collisionDamping: 0.25
14
+ },
15
+ player: {
16
+ health: 100,
17
+ capsuleRadius: 0.4,
18
+ capsuleHalfHeight: 0.9,
19
+ crouchHalfHeight: 0.45,
20
+ mass: 120,
21
+ modelScale: 1.323,
22
+ feetOffset: 0.212
23
+ },
24
+ scene: {
25
+ skyColor: 0x87ceeb,
26
+ fogColor: 0x87ceeb,
27
+ fogNear: 80,
28
+ fogFar: 200,
29
+ ambientColor: 0xfff4d6,
30
+ ambientIntensity: 0.3,
31
+ sunColor: 0xffffff,
32
+ sunIntensity: 1.5,
33
+ sunPosition: [21, 50, 20],
34
+ fillColor: 0x4488ff,
35
+ fillIntensity: 0.4,
36
+ fillPosition: [-20, 30, -10],
37
+ shadowMapSize: 1024,
38
+ shadowBias: 0.0038,
39
+ shadowNormalBias: 0.6,
40
+ shadowRadius: 12,
41
+ shadowBlurSamples: 8
42
+ },
43
+ camera: {
44
+ fov: 70,
45
+ shoulderOffset: 0.35,
46
+ headHeight: 0.4,
47
+ zoomStages: [0, 1.5, 3, 5, 8],
48
+ defaultZoomIndex: 2,
49
+ followSpeed: 12.0,
50
+ snapSpeed: 30.0,
51
+ mouseSensitivity: 0.002,
52
+ pitchRange: [-1.4, 1.4]
53
+ },
54
+ animation: {
55
+ mixerTimeScale: 1.3,
56
+ walkTimeScale: 2.0,
57
+ sprintTimeScale: 0.56,
58
+ fadeTime: 0.15
59
+ },
60
+ entities: [
61
+ { id: 'environment', model: './apps/tps-game/schwust.glb', position: [0, 0, 0], app: 'environment' }
62
+ ],
63
+ playerModel: './apps/tps-game/cleetus.vrm',
64
+ spawnPoint: [-30, 7.6, -30]
65
+ }
package/client/app.js CHANGED
@@ -5,6 +5,7 @@ THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree
5
5
  THREE.Mesh.prototype.raycast = acceleratedRaycast
6
6
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
7
7
  import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
8
+ import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'
8
9
  import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'
9
10
  import { PhysicsNetworkClient, InputHandler, MSG } from '/src/index.client.js'
10
11
  import { createElement, applyDiff } from 'webjsx'
@@ -675,6 +676,7 @@ const dracoLoader = new DRACOLoader(loadingManager)
675
676
  dracoLoader.setDecoderPath('/draco/')
676
677
  dracoLoader.setWorkerLimit(1)
677
678
  gltfLoader.setDRACOLoader(dracoLoader)
679
+ gltfLoader.setMeshoptDecoder(MeshoptDecoder)
678
680
  gltfLoader.register((parser) => new VRMLoaderPlugin(parser))
679
681
  const playerMeshes = new Map()
680
682
  const playerAnimators = new Map()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -69,10 +69,10 @@ export class AppContext {
69
69
  }
70
70
  },
71
71
  addMeshCollider: (m) => { ent.collider = { type: 'mesh', mesh: m } },
72
- addTrimeshCollider: () => {
72
+ addTrimeshCollider: async () => {
73
73
  ent.collider = { type: 'trimesh', model: ent.model }
74
74
  if (runtime._physics && ent.model) {
75
- const bodyId = runtime._physics.addStaticTrimesh(runtime.resolveAssetPath(ent.model), 0)
75
+ const bodyId = await runtime._physics.addStaticTrimeshAsync(runtime.resolveAssetPath(ent.model), 0, ent.position)
76
76
  ent._physicsBodyId = bodyId
77
77
  }
78
78
  },
@@ -64,25 +64,27 @@ export class AppRuntime {
64
64
  }
65
65
  if (config.autoTrimesh && entity.model && this._physics) {
66
66
  entity.collider = { type: 'trimesh', model: entity.model }
67
- entity._physicsBodyId = this._physics.addStaticTrimesh(this.resolveAssetPath(entity.model), 0)
67
+ this._physics.addStaticTrimeshAsync(this.resolveAssetPath(entity.model), 0, entity.position || [0,0,0])
68
+ .then(id => { entity._physicsBodyId = id })
69
+ .catch(e => console.error(`[AppRuntime] Failed to create trimesh for ${entity.model}:`, e.message))
68
70
  }
69
- if (config.app) this._attachApp(entityId, config.app)
71
+ if (config.app) this._attachApp(entityId, config.app).catch(e => console.error(`[AppRuntime] Failed to attach app ${config.app}:`, e.message))
70
72
  this._spatialInsert(entity)
71
73
  return entity
72
74
  }
73
75
 
74
- _attachApp(entityId, appName) {
76
+ async _attachApp(entityId, appName) {
75
77
  const entity = this.entities.get(entityId), appDef = this._appDefs.get(appName)
76
78
  if (!entity || !appDef) return
77
79
  const ctx = new AppContext(entity, this)
78
80
  this.contexts.set(entityId, ctx); this.apps.set(entityId, appDef)
79
- this._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`)
81
+ await this._safeCall(appDef.server || appDef, 'setup', [ctx], `setup(${appName})`)
80
82
  }
81
83
 
82
- attachApp(entityId, appName) { this._attachApp(entityId, appName) }
83
- spawnWithApp(id, cfg = {}, app) { return this.spawnEntity(id, { ...cfg, app }) }
84
- attachAppToEntity(eid, app, cfg = {}) { const e = this.getEntity(eid); if (!e) return false; e._config = cfg; this._attachApp(eid, app); return true }
85
- reattachAppToEntity(eid, app) { this.detachApp(eid); this._attachApp(eid, app) }
84
+ async attachApp(entityId, appName) { await this._attachApp(entityId, appName) }
85
+ async spawnWithApp(id, cfg = {}, app) { return await this.spawnEntity(id, { ...cfg, app }) }
86
+ async attachAppToEntity(eid, app, cfg = {}) { const e = this.getEntity(eid); if (!e) return false; e._config = cfg; await this._attachApp(eid, app); return true }
87
+ async reattachAppToEntity(eid, app) { this.detachApp(eid); await this._attachApp(eid, app) }
86
88
  getEntityWithApp(eid) { const e = this.entities.get(eid); return { entity: e, appName: e?._appName, hasApp: !!e?._appName } }
87
89
 
88
90
  detachApp(entityId) {
@@ -234,5 +236,17 @@ export class AppRuntime {
234
236
  relevantEntities(position, radius) { if (!this._stageLoader) return Array.from(this.entities.keys()); return this._stageLoader.getRelevantEntities(position, radius) }
235
237
 
236
238
  _log(type, data, meta = {}) { if (this._eventLog) this._eventLog.record(type, data, { ...meta, tick: this.currentTick }) }
237
- _safeCall(o, m, a, l) { if (!o?.[m]) return; try { o[m](...a) } catch (e) { console.error(`[AppRuntime] ${l}: ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`) } }
239
+ _safeCall(o, m, a, l) {
240
+ if (!o?.[m]) return Promise.resolve()
241
+ try {
242
+ const result = o[m](...a)
243
+ if (result && typeof result.catch === 'function') {
244
+ return result.catch(e => console.error(`[AppRuntime] ${l}: ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`))
245
+ }
246
+ return Promise.resolve()
247
+ } catch (e) {
248
+ console.error(`[AppRuntime] ${l}: ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`)
249
+ return Promise.reject(e)
250
+ }
251
+ }
238
252
  }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
 
3
3
  let _dracoDecoderPromise = null
4
+ let _meshoptDecoderPromise = null
4
5
 
5
6
  async function getDracoDecoder() {
6
7
  if (!_dracoDecoderPromise) {
@@ -14,6 +15,19 @@ async function getDracoDecoder() {
14
15
  return _dracoDecoderPromise
15
16
  }
16
17
 
18
+ async function getMeshoptDecoder() {
19
+ if (!_meshoptDecoderPromise) {
20
+ try {
21
+ const meshopt = await import('meshoptimizer')
22
+ _meshoptDecoderPromise = meshopt.MeshoptDecoder
23
+ await _meshoptDecoderPromise.ready
24
+ } catch(e) {
25
+ throw new Error(`Failed to load Meshopt decoder: ${e.message}`)
26
+ }
27
+ }
28
+ return _meshoptDecoderPromise
29
+ }
30
+
17
31
  /**
18
32
  * Extract mesh from GLB file for physics collider creation.
19
33
  * Supports both standard and Draco-compressed meshes.
@@ -42,6 +56,13 @@ export function extractMeshFromGLB(filepath, meshIndex = 0) {
42
56
  throw new Error('Draco-compressed mesh detected. Use extractMeshFromGLBAsync() instead.')
43
57
  }
44
58
 
59
+ // Check for meshopt compression (not supported)
60
+ const hasMeshopt = json.bufferViews?.some(bv => bv.extensions?.EXT_meshopt_compression) ||
61
+ json.meshes.some(m => m.primitives.some(p => p.extensions?.EXT_meshopt_compression))
62
+ if (hasMeshopt) {
63
+ throw new Error('Meshopt-compressed mesh detected. Decompress with gltfpack first: gltfpack -i input.glb -o output.glb -noq')
64
+ }
65
+
45
66
  // Standard uncompressed GLB mesh extraction
46
67
  const posAcc = json.accessors[prim.attributes.POSITION]
47
68
  const posView = json.bufferViews[posAcc.bufferView]
@@ -61,13 +82,24 @@ export function extractMeshFromGLB(filepath, meshIndex = 0) {
61
82
  }
62
83
  }
63
84
 
64
- return {
85
+ const result = {
65
86
  vertices,
66
87
  indices,
67
88
  vertexCount: posAcc.count,
68
89
  triangleCount: indices ? indices.length / 3 : 0,
69
90
  name: mesh.name
70
91
  }
92
+
93
+ const node = json.nodes?.find(n => n.mesh === meshIndex)
94
+ if (node) {
95
+ result.nodeTransform = {
96
+ scale: node.scale ? [...node.scale] : null,
97
+ rotation: node.rotation ? [...node.rotation] : null,
98
+ translation: node.translation ? [...node.translation] : null
99
+ }
100
+ }
101
+
102
+ return result
71
103
  }
72
104
 
73
105
  /**
@@ -92,12 +124,29 @@ export async function extractMeshFromGLBAsync(filepath, meshIndex = 0) {
92
124
 
93
125
  const prim = mesh.primitives[0]
94
126
 
95
- // Handle Draco-compressed mesh
127
+ let result
128
+
96
129
  if (prim.extensions?.KHR_draco_mesh_compression) {
97
- return decompressDracoMesh(buf, json, prim, binOffset, mesh.name)
130
+ result = await decompressDracoMesh(buf, json, prim, binOffset, mesh.name)
131
+ } else if (json.bufferViews?.some(bv => bv.extensions?.EXT_meshopt_compression)) {
132
+ result = await extractMeshWithMeshopt(buf, json, prim, binOffset, mesh.name)
133
+ } else {
134
+ result = extractStandardMesh(buf, json, prim, binOffset, mesh.name)
98
135
  }
99
136
 
100
- // Standard uncompressed extraction
137
+ const node = json.nodes?.find(n => n.mesh === meshIndex)
138
+ if (node) {
139
+ result.nodeTransform = {
140
+ scale: node.scale ? [...node.scale] : null,
141
+ rotation: node.rotation ? [...node.rotation] : null,
142
+ translation: node.translation ? [...node.translation] : null
143
+ }
144
+ }
145
+
146
+ return result
147
+ }
148
+
149
+ function extractStandardMesh(buf, json, prim, binOffset, meshName) {
101
150
  const posAcc = json.accessors[prim.attributes.POSITION]
102
151
  const posView = json.bufferViews[posAcc.bufferView]
103
152
  const posOff = binOffset + (posView.byteOffset || 0) + (posAcc.byteOffset || 0)
@@ -121,7 +170,40 @@ export async function extractMeshFromGLBAsync(filepath, meshIndex = 0) {
121
170
  indices,
122
171
  vertexCount: posAcc.count,
123
172
  triangleCount: indices ? indices.length / 3 : 0,
124
- name: mesh.name
173
+ name: meshName
174
+ }
175
+ }
176
+
177
+ function applyNodeTransform(vertices, node) {
178
+ const scale = node.scale ? [node.scale[0], node.scale[1], node.scale[2]] : [1, 1, 1]
179
+ const translation = node.translation ? [node.translation[0], node.translation[1], node.translation[2]] : [0, 0, 0]
180
+
181
+ if (node.rotation) {
182
+ const [qx, qy, qz, qw] = node.rotation
183
+ for (let i = 0; i < vertices.length / 3; i++) {
184
+ let x = vertices[i * 3] * scale[0]
185
+ let y = vertices[i * 3 + 1] * scale[1]
186
+ let z = vertices[i * 3 + 2] * scale[2]
187
+
188
+ const ix = qw * x + qy * z - qz * y
189
+ const iy = qw * y + qz * x - qx * z
190
+ const iz = qw * z + qx * y - qy * x
191
+ const iw = -qx * x - qy * y - qz * z
192
+
193
+ x = ix * qw - iw * qx - iy * qz + iz * qy
194
+ y = iy * qw - iw * qy - iz * qx + ix * qz
195
+ z = iz * qw - iw * qz - ix * qy + iy * qx
196
+
197
+ vertices[i * 3] = x + translation[0]
198
+ vertices[i * 3 + 1] = y + translation[1]
199
+ vertices[i * 3 + 2] = z + translation[2]
200
+ }
201
+ } else {
202
+ for (let i = 0; i < vertices.length / 3; i++) {
203
+ vertices[i * 3] = vertices[i * 3] * scale[0] + translation[0]
204
+ vertices[i * 3 + 1] = vertices[i * 3 + 1] * scale[1] + translation[1]
205
+ vertices[i * 3 + 2] = vertices[i * 3 + 2] * scale[2] + translation[2]
206
+ }
125
207
  }
126
208
  }
127
209
 
@@ -135,76 +217,341 @@ async function decompressDracoMesh(buf, json, prim, binOffset, meshName) {
135
217
  const length = bufView.byteLength
136
218
  const dracoData = buf.slice(offset, offset + length)
137
219
 
138
- // Create decoder and buffer
139
220
  const d = new decoder.Decoder()
140
221
  const db = new decoder.DecoderBuffer()
222
+ const decodedGeom = new decoder.Mesh()
141
223
 
142
224
  try {
143
- // Initialize buffer
144
225
  const dracoArray = new Uint8Array(dracoData)
145
226
  db.Init(dracoArray, dracoArray.length)
146
227
 
147
- // Decode mesh
148
- const decodedGeom = d.DecodeBufferToMesh(db)
149
- if (!decodedGeom) {
150
- throw new Error('Draco decompression failed: empty result')
228
+ const status = d.DecodeBufferToMesh(db, decodedGeom)
229
+ if (!status.ok()) {
230
+ throw new Error(`Draco decompression failed: ${status.error_msg()}`)
151
231
  }
152
232
 
153
- // Get position attribute
154
- const posAttrId = d.GetAttributeIdByName(decodedGeom, 'POSITION')
233
+ const posAttrId = d.GetAttributeId(decodedGeom, decoder.POSITION)
155
234
  if (posAttrId < 0) {
156
235
  throw new Error('No POSITION attribute in decompressed mesh')
157
236
  }
158
237
 
159
238
  const posAttr = d.GetAttribute(decodedGeom, posAttrId)
160
- const posData = d.GetAttributeFloatForAllPoints(decodedGeom, posAttr)
161
- const vertices = new Float32Array(posData)
239
+ const numPoints = decodedGeom.num_points()
240
+ const posData = new decoder.DracoFloat32Array()
241
+ d.GetAttributeFloatForAllPoints(decodedGeom, posAttr, posData)
242
+
243
+ const vertices = new Float32Array(numPoints * 3)
244
+ for (let i = 0; i < numPoints * 3; i++) {
245
+ vertices[i] = posData.GetValue(i)
246
+ }
162
247
 
163
- // Get indices if available
164
248
  let indices = null
165
- if (decodedGeom.num_faces() > 0) {
166
- const indicesData = d.GetTrianglesUInt32Array(decodedGeom, decodedGeom.num_faces())
167
- indices = new Uint32Array(indicesData)
249
+ const numFaces = decodedGeom.num_faces()
250
+ if (numFaces > 0) {
251
+ indices = new Uint32Array(numFaces * 3)
252
+ const faceIndices = new decoder.DracoUInt32Array()
253
+ for (let i = 0; i < numFaces; i++) {
254
+ d.GetFaceFromMesh(decodedGeom, i, faceIndices)
255
+ indices[i * 3] = faceIndices.GetValue(0)
256
+ indices[i * 3 + 1] = faceIndices.GetValue(1)
257
+ indices[i * 3 + 2] = faceIndices.GetValue(2)
258
+ }
259
+ decoder.destroy(faceIndices)
168
260
  }
169
261
 
170
- decoder.destroy(decodedGeom)
262
+ decoder.destroy(posData)
263
+ decoder.destroy(status)
171
264
 
172
265
  return {
173
266
  vertices,
174
267
  indices,
175
- vertexCount: decodedGeom.num_points(),
176
- triangleCount: decodedGeom.num_faces(),
268
+ vertexCount: numPoints,
269
+ triangleCount: numFaces,
177
270
  name: meshName
178
271
  }
179
272
  } finally {
273
+ decoder.destroy(decodedGeom)
180
274
  decoder.destroy(d)
181
275
  decoder.destroy(db)
182
276
  }
183
277
  }
184
278
 
279
+ async function extractMeshWithMeshopt(buf, json, prim, binOffset, meshName) {
280
+ const decoder = await getMeshoptDecoder()
281
+
282
+ const posAcc = json.accessors[prim.attributes.POSITION]
283
+ const posView = json.bufferViews[posAcc.bufferView]
284
+ const posExt = posView.extensions?.EXT_meshopt_compression
285
+
286
+ if (!posExt) {
287
+ throw new Error('Position buffer view has no EXT_meshopt_compression extension')
288
+ }
289
+
290
+ const posSrcOff = binOffset + (posExt.byteOffset || 0)
291
+ const posSrcLen = posExt.byteLength
292
+ const posSrc = new Uint8Array(buf.buffer.slice(posSrcOff, posSrcOff + posSrcLen))
293
+
294
+ const numVertices = posExt.count
295
+ const stride = posExt.byteStride || 12
296
+ const posDst = new Uint8Array(numVertices * stride)
297
+
298
+ const mode = posExt.mode || 'ATTRIBUTES'
299
+ const filter = posExt.filter || 'NONE'
300
+ decoder.decodeGltfBuffer(posDst, numVertices, stride, posSrc, mode, filter)
301
+
302
+ const vertices = new Float32Array(numVertices * 3)
303
+
304
+ // Handle normalized INT16 positions (common in meshopt-compressed models)
305
+ // Normalized INT16: float = raw / 32767.0
306
+ if (stride === 8 && posAcc.normalized && posAcc.componentType === 5122) {
307
+ const raw = new Int16Array(posDst.buffer)
308
+ for (let i = 0; i < numVertices; i++) {
309
+ vertices[i * 3] = raw[i * 4] / 32767.0
310
+ vertices[i * 3 + 1] = raw[i * 4 + 1] / 32767.0
311
+ vertices[i * 3 + 2] = raw[i * 4 + 2] / 32767.0
312
+ }
313
+ } else if (stride === 12) {
314
+ vertices.set(new Float32Array(posDst.buffer))
315
+ } else {
316
+ const floats = new Float32Array(posDst.buffer)
317
+ for (let i = 0; i < numVertices; i++) {
318
+ vertices[i * 3] = floats[i * (stride / 4)]
319
+ vertices[i * 3 + 1] = floats[i * (stride / 4) + 1]
320
+ vertices[i * 3 + 2] = floats[i * (stride / 4) + 2]
321
+ }
322
+ }
323
+
324
+ let indices = null
325
+ if (prim.indices !== undefined) {
326
+ const idxAcc = json.accessors[prim.indices]
327
+ const idxView = json.bufferViews[idxAcc.bufferView]
328
+ const idxExt = idxView.extensions?.EXT_meshopt_compression
329
+
330
+ if (idxExt) {
331
+ const idxSrcOff = binOffset + (idxExt.byteOffset || 0)
332
+ const idxSrcLen = idxExt.byteLength
333
+ const idxSrc = new Uint8Array(buf.buffer.slice(idxSrcOff, idxSrcOff + idxSrcLen))
334
+
335
+ const idxStride = idxExt.byteStride || 2
336
+ const numIndices = idxExt.count
337
+ const idxDst = new Uint8Array(numIndices * idxStride)
338
+
339
+ const idxMode = idxExt.mode || 'TRIANGLES'
340
+ const idxFilter = idxExt.filter || 'NONE'
341
+ decoder.decodeGltfBuffer(idxDst, numIndices, idxStride, idxSrc, idxMode, idxFilter)
342
+
343
+ if (idxAcc.componentType === 5123) {
344
+ indices = new Uint32Array(new Uint16Array(idxDst.buffer))
345
+ } else {
346
+ indices = new Uint32Array(idxDst.buffer)
347
+ }
348
+ } else {
349
+ const idxOff = binOffset + (idxView.byteOffset || 0) + (idxAcc.byteOffset || 0)
350
+ if (idxAcc.componentType === 5123) {
351
+ indices = new Uint32Array(new Uint16Array(buf.buffer.slice(idxOff, idxOff + idxAcc.count * 2)))
352
+ } else {
353
+ indices = new Uint32Array(buf.buffer.slice(idxOff, idxOff + idxAcc.count * 4))
354
+ }
355
+ }
356
+ }
357
+
358
+ return {
359
+ vertices,
360
+ indices,
361
+ vertexCount: numVertices,
362
+ triangleCount: indices ? indices.length / 3 : 0,
363
+ name: meshName
364
+ }
365
+ }
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
+
185
525
  /**
186
526
  * Check if a GLB file has Draco-compressed meshes without attempting extraction.
187
527
  * Useful for validation and error reporting.
188
528
  *
189
529
  * @param {string} filepath - Path to GLB file
190
- * @returns {Object} {hasDraco: boolean, meshes: Array<{name, hasDraco}>}
530
+ * @returns {Object} {hasDraco: boolean, hasMeshopt: boolean, meshes: Array<{name, hasDraco, hasMeshopt}>}
191
531
  */
192
532
  export function detectDracoInGLB(filepath) {
193
533
  try {
194
534
  const buf = readFileSync(filepath)
195
- if (buf.toString('ascii', 0, 4) !== 'glTF') return { hasDraco: false, meshes: [] }
535
+ if (buf.toString('ascii', 0, 4) !== 'glTF') return { hasDraco: false, hasMeshopt: false, meshes: [] }
196
536
 
197
537
  const jsonLen = buf.readUInt32LE(12)
198
538
  const json = JSON.parse(buf.toString('utf-8', 20, 20 + jsonLen))
199
539
 
200
- const meshes = (json.meshes || []).map(mesh => ({
201
- name: mesh.name || 'unnamed',
202
- hasDraco: mesh.primitives?.some(p => p.extensions?.KHR_draco_mesh_compression) || false
203
- }))
540
+ const bufferViewHasMeshopt = (bv) => bv.extensions?.EXT_meshopt_compression
541
+ const hasMeshoptGlobally = (json.bufferViews || []).some(bufferViewHasMeshopt)
204
542
 
205
- const hasDraco = meshes.some(m => m.hasDraco)
206
- return { hasDraco, meshes }
543
+ const meshes = (json.meshes || []).map(mesh => {
544
+ const hasDraco = mesh.primitives?.some(p => p.extensions?.KHR_draco_mesh_compression) || false
545
+ const hasMeshopt = hasMeshoptGlobally || mesh.primitives?.some(p => p.extensions?.EXT_meshopt_compression) || false
546
+ return { name: mesh.name || 'unnamed', hasDraco, hasMeshopt }
547
+ })
548
+
549
+ return {
550
+ hasDraco: meshes.some(m => m.hasDraco),
551
+ hasMeshopt: meshes.some(m => m.hasMeshopt),
552
+ meshes
553
+ }
207
554
  } catch (e) {
208
- return { hasDraco: false, meshes: [], error: e.message }
555
+ return { hasDraco: false, hasMeshopt: false, meshes: [], error: e.message }
209
556
  }
210
557
  }
@@ -1,5 +1,5 @@
1
1
  import initJolt from 'jolt-physics/wasm-compat'
2
- import { extractMeshFromGLB } from './GLBLoader.js'
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 }
@@ -74,17 +74,89 @@ export class PhysicsWorld {
74
74
  addStaticTrimesh(glbPath, meshIndex = 0) {
75
75
  const J = this.Jolt
76
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
+
77
111
  const triangles = new J.TriangleList(); triangles.resize(mesh.triangleCount)
112
+ const f3 = new J.Float3(0, 0, 0)
78
113
  for (let t = 0; t < mesh.triangleCount; t++) {
79
114
  const tri = triangles.at(t)
80
115
  for (let v = 0; v < 3; v++) {
81
116
  const idx = mesh.indices[t * 3 + v]
82
- tri.set_mV(v, new J.Float3(mesh.vertices[idx * 3], mesh.vertices[idx * 3 + 1], mesh.vertices[idx * 3 + 2]))
117
+ f3.x = vertices[idx * 3]; f3.y = vertices[idx * 3 + 1]; f3.z = vertices[idx * 3 + 2]
118
+ tri.set_mV(v, f3)
83
119
  }
84
120
  }
85
- const shape = new J.MeshShapeSettings(triangles).Create().Get()
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)
86
126
  return this._addBody(shape, [0, 0, 0], J.EMotionType_Static, LAYER_STATIC, { meta: { type: 'static', shape: 'trimesh', mesh: mesh.name, triangles: mesh.triangleCount } })
87
127
  }
128
+
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
+ }
88
160
  addPlayerCharacter(radius, halfHeight, position, mass) {
89
161
  const J = this.Jolt
90
162
  const cvs = new J.CharacterVirtualSettings()
@@ -142,9 +214,7 @@ export class PhysicsWorld {
142
214
  getCharacterPosition(charId) {
143
215
  const ch = this.characters?.get(charId); if (!ch) return [0, 0, 0]
144
216
  const p = ch.GetPosition()
145
- const r = [p.GetX(), p.GetY(), p.GetZ()]
146
- this.Jolt.destroy(p)
147
- return r
217
+ return [p.GetX(), p.GetY(), p.GetZ()]
148
218
  }
149
219
  getCharacterVelocity(charId) {
150
220
  const ch = this.characters?.get(charId); if (!ch) return [0, 0, 0]
@@ -230,8 +300,8 @@ export class PhysicsWorld {
230
300
  const dir = len > 0 ? [direction[0] / len, direction[1] / len, direction[2] / len] : direction
231
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))
232
302
  const rs = new J.RayCastSettings(), col = new J.CastRayClosestHitCollisionCollector()
233
- const bp = new J.DefaultBroadPhaseLayerFilter(this._ovbp)
234
- 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)
235
305
  const eb = excludeBodyId != null ? this._getBody(excludeBodyId) : null
236
306
  const bf = eb ? new J.IgnoreSingleBodyFilter(eb.GetID()) : new J.BodyFilter()
237
307
  const sf = new J.ShapeFilter()
@@ -245,13 +315,25 @@ export class PhysicsWorld {
245
315
  return result
246
316
  }
247
317
  destroy() {
318
+ if (!this.Jolt) return
319
+ const J = this.Jolt
248
320
  if (this.characters) {
249
- for (const [id, ch] of this.characters) {
250
- this.Jolt.destroy(ch)
251
- }
321
+ for (const ch of this.characters.values()) J.destroy(ch)
252
322
  this.characters.clear()
253
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 }
254
333
  for (const [id] of this.bodies) this.removeBody(id)
255
- 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
256
338
  }
257
339
  }
@@ -102,8 +102,7 @@ export function createTickHandler(deps) {
102
102
  const entitySnap = appRuntime.getSnapshotForPlayer(pos, stageLoader.getActiveStage().spatial.relevanceRadius)
103
103
  const combined = { tick: playerSnap.tick, timestamp: playerSnap.timestamp, players: playerSnap.players, entities: entitySnap.entities }
104
104
  if (isKeyframe || !playerEntityMaps.has(player.id)) {
105
- const encoded = SnapshotEncoder.encode(combined)
106
- const { entityMap } = SnapshotEncoder.encodeDelta(combined, new Map())
105
+ const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, new Map())
107
106
  playerEntityMaps.set(player.id, entityMap)
108
107
  connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
109
108
  } else {
@@ -1,83 +0,0 @@
1
- const ASSET_BASE = 'https://raw.githubusercontent.com/anEntrypoint/assets/main'
2
-
3
- const JUNK_MODELS = [
4
- `${ASSET_BASE}/dumpster_b076662a_v1.glb`,
5
- `${ASSET_BASE}/garbage_can_6b3d052b_v1.glb`,
6
- `${ASSET_BASE}/fire_hydrant_ba0175c1_v1.glb`,
7
- `${ASSET_BASE}/crushed_oil_barrel_e450f43f_v1.glb`,
8
- ]
9
-
10
- const HALF = 12
11
- const WALL_H = 3
12
- const WALL_T = 0.5
13
-
14
- const WALLS = [
15
- { id: 'arena-wall-n', x: 0, y: WALL_H / 2, z: -HALF, hx: HALF, hy: WALL_H / 2, hz: WALL_T / 2 },
16
- { id: 'arena-wall-s', x: 0, y: WALL_H / 2, z: HALF, hx: HALF, hy: WALL_H / 2, hz: WALL_T / 2 },
17
- { id: 'arena-wall-e', x: HALF, y: WALL_H / 2, z: 0, hx: WALL_T / 2, hy: WALL_H / 2, hz: HALF },
18
- { id: 'arena-wall-w', x: -HALF, y: WALL_H / 2, z: 0, hx: WALL_T / 2, hy: WALL_H / 2, hz: HALF },
19
- ]
20
-
21
- const PROPS = [
22
- { model: 0, x: -6, z: -6, rot: 0.5 },
23
- { model: 0, x: 7, z: 5, rot: 2.1 },
24
- { model: 1, x: -8, z: 3, rot: 1.0 },
25
- { model: 1, x: 5, z: -7, rot: 3.2 },
26
- { model: 1, x: 3, z: 8, rot: 0.3 },
27
- { model: 2, x: -4, z: -9, rot: 1.5 },
28
- { model: 2, x: 9, z: -3, rot: 4.0 },
29
- { model: 3, x: -7, z: 7, rot: 0.8 },
30
- { model: 3, x: 6, z: 2, rot: 2.7 },
31
- { model: 3, x: -3, z: -5, rot: 1.2 },
32
- ]
33
-
34
- export default {
35
- server: {
36
- setup(ctx) {
37
- ctx.state.ids = ctx.state.ids || []
38
- if (ctx.state.ids.length > 0) return
39
-
40
- // Ground (this entity itself)
41
- ctx.entity.custom = { mesh: 'box', color: 0x5a7a4a, roughness: 1, sx: HALF * 2, sy: 0.5, sz: HALF * 2 }
42
- ctx.physics.setStatic(true)
43
- ctx.physics.addBoxCollider([HALF, 0.25, HALF])
44
-
45
- // Walls - each gets box-static app which adds its own collider
46
- for (const w of WALLS) {
47
- const e = ctx.world.spawn(w.id, {
48
- position: [w.x, w.y, w.z],
49
- app: 'box-static',
50
- config: { hx: w.hx, hy: w.hy, hz: w.hz, color: 0x7a6a5a, roughness: 0.9 }
51
- })
52
- if (e) ctx.state.ids.push(w.id)
53
- }
54
-
55
- // Props from remote asset repo (static, convex hull from model)
56
- for (let i = 0; i < PROPS.length; i++) {
57
- const p = PROPS[i]
58
- const id = `arena-prop-${i}`
59
- const a = p.rot / 2
60
- const e = ctx.world.spawn(id, {
61
- model: JUNK_MODELS[p.model],
62
- position: [p.x, 0, p.z],
63
- rotation: [0, Math.sin(a), 0, Math.cos(a)],
64
- app: 'prop-static'
65
- })
66
- if (e) ctx.state.ids.push(id)
67
- }
68
-
69
- ctx.debug.log(`[arena] setup: ground + ${WALLS.length} walls + ${PROPS.length} props`)
70
- },
71
-
72
- teardown(ctx) {
73
- for (const id of ctx.state.ids || []) ctx.world.destroy(id)
74
- ctx.state.ids = []
75
- }
76
- },
77
-
78
- client: {
79
- render(ctx) {
80
- return { position: ctx.entity.position, rotation: ctx.entity.rotation, custom: ctx.entity.custom }
81
- }
82
- }
83
- }