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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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)
|