import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, pickEntities, rand, smoothstep, valueNoise, xyOf } from "./mapUtils.js"; import { extractMaskBorder, makePrefectureMask, neighbors8, } from "./mapGeneratorHelpers.js"; import { buildNaturalCompartments } from "./adminRegions.js"; const ASPECT = MAP_W / MAP_H; const SQRT2 = Math.SQRT2; function normalizeCoord(x, y) { return { px: (x + 0.5) / MAP_W, py: (y + 0.5) / MAP_H, }; } function distNorm(ax, ay, bx, by) { const dx = (ax - bx) * ASPECT; const dy = ay - by; return Math.hypot(dx, dy); } function rotate(dx, dy, angle) { const c = Math.cos(angle); const s = Math.sin(angle); return { u: dx * c + dy * s, v: -dx * s + dy * c }; } function quantile(values, q) { const arr = Array.from(values).filter(Number.isFinite).sort((a, b) => a - b); if (!arr.length) return 0; const p = clamp(q) * (arr.length - 1); const i = Math.floor(p); const f = p - i; return lerp(arr[i], arr[Math.min(arr.length - 1, i + 1)], f); } function softCapElevation(e, start = 0.91, cap = 1.08) { if (e <= start) return e; const over = e - start; const span = Math.max(0.001, cap - start); // Hard clipping made high mountains become flat mesas. This keeps peaks high, // but compresses only the excess so local relief survives near the top. return start + span * (1 - Math.exp(-over / span)); } function forDisk(cx, cy, radius, fn) { const r = Math.ceil(radius); for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { const x = cx + dx; const y = cy + dy; if (!inside(x, y)) continue; const d = Math.hypot(dx, dy); if (d <= radius) fn(x, y, d); } } } function largestComponent(mask, allowEdgePreference = false) { const seen = new Uint8Array(SIZE); let best = []; let bestScore = -1; for (let i = 0; i < SIZE; i++) { if (!mask[i] || seen[i]) continue; const queue = [i]; const cells = []; let touchesEdge = false; seen[i] = 1; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; cells.push(cur); const x = cur % MAP_W; const y = Math.floor(cur / MAP_W); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesEdge = true; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (!mask[ni] || seen[ni]) continue; seen[ni] = 1; queue.push(ni); } } const score = cells.length + (allowEdgePreference && touchesEdge ? SIZE : 0); if (score > bestScore) { bestScore = score; best = cells; } } const out = new Uint8Array(SIZE); for (const i of best) out[i] = 1; return out; } function distanceField(sourceMask, maxDistance = 999) { const dist = new Float32Array(SIZE); dist.fill(maxDistance); const heap = new MinHeap(); for (let i = 0; i < SIZE; i++) { if (!sourceMask[i]) continue; dist[i] = 0; heap.push({ i, f: 0 }); } while (heap.length) { const cur = heap.pop(); if (!cur || cur.f > dist[cur.i] + 1e-5) continue; const x = cur.i % MAP_W; const y = Math.floor(cur.i / MAP_W); for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); const step = (nx !== x && ny !== y) ? SQRT2 : 1; const nd = cur.f + step; if (nd >= dist[ni] || nd > maxDistance) continue; dist[ni] = nd; heap.push({ i: ni, f: nd }); } } return dist; } function ridgeContribution(px, py, ridge, seed) { const dx = (px - ridge.x) * ASPECT; const dy = py - ridge.y; const { u, v } = rotate(dx, dy, ridge.angle); const half = ridge.length * 0.5; const along = Math.abs(u / Math.max(0.001, half)); if (along >= 1.22) return 0; const taper = smoothstep(1 - clamp((along - 0.68) / 0.54)); const wobble = (valueNoise((u + ridge.phase) * 720, (v + ridge.phase * 0.37) * 980, seed + ridge.seedOffset, 14) - 0.5) * ridge.width * ridge.wobble; const cross = Math.abs(v + wobble); const core = Math.exp(-Math.pow(cross / Math.max(0.0008, ridge.width), 2.0)); const serration = 0.82 + 0.36 * valueNoise((u + ridge.phase) * 900, (v - ridge.phase * 0.41) * 1200, seed + ridge.seedOffset + 71, 7.5); return ridge.height * core * taper * serration; } function ellipticalMask(px, py, system) { const dx = (px - system.x) * ASPECT; const dy = py - system.y; const { u, v } = rotate(dx, dy, system.angle); const a = Math.max(0.01, system.length * 0.5); const along = clamp((u / a + 1) * 0.5); const widthWave = 1 + (system.widthVariance ?? 0.28) * Math.sin((along + (system.phase ?? 0)) * Math.PI * 2.0) + (system.widthVariance ?? 0.28) * 0.50 * Math.sin((along * 2.7 + (system.phase ?? 0) * 1.7) * Math.PI * 2.0); const endTaper = lerp(0.60, 1.0, Math.sin(along * Math.PI)); const b = Math.max(0.012, system.width * 0.5 * clamp(widthWave, 0.62, 1.60) * endTaper); const r = Math.sqrt((u / a) ** 2 + (v / b) ** 2); return clamp(1 - smoothstep((r - 0.55) / 0.65)); } function sampleInsideUnitDisk(seed, n) { const r = Math.sqrt(rand(seed, n)); const a = rand(seed, n + 1) * Math.PI * 2; return { x: Math.cos(a) * r, y: Math.sin(a) * r, r }; } const TERRAIN_TYPES = [ { id: "tohoku_spine", label: "東北型・長大脊梁", weight: 0.24, coastStyle: "parallel_spine", mountainMode: "range", massifnessRange: [0.06, 0.26], seaRatioRange: [0.13, 0.23], twoSidedChance: 0.96, mountainOffsetRange: [0.47, 0.53], baseHeightRange: [0.74, 1.10], primaryLengthRange: [0.76, 0.96], primaryWidthRange: [0.13, 0.22], systemCountRange: [12, 16], beltCountRange: [3, 4], angleSpread: 0.14, crossSpread: 0.38, lengthScale: 1.22, widthScale: 0.92, heightScale: 1.24, coastStrength: 0.90, plainBiasRange: [0.16, 0.34], riverRichnessRange: [0.70, 1.18], bigRiverChanceRange: [0.22, 0.46], }, { id: "chubu_mountain", label: "中部型・交差高山地", weight: 0.24, coastStyle: "outer_coast", mountainMode: "massif", massifnessRange: [0.42, 0.74], seaRatioRange: [0.10, 0.20], twoSidedChance: 0.20, mountainOffsetRange: [0.16, 0.36], baseHeightRange: [0.68, 1.04], primaryLengthRange: [0.62, 0.92], primaryWidthRange: [0.30, 0.58], systemCountRange: [16, 20], beltCountRange: [3, 5], angleSpread: 0.92, crossSpread: 0.82, lengthScale: 1.34, widthScale: 1.30, heightScale: 1.12, coastStrength: 0.74, plainBiasRange: [0.08, 0.24], riverRichnessRange: [0.72, 1.12], bigRiverChanceRange: [0.28, 0.58], }, { id: "setouchi_inland_sea", label: "瀬戸内型・内海多島", weight: 0.16, coastStyle: "inland_sea", mountainMode: "mixed", massifnessRange: [0.24, 0.48], seaRatioRange: [0.20, 0.33], twoSidedChance: 0.92, mountainOffsetRange: [0.22, 0.34], baseHeightRange: [0.56, 1.00], primaryLengthRange: [0.52, 0.76], primaryWidthRange: [0.18, 0.34], systemCountRange: [12, 16], beltCountRange: [2, 3], angleSpread: 0.24, crossSpread: 0.70, lengthScale: 0.98, widthScale: 1.08, heightScale: 1.08, coastStrength: 1.10, plainBiasRange: [0.26, 0.50], riverRichnessRange: [0.58, 0.96], bigRiverChanceRange: [0.18, 0.42], }, { id: "kanto_alluvial", label: "関東・濃尾型・大河川平野", weight: 0.16, coastStyle: "open_bay", mountainMode: "range", massifnessRange: [0.18, 0.44], seaRatioRange: [0.15, 0.26], twoSidedChance: 0.18, mountainOffsetRange: [0.28, 0.46], baseHeightRange: [0.62, 1.04], primaryLengthRange: [0.42, 0.70], primaryWidthRange: [0.20, 0.36], systemCountRange: [10, 14], beltCountRange: [2, 3], angleSpread: 0.34, crossSpread: 0.62, lengthScale: 0.92, widthScale: 1.10, heightScale: 1.10, coastStrength: 0.92, plainBiasRange: [0.56, 0.86], riverRichnessRange: [0.98, 1.38], bigRiverChanceRange: [0.62, 0.90], }, { id: "mixed_archipelago", label: "混合型・列島変化", weight: 0.20, coastStyle: "mixed_archipelago", mountainMode: "mixed", massifnessRange: [0.16, 0.72], seaRatioRange: [0.13, 0.29], twoSidedChance: 0.42, mountainOffsetRange: [0.18, 0.40], baseHeightRange: [0.68, 1.18], primaryLengthRange: [0.46, 0.82], primaryWidthRange: [0.20, 0.48], systemCountRange: [14, 17], beltCountRange: [3, 4], angleSpread: 0.50, crossSpread: 0.74, lengthScale: 1.00, widthScale: 1.18, heightScale: 1.25, coastStrength: 0.96, plainBiasRange: [0.22, 0.52], riverRichnessRange: [0.72, 1.26], bigRiverChanceRange: [0.34, 0.68], }, ]; function pickTerrainType(seed) { const total = TERRAIN_TYPES.reduce((sum, type) => sum + type.weight, 0); let r = rand(seed, 10001) * total; for (const type of TERRAIN_TYPES) { r -= type.weight; if (r <= 0) return type; } return TERRAIN_TYPES[TERRAIN_TYPES.length - 1]; } function rangeValue(seed, salt, [lo, hi]) { return lo + rand(seed, salt) * (hi - lo); } function rangeInt(seed, salt, [lo, hi]) { return Math.round(lo + rand(seed, salt) * (hi - lo)); } export function buildTerrainTemplate(seed) { const terrainType = pickTerrainType(seed); const mountainMode = terrainType.mountainMode === "mixed" ? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif") : terrainType.mountainMode; let mountainMassifness = rangeValue(seed, 14, terrainType.massifnessRange); if (mountainMode === "range") mountainMassifness *= 0.70; if (mountainMode === "massif") mountainMassifness = clamp(mountainMassifness + 0.12); let coastAngle = rand(seed, 21) * Math.PI * 2; let twoSidedCoast = rand(seed, 22) < terrainType.twoSidedChance; const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange); let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange)); if (terrainType.id === "tohoku_spine") { // 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。 // coastAngle は海へ向かう勾配方向、等値線としての海岸線は +90° 方向。 coastAngle = (rand(seed, 2101) < 0.5 ? 0 : Math.PI / 2) + (rand(seed, 2102) - 0.5) * 0.10; twoSidedCoast = true; mountainAngle = coastAngle + Math.PI / 2 + (rand(seed, 2103) - 0.5) * 0.16; } const baseHeight = rangeValue(seed, 25, terrainType.baseHeightRange); const primaryLength = rangeValue(seed, 26, terrainType.primaryLengthRange); const primaryWidth = rangeValue(seed, 28, terrainType.primaryWidthRange); const scratchCount = Math.round(lerp(24 + rand(seed, 30) * 20, 16 + rand(seed, 31) * 18, mountainMassifness)); const mountainSystemCount = rangeInt(seed, 32, terrainType.systemCountRange); const mountainBeltCount = rangeInt(seed, 46, terrainType.beltCountRange); return { seed, terrainType: terrainType.id, terrainTypeLabel: terrainType.label, coastStyle: terrainType.coastStyle, seaRatio, coastAngle, twoSidedCoast, coastNoise: 0.040 + rand(seed, 33) * 0.056, coastStrength: terrainType.coastStrength, mountainMode, mountainMassifness, mountainAngle, mountainAngleSpread: terrainType.angleSpread, mountainCrossSpread: terrainType.crossSpread, mountainLengthScale: terrainType.lengthScale, mountainWidthScale: terrainType.widthScale, mountainHeightScale: terrainType.heightScale, mountainBeltCount, mountainBaseHeight: baseHeight, mountainDensity: 0.58 + rand(seed, 34) * 0.39, primaryMountain: { x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.36, 0.18, 0.82), y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.36, 0.18, 0.82), angle: mountainAngle, length: primaryLength, width: primaryWidth, height: baseHeight, scratchCount, massifness: mountainMassifness, }, mountainSystemCount, secondaryCount: mountainSystemCount - 1, macroNoiseScale: 0.020 + rand(seed, 37) * 0.030, macroNoiseStrength: 0.028 + rand(seed, 38) * 0.025, scratchNoiseStrength: 0.018 + rand(seed, 39) * 0.022, roughness: 0.40 + rand(seed, 40) * 0.50, erosion: 0.34 + rand(seed, 41) * 0.48, deposition: 0.28 + rand(seed, 42) * 0.56, riverRichness: rangeValue(seed, 43, terrainType.riverRichnessRange), bigRiverChance: rangeValue(seed, 44, terrainType.bigRiverChanceRange), plainBias: rangeValue(seed, 45, terrainType.plainBiasRange), }; } function buildMountainSystems(template, seed) { const systems = []; const targetCount = Math.max(8, template.mountainSystemCount ?? 15); const baseAngle = template.mountainAngle; const angleSpread = template.mountainAngleSpread ?? 0.42; const crossSpread = template.mountainCrossSpread ?? 0.70; const lengthScale = template.mountainLengthScale ?? 1; const widthScale = template.mountainWidthScale ?? 1; const heightScale = template.mountainHeightScale ?? 1; const isChubu = template.terrainType === "chubu_mountain"; const isTohoku = template.terrainType === "tohoku_spine"; // 山脈システムは完全ランダムではなく、複数の広い造山帯に沿って配置する。 // これにより「方向性はそこそこ揃う」が、「中央一点に集まらない」分布になる。 const beltCount = Math.max(1, template.mountainBeltCount ?? (targetCount >= 15 ? 4 : 3)); const belts = []; for (let b = 0; b < beltCount; b++) { const t = beltCount === 1 ? 0 : (b / (beltCount - 1) - 0.5); const angle = baseAngle + (rand(seed, 1000 + b) - 0.5) * angleSpread; const axisX = Math.cos(angle); const axisY = Math.sin(angle); const crossX = Math.cos(angle + Math.PI / 2); const crossY = Math.sin(angle + Math.PI / 2); const crossOffset = t * crossSpread + (rand(seed, 1010 + b) - 0.5) * (0.10 + crossSpread * 0.08); const alongShift = (rand(seed, 1020 + b) - 0.5) * 0.22; belts.push({ angle, x: clamp(0.50 + axisX * alongShift / ASPECT + crossX * crossOffset / ASPECT, 0.08, 0.92), y: clamp(0.50 + axisY * alongShift + crossY * crossOffset, 0.08, 0.92), lengthBias: 0.82 + rand(seed, 1030 + b) * 0.32, heightBias: 0.82 + rand(seed, 1040 + b) * 0.42, }); } function beltCandidate(k, attempt) { const beltIndex = (k + Math.floor(k / beltCount)) % beltCount; const belt = belts[beltIndex]; const perBelt = Math.ceil(targetCount / beltCount); const ordinal = Math.floor(k / beltCount); const baseT = perBelt <= 1 ? 0 : ordinal / (perBelt - 1) - 0.5; const alongJitter = (rand(seed, 1100 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? 0.15 : 0.28); const crossJitter = (rand(seed, 1200 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? crossSpread * 0.22 : crossSpread * 0.40); const along = (baseT + alongJitter) * 1.03 * belt.lengthBias; const cross = crossJitter; const axisX = Math.cos(belt.angle); const axisY = Math.sin(belt.angle); const crossX = Math.cos(belt.angle + Math.PI / 2); const crossY = Math.sin(belt.angle + Math.PI / 2); return { x: clamp(belt.x + axisX * along / ASPECT + crossX * cross / ASPECT, 0.045, 0.955), y: clamp(belt.y + axisY * along + crossY * cross, 0.045, 0.955), angle: belt.angle + (rand(seed, 1300 + k * 79 + attempt) - 0.5) * angleSpread * 0.82, beltIndex, }; } function edgeAwareScore(c) { const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y); const centerD = distNorm(c.x, c.y, 0.5, 0.5); return Math.min(edgeD, 0.16) * 0.16 + centerD * 0.08; } if (isTohoku) { const centralAngle = baseAngle + (rand(seed, 3330) - 0.5) * 0.06; const centralAlong = (rand(seed, 3331) - 0.5) * 0.10; const centralCross = (rand(seed, 3332) - 0.5) * 0.045; systems.push({ x: clamp(0.50 + Math.cos(centralAngle) * centralAlong / ASPECT + Math.cos(centralAngle + Math.PI / 2) * centralCross / ASPECT, 0.12, 0.88), y: clamp(0.50 + Math.sin(centralAngle) * centralAlong + Math.sin(centralAngle + Math.PI / 2) * centralCross, 0.12, 0.88), angle: centralAngle, length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale, width: (0.12 + rand(seed, 3334) * 0.07) * widthScale, height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18), scratchCount: Math.round(20 + rand(seed, 3336) * 10), massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55), role: "central-primary", beltIndex: 0, widthVariance: 0.30 + rand(seed, 3337) * 0.46, phase: rand(seed, 3338), }); if (rand(seed, 3339) < 0.72) { const side = rand(seed, 3340) < 0.5 ? -1 : 1; systems.push({ x: clamp(0.50 + Math.cos(centralAngle) * (centralAlong + side * 0.18) / ASPECT + Math.cos(centralAngle + Math.PI / 2) * (centralCross + side * 0.028) / ASPECT, 0.10, 0.90), y: clamp(0.50 + Math.sin(centralAngle) * (centralAlong + side * 0.18) + Math.sin(centralAngle + Math.PI / 2) * (centralCross + side * 0.028), 0.10, 0.90), angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08, length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale, width: (0.075 + rand(seed, 3343) * 0.055) * widthScale, height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16), scratchCount: Math.round(12 + rand(seed, 3345) * 8), massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65), role: "central-secondary", beltIndex: 0, widthVariance: 0.24 + rand(seed, 3346) * 0.36, phase: rand(seed, 3347), }); } } for (let k = systems.length; k < targetCount; k++) { let best = beltCandidate(k, 0); let bestScore = -INF; for (let attempt = 0; attempt < 12; attempt++) { const c = beltCandidate(k, attempt); let minD = 999; for (const s of systems) minD = Math.min(minD, distNorm(c.x, c.y, s.x, s.y)); const score = minD * 1.05 + edgeAwareScore(c) + rand(seed, 2300 + k * 101 + attempt) * 0.035; if (score > bestScore) { bestScore = score; best = c; } } const belt = belts[best.beltIndex]; const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.38); const isMassif = m > 0.58; const major = k < beltCount || rand(seed, 2500 + k) > 0.72; const lengthBaseRange = isChubu ? (major ? [0.48, 0.78] : [0.34, 0.58]) : isTohoku ? (major ? [0.44, 0.72] : [0.28, 0.48]) : (major ? [0.32, 0.52] : [0.21, 0.36]); const lengthMassifRange = isChubu ? (major ? [0.38, 0.58] : [0.28, 0.44]) : (major ? [0.23, 0.35] : [0.17, 0.27]); const length = lerp( lengthBaseRange[0] + rand(seed, 2600 + k) * (lengthBaseRange[1] - lengthBaseRange[0]), lengthMassifRange[0] + rand(seed, 2620 + k) * (lengthMassifRange[1] - lengthMassifRange[0]), m ) * belt.lengthBias * lengthScale; const widthRangeA = major ? [0.082, 0.170] : [0.060, 0.120]; const widthRangeB = major ? [0.150, 0.260] : [0.110, 0.200]; const width = lerp( widthRangeA[0] + rand(seed, 2700 + k) * (widthRangeA[1] - widthRangeA[0]), widthRangeB[0] + rand(seed, 2720 + k) * (widthRangeB[1] - widthRangeB[0]), m ) * widthScale * (0.82 + rand(seed, 2740 + k) * 0.46); const heightBase = template.mountainBaseHeight * belt.heightBias * heightScale * ( major ? 0.44 + rand(seed, 2800 + k) * 0.28 : 0.26 + rand(seed, 2810 + k) * 0.20 ); const height = isChubu ? heightBase * 0.82 : heightBase; const scratchCount = Math.round(lerp( major ? 9 + rand(seed, 2900 + k) * 9 : 6 + rand(seed, 2910 + k) * 6, isMassif ? 8 + rand(seed, 2920 + k) * 8 : 6 + rand(seed, 2930 + k) * 6, m )); systems.push({ x: best.x, y: best.y, angle: best.angle, length, width, height, scratchCount, massifness: m, role: major ? (k < beltCount ? "primary" : "major") : "minor", beltIndex: best.beltIndex, widthVariance: 0.18 + rand(seed, 3100 + k) * 0.46, phase: rand(seed, 3200 + k), }); } return systems; } function buildScratchRidges(system, seed, systemId) { const ridges = []; const count = Math.max(6, Math.round(system.scratchCount)); for (let i = 0; i < count; i++) { const p = sampleInsideUnitDisk(seed + systemId * 10000, 2000 + i * 7); const density = clamp(1 - p.r * 0.78); const localAngle = system.massifness > 0.55 ? system.angle + (rand(seed, 2100 + i + systemId * 331) - 0.5) * Math.PI * 1.45 : system.angle + (rand(seed, 2100 + i + systemId * 331) - 0.5) * (0.36 + system.massifness * 0.80); const along = p.x * system.length * 0.45; const cross = p.y * system.width * 0.45; const x = clamp(system.x + Math.cos(system.angle) * along / ASPECT + Math.cos(system.angle + Math.PI / 2) * cross / ASPECT, 0.03, 0.97); const y = clamp(system.y + Math.sin(system.angle) * along + Math.sin(system.angle + Math.PI / 2) * cross, 0.03, 0.97); const len = lerp(system.length * (0.18 + rand(seed, 2200 + i) * 0.20), system.width * (0.32 + rand(seed, 2200 + i) * 0.30), system.massifness); const width = lerp(0.014 + rand(seed, 2300 + i) * 0.018, 0.024 + rand(seed, 2300 + i) * 0.026, system.massifness) * (0.85 + density * 0.70); const height = system.height * (0.060 + density * 0.128 + rand(seed, 2400 + i) * 0.050); ridges.push({ x, y, angle: localAngle, length: len, width, height, wobble: 1.2 + rand(seed, 2500 + i) * 2.0, phase: rand(seed, 2600 + i) * 10, seedOffset: 2700 + systemId * 997 + i * 37, density, systemId, }); } return ridges; } function computeCoastLower(px, py, template, seed) { const angle = template.coastAngle; const axis = (px - 0.5) * Math.cos(angle) * ASPECT + (py - 0.5) * Math.sin(angle); const cross = -(px - 0.5) * Math.sin(angle) * ASPECT + (py - 0.5) * Math.cos(angle); const wave = (fbm(px * 220, py * 220, seed + 300) - 0.5) * template.coastNoise; const bay = (valueNoise(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055; const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032; let pressure = 0; if (template.coastStyle === "inland_sea") { // 瀬戸内型だけは中央を横切る浅い内海を許す。出現率は地形タイプ側で管理する。 const sideA = smoothstep((-axis + 0.25 + wave + bay) / 0.26); const sideB = smoothstep((axis + 0.23 - wave + bay * 0.7) / 0.27); const channel = smoothstep((0.060 - Math.abs(cross + wave * 0.65 + islandNoise)) / 0.090) * 0.82; pressure = Math.max(sideA, sideB, channel); } else if (template.coastStyle === "parallel_spine") { // 東北型: 左右端または上下端に海を置く。海岸線は脊梁山脈とおおよそ平行。 // 内海的な中央水路は作らない。 const edgeA = smoothstep((-axis - 0.26 + wave * 0.42 + bay * 0.35) / 0.20); const edgeB = template.twoSidedCoast ? smoothstep((axis - 0.26 - wave * 0.42 + bay * 0.25) / 0.22) * 0.86 : 0; pressure = Math.max(edgeA, edgeB); } else if (template.coastStyle === "outer_coast") { // 中部型: 外縁海を中心にし、内陸へ海が入り込みすぎないようにする。 const radial = distNorm(px, py, 0.5, 0.5); const outer = smoothstep((radial - 0.44 + wave * 0.8 + bay * 0.5) / 0.24); const side = smoothstep((-axis + 0.30 + wave) / 0.30) * 0.45; pressure = Math.max(outer, side); } else if (template.coastStyle === "open_bay") { // 関東・濃尾型: 一方向に開いた湾と、その背後の沖積平野を作りやすくする。 const openSide = smoothstep((-axis + 0.29 + wave + bay) / 0.25); const bayMouth = smoothstep((0.22 - Math.abs(cross + wave * 0.8)) / 0.25) * smoothstep((-axis + 0.16 + bay) / 0.22) * 0.68; pressure = Math.max(openSide, bayMouth); } else { const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26); const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0; const outerBite = smoothstep((distNorm(px, py, 0.5, 0.5) - 0.54 + islandNoise) / 0.22) * 0.25; pressure = Math.max(sideA, sideB, outerBite); } return { pressure: clamp(pressure), signedAxis: axis }; } function recomputeSlope(elevation, sea, slope) { slope.fill(0); for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); if (sea[i]) continue; const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; slope[i] = clamp(Math.hypot(gx, gy) * 8.2); } } } function classifyWater(elevation, seaLevel, sea, ocean, lake) { sea.fill(0); ocean.fill(0); lake.fill(0); const water = new Uint8Array(SIZE); for (let i = 0; i < SIZE; i++) water[i] = elevation[i] <= seaLevel ? 1 : 0; const oceanMask = largestComponent(water, true); const seen = new Uint8Array(SIZE); for (let i = 0; i < SIZE; i++) { if (!water[i] || seen[i]) continue; const queue = [i]; const cells = []; seen[i] = 1; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; cells.push(cur); const x = cur % MAP_W; const y = Math.floor(cur / MAP_W); for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (!water[ni] || seen[ni]) continue; seen[ni] = 1; queue.push(ni); } } const isOcean = cells.some((ci) => oceanMask[ci]); if (isOcean || cells.length >= 22) { for (const ci of cells) { sea[ci] = 1; if (isOcean) ocean[ci] = 1; else lake[ci] = 1; } } else { for (const ci of cells) elevation[ci] = seaLevel + 0.010; } } } function priorityFloodFlow(elevation, sea, flowTo, filled) { flowTo.fill(-1); filled.set(elevation); const visited = new Uint8Array(SIZE); const heap = new MinHeap(); let seedCount = 0; for (let i = 0; i < SIZE; i++) { if (sea[i]) { visited[i] = 1; heap.push({ i, f: filled[i] }); seedCount++; } } if (seedCount === 0) { for (let i = 0; i < SIZE; i++) { const x = i % MAP_W; const y = Math.floor(i / MAP_W); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) { visited[i] = 1; heap.push({ i, f: filled[i] }); } } } while (heap.length) { const cur = heap.pop(); if (!cur) continue; const cx = cur.i % MAP_W; const cy = Math.floor(cur.i / MAP_W); for (const [nx, ny] of neighbors8(cx, cy)) { const ni = indexOf(nx, ny); if (visited[ni]) continue; visited[ni] = 1; if (filled[ni] < filled[cur.i] + 0.00002) filled[ni] = filled[cur.i] + 0.00002; heap.push({ i: ni, f: filled[ni] }); } } for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); if (sea[i]) continue; let best = -1; let bestScore = filled[i]; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); const stepPenalty = (nx !== x && ny !== y) ? 0.000015 : 0; const score = filled[ni] + stepPenalty + hash2(nx, ny, 9000) * 0.000002; if (score < bestScore - 0.000001 || sea[ni]) { bestScore = score; best = ni; if (sea[ni]) break; } } flowTo[i] = best; } } } function computeFlowAccumulation(sea, flowTo, filled, flowAccum) { const area = new Float32Array(SIZE); const order = []; for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; area[i] = 1; order.push(i); } order.sort((a, b) => filled[b] - filled[a]); for (const i of order) { const to = flowTo[i]; if (to >= 0 && !sea[to]) area[to] += area[i]; } let maxArea = 1; for (let i = 0; i < SIZE; i++) if (!sea[i]) maxArea = Math.max(maxArea, area[i]); for (let i = 0; i < SIZE; i++) flowAccum[i] = sea[i] ? 0 : clamp(Math.pow(area[i] / maxArea, 0.42)); return area; } function traceFlowPath(start, sea, flowTo, maxSteps = 900) { const path = []; const seen = new Set(); let i = start; for (let step = 0; step < maxSteps && i >= 0 && !seen.has(i); step++) { seen.add(i); const x = i % MAP_W; const y = Math.floor(i / MAP_W); path.push([x, y]); if (sea[i]) break; const next = flowTo[i]; if (next < 0 || next === i) break; i = next; } return path; } function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField) { river.fill(0); const candidates = []; for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { const i = indexOf(x, y); if (sea[i]) continue; const f = flowAccum[i]; const high = clamp((elevation[i] - 0.36) * 2.2); const wet = valueNoise(x * 1.7, y * 1.7, seed + 12000, 24); const score = f * 0.80 + high * 0.25 + wet * 0.16 - slope[i] * 0.10; if (score > 0.24) candidates.push({ x, y, score }); } } const desired = 44 + Math.floor(template.riverRichness * 28); const sources = pickEntities(candidates, { max: desired, minDistance: 6, threshold: 0.26, seed: seed + 12100, jitter: 0.035 }); const riverPaths = []; for (const s of sources) { const path = traceFlowPath(indexOf(s.x, s.y), sea, flowTo); if (path.length >= 8 && path.some(([x, y], k) => k > 5 && (sea[indexOf(x, y)] || lake[indexOf(x, y)]))) riverPaths.push(path); else if (path.length >= 14) riverPaths.push(path); } const longPaths = riverPaths .map((path) => { let maxFlow = 0; let meanFlow = 0; for (const [x, y] of path) { const f = flowAccum[indexOf(x, y)]; maxFlow = Math.max(maxFlow, f); meanFlow += f; } meanFlow /= Math.max(1, path.length); return { path, score: path.length * 0.75 + maxFlow * 90 + meanFlow * 35 }; }) .sort((a, b) => b.score - a.score); const mainCount = Math.min(longPaths.length, template.bigRiverChance > 0.56 ? 4 : 3); const mainSet = new Set(longPaths.slice(0, mainCount).map((p) => p.path)); const riverThreshold = 0.26 - template.riverRichness * 0.035 - (template.bigRiverChance > 0.56 ? 0.030 : 0); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; const f = flowAccum[i]; if (f > riverThreshold) river[i] = clamp((f - riverThreshold) / (0.55 - riverThreshold)); } for (const path of riverPaths) { const main = mainSet.has(path); for (let k = 0; k < path.length; k++) { const [x, y] = path[k]; const i = indexOf(x, y); if (sea[i]) continue; const downstream = k / Math.max(1, path.length - 1); const boost = main ? 0.54 + downstream * 0.42 : 0.30 + downstream * 0.22; river[i] = clamp(Math.max(river[i], boost + flowAccum[i] * (main ? 0.70 : 0.42))); } } const riverCells = []; for (let i = 0; i < SIZE; i++) if (!sea[i] && river[i] > 0.12) riverCells.push(i); for (const i of riverCells) { const x = i % MAP_W; const y = Math.floor(i / MAP_W); const strength = river[i]; const width = 1.15 + clamp((strength - 0.35) * 2.2) * 1.65; const depth = (0.006 + strength * (0.014 + template.erosion * 0.018) + flowAccum[i] * 0.018) * (0.65 + clamp((elevation[i] - 0.28) * 1.4) * 0.55); forDisk(x, y, width, (nx, ny, d) => { const ni = indexOf(nx, ny); if (sea[ni]) return; const profile = Math.pow(Math.max(0, 1 - d / Math.max(0.1, width)), 2.15); const sideGuard = d === 0 ? 1 : 0.38; const cut = depth * profile * sideGuard; const floor = 0.075; elevation[ni] = Math.max(floor, elevation[ni] - cut); erosionField[ni] = clamp(erosionField[ni] + cut * 9.0); }); } const sortedPaths = longPaths.map((p) => p.path); const mainRivers = sortedPaths.filter((p) => mainSet.has(p)).slice(0, mainCount); const tributaryRivers = sortedPaths.filter((p) => !mainSet.has(p)).slice(0, 24); const smallStreams = sortedPaths.slice(mainCount + 8, mainCount + 58); return { riverPaths: sortedPaths.slice(0, 80), mainRivers, tributaryRivers, smallStreams }; } function deriveFields(seed, template, fields, seaLevel) { const { elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, } = fields; recomputeSlope(elevation, sea, slope); const waterDist = distanceField(sea, 80); const riverMask = new Uint8Array(SIZE); for (let i = 0; i < SIZE; i++) if (river[i] > 0.18) riverMask[i] = 1; const riverDist = distanceField(riverMask, 40); for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); if (sea[i]) continue; const e = elevation[i]; const mean4 = (elevation[indexOf(x - 1, y)] + elevation[indexOf(x + 1, y)] + elevation[indexOf(x, y - 1)] + elevation[indexOf(x, y + 1)]) * 0.25; const relief = e - mean4; const coast = clamp((24 - waterDist[i]) / 24) * clamp((0.43 - e) * 2.4) * clamp((0.42 - slope[i]) * 2.4); coastalLowland[i] = coast; const riverNear = clamp((8 - riverDist[i]) / 8); const valley = clamp(flowAccum[i] * 0.82 + river[i] * 0.72 + Math.max(0, -relief) * 10.0 + riverNear * 0.38 - slope[i] * 0.22); valleyField[i] = clamp(Math.max(valleyField[i] * 0.30, valley)); const ridge = clamp(arcSpineField[i] * 0.76 + branchRidgeField[i] * 0.86 + Math.max(0, relief) * 9.0 + slope[i] * 0.30 + Math.max(0, e - 0.54) * 0.88 - valleyField[i] * 0.34); ridgeField[i] = clamp(Math.max(ridgeField[i] * 0.30, ridge)); basinField[i] = clamp((0.42 - slope[i]) * 1.55 + Math.max(0, -relief) * 5.0 + clamp((0.48 - e) * 1.35) - coast * 0.40 - river[i] * 0.32); const low = clamp((0.52 - e) * 1.70); const lowSlope = clamp((0.34 - slope[i]) * 2.8); const riverGate = clamp(riverNear * 0.72 + flowAccum[i] * 0.82 + coast * 0.70 + basinField[i] * 0.20 - ridgeField[i] * 0.40); plain[i] = clamp(low * lowSlope * (0.12 + template.plainBias * 0.34 + riverGate * 0.84)); floodplain[i] = clamp(lowSlope * riverNear * (0.35 + flowAccum[i] * 0.82 + river[i] * 0.52)); deltaField[i] = clamp(coast * river[i] * 1.4 + coast * flowAccum[i] * 0.72); alluvialFanField[i] = clamp(riverNear * clamp(slope[i] * 2.5) * clamp((0.58 - e) * 1.7) * clamp(ridgeField[i] * 0.8 + arcSpineField[i] * 0.4)); depositionalLowland[i] = clamp(floodplain[i] * 0.54 + deltaField[i] * 0.66 + alluvialFanField[i] * 0.42 + coast * 0.28 + basinField[i] * 0.18); depositionField[i] = clamp(depositionalLowland[i] * (0.25 + template.deposition * 0.30)); agriculture[i] = clamp(plain[i] * 0.72 + floodplain[i] * 0.42 + depositionalLowland[i] * 0.36 - slope[i] * 0.20); visibleRavineField[i] = clamp(visibleRavineField[i] + valleyField[i] * 0.22 + erosionField[i] * 0.35); surfaceTextureField[i] = clamp(surfaceTextureField[i] + slope[i] * 0.26 + visibleRavineField[i] * 0.38 + Math.max(0, relief) * 2.2); naturalBarrierScore[i] = clamp(ridgeField[i] * 0.82 + slope[i] * 0.44 + river[i] * 0.42 + Math.max(0, e - 0.58) * 0.34 - plain[i] * 0.24); portSuitability[i] = clamp(coast * (1 - slope[i]) * (0.50 + plain[i] * 0.32) - ridgeField[i] * 0.20); crossingSuitability[i] = clamp((1 - slope[i]) * 0.38 + plain[i] * 0.34 + floodplain[i] * 0.24 - river[i] * 0.20 - ridgeField[i] * 0.28); passSuitability[i] = clamp(slope[i] * 0.30 + valleyField[i] * 0.34 + clamp((0.75 - ridgeField[i]) * 0.8) + plain[i] * 0.18); moisture[i] = clamp(0.30 + (1 - waterDist[i] / 65) * 0.36 + valleyField[i] * 0.22 + riverNear * 0.26 - Math.max(0, e - 0.55) * 0.36 + (fbm(x * 1.6, y * 1.6, seed + 15000) - 0.5) * 0.18); } } for (let i = 0; i < SIZE; i++) { if (!sea[i]) continue; moisture[i] = 1; slope[i] = 0; river[i] = 0; ridgeField[i] = 0; valleyField[i] = 0; plain[i] = 0; agriculture[i] = 0; coastalLowland[i] = 0; naturalBarrierScore[i] = 0; } } function enforceLandGradient(elevation, sea, seaLevel) { // Keep extreme cliffs rare without flattening normal mountain relief. for (let pass = 0; pass < 2; pass++) { const next = new Float32Array(elevation); for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); if (sea[i]) continue; let minN = elevation[i]; let maxN = elevation[i]; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (sea[ni]) continue; minN = Math.min(minN, elevation[ni]); maxN = Math.max(maxN, elevation[ni]); } const range = maxN - minN; if (range > 0.34) next[i] = lerp(elevation[i], (elevation[i] + minN + maxN) / 3, 0.22); next[i] = Math.max(next[i], seaLevel + 0.006); } } elevation.set(next); } } export function generateTerrainAndRivers(seed) { const fields = createMapFields(); fields.visibleRavineField = new Float32Array(SIZE); fields.surfaceTextureField = new Float32Array(SIZE); const { elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, flowTo, portSuitability, crossingSuitability, passSuitability, } = fields; const terrainTemplate = buildTerrainTemplate(seed); const systems = buildMountainSystems(terrainTemplate, seed); const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id)); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); const { px, py } = normalizeCoord(x, y); const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23; const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16; const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed); let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040); let mountainMaskMax = 0; for (let s = 0; s < systems.length; s++) { const system = systems[s]; const mask = ellipticalMask(px, py, system); mountainMaskMax = Math.max(mountainMaskMax, mask); const broad = Math.pow(mask, lerp(1.70, 1.16, system.massifness)) * system.height * lerp(0.22, 0.34, system.massifness); e += broad; arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72)); } for (const ridge of allRidges) { const r = ridgeContribution(px, py, ridge, seed); if (r <= 0) continue; e += r; branchRidgeField[i] = clamp(branchRidgeField[i] + r * 5.0); arcSpineField[i] = clamp(Math.max(arcSpineField[i], r * 4.6)); } let macro; let scratch; if (terrainTemplate.terrainType === "tohoku_spine") { const dx = (px - 0.5) * ASPECT; const dy = py - 0.5; const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle); const warp = (valueNoise(x * 0.50, y * 0.50, seed + 504, 28) - 0.5) * 18; macro = (fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2; scratch = (fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2; const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6); const lateralBranch = clamp((valueNoise(v * 1700 - 3, u * 540 + 5, seed + 506, 16) - 0.54) * 2.1); e += lateralBranch * mountainMaskMax * 0.022; e -= passBreak * mountainMaskMax * 0.052; } else { macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2; scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2; } const global = (valueNoise(x * 0.23, y * 0.23, seed + 501, 38) - 0.5) * 2; e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80); e += global * 0.020; e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax; elevation[i] = clamp(softCapElevation(e, terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91, 1.08), 0.025, 1.08); visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40); surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46); valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18); moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(x * 1.1, y * 1.1, seed + 503) - 0.5) * 0.16); } } let seaLevel = quantile(elevation, terrainTemplate.seaRatio); seaLevel = clamp(seaLevel, 0.14, 0.47); classifyWater(elevation, seaLevel, sea, ocean, lake); recomputeSlope(elevation, sea, slope); const filled = new Float32Array(SIZE); priorityFloodFlow(elevation, sea, flowTo, filled); computeFlowAccumulation(sea, flowTo, filled, flowAccum); const { riverPaths, mainRivers, tributaryRivers, smallStreams } = buildRiverNetwork(seed, terrainTemplate, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField); enforceLandGradient(elevation, sea, seaLevel); deriveFields(seed, terrainTemplate, fields, seaLevel); const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); const landMask = new Uint8Array(SIZE); for (let i = 0; i < SIZE; i++) landMask[i] = sea[i] ? 0 : 1; const zeroDensity = new Float32Array(SIZE); const zeroLanduse = new Int8Array(SIZE); const natural = buildNaturalCompartments( landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, zeroDensity, zeroLanduse, { seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 30), 80, 520) } ); const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore; const prefectureBorder = extractMaskBorder(prefectureMask, sea); let landCount = 0; let waterCount = 0; let mountainCount = 0; let plainCount = 0; let primarySpineStrength = 0; let spineSamples = 0; for (let i = 0; i < SIZE; i++) { if (sea[i]) { waterCount++; continue; } landCount++; if (elevation[i] > 0.56 || ridgeField[i] > 0.52) mountainCount++; if (plain[i] > 0.36) plainCount++; if (arcSpineField[i] > 0.55) { primarySpineStrength += arcSpineField[i]; spineSamples++; } } primarySpineStrength /= Math.max(1, spineSamples); const terrainDebug = { terrainType: terrainTemplate.terrainType, terrainTypeLabel: terrainTemplate.terrainTypeLabel, coastStyle: terrainTemplate.coastStyle, primarySpineStrength, riverConnectivityRate: mainRivers.length ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && sea[indexOf(x, y)])).length / mainRivers.length : 0, smallIslandCount: 0, largeInlandLakeCount: lake.reduce((a, v) => a + v, 0) > 120 ? 1 : 0, depositionLowlandArea: depositionalLowland.reduce((a, v, i) => a + (!sea[i] && v > 0.24 ? 1 : 0), 0), smallStreamCount: smallStreams.length, erosionGullyCount: 0, branchRavineCount: 0, simpleTerrainSystem: true, seaRatio: waterCount / SIZE, landCount, mountainRatio: mountainCount / Math.max(1, landCount), plainRatio: plainCount / Math.max(1, landCount), mountainSystemCount: systems.length, }; return { terrainTemplate, seaLevel, elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore: sharedNaturalBarrierScore, portSuitability, crossingSuitability, passSuitability, prefectureMask, landMask, prefectureBorder, naturalCompartmentId: natural.compartmentId, naturalCompartments: natural.compartments, terrainDebug, riverPaths, mainRivers, tributaryRivers, smallStreams, }; }