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 +52 -27
- 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
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
926
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
1002
|
-
? h('div', { style: 'position:fixed;
|
|
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
|
|
1106
|
+
### Animation library uses two-phase cache
|
|
1090
1107
|
|
|
1091
|
-
`
|
|
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.
|
|
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
|