spoint 0.1.44 → 0.1.46

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.
@@ -1,3 +1,5 @@
1
+ import { fetchCached } from './ModelCache.js'
2
+
1
3
  export class LoadingManager extends EventTarget {
2
4
  static STAGES = {
3
5
  CONNECTING: { name: 'CONNECTING', label: 'Connecting...', range: [0, 10] },
@@ -76,30 +78,7 @@ export class LoadingManager extends EventTarget {
76
78
 
77
79
  async fetchWithProgress(url) {
78
80
  try {
79
- const response = await fetch(url)
80
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
81
- const contentLength = parseInt(response.headers.get('content-length') || '0', 10)
82
- const reader = response.body.getReader()
83
- const chunks = []
84
- let receivedLength = 0
85
-
86
- while (true) {
87
- const { done, value } = await reader.read()
88
- if (done) break
89
- chunks.push(value)
90
- receivedLength += value.length
91
- if (contentLength > 0) {
92
- this.updateProgress(receivedLength, contentLength)
93
- }
94
- }
95
-
96
- const result = new Uint8Array(receivedLength)
97
- let position = 0
98
- for (const chunk of chunks) {
99
- result.set(chunk, position)
100
- position += chunk.length
101
- }
102
- return result
81
+ return await fetchCached(url, (received, total) => this.updateProgress(received, total))
103
82
  } catch (error) {
104
83
  console.error('[loading] fetch failed:', url, error)
105
84
  throw error
@@ -0,0 +1,76 @@
1
+ const DB_NAME = 'spawnpoint-model-cache'
2
+ const DB_VERSION = 1
3
+ const STORE = 'models'
4
+
5
+ let _db = null
6
+
7
+ async function openDB() {
8
+ if (_db) return _db
9
+ return new Promise((resolve, reject) => {
10
+ const req = indexedDB.open(DB_NAME, DB_VERSION)
11
+ req.onupgradeneeded = e => e.target.result.createObjectStore(STORE)
12
+ req.onsuccess = e => { _db = e.target.result; resolve(_db) }
13
+ req.onerror = () => reject(req.error)
14
+ })
15
+ }
16
+
17
+ async function dbGet(key) {
18
+ const db = await openDB()
19
+ return new Promise((resolve, reject) => {
20
+ const tx = db.transaction(STORE, 'readonly')
21
+ const req = tx.objectStore(STORE).get(key)
22
+ req.onsuccess = () => resolve(req.result)
23
+ req.onerror = () => reject(req.error)
24
+ })
25
+ }
26
+
27
+ async function dbPut(key, value) {
28
+ const db = await openDB()
29
+ return new Promise((resolve, reject) => {
30
+ const tx = db.transaction(STORE, 'readwrite')
31
+ const req = tx.objectStore(STORE).put(value, key)
32
+ req.onsuccess = () => resolve()
33
+ req.onerror = () => reject(req.error)
34
+ })
35
+ }
36
+
37
+ export async function fetchCached(url, onProgress) {
38
+ let cached = null
39
+ try { cached = await dbGet(url) } catch {}
40
+
41
+ if (cached?.etag) {
42
+ const head = await fetch(url, { method: 'HEAD' }).catch(() => null)
43
+ const serverEtag = head?.headers?.get('etag')
44
+ if (serverEtag && serverEtag === cached.etag) {
45
+ return new Uint8Array(cached.buffer)
46
+ }
47
+ }
48
+
49
+ const response = await fetch(url)
50
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
51
+ const etag = response.headers.get('etag') || ''
52
+ const contentLength = parseInt(response.headers.get('content-length') || '0', 10)
53
+ const isGzip = (response.headers.get('content-encoding') || '').includes('gzip')
54
+ const useTotal = contentLength > 0 && !isGzip
55
+ const reader = response.body.getReader()
56
+ const chunks = []
57
+ let received = 0
58
+
59
+ while (true) {
60
+ const { done, value } = await reader.read()
61
+ if (done) break
62
+ chunks.push(value)
63
+ received += value.length
64
+ if (useTotal && onProgress) onProgress(received, contentLength)
65
+ }
66
+
67
+ const result = new Uint8Array(received)
68
+ let pos = 0
69
+ for (const chunk of chunks) { result.set(chunk, pos); pos += chunk.length }
70
+
71
+ if (etag) {
72
+ try { await dbPut(url, { etag, buffer: result.buffer }) } catch {}
73
+ }
74
+
75
+ return result
76
+ }
package/client/app.js CHANGED
@@ -15,6 +15,7 @@ import { VRButton } from 'three/addons/webxr/VRButton.js'
15
15
  import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js'
16
16
  import { XRHandModelFactory } from 'three/addons/webxr/XRHandModelFactory.js'
17
17
  import { LoadingManager } from './LoadingManager.js'
18
+ import { fetchCached } from './ModelCache.js'
18
19
  import { createLoadingScreen } from './createLoadingScreen.js'
19
20
  import { MobileControls, detectDevice } from './MobileControls.js'
20
21
  import { ARControls, createARButton } from './ARControls.js'
@@ -956,7 +957,7 @@ function loadEntityModel(entityId, entityState) {
956
957
  }
957
958
  loadingMgr.setStage('RESOURCES')
958
959
  const url = entityState.model.startsWith('./') ? '/' + entityState.model.slice(2) : entityState.model
959
- gltfLoader.load(url, (gltf) => {
960
+ const onGltfLoad = (gltf) => {
960
961
  const model = gltf.scene
961
962
  const mp = entityState.position; model.position.set(mp[0], mp[1], mp[2])
962
963
  const mr = entityState.rotation; if (mr) model.quaternion.set(mr[0], mr[1], mr[2], mr[3])
@@ -977,8 +978,13 @@ function loadEntityModel(entityId, entityState) {
977
978
  fitShadowFrustum()
978
979
  pendingLoads.delete(entityId)
979
980
  if (!environmentLoaded) { environmentLoaded = true; checkAllLoaded() }
981
+ if (firstSnapshotEntityPending.has(entityId)) { firstSnapshotEntityPending.delete(entityId); if (firstSnapshotEntityPending.size === 0) checkAllLoaded() }
980
982
  if (loadingScreenHidden) renderer.compileAsync(scene, camera).catch(() => renderer.compile(scene, camera))
981
- }, (progress) => { if (progress.total > 0) console.log('[gltf]', url, Math.round(progress.loaded / progress.total * 100) + '%') }, (err) => { console.error('[gltf]', url, err); pendingLoads.delete(entityId) })
983
+ }
984
+ const onGltfErr = (err) => { console.error('[gltf]', url, err); pendingLoads.delete(entityId); if (firstSnapshotEntityPending.has(entityId)) { firstSnapshotEntityPending.delete(entityId); if (firstSnapshotEntityPending.size === 0) checkAllLoaded() } }
985
+ fetchCached(url).then(buf => {
986
+ gltfLoader.parse(buf.buffer.slice(0), '', onGltfLoad, onGltfErr)
987
+ }).catch(onGltfErr)
982
988
  }
983
989
 
984
990
  function renderAppUI(state) {
@@ -1026,7 +1032,13 @@ const client = new PhysicsNetworkClient({
1026
1032
  }
1027
1033
  rebuildEntityHierarchy(smoothState.entities)
1028
1034
  latestState = state
1029
- if (!firstSnapshotReceived) { firstSnapshotReceived = true; checkAllLoaded() }
1035
+ if (!firstSnapshotReceived) {
1036
+ firstSnapshotReceived = true
1037
+ for (const e of smoothState.entities) {
1038
+ if (e.model && !entityMeshes.has(e.id)) firstSnapshotEntityPending.add(e.id)
1039
+ }
1040
+ checkAllLoaded()
1041
+ }
1030
1042
  },
1031
1043
  onPlayerJoined: (id) => { if (!playerMeshes.has(id)) createPlayerVRM(id) },
1032
1044
  onPlayerLeft: (id) => removePlayerMesh(id),
@@ -1091,6 +1103,7 @@ let inputLoopId = null
1091
1103
  let loadingScreenHidden = false
1092
1104
  let environmentLoaded = false
1093
1105
  let firstSnapshotReceived = false
1106
+ const firstSnapshotEntityPending = new Set()
1094
1107
  let lastShootState = false
1095
1108
  let lastHealth = 100
1096
1109
 
@@ -1123,6 +1136,7 @@ function checkAllLoaded() {
1123
1136
  if (!assetsLoaded) return
1124
1137
  if (!environmentLoaded) return
1125
1138
  if (!firstSnapshotReceived) return
1139
+ if (firstSnapshotEntityPending.size > 0) return
1126
1140
  loadingMgr.setStage('INIT')
1127
1141
  loadingMgr.complete()
1128
1142
  loadingScreenHidden = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -45,9 +45,18 @@ export function createStaticHandler(dirs) {
45
45
  } else if (ext === '.glb' || ext === '.vrm' || ext === '.gltf') {
46
46
  headers['Cache-Control'] = 'public, max-age=86400, immutable'
47
47
  }
48
- const { content, gzipped } = getCached(fp, ext)
48
+ const { content, gzipped, mtime } = getCached(fp, ext)
49
49
  if (gzipped) headers['Content-Encoding'] = 'gzip'
50
50
  headers['Content-Length'] = content.length
51
+ if (ext === '.glb' || ext === '.vrm' || ext === '.gltf') {
52
+ headers['ETag'] = `"${mtime.toString(16)}"`
53
+ const ifNoneMatch = req.headers['if-none-match']
54
+ if (ifNoneMatch === headers['ETag']) {
55
+ res.writeHead(304, { 'ETag': headers['ETag'], 'Cache-Control': headers['Cache-Control'] })
56
+ res.end()
57
+ return
58
+ }
59
+ }
51
60
  res.writeHead(200, headers)
52
61
  res.end(content)
53
62
  return