spoint 0.1.603 → 0.1.604

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/client/app.js CHANGED
@@ -43,6 +43,7 @@ import { createTerrainBackdrop } from './core/TerrainBackdrop.js'
43
43
  import { createSculptOverlay } from './core/SculptOverlay.js'
44
44
  import { createVegetation } from './core/Vegetation.js'
45
45
  import { createRocks } from './core/Rocks.js'
46
+ import { createCaveMeshes } from './core/CaveMeshes.js'
46
47
  import { createSceneOcclusion } from './core/SceneOcclusion.js'
47
48
  import { createOcclusionQueryBudget } from './core/OcclusionQueryBudget.js'
48
49
  import { createGrass } from './core/Grass.js'
@@ -502,6 +503,16 @@ function _ensureRocks(tb) {
502
503
  .then(r => { rocks = r; if (window.__app) window.__app.rocks = r; sceneOcclusion.register('rocks', r) })
503
504
  .catch(e => console.error('[rocks] init failed:', e?.message || e))
504
505
  }
506
+ function _ensureCaves(tb) {
507
+ if (caveMeshes || !tb || !_terrainCfg) return null
508
+ const caveCfg = _terrainCfg.caveCarve
509
+ if (!Array.isArray(caveCfg) || caveCfg.length === 0) return null
510
+ try {
511
+ caveMeshes = createCaveMeshes({ scene, cfg: caveCfg })
512
+ if (window.__app) window.__app.caveMeshes = caveMeshes
513
+ } catch (e) { console.error('[caves] init failed:', e?.message || e) }
514
+ return null
515
+ }
505
516
  function _ensureGrass(tb) {
506
517
  if (grass || !tb || !_terrainCfg) return null
507
518
  const vcfg = _terrainCfg.vegetation || {}
@@ -580,6 +591,7 @@ async function _buildWorldScenery() {
580
591
  const rp = _ensureRocks(tb); if (rp) { await rp; _hp('after-rocks') }
581
592
  const gp = _ensureGrass(tb); if (gp) { await gp; _hp('after-grass') }
582
593
  const vp = _ensureVegetation(tb); if (vp) { await vp; _hp('after-veg') }
594
+ _ensureCaves(tb); _hp('after-caves')
583
595
  _ensureWeather(tb); _hp('after-weather')
584
596
  } else {
585
597
  _dbgTerrain('planet backdrop unavailable (init failed) -> skipping vegetation/rocks/grass to avoid a broken-shader GPU leak')
@@ -666,7 +678,7 @@ const clickPrompt = document.getElementById('click-prompt')
666
678
  if (deviceInfo.isMobile && clickPrompt) clickPrompt.style.display = 'none'
667
679
  const _pids = new Set(), _eids = new Set()
668
680
  let worldConfig={}, vrmBuffer=null, animAssets=null, assetsLoaded=false, firstSnapshotReceived=false, _fitShadowTimer=null
669
- let terrainBackdrop=null, _terrainCfg=null, vegetation=null, rocks=null, grass=null, colliderDebug=null, weather=null, sculptOverlay=null
681
+ let terrainBackdrop=null, _terrainCfg=null, vegetation=null, rocks=null, grass=null, colliderDebug=null, weather=null, sculptOverlay=null, caveMeshes=null
670
682
  // Late-join GPU-visible sculpt backfill (terrain-sculpt-late-join-gpu-resync): MSG.TERRAIN_SCULPT_SYNC
671
683
  // can (and on a real connection reliably does -- terrain scenery build is async, WORLD_DEF/SNAPSHOT/this
672
684
  // sync all arrive near-instantly on the same connection) reach the client BEFORE _buildWorldScenery has
@@ -1008,7 +1020,8 @@ const engineCtx = {
1008
1020
  try { rocks && rocks.dispose && rocks.dispose() } catch (e) { _dbgTerrain('rocks dispose failed on reseed:', e?.message || e) }
1009
1021
  sceneOcclusion.unregister('vegetation'); sceneOcclusion.unregister('rocks'); sceneOcclusion.unregister('grass')
1010
1022
  try { grass && grass.dispose && grass.dispose() } catch (e) { _dbgTerrain('grass dispose failed on reseed:', e?.message || e) }
1011
- vegetation = null; rocks = null; grass = null
1023
+ try { caveMeshes && caveMeshes.dispose && caveMeshes.dispose() } catch (e) { _dbgTerrain('caveMeshes dispose failed on reseed:', e?.message || e) }
1024
+ vegetation = null; rocks = null; grass = null; caveMeshes = null
1012
1025
  // Weather itself isn't seed-derived (camera-relative, no placement hash), but it holds the OLD
1013
1026
  // terrain frame closed over for ground-height sampling -- a stale frame after reseed would sample
1014
1027
  // splash contact against the pre-reseed terrain. Dispose + let _ensureWeather below rebuild
@@ -1024,7 +1037,7 @@ const engineCtx = {
1024
1037
  try { const f = tb.frame; if (f) setSeaLevelY((f.offsetY || 0) - (f.anchorHeight || 0), scene) } catch (_) {}
1025
1038
  if (seedChanged && tb && window.__terrain) {
1026
1039
  const rp = _ensureRocks(tb); const gp = _ensureGrass(tb); const vp = _ensureVegetation(tb)
1027
- _ensureWeather(tb)
1040
+ _ensureCaves(tb); _ensureWeather(tb)
1028
1041
  return Promise.all([rp, gp, vp].filter(Boolean)).catch(e => console.error('[terrain] reseed veg/rock/grass rebuild failed:', e?.message || e))
1029
1042
  }
1030
1043
  })
@@ -0,0 +1,42 @@
1
+ import * as THREE from 'three'
2
+ import { polygonizeCaveVolume, loadCaveCarveLayer } from '/src/terrain/CaveSDF.js'
3
+
4
+ function buildVolumeMesh(vol, res, material) {
5
+ const mesh = polygonizeCaveVolume(vol, res)
6
+ if (mesh.vc === 0) return null
7
+ const g = new THREE.BufferGeometry()
8
+ g.setAttribute('position', new THREE.BufferAttribute(mesh.positions.slice(), 3))
9
+ g.setIndex(new THREE.BufferAttribute(mesh.indices.slice(0, mesh.ic), 1))
10
+ g.computeVertexNormals()
11
+ g.computeBoundingBox()
12
+ g.computeBoundingSphere()
13
+ const obj = new THREE.Mesh(g, material)
14
+ obj.matrixAutoUpdate = false
15
+ obj.updateMatrix()
16
+ return obj
17
+ }
18
+
19
+ export function createCaveMeshes({ scene, cfg, res = 24 }) {
20
+ const group = new THREE.Group()
21
+ group.name = 'cave-meshes'
22
+ const material = new THREE.MeshStandardMaterial({ color: 0x2a2a2c, roughness: 0.95, metalness: 0.0, side: THREE.BackSide })
23
+ const objects = []
24
+ const volumesSpec = Array.isArray(cfg) ? cfg : []
25
+ const layer = loadCaveCarveLayer({ version: 2, volumes: volumesSpec })
26
+ for (const vol of layer.volumes) {
27
+ const obj = buildVolumeMesh(vol, res, material)
28
+ if (obj) { group.add(obj); objects.push(obj) }
29
+ }
30
+ if (scene) scene.add(group)
31
+
32
+ function dispose() {
33
+ for (const obj of objects) { obj.geometry.dispose(); scene && scene.remove(obj) }
34
+ objects.length = 0
35
+ scene && scene.remove(group)
36
+ material.dispose()
37
+ }
38
+
39
+ const api = { group, objects, dispose, get volumeCount() { return objects.length } }
40
+ if (typeof window !== 'undefined') window.__caveMeshes = api
41
+ return api
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.603",
3
+ "version": "0.1.604",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -65,7 +65,7 @@ export class RoomOrchestrator {
65
65
  * @param {number} [opts.maxRestarts] - max restarts per worker within restartWindowMs; default 3
66
66
  * @param {number} [opts.restartWindowMs] - sliding window for crash rate limiting; default 60000
67
67
  */
68
- constructor({ sdkRoot, projectRoot, workerCount = 2, portRange = [19000, 19999], elasticScaling = false, elasticScaleUpThreshold = 0.8, elasticScaleDownCooldownMs = 120000, elasticScaleCheckIntervalMs = 30000, workerHosts = {}, restartOnCrash = true, maxRestarts = 3, restartWindowMs = 60000 } = {}) {
68
+ constructor({ sdkRoot, projectRoot, workerCount = 2, portRange = [19000, 19999], elasticScaling = false, elasticScaleUpThreshold = 0.8, elasticScaleDownCooldownMs = 120000, elasticScaleCheckIntervalMs = 30000, workerHosts = {}, restartOnCrash = true, maxRestarts = 3, restartWindowMs = 60000, externalWorkerHeartbeatIntervalMs = 10000 } = {}) {
69
69
  if (!sdkRoot) throw new Error('RoomOrchestrator requires { sdkRoot }')
70
70
  this.sdkRoot = sdkRoot
71
71
  this.projectRoot = projectRoot || sdkRoot
@@ -97,6 +97,8 @@ export class RoomOrchestrator {
97
97
  this._emptiedAt = new Map() // workerIndex -> timestamp when it last became empty (for scale-down cooldown)
98
98
  this._retiring = new Set() // workerIndexes currently draining (reject new room placement, retiring handled by _onWorkerRetired once empty)
99
99
  this._elasticStats = { spawns: 0, retires: 0, lastCheck: 0, lastDecision: '' }
100
+ this._externalWorkerHeartbeatIntervalMs = externalWorkerHeartbeatIntervalMs
101
+ this._externalHeartbeatTimer = null
100
102
  }
101
103
 
102
104
  // Non-overlapping sub-range per worker so N independent RoomDirectory instances (each with zero
@@ -268,6 +270,25 @@ export class RoomOrchestrator {
268
270
  if (this._elasticTimer) { clearInterval(this._elasticTimer); this._elasticTimer = null }
269
271
  }
270
272
 
273
+ startExternalWorkerHeartbeat() {
274
+ if (this._externalHeartbeatTimer) return
275
+ this._externalHeartbeatTimer = setInterval(() => this._externalHeartbeatCheck(), this._externalWorkerHeartbeatIntervalMs)
276
+ this._externalHeartbeatTimer.unref?.()
277
+ }
278
+
279
+ stopExternalWorkerHeartbeat() {
280
+ if (this._externalHeartbeatTimer) { clearInterval(this._externalHeartbeatTimer); this._externalHeartbeatTimer = null }
281
+ }
282
+
283
+ async _externalHeartbeatCheck() {
284
+ const checks = []
285
+ for (let i = 0; i < this.workers.length; i++) {
286
+ const entry = this.workers[i]
287
+ if (entry && entry.ready && entry.isExternal) checks.push(this._sendExternal(entry, { type: 'GET_STATUS' }).catch(() => null))
288
+ }
289
+ await Promise.all(checks)
290
+ }
291
+
271
292
  async _elasticCheck() {
272
293
  const now = Date.now()
273
294
  this._elasticStats.lastCheck = now
@@ -1,5 +1,14 @@
1
1
  import { marchRockSurface } from './RockShapes.js'
2
2
 
3
+ function tunnelHash(seed, i) {
4
+ let h = seed | 0
5
+ h = Math.imul(h ^ (i | 0), 0x27d4eb2d) >>> 0
6
+ h ^= h >>> 15; h = Math.imul(h, 0x2c1b3c6d) >>> 0
7
+ h ^= h >>> 12; h = Math.imul(h, 0x297a2d39) >>> 0
8
+ h ^= h >>> 15
9
+ return (h >>> 0) / 4294967296
10
+ }
11
+
3
12
  export function makeSphereCaveSDF(cx, cy, cz, radius) {
4
13
  return (x, y, z) => {
5
14
  const dx = x - cx, dy = y - cy, dz = z - cz
@@ -7,23 +16,65 @@ export function makeSphereCaveSDF(cx, cy, cz, radius) {
7
16
  }
8
17
  }
9
18
 
19
+ export function makeCylinderCaveSDF(cx, cy, cz, radius, halfHeight, axis = 'y') {
20
+ return (x, y, z) => {
21
+ const dx = x - cx, dy = y - cy, dz = z - cz
22
+ let radial, along, halfSpan
23
+ if (axis === 'x') { radial = Math.hypot(dy, dz); along = dx; halfSpan = halfHeight }
24
+ else if (axis === 'z') { radial = Math.hypot(dx, dy); along = dz; halfSpan = halfHeight }
25
+ else { radial = Math.hypot(dx, dz); along = dy; halfSpan = halfHeight }
26
+ const dRadial = radial - radius
27
+ const dAlong = Math.abs(along) - halfSpan
28
+ const outsideX = Math.max(dRadial, 0), outsideY = Math.max(dAlong, 0)
29
+ const outsideDist = Math.hypot(outsideX, outsideY)
30
+ const insideDist = Math.min(Math.max(dRadial, dAlong), 0)
31
+ return outsideDist + insideDist
32
+ }
33
+ }
34
+
35
+ export function makeTunnelCaveSDF(seed, radius, length, axis = 'y', warpAmp = 0.15, warpFreq = 2.5) {
36
+ return (x, y, z) => {
37
+ let along, u, v
38
+ if (axis === 'x') { along = x; u = y; v = z }
39
+ else if (axis === 'z') { along = z; u = x; v = y }
40
+ else { along = y; u = x; v = z }
41
+ const t = Math.max(-1, Math.min(1, along / (length * 0.5)))
42
+ const sampleIdx = Math.round((t + 1) * 0.5 * 1000)
43
+ const warpU = (tunnelHash(seed, sampleIdx * 2) * 2 - 1) * warpAmp * Math.sin(t * Math.PI * warpFreq + tunnelHash(seed, 1) * 6.28318)
44
+ const warpV = (tunnelHash(seed, sampleIdx * 2 + 1) * 2 - 1) * warpAmp * Math.cos(t * Math.PI * warpFreq + tunnelHash(seed, 2) * 6.28318)
45
+ const du = u - warpU, dv = v - warpV
46
+ const dRadial = Math.hypot(du, dv) - radius
47
+ const dAlong = Math.abs(along) - length * 0.5
48
+ const outsideX = Math.max(dRadial, 0), outsideY = Math.max(dAlong, 0)
49
+ const outsideDist = Math.hypot(outsideX, outsideY)
50
+ const insideDist = Math.min(Math.max(dRadial, dAlong), 0)
51
+ return outsideDist + insideDist
52
+ }
53
+ }
54
+
10
55
  export function polygonizeCaveSDF(sdf, res = 24) {
11
56
  return marchRockSurface(res, sdf)
12
57
  }
13
58
 
14
- export function createCaveVolume(worldX, worldY, worldZ, worldRadius) {
15
- const sdf = makeSphereCaveSDF(0, 0, 0, 1)
59
+ function localSDFFor(shape) {
60
+ if (shape.kind === 'cylinder') return makeCylinderCaveSDF(0, 0, 0, 1, shape.halfHeight ?? 1, shape.axis ?? 'y')
61
+ if (shape.kind === 'tunnel') return makeTunnelCaveSDF(shape.seed ?? 0, 1, shape.length ?? 2, shape.axis ?? 'y', shape.warpAmp ?? 0.15, shape.warpFreq ?? 2.5)
62
+ return makeSphereCaveSDF(0, 0, 0, 1)
63
+ }
64
+
65
+ export function createCaveVolume(worldX, worldY, worldZ, worldRadius, shape = { kind: 'sphere' }) {
66
+ const sdf = localSDFFor(shape)
16
67
  const localToWorld = (lx, ly, lz) => [worldX + lx * worldRadius, worldY + ly * worldRadius, worldZ + lz * worldRadius]
17
68
  const worldToLocal = (wx, wy, wz) => [(wx - worldX) / worldRadius, (wy - worldY) / worldRadius, (wz - worldZ) / worldRadius]
18
69
  const worldSDF = (wx, wy, wz) => {
19
70
  const [lx, ly, lz] = worldToLocal(wx, wy, wz)
20
71
  return sdf(lx, ly, lz) * worldRadius
21
72
  }
22
- return { sdf: worldSDF, localToWorld, worldToLocal, worldX, worldY, worldZ, worldRadius }
73
+ return { sdf: worldSDF, localSDF: sdf, localToWorld, worldToLocal, worldX, worldY, worldZ, worldRadius, shape }
23
74
  }
24
75
 
25
76
  export function polygonizeCaveVolume(caveVolume, res = 24) {
26
- const local = polygonizeCaveSDF((lx, ly, lz) => makeSphereCaveSDF(0, 0, 0, 1)(lx, ly, lz), res)
77
+ const local = polygonizeCaveSDF(caveVolume.localSDF, res)
27
78
  const positions = new Float32Array(local.positions.length)
28
79
  for (let v = 0; v < local.vc; v++) {
29
80
  const [wx, wy, wz] = caveVolume.localToWorld(local.positions[v * 3], local.positions[v * 3 + 1], local.positions[v * 3 + 2])
@@ -34,13 +85,37 @@ export function polygonizeCaveVolume(caveVolume, res = 24) {
34
85
  return { positions: positions.subarray(0, local.vc * 3), vc: local.vc, indices: local.indices, ic: local.ic }
35
86
  }
36
87
 
88
+ function volumeVerticalSpan(vol, horizontalDist) {
89
+ if (vol.shape.kind === 'cylinder' || vol.shape.kind === 'tunnel') {
90
+ if (horizontalDist > vol.worldRadius) return 0
91
+ return vol.worldRadius * (vol.shape.halfHeight ?? 1)
92
+ }
93
+ return Math.sqrt(Math.max(0, vol.worldRadius * vol.worldRadius - horizontalDist * horizontalDist))
94
+ }
95
+
37
96
  export function createCaveCarveLayer() {
38
97
  const volumes = []
39
98
 
40
99
  function addSphereCave(worldX, worldY, worldZ, worldRadius) {
41
100
  if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(worldZ)) return { added: false }
42
101
  if (!Number.isFinite(worldRadius) || worldRadius <= 0) return { added: false }
43
- volumes.push(createCaveVolume(worldX, worldY, worldZ, worldRadius))
102
+ volumes.push(createCaveVolume(worldX, worldY, worldZ, worldRadius, { kind: 'sphere' }))
103
+ return { added: true, count: volumes.length }
104
+ }
105
+
106
+ function addCylinderCave(worldX, worldY, worldZ, worldRadius, halfHeight, axis = 'y') {
107
+ if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(worldZ)) return { added: false }
108
+ if (!Number.isFinite(worldRadius) || worldRadius <= 0) return { added: false }
109
+ if (!Number.isFinite(halfHeight) || halfHeight <= 0) return { added: false }
110
+ volumes.push(createCaveVolume(worldX, worldY, worldZ, worldRadius, { kind: 'cylinder', halfHeight, axis }))
111
+ return { added: true, count: volumes.length }
112
+ }
113
+
114
+ function addTunnelCave(worldX, worldY, worldZ, worldRadius, length, axis = 'y', seed = 0, warpAmp = 0.15, warpFreq = 2.5) {
115
+ if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(worldZ)) return { added: false }
116
+ if (!Number.isFinite(worldRadius) || worldRadius <= 0) return { added: false }
117
+ if (!Number.isFinite(length) || length <= 0) return { added: false }
118
+ volumes.push(createCaveVolume(worldX, worldY, worldZ, worldRadius, { kind: 'tunnel', length: length / worldRadius, axis, seed, warpAmp, warpFreq }))
44
119
  return { added: true, count: volumes.length }
45
120
  }
46
121
 
@@ -51,7 +126,8 @@ export function createCaveCarveLayer() {
51
126
  const dx = x - vol.worldX, dz = z - vol.worldZ
52
127
  const horizontalDist = Math.hypot(dx, dz)
53
128
  if (horizontalDist > vol.worldRadius) continue
54
- const verticalSpan = Math.sqrt(Math.max(0, vol.worldRadius * vol.worldRadius - horizontalDist * horizontalDist))
129
+ const verticalSpan = volumeVerticalSpan(vol, horizontalDist)
130
+ if (verticalSpan <= 0) continue
55
131
  const caveTop = vol.worldY + verticalSpan
56
132
  if (caveTop < surfaceY) continue
57
133
  delta -= (caveTop - surfaceY) + verticalSpan
@@ -69,13 +145,22 @@ export function createCaveCarveLayer() {
69
145
  }
70
146
 
71
147
  function toJSON() {
72
- return { version: 1, volumes: volumes.map(v => ({ worldX: v.worldX, worldY: v.worldY, worldZ: v.worldZ, worldRadius: v.worldRadius })) }
148
+ return {
149
+ version: 2,
150
+ volumes: volumes.map(v => ({
151
+ worldX: v.worldX, worldY: v.worldY, worldZ: v.worldZ, worldRadius: v.worldRadius,
152
+ kind: v.shape.kind, halfHeight: v.shape.halfHeight, axis: v.shape.axis,
153
+ length: v.shape.length != null ? v.shape.length * v.worldRadius : undefined,
154
+ seed: v.shape.seed, warpAmp: v.shape.warpAmp, warpFreq: v.shape.warpFreq,
155
+ })),
156
+ }
73
157
  }
74
158
 
75
159
  function clear() { volumes.length = 0 }
76
160
 
77
161
  return {
78
- addSphereCave, heightDeltaAt, wrapHeightFn, toJSON, clear,
162
+ addSphereCave, addCylinderCave, addTunnelCave, heightDeltaAt, wrapHeightFn, toJSON, clear,
163
+ get volumes() { return volumes.slice() },
79
164
  get volumeCount() { return volumes.length },
80
165
  }
81
166
  }
@@ -85,7 +170,9 @@ export function loadCaveCarveLayer(json) {
85
170
  if (json && Array.isArray(json.volumes)) {
86
171
  for (const v of json.volumes) {
87
172
  if (!v || !Number.isFinite(v.worldX) || !Number.isFinite(v.worldY) || !Number.isFinite(v.worldZ) || !Number.isFinite(v.worldRadius)) continue
88
- layer.addSphereCave(v.worldX, v.worldY, v.worldZ, v.worldRadius)
173
+ if (v.kind === 'cylinder') layer.addCylinderCave(v.worldX, v.worldY, v.worldZ, v.worldRadius, v.halfHeight ?? 1, v.axis ?? 'y')
174
+ else if (v.kind === 'tunnel') layer.addTunnelCave(v.worldX, v.worldY, v.worldZ, v.worldRadius, v.length ?? v.worldRadius * 2, v.axis ?? 'y', v.seed ?? 0, v.warpAmp ?? 0.15, v.warpFreq ?? 2.5)
175
+ else layer.addSphereCave(v.worldX, v.worldY, v.worldZ, v.worldRadius)
89
176
  }
90
177
  }
91
178
  return layer
@@ -286,7 +286,7 @@ export async function setupTerrainStreaming({ physics, playerManager, worldDef,
286
286
  // stays byte-identical, sculpting is purely an on-top layer. heightDeltaJSON (if any) replays prior
287
287
  // strokes so a rebuild (reseed/streamer restart) keeps existing sculpting intact.
288
288
  const heightDelta = loadHeightDelta(heightDeltaJSON, baseHeightFn)
289
- const caveCarve = loadCaveCarveLayer(caveCarveJSON)
289
+ const caveCarve = loadCaveCarveLayer(caveCarveJSON || (Array.isArray(tcfg.caveCarve) ? { version: 2, volumes: tcfg.caveCarve } : null))
290
290
  const heightFn = caveCarve.wrapHeightFn(splineCarve.wrapHeightFn(heightDelta.wrapHeightFn(baseHeightFn)))
291
291
  const getCenter = () => {
292
292
  let sx = 0, sz = 0, n = 0