spoint 0.1.45 → 0.1.47

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
@@ -219,6 +219,27 @@ Read-only. Set in the world entities array under `config: {}`.
219
219
  ctx.config.radius // 5
220
220
  ```
221
221
 
222
+ ### ctx.interactable
223
+
224
+ Declares this entity as interactable. The engine handles proximity detection, the E-key prompt UI, and cooldown automatically. The app only needs to implement `onInteract`.
225
+
226
+ ```js
227
+ setup(ctx) {
228
+ ctx.interactable({ prompt: 'Press E to open', radius: 2, cooldown: 1000 })
229
+ },
230
+
231
+ onInteract(ctx, player) {
232
+ ctx.players.send(player.id, { type: 'opened', message: 'Opened!' })
233
+ }
234
+ ```
235
+
236
+ Options:
237
+ - `prompt` — text shown in the HUD when player is within range (default: `'Press E'`)
238
+ - `radius` — interaction distance in world units (default: `3`)
239
+ - `cooldown` — milliseconds between allowed interactions per player (default: `500`)
240
+
241
+ The engine client automatically shows and hides the prompt when the local player enters or leaves the radius. No client-side code is needed for basic interactables.
242
+
222
243
  ### ctx.physics
223
244
 
224
245
  ```js
@@ -909,21 +930,16 @@ onMessage(ctx, msg) {
909
930
 
910
931
  ### Interact detection with cooldown
911
932
 
933
+ Use `ctx.interactable()` — the engine handles proximity, prompt UI, and cooldown automatically.
934
+
912
935
  ```js
913
936
  setup(ctx) {
914
- ctx.state.cooldowns = new Map()
915
- ctx.time.every(0.1, () => {
916
- const player = ctx.players.getNearest(ctx.entity.position, 4)
917
- if (!player?.state?.interact) return
918
- const now = Date.now()
919
- if (now - (ctx.state.cooldowns.get(player.id) || 0) < 500) return
920
- ctx.state.cooldowns.set(player.id, now)
921
- ctx.players.send(player.id, { type: 'interact_response', message: 'Hello!' })
922
- ctx.network.broadcast({ type: 'interact_effect', position: ctx.entity.position })
923
- })
937
+ ctx.interactable({ prompt: 'Press E', radius: 4, cooldown: 500 })
924
938
  },
925
- teardown(ctx) {
926
- ctx.state.cooldowns?.clear()
939
+
940
+ onInteract(ctx, player) {
941
+ ctx.players.send(player.id, { type: 'interact_response', message: 'Hello!' })
942
+ ctx.network.broadcast({ type: 'interact_effect', position: ctx.entity.position })
927
943
  }
928
944
  ```
929
945
 
@@ -977,19 +993,14 @@ update(ctx, dt) {
977
993
  }
978
994
  ```
979
995
 
980
- ### Client UI with proximity check
996
+ ### Client UI with custom HUD
997
+
998
+ For interaction prompts, use `ctx.interactable()` on the server — the engine renders the prompt automatically. For custom HUD elements (health bars, messages), use `render()`:
981
999
 
982
1000
  ```js
983
1001
  client: {
984
- onFrame(dt, engine) {
985
- const ent = engine.client?.state?.entities?.find(e => e.app === 'my-app')
986
- const local = engine.client?.state?.players?.find(p => p.id === engine.playerId)
987
- if (!ent?.position || !local?.position) { this._canInteract = false; return }
988
- const dist = Math.hypot(
989
- ent.position[0] - local.position[0],
990
- ent.position[2] - local.position[2]
991
- )
992
- this._canInteract = dist < 4
1002
+ onEvent(payload) {
1003
+ if (payload.type === 'interact_response') { this._msg = payload.message; this._expire = Date.now() + 3000 }
993
1004
  },
994
1005
 
995
1006
  render(ctx) {
@@ -998,8 +1009,8 @@ client: {
998
1009
  return {
999
1010
  position: ctx.entity.position,
1000
1011
  custom: ctx.entity.custom,
1001
- ui: this._canInteract
1002
- ? h('div', { style: 'position:fixed;bottom:40%;left:50%;transform:translateX(-50%);color:#fff;background:rgba(0,0,0,0.7);padding:8px 16px;border-radius:8px' }, 'Press E to interact')
1012
+ ui: this._msg && Date.now() < this._expire
1013
+ ? h('div', { style: 'position:fixed;top:30%;left:50%;transform:translate(-50%,-50%);padding:16px 32px;background:rgba(0,0,0,0.8);border-radius:12px;color:#0f0;font-weight:bold' }, this._msg)
1003
1014
  : null
1004
1015
  }
1005
1016
  }
@@ -3,10 +3,9 @@ export default {
3
3
  setup(ctx) {
4
4
  ctx.entity.custom = { mesh: 'box', color: 0x00ff88, sx: 1.5, sy: 0.5, sz: 1.5, label: 'INTERACT' }
5
5
  ctx.state.interactionCount = 0
6
-
7
6
  ctx.physics.setStatic(true)
8
7
  ctx.physics.addBoxCollider([0.75, 0.25, 0.75])
9
- ctx.physics.setInteractable(3.5)
8
+ ctx.interactable({ prompt: 'Press E to interact', radius: 3.5, cooldown: 500 })
10
9
  },
11
10
 
12
11
  onInteract(ctx, player) {
@@ -25,33 +24,7 @@ export default {
25
24
  },
26
25
 
27
26
  client: {
28
- setup(engine) {
29
- this._lastMessage = null
30
- this._messageExpire = 0
31
- this._canInteract = false
32
- this._wasRegistered = false
33
- },
34
-
35
- onFrame(dt, engine) {
36
- const ent = engine.client?.state?.entities?.find(e => e.app === 'interactable')
37
- const pos = ent?.position
38
- const local = engine.client?.state?.players?.find(p => p.id === engine.playerId)
39
- if (!pos || !local?.position) {
40
- if (this._wasRegistered) { engine.mobileControls?.unregisterInteractable(this._wasRegistered); this._wasRegistered = false }
41
- return
42
- }
43
- const dx = pos[0]-local.position[0], dy = pos[1]-local.position[1], dz = pos[2]-local.position[2]
44
- const canInteract = dx*dx+dy*dy+dz*dz < 3.5*3.5
45
- if (canInteract && !this._wasRegistered) { engine.mobileControls?.registerInteractable(ent.id, 'INTERACT'); this._wasRegistered = ent.id }
46
- else if (!canInteract && this._wasRegistered) { engine.mobileControls?.unregisterInteractable(this._wasRegistered); this._wasRegistered = false }
47
- this._canInteract = canInteract
48
- },
49
-
50
- teardown(engine) {
51
- if (this._wasRegistered) { engine.mobileControls?.unregisterInteractable(this._wasRegistered); this._wasRegistered = false }
52
- },
53
-
54
- onEvent(payload, engine) {
27
+ onEvent(payload) {
55
28
  if (payload.type === 'interact_response') { this._lastMessage = payload.message; this._messageExpire = Date.now() + 3000 }
56
29
  if (payload.type === 'interact_effect') { this._lastMessage = 'Someone interacted!'; this._messageExpire = Date.now() + 1500 }
57
30
  },
@@ -65,9 +38,7 @@ export default {
65
38
  const opacity = Math.min(1, ((this._messageExpire - Date.now()) / 3000) * 2)
66
39
  ui.push(h('div', { style: `position:fixed;top:30%;left:50%;transform:translate(-50%,-50%);padding:16px 32px;background:rgba(0,0,0,0.8);border-radius:12px;color:#0f0;font-weight:bold;font-size:20px;text-align:center;border:2px solid #0f0;opacity:${opacity}` }, this._lastMessage))
67
40
  }
68
- const custom = { ...ctx.entity.custom }
69
- if (this._canInteract) { custom.glow = true; custom.glowColor = 0x00ff88; custom.glowIntensity = 0.5 }
70
- return { position: pos, rotation: ctx.entity.rotation, custom, ui: ui.length > 0 ? h('div', null, ...ui) : null }
41
+ return { position: pos, rotation: ctx.entity.rotation, custom: ctx.entity.custom, ui: ui.length > 0 ? h('div', null, ...ui) : null }
71
42
  }
72
43
  }
73
44
  }
@@ -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) {
@@ -994,13 +999,32 @@ function renderAppUI(state) {
994
999
  if (result?.ui) uiFragments.push({ id: entity.id, ui: result.ui })
995
1000
  } catch (e) { console.error('[ui]', entity.id, e.message) }
996
1001
  }
997
- const hudVdom = createElement('div', { id: 'hud' },
1002
+ const interactPrompt = _buildInteractPrompt(state)
1003
+ const hudVdom = createElement('div', { id: 'hud' },
998
1004
  createElement('div', { id: 'info' }, `FPS: ${fpsDisplay} | Players: ${state.players.length} | Tick: ${client.currentTick} | RTT: ${Math.round(client.getRTT())}ms | Buf: ${client.getBufferHealth()}`),
999
- ...uiFragments.map(f => createElement('div', { 'data-app': f.id }, f.ui))
1005
+ ...uiFragments.map(f => createElement('div', { 'data-app': f.id }, f.ui)),
1006
+ interactPrompt
1000
1007
  )
1001
1008
  try { applyDiff(uiRoot, hudVdom) } catch (e) { console.error('[ui] diff:', e.message) }
1002
1009
  }
1003
1010
 
1011
+ function _buildInteractPrompt(state) {
1012
+ const local = state.players.find(p => p.id === client.playerId)
1013
+ if (!local?.position) return null
1014
+ const lx = local.position[0], ly = local.position[1], lz = local.position[2]
1015
+ for (const entity of state.entities) {
1016
+ const cfg = entity.custom?._interactable
1017
+ if (!cfg || !entity.position) continue
1018
+ const dx = entity.position[0] - lx, dy = entity.position[1] - ly, dz = entity.position[2] - lz
1019
+ if (dx * dx + dy * dy + dz * dz < cfg.radius * cfg.radius) {
1020
+ return createElement('div', {
1021
+ style: 'position:fixed;bottom:40%;left:50%;transform:translateX(-50%);color:#fff;background:rgba(0,0,0,0.7);padding:8px 16px;border-radius:8px;pointer-events:none'
1022
+ }, cfg.prompt)
1023
+ }
1024
+ }
1025
+ return null
1026
+ }
1027
+
1004
1028
  const client = new PhysicsNetworkClient({
1005
1029
  url: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`,
1006
1030
  predictionEnabled: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -173,6 +173,18 @@ export class AppContext {
173
173
 
174
174
  get debug() { return this._debugger }
175
175
 
176
+ interactable(config = {}) {
177
+ const ent = this._entity
178
+ const radius = config.radius ?? 3
179
+ const prompt = config.prompt ?? 'Press E'
180
+ const cooldown = config.cooldown ?? 500
181
+ ent._interactable = true
182
+ ent._interactRadius = radius
183
+ ent._interactCooldown = cooldown
184
+ if (!ent.custom) ent.custom = {}
185
+ ent.custom._interactable = { prompt, radius }
186
+ }
187
+
176
188
  get collider() {
177
189
  const ent = this._entity
178
190
  return {
@@ -194,7 +194,8 @@ export class AppRuntime {
194
194
  if (dx*dx+dy*dy+dz*dz > e._interactRadius**2) continue
195
195
  const key = `${e.id}:${p.id}`
196
196
  const last = this._interactCooldowns.get(key) || 0
197
- if (p.lastInput?.interact && now - last > 500) {
197
+ const cooldown = e._interactCooldown ?? 500
198
+ if (p.lastInput?.interact && now - last > cooldown) {
198
199
  this._interactCooldowns.set(key, now)
199
200
  this.fireEvent(e.id, 'onInteract', p)
200
201
  const bus = this._eventBus.scope ? this._eventBus : null
@@ -142,6 +142,7 @@ export class InputHandler {
142
142
  crouch: this.keys.get('c') || this.keys.get('control') || false,
143
143
  shoot: this.mouseDown,
144
144
  reload: this.keys.get('r') || false,
145
+ interact: this.keys.get('e') || false,
145
146
  editToggle: this.editModeCooldown,
146
147
  mouseX: this.mouseX,
147
148
  mouseY: this.mouseY
@@ -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