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 +35 -24
- package/apps/interactable/index.js +3 -32
- package/client/app.js +21 -2
- package/package.json +1 -1
- package/src/apps/AppContext.js +12 -0
- package/src/apps/AppRuntime.js +2 -1
- package/src/client/InputHandler.js +1 -0
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.
|
|
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
|
-
|
|
926
|
-
|
|
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
|
|
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
|
-
|
|
985
|
-
|
|
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.
|
|
1002
|
-
? h('div', { style: 'position:fixed;
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
package/src/apps/AppContext.js
CHANGED
|
@@ -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 {
|
package/src/apps/AppRuntime.js
CHANGED
|
@@ -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
|
-
|
|
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
|