spoint 0.1.46 → 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
  }
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.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