spoint 0.1.46 → 0.1.48

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
@@ -778,7 +799,13 @@ App reloads never happen mid-tick. Queue drains at end of each tick. After each
778
799
 
779
800
  ### GLB Shader Stall Prevention
780
801
 
781
- The engine automatically calls `renderer.compileAsync(object, camera)` immediately after adding any GLB or procedural mesh to the scene. This prevents first-draw GPU stall for dynamically loaded entities (environment models, physics crates, power crates, smart objects, drag-and-drop models). No action is needed from app code — warmup is handled in `loadEntityModel` and `loadQueuedModels`. VRM players use a separate one-time warmup path.
802
+ The engine handles GPU shader warmup in two phases no action is needed from app code:
803
+
804
+ 1. **Initial load**: After the loading screen gates pass (assets, environment, first snapshot, first-snapshot entities all loaded), the loading screen hides and `warmupShaders()` runs asynchronously. It calls `renderer.compileAsync(scene, camera)`, disables frustum culling, renders twice to upload GPU data, then restores culling. This covers all entities present at startup.
805
+
806
+ 2. **Post-load dynamic entities**: For GLBs added after the loading screen is hidden, `loadEntityModel` calls `renderer.compileAsync(scene, camera)` immediately after adding the mesh to the scene.
807
+
808
+ VRM players use a separate one-time warmup (`_vrmWarmupDone`) that fires `renderer.compileAsync(scene, camera)` after the first player model loads.
782
809
 
783
810
  ### render(ctx) Return Value
784
811
 
@@ -909,21 +936,16 @@ onMessage(ctx, msg) {
909
936
 
910
937
  ### Interact detection with cooldown
911
938
 
939
+ Use `ctx.interactable()` — the engine handles proximity, prompt UI, and cooldown automatically.
940
+
912
941
  ```js
913
942
  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
- })
943
+ ctx.interactable({ prompt: 'Press E', radius: 4, cooldown: 500 })
924
944
  },
925
- teardown(ctx) {
926
- ctx.state.cooldowns?.clear()
945
+
946
+ onInteract(ctx, player) {
947
+ ctx.players.send(player.id, { type: 'interact_response', message: 'Hello!' })
948
+ ctx.network.broadcast({ type: 'interact_effect', position: ctx.entity.position })
927
949
  }
928
950
  ```
929
951
 
@@ -977,19 +999,14 @@ update(ctx, dt) {
977
999
  }
978
1000
  ```
979
1001
 
980
- ### Client UI with proximity check
1002
+ ### Client UI with custom HUD
1003
+
1004
+ 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
1005
 
982
1006
  ```js
983
1007
  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
1008
+ onEvent(payload) {
1009
+ if (payload.type === 'interact_response') { this._msg = payload.message; this._expire = Date.now() + 3000 }
993
1010
  },
994
1011
 
995
1012
  render(ctx) {
@@ -998,8 +1015,8 @@ client: {
998
1015
  return {
999
1016
  position: ctx.entity.position,
1000
1017
  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')
1018
+ ui: this._msg && Date.now() < this._expire
1019
+ ? 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
1020
  : null
1004
1021
  }
1005
1022
  }
@@ -1086,9 +1103,9 @@ The engine manually applies `gravity[1] * dt` to Y velocity. This is already han
1086
1103
 
1087
1104
  Use `addConvexCollider(points)` or `addConvexFromModel()` for dynamic/kinematic bodies that need shape-accurate physics (vehicles, crates). Convex hulls support all motion types unlike trimesh. `addConvexFromModel()` reads vertices from the entity's GLB at setup time - call it after setting `entity.model`.
1088
1105
 
1089
- ### Animation library is cached globally
1106
+ ### Animation library uses two-phase cache
1090
1107
 
1091
- `loadAnimationLibrary()` loads `/anim-lib.glb` only once and caches the result. All subsequent calls return the cached result immediately. The library is also pre-fetched in parallel with the VRM download during initialization.
1108
+ `preloadAnimationLibrary()` kicks off the `/anim-lib.glb` fetch and caches the promise (`_gltfPromise`). `loadAnimationLibrary(vrmVersion, vrmHumanoid)` awaits that fetch and caches the normalized clip result (`_normalizedCache`). The engine calls `preloadAnimationLibrary()` early during asset init so the GLB is already fetching while the VRM downloads. Subsequent calls to `loadAnimationLibrary()` return the normalized cache immediately. Both functions are idempotent and safe to call concurrently.
1092
1109
 
1093
1110
  ### Tick drops under load
1094
1111
 
@@ -1110,6 +1127,14 @@ If any blocked string (including in comments) appears anywhere in the source, th
1110
1127
 
1111
1128
  All `import` statements in client app source are stripped by regex before evaluation. Use `engine.THREE`, `engine.scene`, etc. for all dependencies.
1112
1129
 
1130
+ ### GLB/VRM assets are cached in IndexedDB
1131
+
1132
+ On repeat page loads, `fetchCached()` in `client/ModelCache.js` validates cached GLB/VRM ArrayBuffers against the server ETag via a HEAD request. If the ETag matches, the cached bytes are returned without a network fetch. Cache misses or stale entries trigger a full fetch and re-store. Cache failures (quota, unavailable) fall back to normal fetch transparently. This is fully automatic — no app code needed.
1133
+
1134
+ ### Loading screen hides before shader warmup completes
1135
+
1136
+ After the four gate conditions pass, the loading screen hides immediately. `warmupShaders()` then runs asynchronously in the background. The very first rendered frame after the loading screen hides may have a brief GPU stall if shader compilation is not yet complete. This is a deliberate tradeoff to avoid the loading screen adding warmup time on top of actual asset loading.
1137
+
1113
1138
  ### setTimeout not cleared on hot reload
1114
1139
 
1115
1140
  `ctx.time.after/every` timers are cleared on teardown. `setTimeout` and `setInterval` are NOT. Use `ctx.time` for game logic. Use `setTimeout` only for external timing (e.g., reload cooldown) and manage cleanup in teardown manually.
@@ -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
  }
package/client/app.js CHANGED
@@ -999,13 +999,32 @@ function renderAppUI(state) {
999
999
  if (result?.ui) uiFragments.push({ id: entity.id, ui: result.ui })
1000
1000
  } catch (e) { console.error('[ui]', entity.id, e.message) }
1001
1001
  }
1002
- const hudVdom = createElement('div', { id: 'hud' },
1002
+ const interactPrompt = _buildInteractPrompt(state)
1003
+ const hudVdom = createElement('div', { id: 'hud' },
1003
1004
  createElement('div', { id: 'info' }, `FPS: ${fpsDisplay} | Players: ${state.players.length} | Tick: ${client.currentTick} | RTT: ${Math.round(client.getRTT())}ms | Buf: ${client.getBufferHealth()}`),
1004
- ...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
1005
1007
  )
1006
1008
  try { applyDiff(uiRoot, hudVdom) } catch (e) { console.error('[ui] diff:', e.message) }
1007
1009
  }
1008
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
+
1009
1028
  const client = new PhysicsNetworkClient({
1010
1029
  url: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`,
1011
1030
  predictionEnabled: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
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