spoint 0.1.584 → 0.1.586

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.584",
3
+ "version": "0.1.586",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -46,6 +46,7 @@ export function classify(x, z, frame, anchorField, h, cellIx, cellIz) {
46
46
  const temp = clim && Number.isFinite(clim.temp) ? clim.temp : 0.5
47
47
  const humidity = clim && Number.isFinite(clim.humidity) ? clim.humidity : 0.5
48
48
  if (clim && Number.isFinite(clim.seaBias) && clim.seaBias < GRASS.SEA_REJECT) return null
49
+ if (clim && clim.blocked === 'road') return null
49
50
 
50
51
  const ix = (cellIx !== undefined) ? cellIx : Math.round(x / GRASS.CELL)
51
52
  const iz = (cellIz !== undefined) ? cellIz : Math.round(z / GRASS.CELL)
@@ -124,6 +124,7 @@ export function classify(x, z, frame, anchorField, h, cellIx, cellIz) {
124
124
  const erosion = clim && Number.isFinite(clim.erosion) ? clim.erosion : 0.3
125
125
  const humidity = clim && Number.isFinite(clim.humidity) ? clim.humidity : 0.5
126
126
  if (clim && Number.isFinite(clim.seaBias) && clim.seaBias < ROCK.SEA_REJECT) return null
127
+ if (clim && clim.blocked) return null
127
128
 
128
129
  // shares VEG.TREELINE (not a separately-hardcoded 4000) so rock elevation banding and tree treeline
129
130
  // normalization never silently diverge -- see ARIDITY_LINE/RELIEF_CALIBRATION_BASELINE above for the
@@ -1,22 +1,3 @@
1
- // SplineCarve.js -- Catmull-Rom spline-based heightfield carving for rivers and roads.
2
- //
3
- // DESIGN: takes a sequence of control points, evaluates a Catmull-Rom spline through
4
- // them, and carves a channel of configurable width/depth into the heightfield along
5
- // the spline. Additionally marks the carved cells with a climate flag so vegetation
6
- // placement can skip them (no trees on roads/rivers; roads double as AI nav hints).
7
- //
8
- // This is a BAKE-TIME pass (runs once per world seed, not per-frame). The spline
9
- // evaluation is deterministic for a given set of control points.
10
- //
11
- // CLIMATE FLAGS: carved cells are recorded in a per-cell climate mask (a Set of packed
12
- // integer cell keys using the same cellKey scheme as HeightDelta.js/BiomeOverride.js)
13
- // so the existing ClimateCache/BiomeOverride pipeline can query them. A separate
14
- // `isCarved(key)` function is exported for placement code to check.
15
-
16
- // Packed integer cell key, matching HeightDelta.js/BiomeOverride.js convention.
17
- // CELL_M = 8 metres (matches ClimateCache.SECTOR_M and BiomeOverride.CELL_M for
18
- // sector-level granularity -- a road/river carve at this resolution is visible
19
- // at the same scale vegetation placement already samples climate).
20
1
  const CELL_M = 8
21
2
  const BIG = 1 << 23
22
3
  const OFF = BIG >> 1
@@ -25,11 +6,6 @@ function cellKey(cx, cz) {
25
6
  return (cx + OFF) * BIG + (cz + OFF)
26
7
  }
27
8
 
28
- /**
29
- * Evaluate a Catmull-Rom spline at parameter t (0..1 per segment).
30
- * Control points are {x, z} or [x, z] pairs.
31
- * Returns {x, z} interpolated point.
32
- */
33
9
  function catmullRom(p0, p1, p2, p3, t) {
34
10
  const getX = (p) => Array.isArray(p) ? p[0] : p.x
35
11
  const getZ = (p) => Array.isArray(p) ? p[1] : p.z
@@ -37,7 +13,6 @@ function catmullRom(p0, p1, p2, p3, t) {
37
13
  const t2 = t * t
38
14
  const t3 = t2 * t
39
15
 
40
- // Catmull-Rom basis matrix
41
16
  const x = 0.5 * (
42
17
  (2 * getX(p1)) +
43
18
  (-getX(p0) + getX(p2)) * t +
@@ -54,15 +29,6 @@ function catmullRom(p0, p1, p2, p3, t) {
54
29
  return { x, z }
55
30
  }
56
31
 
57
- /**
58
- * Sample the spline at a uniform step size, returning an array of {x, z} points.
59
- * The spline is closed if `closed` is true (last point connects back to first).
60
- *
61
- * @param {Array} controlPoints - Array of {x,z} or [x,z] control points.
62
- * @param {number} stepSize - World-space distance between sampled points.
63
- * @param {boolean} [closed=false] - Whether the spline is closed.
64
- * @returns {Array<{x:number, z:number}>} Sampled spline points.
65
- */
66
32
  export function sampleSpline(controlPoints, stepSize, closed = false) {
67
33
  if (!controlPoints || controlPoints.length < 2) return []
68
34
  const pts = controlPoints
@@ -88,7 +54,6 @@ export function sampleSpline(controlPoints, stepSize, closed = false) {
88
54
  }
89
55
  }
90
56
 
91
- // Add the final point if not closed
92
57
  if (!closed) {
93
58
  const last = pts[pts.length - 1]
94
59
  samples.push({ x: Array.isArray(last) ? last[0] : last.x, z: Array.isArray(last) ? last[1] : last.z })
@@ -97,22 +62,6 @@ export function sampleSpline(controlPoints, stepSize, closed = false) {
97
62
  return samples
98
63
  }
99
64
 
100
- /**
101
- * Carve a spline-based channel (river or road) into a heightfield.
102
- *
103
- * @param {Float32Array} heights - Input heightfield, width*width elements, row-major.
104
- * @param {number} width - Grid dimension (square).
105
- * @param {number} spacing - World-space distance between adjacent cells (metres).
106
- * @param {number} cornerX - World X of the heightfield corner (cell 0,0).
107
- * @param {number} cornerZ - World Z of the heightfield corner (cell 0,0).
108
- * @param {Array} controlPoints - Array of {x,z} or [x,z] control points.
109
- * @param {number} carveWidth - World-space width of the channel (metres).
110
- * @param {number} carveDepth - How deep to carve (metres, positive = lower).
111
- * @param {object} [opts] - Options.
112
- * @param {number} [opts.bankWidth=0] - Extra width for bank/shoulder feathering.
113
- * @param {string} [opts.flag='river'] - Climate flag: 'river' or 'road'.
114
- * @returns {{heights: Float32Array, carvedCells: Set<number>}} New heightfield and carved cell keys.
115
- */
116
65
  export function carveSpline(heights, width, spacing, cornerX, cornerZ, controlPoints, carveWidth, carveDepth, opts = {}) {
117
66
  if (!heights || width < 2 || !Number.isFinite(spacing) || spacing <= 0) return { heights, carvedCells: new Set() }
118
67
  if (!controlPoints || controlPoints.length < 2) return { heights, carvedCells: new Set() }
@@ -123,7 +72,6 @@ export function carveSpline(heights, width, spacing, cornerX, cornerZ, controlPo
123
72
  const out = new Float32Array(heights)
124
73
  const carvedCells = new Set()
125
74
 
126
- // Sample the spline at half-cell spacing for dense coverage
127
75
  const sampleStep = spacing * 0.5
128
76
  const splinePoints = sampleSpline(controlPoints, sampleStep)
129
77
 
@@ -132,20 +80,18 @@ export function carveSpline(heights, width, spacing, cornerX, cornerZ, controlPo
132
80
  const halfWidth = carveWidth / 2
133
81
  const totalHalfWidth = halfWidth + bankWidth
134
82
 
135
- // For each cell in the heightfield, check distance to the nearest spline point
136
83
  for (let iz = 0; iz < n; iz++) {
137
84
  for (let ix = 0; ix < n; ix++) {
138
85
  const wx = cornerX + ix * spacing
139
86
  const wz = cornerZ + iz * spacing
140
87
 
141
- // Find minimum distance to spline
142
88
  let minDist = Infinity
143
89
  for (const sp of splinePoints) {
144
90
  const dx = wx - sp.x
145
91
  const dz = wz - sp.z
146
92
  const dist = Math.sqrt(dx * dx + dz * dz)
147
93
  if (dist < minDist) minDist = dist
148
- if (minDist <= totalHalfWidth) break // early exit
94
+ if (minDist <= totalHalfWidth) break
149
95
  }
150
96
 
151
97
  if (minDist > totalHalfWidth) continue
@@ -153,17 +99,15 @@ export function carveSpline(heights, width, spacing, cornerX, cornerZ, controlPo
153
99
  const idx = iz * n + ix
154
100
  if (!Number.isFinite(out[idx])) continue
155
101
 
156
- // Compute falloff: 1 inside carve width, cosine feather in bank region
157
102
  let falloff = 1
158
103
  if (minDist > halfWidth && bankWidth > 0) {
159
104
  falloff = 0.5 * (1 + Math.cos(((minDist - halfWidth) / bankWidth) * Math.PI))
160
105
  } else if (minDist > halfWidth) {
161
- continue // no bank, hard edge
106
+ continue
162
107
  }
163
108
 
164
109
  out[idx] -= carveDepth * falloff
165
110
 
166
- // Mark climate cells
167
111
  const cx = Math.floor(wx / CELL_M)
168
112
  const cz = Math.floor(wz / CELL_M)
169
113
  carvedCells.add(cellKey(cx, cz))
@@ -173,30 +117,111 @@ export function carveSpline(heights, width, spacing, cornerX, cornerZ, controlPo
173
117
  return { heights: out, carvedCells, flag }
174
118
  }
175
119
 
176
- /**
177
- * Check if a cell key is marked as carved (for use by vegetation placement code).
178
- * @param {Set<number>} carvedCells - Set of packed cell keys from carveSpline.
179
- * @param {number} cx - Cell X coordinate (in CELL_M units).
180
- * @param {number} cz - Cell Z coordinate (in CELL_M units).
181
- * @returns {boolean}
182
- */
183
120
  export function isCarved(carvedCells, cx, cz) {
184
121
  return carvedCells && carvedCells.has(cellKey(cx, cz))
185
122
  }
186
123
 
187
- /**
188
- * Create a climate-flag-aware wrapper for vegetation density queries.
189
- * Returns a function that, given a climate sample and cell coordinates,
190
- * returns the climate sample with density zeroed if the cell is carved.
191
- *
192
- * @param {Set<number>} carvedCells - Set of packed cell keys.
193
- * @returns {function} climateFilter(climate, cx, cz) -> climate | null
194
- */
195
124
  export function createCarvedClimateFilter(carvedCells) {
196
125
  if (!carvedCells || carvedCells.size === 0) return null
197
126
  return function carvedClimateFilter(climate, cx, cz) {
198
127
  if (!climate) return null
199
- if (carvedCells.has(cellKey(cx, cz))) return null // exclude vegetation
128
+ if (carvedCells.has(cellKey(cx, cz))) return null
200
129
  return climate
201
130
  }
202
- }
131
+ }
132
+
133
+ export function createSplineCarveLayer() {
134
+ const cells = new Map()
135
+ const appliedSplines = []
136
+
137
+ function carve(controlPoints, width, depth, kind, baseHeightFn) {
138
+ if (!Array.isArray(controlPoints) || controlPoints.length < 2) return { touched: 0 }
139
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(depth)) return { touched: 0 }
140
+ if (kind !== 'river' && kind !== 'road') return { touched: 0 }
141
+ const stepSize = Math.max(1, width * 0.5)
142
+ const spinePoints = sampleSpline(controlPoints, stepSize)
143
+ if (spinePoints.length === 0) return { touched: 0 }
144
+ const halfWidth = width * 0.5
145
+ let touched = 0
146
+ for (const sp of spinePoints) {
147
+ 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)
150
+ for (let cz = cz0; cz <= cz1; cz++) {
151
+ for (let cx = cx0; cx <= cx1; cx++) {
152
+ const ccx = cx * CELL_M + CELL_M * 0.5, ccz = cz * CELL_M + CELL_M * 0.5
153
+ const dx = ccx - sx, dz = ccz - sz
154
+ const d = Math.hypot(dx, dz)
155
+ if (d > halfWidth) continue
156
+ const falloff = 0.5 * (1 + Math.cos((d / halfWidth) * Math.PI))
157
+ const key = cellKey(cx, cz)
158
+ const base = typeof baseHeightFn === 'function' ? baseHeightFn(ccx, ccz) : null
159
+ const targetDelta = Number.isFinite(base) ? -depth * falloff : 0
160
+ const prev = cells.get(key)
161
+ const nextDelta = prev ? Math.min(prev.delta, targetDelta) : targetDelta
162
+ const nextKind = prev && prev.kind === 'river' ? 'river' : kind
163
+ cells.set(key, { delta: nextDelta, kind: nextKind })
164
+ touched++
165
+ }
166
+ }
167
+ }
168
+ if (touched > 0) appliedSplines.push({ controlPoints: controlPoints.map(p => Array.isArray(p) ? [p[0], p[1]] : [p.x, p.z]), width, depth, kind })
169
+ return { touched }
170
+ }
171
+
172
+ function deltaAt(x, z) {
173
+ if (cells.size === 0) return 0
174
+ const cx = Math.floor(x / CELL_M), cz = Math.floor(z / CELL_M)
175
+ const c = cells.get(cellKey(cx, cz))
176
+ return c ? c.delta : 0
177
+ }
178
+
179
+ function kindAt(x, z) {
180
+ if (cells.size === 0) return null
181
+ const cx = Math.floor(x / CELL_M), cz = Math.floor(z / CELL_M)
182
+ const c = cells.get(cellKey(cx, cz))
183
+ return c ? c.kind : null
184
+ }
185
+
186
+ function wrapHeightFn(baseHeightFn) {
187
+ if (typeof baseHeightFn !== 'function') return baseHeightFn
188
+ return function splineCarveWrappedHeightFn(x, z) {
189
+ const base = baseHeightFn(x, z)
190
+ if (!Number.isFinite(base)) return base
191
+ return base + deltaAt(x, z)
192
+ }
193
+ }
194
+
195
+ function wrapClimateField(baseField) {
196
+ if (!baseField || typeof baseField.climateAtLocal !== 'function') return baseField
197
+ return {
198
+ ...baseField,
199
+ climateAtLocal(x, z) {
200
+ const base = baseField.climateAtLocal(x, z)
201
+ const kind = kindAt(x, z)
202
+ if (!kind) return base
203
+ return base ? { ...base, blocked: kind } : { blocked: kind }
204
+ },
205
+ }
206
+ }
207
+
208
+ function toJSON() { return { version: 1, cellM: CELL_M, splines: appliedSplines.slice() } }
209
+
210
+ function clear() { cells.clear(); appliedSplines.length = 0 }
211
+
212
+ return {
213
+ carve, deltaAt, kindAt, wrapHeightFn, wrapClimateField, toJSON, clear,
214
+ get cellCount() { return cells.size }, get splineCount() { return appliedSplines.length },
215
+ }
216
+ }
217
+
218
+ export function loadSplineCarveLayer(json, baseHeightFn) {
219
+ const layer = createSplineCarveLayer()
220
+ if (json && Array.isArray(json.splines)) {
221
+ for (const s of json.splines) {
222
+ if (!s || !Array.isArray(s.controlPoints) || !Number.isFinite(s.width) || !Number.isFinite(s.depth)) continue
223
+ layer.carve(s.controlPoints, s.width, s.depth, s.kind, baseHeightFn)
224
+ }
225
+ }
226
+ return layer
227
+ }
@@ -5,6 +5,7 @@ import { createPlanetFrame, DEFAULT_PATCH_MAX_LEVEL } from './PlanetFrame.js'
5
5
  import { createCachedAnchorField } from './ClimateCache.js'
6
6
  import { createHeightDelta, loadHeightDelta } from './HeightDelta.js'
7
7
  import { createBiomeOverride, loadBiomeOverride } from './BiomeOverride.js'
8
+ import { loadSplineCarveLayer } from './SplineCarve.js'
8
9
 
9
10
  const _now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())
10
11
 
@@ -234,7 +235,7 @@ async function createGpuPatchHeightFn({ frame, tcfg, offsetY }) {
234
235
  // (re)build -- kept strictly separate from tcfg/seed (never merged into it) so seeds stay shareable.
235
236
  // biomeOverrideJSON (optional): a previously-serialized src/terrain/BiomeOverride.js toJSON() payload
236
237
  // (paint-biome strokes), same replay discipline as heightDeltaJSON.
237
- export async function setupTerrainStreaming({ physics, playerManager, worldDef, terrain, heightDeltaJSON, biomeOverrideJSON }) {
238
+ export async function setupTerrainStreaming({ physics, playerManager, worldDef, terrain, heightDeltaJSON, biomeOverrideJSON, splineCarveJSON }) {
238
239
  // accepts either the terrain app's merged tcfg directly, or legacy worldDef.terrain.
239
240
  const tcfg = terrain || (worldDef && worldDef.terrain) || null
240
241
  if (!tcfg || tcfg.enabled === false || !physics || typeof physics.addHeightField !== 'function') return null
@@ -249,7 +250,8 @@ export async function setupTerrainStreaming({ physics, playerManager, worldDef,
249
250
  // streamers below so a painted biome changes which species/how-dense a server collider places, not
250
251
  // just a cosmetic client-only readout.
251
252
  const biomeOverride = loadBiomeOverride(biomeOverrideJSON)
252
- const paintedAnchorField = biomeOverride.wrapClimateField(cachedAnchorField)
253
+ const splineCarve = loadSplineCarveLayer(splineCarveJSON, (x, z) => frame.groundHeightLocal(x, z))
254
+ const paintedAnchorField = splineCarve.wrapClimateField(biomeOverride.wrapClimateField(cachedAnchorField))
253
255
  const offsetY = tcfg.offsetY || 0
254
256
  // preferred: live GPU patch baker (whole-planet, exact, nothing stored); falls back to baked .hf, then live CPU sampler.
255
257
  const gpuPatch = (tcfg.gpuPatchCollider !== false)
@@ -283,7 +285,7 @@ export async function setupTerrainStreaming({ physics, playerManager, worldDef,
283
285
  // stays byte-identical, sculpting is purely an on-top layer. heightDeltaJSON (if any) replays prior
284
286
  // strokes so a rebuild (reseed/streamer restart) keeps existing sculpting intact.
285
287
  const heightDelta = loadHeightDelta(heightDeltaJSON, baseHeightFn)
286
- const heightFn = heightDelta.wrapHeightFn(baseHeightFn)
288
+ const heightFn = splineCarve.wrapHeightFn(heightDelta.wrapHeightFn(baseHeightFn))
287
289
  const getCenter = () => {
288
290
  let sx = 0, sz = 0, n = 0
289
291
  const players = playerManager && playerManager.players
@@ -359,6 +361,7 @@ export async function setupTerrainStreaming({ physics, playerManager, worldDef,
359
361
  // clearChunkCache header) since it memoizes classify() output for world lifetime and would otherwise
360
362
  // keep serving pre-paint species/density for already-visited territory.
361
363
  streamer.biomeOverride = biomeOverride
364
+ streamer.splineCarve = splineCarve
362
365
  streamer.repaintBiome = async function repaintBiome() {
363
366
  // Re-cook the FULL current multi-player ring (not just one point) so every connected player's
364
367
  // territory picks up the repaint, matching this row's "around each player" streaming policy.
@@ -124,6 +124,7 @@ export function classify(x, z, frame, anchorField, h, cellIx, cellIz) {
124
124
  const humidity = clim && Number.isFinite(clim.humidity) ? clim.humidity : 0.5
125
125
  const erosion = clim && Number.isFinite(clim.erosion) ? clim.erosion : 0.3
126
126
  if (clim && Number.isFinite(clim.seaBias) && clim.seaBias < VEG.SEA_REJECT) return null
127
+ if (clim && clim.blocked) return null
127
128
 
128
129
  const base = baseDensity(temp, humidity, erosion)
129
130
  const ix = (cellIx !== undefined) ? cellIx : Math.round(x / VEG.CELL)