spoint 0.1.45 → 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,32 +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 isGzip = (response.headers.get('content-encoding') || '').includes('gzip')
83
- const useTotal = contentLength > 0 && !isGzip
84
- const reader = response.body.getReader()
85
- const chunks = []
86
- let receivedLength = 0
87
-
88
- while (true) {
89
- const { done, value } = await reader.read()
90
- if (done) break
91
- chunks.push(value)
92
- receivedLength += value.length
93
- if (useTotal) {
94
- this.updateProgress(receivedLength, contentLength)
95
- }
96
- }
97
-
98
- const result = new Uint8Array(receivedLength)
99
- let position = 0
100
- for (const chunk of chunks) {
101
- result.set(chunk, position)
102
- position += chunk.length
103
- }
104
- return result
81
+ return await fetchCached(url, (received, total) => this.updateProgress(received, total))
105
82
  } catch (error) {
106
83
  console.error('[loading] fetch failed:', url, error)
107
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])
@@ -979,7 +980,11 @@ function loadEntityModel(entityId, entityState) {
979
980
  if (!environmentLoaded) { environmentLoaded = true; checkAllLoaded() }
980
981
  if (firstSnapshotEntityPending.has(entityId)) { firstSnapshotEntityPending.delete(entityId); if (firstSnapshotEntityPending.size === 0) checkAllLoaded() }
981
982
  if (loadingScreenHidden) renderer.compileAsync(scene, camera).catch(() => renderer.compile(scene, camera))
982
- }, (progress) => { if (progress.total > 0) console.log('[gltf]', url, Math.round(Math.min(progress.loaded, progress.total) / progress.total * 100) + '%') }, (err) => { console.error('[gltf]', url, err); pendingLoads.delete(entityId); if (firstSnapshotEntityPending.has(entityId)) { firstSnapshotEntityPending.delete(entityId); if (firstSnapshotEntityPending.size === 0) checkAllLoaded() } })
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)
983
988
  }
984
989
 
985
990
  function renderAppUI(state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.45",
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