spoint 0.1.602 → 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.602",
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
@@ -6,6 +6,15 @@ function cellKey(cx, cz) {
6
6
  return (cx + OFF) * BIG + (cz + OFF)
7
7
  }
8
8
 
9
+ function carveHash(seed, i) {
10
+ let h = seed | 0
11
+ h = Math.imul(h ^ (i | 0), 0x27d4eb2d) >>> 0
12
+ h ^= h >>> 15; h = Math.imul(h, 0x2c1b3c6d) >>> 0
13
+ h ^= h >>> 12; h = Math.imul(h, 0x297a2d39) >>> 0
14
+ h ^= h >>> 15
15
+ return (h >>> 0) / 4294967296
16
+ }
17
+
9
18
  function catmullRom(p0, p1, p2, p3, t) {
10
19
  const getX = (p) => Array.isArray(p) ? p[0] : p.x
11
20
  const getZ = (p) => Array.isArray(p) ? p[1] : p.z
@@ -134,30 +143,47 @@ export function createSplineCarveLayer() {
134
143
  const cells = new Map()
135
144
  const appliedSplines = []
136
145
 
137
- function carve(controlPoints, width, depth, kind, baseHeightFn) {
146
+ function carve(controlPoints, width, depth, kind, baseHeightFn, opts = {}) {
138
147
  if (!Array.isArray(controlPoints) || controlPoints.length < 2) return { touched: 0 }
139
148
  if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(depth)) return { touched: 0 }
140
149
  if (kind !== 'river' && kind !== 'road') return { touched: 0 }
150
+ const { seed = 0, widthVariance = 0, bankErosion = 0 } = opts
151
+ const isRiver = kind === 'river'
141
152
  const stepSize = Math.max(1, width * 0.5)
142
153
  const spinePoints = sampleSpline(controlPoints, stepSize)
143
154
  if (spinePoints.length === 0) return { touched: 0 }
144
- const halfWidth = width * 0.5
155
+ const maxHalfWidth = (width * 0.5) * (1 + (isRiver ? widthVariance : 0)) + (isRiver ? bankErosion : 0)
145
156
  let touched = 0
146
- for (const sp of spinePoints) {
157
+ let arcLen = 0
158
+ for (let si = 0; si < spinePoints.length; si++) {
159
+ const sp = spinePoints[si]
147
160
  const sx = sp.x, sz = sp.z
148
- const cx0 = Math.floor((sx - halfWidth) / CELL_M), cx1 = Math.ceil((sx + halfWidth) / CELL_M)
149
- const cz0 = Math.floor((sz - halfWidth) / CELL_M), cz1 = Math.ceil((sz + halfWidth) / CELL_M)
161
+ if (si > 0) { const px = spinePoints[si - 1].x, pz = spinePoints[si - 1].z; arcLen += Math.hypot(sx - px, sz - pz) }
162
+ let halfWidth = width * 0.5
163
+ if (isRiver && widthVariance > 0) {
164
+ const t = carveHash(seed, Math.round(arcLen * 0.1))
165
+ halfWidth *= 1 + (t * 2 - 1) * widthVariance
166
+ }
167
+ const cx0 = Math.floor((sx - maxHalfWidth) / CELL_M), cx1 = Math.ceil((sx + maxHalfWidth) / CELL_M)
168
+ const cz0 = Math.floor((sz - maxHalfWidth) / CELL_M), cz1 = Math.ceil((sz + maxHalfWidth) / CELL_M)
150
169
  for (let cz = cz0; cz <= cz1; cz++) {
151
170
  for (let cx = cx0; cx <= cx1; cx++) {
152
171
  const cMinX = cx * CELL_M, cMinZ = cz * CELL_M
172
+ const ccx = cMinX + CELL_M * 0.5, ccz = cMinZ + CELL_M * 0.5
173
+ let bankOffset = 0
174
+ if (isRiver && bankErosion > 0) {
175
+ const angle = carveHash(seed ^ 0x5bd1e995, cx * 92821 + cz) * Math.PI * 2
176
+ bankOffset = (carveHash(seed ^ 0x1b873593, cx * 15485863 + cz) * 2 - 1) * bankErosion * (0.5 + 0.5 * Math.cos(angle))
177
+ }
178
+ const cellHalfWidth = halfWidth + bankOffset
179
+ if (cellHalfWidth <= 0) continue
153
180
  const nearestX = Math.max(cMinX, Math.min(sx, cMinX + CELL_M))
154
181
  const nearestZ = Math.max(cMinZ, Math.min(sz, cMinZ + CELL_M))
155
182
  const d = Math.hypot(nearestX - sx, nearestZ - sz)
156
- if (d > halfWidth) continue
157
- const ccx = cMinX + CELL_M * 0.5, ccz = cMinZ + CELL_M * 0.5
183
+ if (d > cellHalfWidth) continue
158
184
  const dCenter = Math.hypot(ccx - sx, ccz - sz)
159
- const falloffDist = Math.min(dCenter, halfWidth)
160
- const falloff = 0.5 * (1 + Math.cos((falloffDist / halfWidth) * Math.PI))
185
+ const falloffDist = Math.min(dCenter, cellHalfWidth)
186
+ const falloff = 0.5 * (1 + Math.cos((falloffDist / cellHalfWidth) * Math.PI))
161
187
  const key = cellKey(cx, cz)
162
188
  const base = typeof baseHeightFn === 'function' ? baseHeightFn(ccx, ccz) : null
163
189
  const targetDelta = Number.isFinite(base) ? -depth * falloff : 0
@@ -169,7 +195,7 @@ export function createSplineCarveLayer() {
169
195
  }
170
196
  }
171
197
  }
172
- if (touched > 0) appliedSplines.push({ controlPoints: controlPoints.map(p => Array.isArray(p) ? [p[0], p[1]] : [p.x, p.z]), width, depth, kind })
198
+ if (touched > 0) appliedSplines.push({ controlPoints: controlPoints.map(p => Array.isArray(p) ? [p[0], p[1]] : [p.x, p.z]), width, depth, kind, seed, widthVariance, bankErosion })
173
199
  return { touched }
174
200
  }
175
201
 
@@ -224,7 +250,7 @@ export function loadSplineCarveLayer(json, baseHeightFn) {
224
250
  if (json && Array.isArray(json.splines)) {
225
251
  for (const s of json.splines) {
226
252
  if (!s || !Array.isArray(s.controlPoints) || !Number.isFinite(s.width) || !Number.isFinite(s.depth)) continue
227
- layer.carve(s.controlPoints, s.width, s.depth, s.kind, baseHeightFn)
253
+ layer.carve(s.controlPoints, s.width, s.depth, s.kind, baseHeightFn, { seed: s.seed ?? 0, widthVariance: s.widthVariance ?? 0, bankErosion: s.bankErosion ?? 0 })
228
254
  }
229
255
  }
230
256
  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