import { INF, MAP_H, MAP_W, SIZE, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, pickEntities, rand, smoothstep, valueNoise } from "./mapUtils.js"; import { aStar, extractMaskBorder, extractRegionBorderSegments, generateRegionalPrefectures, makePrefectureMask, neighbors8, } from "./mapGeneratorHelpers.js"; export function buildTerrainTemplate(seed) { const deposition = 0.32 + rand(seed, 41) * 0.58; const erosion = 0.42 + rand(seed, 42) * 0.48; const roughness = 0.28 + rand(seed, 43) * 0.48; const coastAxisPick = Math.floor(rand(seed, 10) * 3); const coastAngle = coastAxisPick === 0 ? Math.PI / 2 : coastAxisPick === 1 ? 0 : (rand(seed, 11) > 0.5 ? Math.PI / 4 : -Math.PI / 4) + (rand(seed, 14) - 0.5) * 0.28; const ridgeJaggedness = 0.18 + rand(seed, 44) * 0.48; // Japan-like regional relief: discontinuous mountain belts made of massifs. // Avoid a centered, ruler-like spine. Two separated belts are common; a single // dominant belt or three belts appear occasionally. const spineRoll = rand(seed, 45); const spineCount = spineRoll < 0.18 ? 1 : spineRoll < 0.86 ? 2 : 3; const spineSpacing = 0.215 + rand(seed, 62) * 0.165; const sideAPlain = 0.035 + rand(seed, 56) * 0.115 + deposition * 0.085; const sideBPlain = 0.035 + rand(seed, 57) * 0.115 + deposition * 0.085; return { seed, spineCount, spineSpacing, // The ranges track the long island/coastal axis with modest local wobble. spineAngle: coastAngle + Math.PI / 2 + (rand(seed, 46) - 0.5) * 0.18, spineCurve: (rand(seed, 47) - 0.5) * 0.20, spinePosition: (rand(seed, 48) - 0.5) * 0.62, spineStrength: 1.05 + rand(seed, 49) * 0.44, spineWidth: 0.030 + rand(seed, 50) * 0.024, secondaryMountainCount: 10 + Math.floor(rand(seed, 51) * 10), secondaryMountainSize: 0.040 + rand(seed, 52) * 0.095, secondaryMountainStrength: 0.40 + rand(seed, 53) * 0.52, auxiliaryRangeCount: 6 + Math.floor(rand(seed, 63) * 8), coastAxis: coastAxisPick === 0 ? "east-west" : coastAxisPick === 1 ? "north-south" : "diagonal", coastAngle, coastBias: 0.18 + rand(seed, 12) * 0.24, coastRoughness: 0.34 + rand(seed, 54) * 0.58, coastSides: [ { penetration: 0.24 + rand(seed, 58) * 0.24, inletStrength: 0.18 + rand(seed, 59) * 0.56, plainWidth: sideAPlain, }, { penetration: 0.24 + rand(seed, 60) * 0.24, inletStrength: 0.18 + rand(seed, 61) * 0.56, plainWidth: sideBPlain, }, ], deposition, erosion, roughness, ridgeJaggedness, ridgeBranchiness: 0.26 + rand(seed, 55) * 0.58, }; } function jaggedRidgeContribution(x, y, ridge, seed) { const dx = x - ridge.x; const dy = y - ridge.y; const ca = Math.cos(ridge.angle); const sa = Math.sin(ridge.angle); const along = dx * ca + dy * sa; const perp = -dx * sa + dy * ca; const nAlong = along / Math.max(0.001, ridge.length); const lengthFade = smoothstep(1 - Math.abs(nAlong)); if (lengthFade <= 0) return 0; // Bend the centerline with long waves and coherent noise. This keeps ranges // arcuate and wandering instead of a ruler-straight belt through the map. const low = (valueNoise(along * 0.46 + ridge.seedOffset, ridge.seedOffset * 0.37, seed + 6100, 34) - 0.5) * 2; const mid = (valueNoise(along * 0.95 - ridge.seedOffset, ridge.seedOffset * 0.23, seed + 6200, 18) - 0.5) * 2; const detail = (valueNoise(along * 1.85 + ridge.seedOffset * 0.11, ridge.seedOffset * 0.31, seed + 6217, 9) - 0.5) * 2; const curve = (ridge.curve || 0) * along * along * 0.46 * (along >= 0 ? 1 : -1); const axisOffset = low * ridge.axisWobble * 0.70 + mid * ridge.axisWobble * 0.42 + detail * ridge.axisWobble * 0.18 + curve; // Real mountain belts are made of linked massifs, not sinusoidal ribbons. // Use coherent along-strike noise for strengthening/gaps; avoid periodic waves. let continuity = 1; if (ridge.segmentFrequency) { const segA = valueNoise(along * ridge.segmentFrequency * 0.42 + ridge.seedOffset * 0.19, ridge.seedOffset * 0.41, seed + ridge.seedOffset + 101, 1.35); const segB = valueNoise(along * ridge.segmentFrequency * 0.78 - ridge.seedOffset * 0.27, ridge.seedOffset * 0.33, seed + ridge.seedOffset + 271, 0.78); const seg = segA * 0.68 + segB * 0.32; const broken = smoothstep((seg - 0.24) / 0.46); continuity = lerp(1, broken * 0.90 + 0.10, ridge.gapStrength || 0); } if (continuity <= 0.018) return 0; const widthNoise = 0.82 + (valueNoise(along * 0.88 + ridge.seedOffset, ridge.seedOffset * 0.19, seed + 6300, 21) - 0.5) * ridge.widthVariation; const localWidth = Math.max(0.006, ridge.width * widthNoise); const jaggedPerp = perp - axisOffset; // A rounded Gaussian-like section gives ridges and uplands, while local // summit noise and later erosion prevent broad, flat-looking mountaintops. const d = Math.abs(jaggedPerp) / localWidth; const core = Math.exp(-Math.pow(d, ridge.crestPower || 1.85)); const massifNoise = 0.72 + valueNoise(along * 1.10 + ridge.seedOffset, ridge.seedOffset * 0.53, seed + ridge.seedOffset + 411, 6.5) * 0.56; const serration = 0.78 + (valueNoise(x * 2.3 + along * 0.18, y * 2.3 + perp * 0.18, seed + ridge.seedOffset, 5.2) - 0.5) * 0.42; const summitNoise = 0.82 + (valueNoise(x * 4.6 + ridge.seedOffset, y * 4.6 - ridge.seedOffset, seed + ridge.seedOffset + 333, 2.6) - 0.5) * 0.36; return core * lengthFade * continuity * ridge.h * massifNoise * serration * summitNoise; } function primarySpineCrossOffset(seed, template, i) { const shift = template.spinePosition * 0.24; if (template.spineCount === 1) { const side = rand(seed, 680) > 0.5 ? 1 : -1; return shift + side * (0.105 + rand(seed, 681) * 0.230); } if (template.spineCount === 2) { const side = i === 0 ? -1 : 1; return shift + side * (0.225 + rand(seed, 681 + i) * 0.155) + (rand(seed, 705 + i) - 0.5) * 0.035; } const side = i === 0 ? -1 : i === 1 ? 1 : (rand(seed, 706) > 0.5 ? -1 : 1); const base = i === 2 ? 0.055 + rand(seed, 707) * 0.110 : 0.235 + rand(seed, 708 + i) * 0.125; return shift + side * base + (rand(seed, 705 + i) - 0.5) * 0.045; } function makePrimarySpine(seed, template, spineIndex) { const crossOffset = primarySpineCrossOffset(seed, template, spineIndex); const angle = template.spineAngle + (rand(seed, 700 + spineIndex) - 0.5) * 0.24; const x = 0.5 + Math.cos(angle + Math.PI / 2) * crossOffset + Math.cos(angle) * (rand(seed, 690 + spineIndex) - 0.5) * 0.08; const y = 0.5 + Math.sin(angle + Math.PI / 2) * crossOffset + Math.sin(angle) * (rand(seed, 691 + spineIndex) - 0.5) * 0.08; return { x, y, angle, width: template.spineWidth * (0.92 + rand(seed, 710 + spineIndex) * 0.44), length: 0.46 + rand(seed, 720 + spineIndex) * 0.34, h: template.spineStrength * (0.225 + rand(seed, 730 + spineIndex) * 0.120), curve: template.spineCurve + (rand(seed, 735 + spineIndex) - 0.5) * 0.18, axisWobble: template.spineWidth * (0.52 + template.ridgeJaggedness * 0.90), kinkFrequency: 4 + rand(seed, 740 + spineIndex) * 8, kinkPhase: rand(seed, 750 + spineIndex) * Math.PI * 2, seedOffset: 7600 + spineIndex * 211, widthVariation: 0.20 + template.ridgeJaggedness * 0.30, segmentFrequency: 2.0 + rand(seed, 755 + spineIndex) * 2.0, segmentPhase: rand(seed, 756 + spineIndex), gapStrength: 0.26 + rand(seed, 757 + spineIndex) * 0.30, crestPower: 1.72 + rand(seed, 758 + spineIndex) * 0.36, }; } function spineFieldAt(x, y, template, spineIndex) { const seed = template.seed || 0; return jaggedRidgeContribution(x, y, makePrimarySpine(seed, template, spineIndex), seed); } function broadRidgeContribution(x, y, ridge, seed, widthScale = 4.2, heightScale = 0.14) { return jaggedRidgeContribution(x, y, { ...ridge, width: ridge.width * widthScale, h: ridge.h * heightScale, axisWobble: ridge.axisWobble * 0.55, widthVariation: Math.max(0.06, ridge.widthVariation * 0.42), gapStrength: Math.max(0.14, (ridge.gapStrength || 0) * 0.55), crestPower: 1.65, }, seed); } function recalcSlope(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.sqrt(gx * gx + gy * gy) * 10.8); } } } function buildSpineRidges(seed, template) { const spines = []; const branches = []; const auxRanges = []; for (let i = 0; i < template.spineCount; i++) { const spine = makePrimarySpine(seed, template, i); spines.push(spine); const branchCount = 2 + Math.floor(template.ridgeBranchiness * 4); for (let b = 0; b < branchCount; b++) { const along = (rand(seed, 810 + i * 31 + b) - 0.5) * spine.length * 0.74; const side = rand(seed, 820 + i * 31 + b) > 0.5 ? 1 : -1; const branchAngle = spine.angle + side * (0.46 + rand(seed, 830 + i * 31 + b) * 0.88); branches.push({ x: spine.x + Math.cos(spine.angle) * along, y: spine.y + Math.sin(spine.angle) * along, angle: branchAngle, width: template.spineWidth * (0.48 + rand(seed, 840 + i * 31 + b) * 0.62), length: 0.10 + rand(seed, 850 + i * 31 + b) * 0.22, h: template.spineStrength * (0.055 + template.ridgeBranchiness * 0.062 + rand(seed, 860 + i * 31 + b) * 0.060), curve: template.spineCurve * 0.42 + (rand(seed, 865 + i * 31 + b) - 0.5) * 0.18, axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 0.80), kinkFrequency: 4 + rand(seed, 870 + i * 31 + b) * 9, kinkPhase: rand(seed, 880 + i * 31 + b) * Math.PI * 2, seedOffset: 8800 + i * 311 + b * 37, widthVariation: 0.24 + template.ridgeJaggedness * 0.34, segmentFrequency: 2.4 + rand(seed, 882 + i * 31 + b) * 3.6, segmentPhase: rand(seed, 883 + i * 31 + b), gapStrength: 0.22 + rand(seed, 884 + i * 31 + b) * 0.35, crestPower: 2.30, }); } } // Subsidiary uplands/ranges around the main mountain systems: examples in // the target style are Atsumi-like peninsular uplands and Kitakami-like // parallel outer highlands. They are not dominant spines, but they prevent // the terrain from reading as only two artificial stripes. for (let a = 0; a < template.auxiliaryRangeCount; a++) { const base = spines[Math.floor(rand(seed, 940 + a) * spines.length) % spines.length]; const along = (rand(seed, 941 + a) - 0.5) * base.length * 0.95; const side = rand(seed, 942 + a) > 0.5 ? 1 : -1; const offset = side * (0.105 + rand(seed, 943 + a) * 0.255); const angle = base.angle + (rand(seed, 944 + a) - 0.5) * 0.48 + (rand(seed, 945 + a) > 0.72 ? side * (0.35 + rand(seed, 946 + a) * 0.35) : 0); auxRanges.push({ x: base.x + Math.cos(base.angle) * along + Math.cos(base.angle + Math.PI / 2) * offset, y: base.y + Math.sin(base.angle) * along + Math.sin(base.angle + Math.PI / 2) * offset, angle, width: template.spineWidth * (1.05 + rand(seed, 947 + a) * 1.30), length: 0.16 + rand(seed, 948 + a) * 0.34, h: template.spineStrength * (0.075 + rand(seed, 949 + a) * 0.125), curve: (rand(seed, 950 + a) - 0.5) * 0.22, axisWobble: template.spineWidth * (0.48 + template.ridgeJaggedness * 0.85), kinkFrequency: 3 + rand(seed, 951 + a) * 8, kinkPhase: rand(seed, 952 + a) * Math.PI * 2, seedOffset: 9400 + a * 173, widthVariation: 0.22 + template.ridgeJaggedness * 0.36, segmentFrequency: 1.8 + rand(seed, 953 + a) * 3.0, segmentPhase: rand(seed, 954 + a), gapStrength: 0.16 + rand(seed, 955 + a) * 0.28, crestPower: 1.60, }); } return { spines, branches, auxRanges }; } export function generateTerrainAndRivers(seed) { let prefectureMask; let prefectureBorder; const { elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, flowTo, portSuitability, crossingSuitability, passSuitability, } = createMapFields(); const terrainTemplate = buildTerrainTemplate(seed); const coastAngle = terrainTemplate.coastAngle; const coastX = Math.cos(coastAngle); const coastY = Math.sin(coastAngle); const coastThreshold = terrainTemplate.coastBias; const coastStrength = 0.13 + (1 - terrainTemplate.deposition) * 0.16 + rand(seed, 13) * 0.13; const { spines, branches, auxRanges } = buildSpineRidges(seed, terrainTemplate); function coastPressureAt(x, y, wx = x, wy = y) { const nx = x / (MAP_W - 1) - 0.5; const ny = y / (MAP_H - 1) - 0.5; const axis = nx * coastX + ny * coastY; const waveA = (fbm(wx * 0.72 + 31, wy * 0.72 - 17, seed + 2222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[0].inletStrength * 0.18) + (valueNoise(wx + 19, wy - 23, seed + 2233, 18) - 0.5) * (0.03 + terrainTemplate.coastSides[0].inletStrength * 0.10); const waveB = (fbm(wx * 0.68 - 41, wy * 0.68 + 29, seed + 3222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[1].inletStrength * 0.18) + (valueNoise(wx - 13, wy + 37, seed + 3233, 16) - 0.5) * (0.03 + terrainTemplate.coastSides[1].inletStrength * 0.10); const sideA = smoothstep((axis + waveA - (0.50 - terrainTemplate.coastSides[0].penetration)) / Math.max(0.08, terrainTemplate.coastSides[0].plainWidth * 2.4)); const sideB = smoothstep((-axis + waveB - (0.50 - terrainTemplate.coastSides[1].penetration)) / Math.max(0.08, terrainTemplate.coastSides[1].plainWidth * 2.4)); return { sideA, sideB, pressure: Math.max(sideA, sideB), signedAxis: axis }; } const seaLevel = 0.285; const mountainBlobs = Array.from({ length: terrainTemplate.secondaryMountainCount }, (_, i) => { const spine = spines[i % spines.length]; const nearSpine = rand(seed, 98 + i) < 0.72; const edgeBias = rand(seed, 99 + i) < 0.28; const along = (rand(seed, 100 + i) - 0.5) * spine.length * 0.95; const side = rand(seed, 101 + i) > 0.5 ? 1 : -1; const offset = (0.055 + rand(seed, 102 + i) * 0.22) * side; let x = nearSpine ? spine.x + Math.cos(spine.angle) * along + Math.cos(spine.angle + Math.PI / 2) * offset : rand(seed, 103 + i); let y = nearSpine ? spine.y + Math.sin(spine.angle) * along + Math.sin(spine.angle + Math.PI / 2) * offset : rand(seed, 104 + i); if (edgeBias) { const edgeSide = Math.floor(rand(seed, 105 + i) * 4); if (edgeSide === 0) x = Math.min(x, 0.08 + rand(seed, 106 + i) * 0.10); if (edgeSide === 1) x = Math.max(x, 0.92 - rand(seed, 107 + i) * 0.10); if (edgeSide === 2) y = Math.min(y, 0.08 + rand(seed, 108 + i) * 0.10); if (edgeSide === 3) y = Math.max(y, 0.92 - rand(seed, 109 + i) * 0.10); } const coastSide = (x - 0.5) * coastX + (y - 0.5) * coastY; const mountainSide = coastSide >= 0 ? 1 : -1; if (rand(seed, 110 + i) < 0.46 && Math.abs(coastSide) > 0.28 - coastThreshold * 0.35) { x -= coastX * mountainSide * (0.05 + rand(seed, 111 + i) * 0.11); y -= coastY * mountainSide * (0.05 + rand(seed, 112 + i) * 0.11); } return { x: clamp(x) * MAP_W, y: clamp(y) * MAP_H, r: (terrainTemplate.secondaryMountainSize * (0.72 + rand(seed, 300 + i) * 0.72)) * Math.min(MAP_W, MAP_H), h: terrainTemplate.secondaryMountainStrength * (0.13 + rand(seed, 400 + i) * 0.24), }; }); // Stage 1-3: start from a submerged surface, uplift several roughly // parallel spine ranges, preserve near-sea-level paleo-platforms, then add // terrain noise. This replaces the previous "high central plateau" bias. const axisX = Math.cos(terrainTemplate.spineAngle); const axisY = Math.sin(terrainTemplate.spineAngle); const crossX = Math.cos(terrainTemplate.spineAngle + Math.PI / 2); const crossY = Math.sin(terrainTemplate.spineAngle + Math.PI / 2); const glacialFlatLevel = seaLevel + 0.012 + (rand(seed, 66) - 0.5) * 0.020; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const nx = x / (MAP_W - 1) - 0.5; const ny = y / (MAP_H - 1) - 0.5; const i = indexOf(x, y); const warpX = (fbm(x * 0.46 + 180, y * 0.46 - 90, seed + 3101) - 0.5) * 7.5; const warpY = (fbm(x * 0.46 - 70, y * 0.46 + 210, seed + 3201) - 0.5) * 7.5; const wx = x + warpX; const wy = y + warpY; const px = wx / (MAP_W - 1); const py = wy / (MAP_H - 1); const rx = px - 0.5; const ry = py - 0.5; const along = rx * axisX + ry * axisY; const cross = rx * crossX + ry * crossY; let mountains = 0; for (const blob of mountainBlobs) { const d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r; mountains += Math.exp(-d * d * 2.70) * blob.h; } let spineRidges = 0; let broadSpineUplift = 0; for (const spine of spines) { spineRidges += jaggedRidgeContribution(px, py, spine, seed); broadSpineUplift += broadRidgeContribution(px, py, spine, seed, 4.6, 0.135); } let branchRidges = 0; for (const ridge of branches) branchRidges += jaggedRidgeContribution(px, py, ridge, seed); for (const ridge of auxRanges) branchRidges += jaggedRidgeContribution(px, py, ridge, seed); // Long-island basement. It keeps the map from becoming a square continent, // but does not itself create a high plateau. const coastWave = (fbm(wx * 0.26 + 901, wy * 0.26 - 307, seed + 4210) - 0.5) * (0.060 + terrainTemplate.coastRoughness * 0.075) + (valueNoise(wx + 109, wy - 53, seed + 4211, 30) - 0.5) * 0.050; const longFade = smoothstep((0.82 - Math.abs(along)) / 0.20); const halfWidth = 0.305 + terrainTemplate.deposition * 0.040 + (valueNoise(wx - 141, wy + 70, seed + 4212, 44) - 0.5) * 0.105; const islandCore = smoothstep((halfWidth - Math.abs(cross + coastWave)) / 0.115) * longFade; const offshorePlatform = smoothstep((halfWidth + 0.120 - Math.abs(cross + coastWave)) / 0.145) * longFade; // Intermontane troughs between parallel ridges: a low background around // ridges prevents the ridges from blending into one beige plateau. const nearestSpine = clamp(spineRidges * 3.3); const broadHighland = clamp(broadSpineUplift * 2.2); const betweenRanges = clamp(broadHighland * (1 - nearestSpine * 0.82)); const trough = betweenRanges * (0.028 + terrainTemplate.deposition * 0.020); const terrainLarge = fbm(wx * 0.24 + 40, wy * 0.24 - 60, seed + 710) - 0.5; const terrainRegional = fbm(wx * 0.72 + 80, wy * 0.72 - 20, seed + 777) - 0.5; const terrainLocal = fbm(wx * 1.65 + 17, wy * 1.65 - 31, seed + 1777) - 0.5; const terrainFine = valueNoise(wx * 2.55 + 11, wy * 2.55 - 19, seed + 2444, 4.5) - 0.5; const ridgeNoiseGate = clamp(nearestSpine * 0.55 + branchRidges * 2.4 + mountains * 1.55 + broadSpineUplift * 1.15); const surfaceNoise = terrainLarge * 0.105 + terrainRegional * 0.068 + terrainLocal * (0.028 + terrainTemplate.roughness * 0.024) + terrainFine * (0.010 + terrainTemplate.roughness * 0.016); const mountainTexture = (terrainLocal * 0.064 + terrainFine * 0.036 + terrainRegional * 0.025) * ridgeNoiseGate; const ravineCut = Math.pow(clamp(0.58 - terrainLocal), 1.45) * (0.030 + terrainTemplate.erosion * 0.032) * ridgeNoiseGate; // All cells start below sea. Land exists where the island basement and // mountain belts uplift it above the current sea level. Primary ranges // are massifs on a broad base, not flat, full-width bars. let rawElevation = seaLevel - 0.090 + islandCore * (0.175 + terrainTemplate.deposition * 0.045) + offshorePlatform * 0.026 + broadSpineUplift * 0.43 + spineRidges * 1.34 + branchRidges * 0.82 + mountains * 0.66 + surfaceNoise + mountainTexture - ravineCut - trough; // Randomly preserve flat shelves around the glacial sea-level band. These // later become coastal terraces, valley floors, and broad alluvial plains. const seaBand = clamp(1 - Math.abs(rawElevation - glacialFlatLevel) / (0.070 + terrainTemplate.deposition * 0.045)); const platformMask = clamp(offshorePlatform * (1 - nearestSpine * 0.82) * (0.55 + valueNoise(wx + 314, wy - 271, seed + 4300, 22) * 0.55)); const paleoFlat = seaBand * platformMask; if (paleoFlat > 0.02) { const terraceStep = 0.010 + terrainTemplate.deposition * 0.008; const terraced = glacialFlatLevel + Math.round((rawElevation - glacialFlatLevel) / terraceStep) * terraceStep; rawElevation = lerp(rawElevation, terraced, paleoFlat * 0.72); } elevation[i] = clamp(rawElevation, 0, 1); arcSpineField[i] = clamp(spineRidges * 3.20 + broadSpineUplift * 0.85); branchRidgeField[i] = clamp(branchRidges * 2.80 + mountains * 0.42); ridgeField[i] = clamp(arcSpineField[i] * 0.72 + branchRidgeField[i] * 0.66 + Math.max(0, mountains - 0.06) * 0.90 + ridgeNoiseGate * 0.18); basinField[i] = clamp(paleoFlat * 0.42 + betweenRanges * 0.14 + (1 - islandCore) * offshorePlatform * 0.08); coastalLowland[i] = clamp((elevation[i] < seaLevel + 0.105 ? platformMask * 0.48 + offshorePlatform * 0.18 : 0) * (1 - ridgeField[i] * 0.55)); moisture[i] = clamp(0.46 * fbm(wx + 400, wy - 200, seed + 333) + 0.18 * valueNoise(wx, wy, seed + 343, 11) + 0.24 * offshorePlatform + 0.16 * islandCore - Math.max(0, elevation[i] - 0.68) * 0.24); } } for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); const edgeBleed = Math.max( smoothstep((5 - x) / 5), smoothstep((x - (MAP_W - 6)) / 5), smoothstep((5 - y) / 5), smoothstep((y - (MAP_H - 6)) / 5) ); const coastalNoise = (hash2(x, y, seed + 2311) - 0.5) * 0.010; if (elevation[i] + coastalNoise < seaLevel || (edgeBleed > 0.65 && elevation[i] < seaLevel + 0.050 && ridgeField[i] < 0.28)) sea[i] = 1; if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.020 + hash2(x, y, seed + 2311) * 0.010); } } // Edge-connected water is ocean. Isolated water is only kept when it reads as // a small mountain/valley lake or lagoon; oversized round basins become wet lowland. const waterSeen = new Uint8Array(SIZE); const oceanQueue = []; for (let x = 0; x < MAP_W; x++) { for (const y of [0, MAP_H - 1]) { const i = indexOf(x, y); if (sea[i] && !waterSeen[i]) { waterSeen[i] = 1; ocean[i] = 1; oceanQueue.push(i); } } } for (let y = 0; y < MAP_H; y++) { for (const x of [0, MAP_W - 1]) { const i = indexOf(x, y); if (sea[i] && !waterSeen[i]) { waterSeen[i] = 1; ocean[i] = 1; oceanQueue.push(i); } } } for (let q = 0; q < oceanQueue.length; q++) { const cur = oceanQueue[q]; const [x, y] = [cur % MAP_W, Math.floor(cur / MAP_W)]; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (!sea[ni] || waterSeen[ni]) continue; waterSeen[ni] = 1; ocean[ni] = 1; oceanQueue.push(ni); } } for (let i = 0; i < SIZE; i++) { if (!sea[i] || waterSeen[i]) continue; const queue = [i]; const component = [i]; waterSeen[i] = 1; let sx = 0, sy = 0, perimeter = 0, ridgeSum = 0, valleySum = 0, coastTouch = 0; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; const x = cur % MAP_W; const y = Math.floor(cur / MAP_W); sx += x; sy += y; ridgeSum += ridgeField[cur]; valleySum += valleyField[cur]; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (!sea[ni]) { perimeter++; if (coastalLowland[ni] > 0.12 || coastPressureAt(nx, ny).pressure > 0.42) coastTouch++; continue; } if (waterSeen[ni]) continue; waterSeen[ni] = 1; queue.push(ni); component.push(ni); } } const area = component.length; const cx = sx / area; const cy = sy / area; let radiusSum = 0; for (const ci of component) { const x = ci % MAP_W; const y = Math.floor(ci / MAP_W); radiusSum += Math.hypot(x - cx, y - cy); } const meanRadius = radiusSum / Math.max(1, area); const circularity = perimeter > 0 ? (4 * Math.PI * area) / (perimeter * perimeter) : 1; const mountainLake = area <= 38 && ridgeSum / area > 0.28; const valleyLake = area <= 70 && valleySum / area > 0.24 && circularity < 0.58; const lagoon = area <= 110 && coastTouch / Math.max(1, perimeter) > 0.18 && circularity < 0.70; const rareSpecial = area <= 145 && circularity < 0.52 && hash2(Math.round(cx), Math.round(cy), seed + 2401) > 0.88; const keepLake = mountainLake || valleyLake || lagoon || rareSpecial; for (const ci of component) { if (keepLake) { lake[ci] = 1; continue; } sea[ci] = 0; elevation[ci] = Math.max(seaLevel + 0.012, seaLevel + Math.min(0.055, meanRadius * 0.004) + hash2(ci, area, seed + 2402) * 0.012); basinField[ci] = clamp(basinField[ci] + 0.42); valleyField[ci] = clamp(valleyField[ci] + 0.18); depositionalLowland[ci] = clamp(depositionalLowland[ci] + 0.28); depositionField[ci] = clamp(depositionField[ci] + 0.035); } } // Align coastal elevation with the sea mask. This prevents artificial one-cell cliffs // when the directional coastline cuts through a high terrain cell. 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 nearestSea = INF; let nearestOcean = INF; for (let dy = -7; dy <= 7; dy++) { for (let dx = -7; dx <= 7; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny) || !sea[indexOf(nx, ny)]) continue; nearestSea = Math.min(nearestSea, Math.hypot(dx, dy)); if (ocean[indexOf(nx, ny)]) nearestOcean = Math.min(nearestOcean, Math.hypot(dx, dy)); } } if (nearestSea <= 7) { const coastalCap = seaLevel + 0.018 + nearestSea * (0.022 + terrainTemplate.deposition * 0.012) + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * (0.014 + terrainTemplate.coastRoughness * 0.018); elevation[i] = Math.min(elevation[i], coastalCap); if (nearestOcean <= 7) { const coast = coastPressureAt(x, y); const side = coast.sideA >= coast.sideB ? terrainTemplate.coastSides[0] : terrainTemplate.coastSides[1]; const plainReach = clamp(4.5 + side.plainWidth * 34, 5, 9); coastalLowland[i] = clamp((1 - nearestOcean / plainReach) * (0.62 + terrainTemplate.deposition * 0.48 + side.plainWidth * 1.9) * (1 - ridgeField[i] * 0.35)); } } } } // Sea-level platform smoothing from the glacial-stage surface. Only low, // weakly dissected terrain is affected; mountain belts remain sharp. for (let pass = 0; pass < 2; pass++) { const nextElevation = new Float32Array(elevation); 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 nearSeaLevel = clamp(1 - Math.abs(elevation[i] - (seaLevel + 0.050)) / 0.105); const flatPotential = clamp(nearSeaLevel * (coastalLowland[i] * 0.75 + basinField[i] * 0.42 + (1 - ridgeField[i]) * 0.22)); if (flatPotential <= 0.10) continue; let sum = 0; let wsum = 0; for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { const ni = indexOf(x + dx, y + dy); if (sea[ni]) continue; const d = Math.hypot(dx, dy); if (d > 2.3) continue; const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.12); const w = compatible / (1 + d); sum += elevation[ni] * w; wsum += w; } } if (wsum > 0) { nextElevation[i] = clamp(lerp(elevation[i], sum / wsum, flatPotential * 0.34), seaLevel + 0.006, 1); depositionalLowland[i] = clamp(depositionalLowland[i] + flatPotential * 0.12); basinField[i] = clamp(basinField[i] + flatPotential * 0.08); } } } elevation.set(nextElevation); } for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { 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[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); } } const landOrder = []; 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 low = i; let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468); let localMean = 0; let localMax = elevation[i]; let localMin = elevation[i]; let nCount = 0; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); const ev = elevation[ni]; localMean += ev; localMax = Math.max(localMax, ev); localMin = Math.min(localMin, ev); nCount++; const directed = ev + 0.008 * hash2(nx, ny, seed + 2469); if (directed < best || sea[ni]) { best = directed; low = ni; } } if (low !== i) flowTo[i] = low; localMean /= Math.max(1, nCount); const hollow = Math.max(0, localMean - elevation[i]); const relief = localMax - localMin; valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18); basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0)); flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55; landOrder.push(i); } } landOrder.sort((a, b) => elevation[b] - elevation[a]); for (const i of landOrder) { const to = flowTo[i]; if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82; } let maxFlowAccum = 0; for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]); if (maxFlowAccum > 0) { for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum); } for (let i = 0; i < SIZE; i++) { if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48); } // Stage 4: coarse fluvial simulation on the elevation field before drawing // explicit rivers. Steep, high-flow cells are incised; low-gradient cells // near sea level, basins, and coasts receive sediment and are smoothed. for (let pass = 0; pass < 3; pass++) { recalcSlope(elevation, sea, slope); const nextElevation = new Float32Array(elevation); 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 flow = Math.pow(flowAccum[i], 0.50); const steep = slope[i]; const high = clamp((elevation[i] - seaLevel) / 0.46); const incise = clamp(flow * steep * (0.021 + terrainTemplate.erosion * 0.040) * (0.66 + high * 0.82) * (0.60 + ridgeField[i] * 0.44)); const deposit = clamp(flow * (1 - steep) * (coastalLowland[i] * 0.42 + basinField[i] * 0.34 + (elevation[i] < seaLevel + 0.16 ? 0.18 : 0)) * (0.012 + terrainTemplate.deposition * 0.035) * (1 - ridgeField[i] * 0.60)); if (incise > 0.002 || deposit > 0.002) { nextElevation[i] = clamp(elevation[i] - incise + deposit * 0.56, seaLevel + 0.005, 1); erosionField[i] = clamp(erosionField[i] + incise * 2.7); depositionField[i] = clamp(depositionField[i] + deposit * 2.1); valleyField[i] = clamp(valleyField[i] + incise * 5.4 + flow * 0.10); depositionalLowland[i] = clamp(depositionalLowland[i] + deposit * 8.0); } } } elevation.set(nextElevation); } recalcSlope(elevation, sea, slope); // First-order fluvial shaping: cut valley floors on steep/high-flow cells and // deposit gently in coastal lowlands and basin floors. This gives visible // river valleys without destroying the macro terrain structure. const shapedElevation = 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; const flow = Math.pow(flowAccum[i], 0.46); const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36; const steepValley = clamp(flow * (0.026 + terrainTemplate.erosion * 0.038 + slope[i] * (0.105 + terrainTemplate.erosion * 0.095) + ridgeField[i] * (0.016 + terrainTemplate.erosion * 0.032)) * incisionNoise); const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * (0.032 + terrainTemplate.erosion * 0.046)); const lowSettling = clamp(flow * (coastalLowland[i] * (0.018 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.010 + terrainTemplate.deposition * 0.028) + (elevation[i] < 0.40 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - slope[i] * 0.82) * (1 - ridgeField[i] * 0.45)); erosionField[i] = steepValley + lateralCut; depositionField[i] = lowSettling; depositionalLowland[i] = clamp(lowSettling * 6.5 + basinField[i] * terrainTemplate.deposition * 0.28 + coastalLowland[i] * terrainTemplate.deposition * 0.34); shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1); } } elevation.set(shapedElevation); 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.sqrt(gx * gx + gy * gy) * 10.5); valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06); basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6); } } const sourceCandidates = []; for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i]) continue; const score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + arcSpineField[i] * 0.07 + branchRidgeField[i] * 0.04 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; if (elevation[i] > 0.40 && elevation[i] < 0.84 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.95) sourceCandidates.push({ x, y, score }); } } const sources = pickEntities(sourceCandidates, { max: 20 + Math.floor(rand(seed, 910) * 28), minDistance: 8, threshold: 0.53 + rand(seed, 911) * 0.11, seed, }); function nearestWaterGoal(from) { let bestSea = null; let bestScore = INF; 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; const d = Math.hypot(x - from.x, y - from.y); const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2; if (score < bestScore) { bestScore = score; bestSea = { x, y }; } } } return bestSea; } function riverRouteCost(x, y, cx, cy) { const i = indexOf(x, y); const ci = indexOf(cx, cy); if (sea[i]) return 0.18; const uphill = Math.max(0, elevation[i] - elevation[ci]); const downhill = Math.max(0, elevation[ci] - elevation[i]); if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF; return Math.max( 0.18, 1 + uphill * 86 + slope[i] * 0.38 + elevation[i] * 0.42 - downhill * 2.1 - valleyField[i] * 0.92 - flowAccum[i] * 0.72 - moisture[i] * 0.18 - coastalLowland[i] * 0.22 ); } function forceRiverToWater(path) { if (!path.length) return path; const [ex, ey] = path[path.length - 1]; if (sea[indexOf(ex, ey)]) return path; const goal = nearestWaterGoal({ x: ex, y: ey }); if (!goal) return path; const startElevation = elevation[indexOf(ex, ey)]; const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => { const i = indexOf(x, y); const ci = indexOf(cx, cy); if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF; return riverRouteCost(x, y, cx, cy); }); if (tail.length <= 2) return path; return path.concat(tail.slice(1)); } function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) { if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0; let best = 0.16; const inLen = Math.hypot(dx, dy) || 1; for (const [rx, ry] of neighbors8(nx, ny)) { if (river[indexOf(rx, ry)] < 0.22) continue; const rdx = rx - nx; const rdy = ry - ny; const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1); const angle = Math.acos(cos); const shallow = angle < 0.45 ? 0.28 : 0; best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow); } return best; } function traceRiverPath(startX, startY, bonusSeed = 0) { let x = startX; let y = startY; let lastDx = 0; let lastDy = 0; const path = []; const seen = new Set(); let accum = 0; for (let step = 0; step < 600; step++) { const i = indexOf(x, y); if (seen.has(i)) break; seen.add(i); path.push([x, y]); river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55; accum += river[i] + flowAccum[i]; if (sea[i]) break; let best = null; let bestValue = INF; const currentElevation = elevation[i]; const preferred = flowTo[i]; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); const dx = nx - x; const dy = ny - y; const drop = currentElevation - elevation[ni]; const uphill = Math.max(0, -drop); if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue; let surrounding = 0; let surroundingCount = 0; for (const [vx, vy] of neighbors8(nx, ny)) { surrounding += elevation[indexOf(vx, vy)]; surroundingCount++; } const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]); const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; const straightPenalty = Math.max(0, sameDirection) * 0.075; const turnPenalty = sameDirection < -0.35 ? 0.24 : 0; const sideSwing = Math.abs(dx * lastDy - dy * lastDx); const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5; const meander = sideSwing * (0.032 + meanderPhase * 0.026); const flowBonus = ni === preferred ? 0.62 : 0; const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length); const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04; const value = elevation[ni] * 1.45 + uphill * 88 - Math.max(0, drop) * 2.05 - valley * 1.05 - valleyField[ni] * 1.72 - flowAccum[ni] * 0.94 - moisture[ni] * 0.14 - coastalLowland[ni] * 0.28 - (river[ni] > 0 ? 0.22 : 0) - flowBonus + slope[ni] * 0.04 + straightPenalty + turnPenalty + junctionPenalty * 1.35 - meander + noise - (sea[ni] ? 0.6 : 0); if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; } } if (!best) break; x = best[0]; y = best[1]; lastDx = best[2]; lastDy = best[3]; } const forced = forceRiverToWater(path); if (forced.length > path.length) { for (const [rx, ry] of forced.slice(path.length)) { const ri = indexOf(rx, ry); river[ri] += 0.32 + flowAccum[ri] * 0.4; accum += river[ri] + flowAccum[ri]; } } return { path: forced, accum }; } function traceSmallStreamPath(startX, startY, bonusSeed = 0) { let x = startX; let y = startY; let lastDx = 0; let lastDy = 0; const path = []; const seen = new Set(); for (let step = 0; step < 160; step++) { const i = indexOf(x, y); if (seen.has(i)) break; seen.add(i); path.push([x, y]); river[i] += 0.12 + flowAccum[i] * 0.18; if ((river[i] > 0.48 && path.length > 5) || sea[i]) break; let best = null; let bestValue = INF; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); const dx = nx - x; const dy = ny - y; const drop = elevation[i] - elevation[ni]; const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05; if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; } } if (!best) break; x = best[0]; y = best[1]; lastDx = best[2]; lastDy = best[3]; } return path; } const riverPaths = []; const riverScores = []; for (const source of sources) { const { path, accum } = traceRiverPath(source.x, source.y, 0); if (path.length > 6) { riverPaths.push(path); riverScores.push(path.length + accum * 0.18); } } const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`))); const tributarySources = pickEntities(sourceCandidates .filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`)) .map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), { max: 14 + Math.floor(rand(seed, 915) * 20), minDistance: 6, threshold: 0.45, seed: seed + 916, jitter: 0.02, }); for (const source of tributarySources) { const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11); if (path.length > 8) { riverPaths.push(path); riverScores.push(path.length * 0.7 + accum * 0.12); } } const streamPaths = []; const streamSources = pickEntities(sourceCandidates .map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 })) .filter((p) => p.score > 0.18), { max: 22 + Math.floor(rand(seed, 919) * 20), minDistance: 4, threshold: 0.18, seed: seed + 919, jitter: 0.015, }); for (const source of streamSources) { const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17); if (path.length > 4) streamPaths.push(path); } if (riverPaths.length === 0 && sourceCandidates.length > 0) { const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0]; let bestSea = null; let bestSeaDist = INF; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { if (!sea[indexOf(x, y)]) continue; const d = Math.hypot(x - fallback.x, y - fallback.y); if (d < bestSeaDist) { bestSeaDist = d; bestSea = { x, y }; } } } if (bestSea) { const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => { const i = indexOf(x, y); const ci = indexOf(cx, cy); if (sea[i]) return 0.25; const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24; const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8; return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1)); }); if (fallbackPath.length > 6) { let accum = 0; for (const [x, y] of fallbackPath) { const i = indexOf(x, y); river[i] += 0.42; accum += river[i]; } riverPaths.push(fallbackPath); riverScores.push(fallbackPath.length + accum * 0.18); } } } function sanitizeDownhillRiverPath(path, tolerance = 0.075) { if (!path || path.length < 2) return path || []; const out = [path[0]]; for (let k = 1; k < path.length; k++) { const [px, py] = out[out.length - 1]; const [x, y] = path[k]; const pi = indexOf(px, py); const i = indexOf(x, y); if (!sea[i] && elevation[i] > elevation[pi] + tolerance && flowAccum[i] < flowAccum[pi] + 0.025) break; out.push(path[k]); if (sea[i]) break; } return out.length >= 2 ? out : []; } function trimMountainHeadwaters(path) { if (!path || path.length < 4) return path || []; let start = 0; while (start < path.length - 3) { const [x, y] = path[start]; const i = indexOf(x, y); if (sea[i]) break; if (elevation[i] <= 0.84 && (valleyField[i] >= 0.10 || flowAccum[i] >= 0.022 || river[i] > 0.12)) break; start++; } return path.slice(start); } for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = forceRiverToWater(sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.075)); for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1); for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.060); for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); river.fill(0); for (const path of riverPaths) { for (let k = 0; k < path.length; k++) { const [x, y] = path[k]; const i = indexOf(x, y); river[i] += 0.42 + k / 170 + flowAccum[i] * 0.55; } } for (const path of streamPaths) { for (let k = 0; k < path.length; k++) { const [x, y] = path[k]; const i = indexOf(x, y); river[i] += 0.11 + flowAccum[i] * 0.18; } } const expandedRiver = new Float32Array(river); for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); if (river[i] <= 0) continue; for (const [nx, ny] of neighbors8(x, y)) { expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35); } } } river.set(expandedRiver); // Second fluvial pass uses the actual traced river network. Main channels cut // visible V-shaped valleys; lower reaches accumulate alluvial deposits. const fluvialElevation = 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] || river[i] <= 0.02) continue; const r = clamp(river[i] / 3.4); const channelCut = clamp(Math.pow(r, 0.55) * (0.034 + terrainTemplate.erosion * 0.052 + slope[i] * (0.075 + terrainTemplate.erosion * 0.120) + ridgeField[i] * (0.018 + terrainTemplate.erosion * 0.048))); const valleyWiden = clamp(Math.pow(r, 0.72) * (0.012 + terrainTemplate.erosion * 0.026 + Math.max(0, elevation[i] - seaLevel) * (0.030 + terrainTemplate.erosion * 0.050) + valleyField[i] * (0.020 + terrainTemplate.erosion * 0.045))); const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * (0.014 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.010 + terrainTemplate.deposition * 0.028) + (slope[i] < 0.10 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - ridgeField[i] * 0.45)); erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); depositionField[i] = clamp(depositionField[i] + alluvium); depositionalLowland[i] = clamp(depositionalLowland[i] + alluvium * 5.5); fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1); valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4); basinField[i] = clamp(basinField[i] + alluvium * 3.2); } } // Lateral valley carving around the traced river network deepens valleys and // makes ridge/valley contrast legible at the map scale. for (const path of riverPaths) { for (const [rx, ry] of path) { const ri = indexOf(rx, ry); const r = clamp(river[ri] / 3.0); const radius = r > 0.48 ? 2 : 1; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const nx = rx + dx; const ny = ry + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (sea[ni]) continue; const d = Math.hypot(dx, dy); if (d > radius || d === 0) continue; const weight = (radius + 0.35 - d) / (radius + 0.35); const carve = Math.max(0, weight) * (0.005 + terrainTemplate.erosion * 0.007 + r * (0.014 + terrainTemplate.erosion * 0.022)) * Math.max(0.45, slope[ni] + 0.22); fluvialElevation[ni] = clamp(fluvialElevation[ni] - carve, seaLevel + 0.005, 1); erosionField[ni] = clamp(erosionField[ni] + carve * 3.0); valleyField[ni] = clamp(valleyField[ni] + carve * 12.0); } } } } // Large downstream alluvial plains: expand lowland around the lower reaches of // the strongest rivers before the generic deposition pass. This creates Kanto- // or Nobi-like broad plains while still rejecting ridge/high-slope cells. const protoMainRivers = riverPaths .map((path, i) => ({ path, score: riverScores[i] ?? path.length })) .sort((a, b) => b.score - a.score) .slice(0, Math.min(4, riverPaths.length)) .map((entry) => entry.path); for (const path of protoMainRivers) { const start = Math.floor(path.length * 0.45); for (let k = start; k < path.length; k += 2) { const [rx, ry] = path[k]; const lowerReach = k / Math.max(1, path.length - 1); const radius = 3.5 + lowerReach * 6.5 + terrainTemplate.deposition * 4.0; const radiusCells = Math.ceil(radius); for (let dy = -radiusCells; dy <= radiusCells; dy++) { for (let dx = -radiusCells; dx <= radiusCells; dx++) { const nx = rx + dx; const ny = ry + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (sea[ni]) continue; const d = Math.hypot(dx, dy); if (d > radius) continue; const radial = smoothstep(1 - d / radius); const lowEnergy = clamp( coastalLowland[ni] * 0.55 + basinField[ni] * 0.38 + Math.pow(flowAccum[ni], 0.45) * 0.34 + (1 - slope[ni]) * 0.18 - ridgeField[ni] * 0.58 - Math.max(0, fluvialElevation[ni] - 0.50) * 1.35 ); const w = radial * lowEnergy * (0.25 + terrainTemplate.deposition * 0.75); if (w <= 0.015) continue; depositionalLowland[ni] = clamp(depositionalLowland[ni] + w * 0.65); deltaField[ni] = clamp(deltaField[ni] + w * coastalLowland[ni] * 0.55); floodplain[ni] = clamp(floodplain[ni] + w * 0.45); valleyField[ni] = clamp(valleyField[ni] + w * 0.22); basinField[ni] = clamp(basinField[ni] + w * 0.18); const floor = seaLevel + 0.018 + coastalLowland[ni] * 0.010 + basinField[ni] * 0.020 + d * 0.0015; fluvialElevation[ni] = clamp( lerp(fluvialElevation[ni], Math.max(floor, fluvialElevation[ni] - 0.035), w * 0.26), seaLevel + 0.006, 1 ); } } } } // Template-driven deposition is limited to plausible low-energy places: // river mouths, basin floors, coastal plains, and slope breaks below ridges. const depositionElevation = new Float32Array(fluvialElevation); 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; let nearSea = 0; let localRiver = river[i]; let highSide = 0; let lowSide = 1; for (let dy = -4; dy <= 4; dy++) { for (let dx = -4; dx <= 4; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); const d = Math.hypot(dx, dy); if (d > 4.25) continue; if (sea[ni]) nearSea = Math.max(nearSea, 1 - d / 4.25); localRiver = Math.max(localRiver, river[ni] / (1 + d * 0.5)); highSide = Math.max(highSide, fluvialElevation[ni]); lowSide = Math.min(lowSide, fluvialElevation[ni]); } } const reliefDrop = clamp((highSide - lowSide - 0.075) * 4.5); const lowlandPotential = clamp( basinField[i] * 0.44 + coastalLowland[i] * 0.52 + Math.pow(flowAccum[i], 0.56) * 0.32 + plain[i] * 0.18 + localRiver * 0.16 - ridgeField[i] * 0.48 - slope[i] * 0.52 - Math.max(0, fluvialElevation[i] - 0.55) * 1.35 ); const delta = clamp(nearSea * localRiver * coastalLowland[i] * (0.32 + terrainTemplate.deposition * 1.25) * (1 - ridgeField[i] * 0.55)); const fan = clamp(reliefDrop * localRiver * valleyField[i] * (0.20 + terrainTemplate.deposition * 0.95) * (1 - coastalLowland[i] * 0.45)); const lowland = clamp(lowlandPotential * terrainTemplate.deposition + delta * 0.72 + fan * 0.42); if (lowland <= 0.01) continue; deltaField[i] = clamp(deltaField[i] + delta); alluvialFanField[i] = clamp(alluvialFanField[i] + fan); depositionalLowland[i] = clamp(depositionalLowland[i] + lowland); depositionField[i] = clamp(depositionField[i] + lowland * 0.050); erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.018); const floor = seaLevel + 0.008 + basinField[i] * 0.012 + coastalLowland[i] * 0.010; depositionElevation[i] = clamp(lerp(fluvialElevation[i], Math.max(floor, fluvialElevation[i] - 0.032), lowland * 0.55), seaLevel + 0.005, 1); } } fluvialElevation.set(depositionElevation); // Restore rugged summit relief after strong river incision. This prevents highlands // from becoming unnaturally flat or visually concave while keeping valleys cut. 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 high = clamp((fluvialElevation[i] - 0.54) / 0.30); const summit = high * clamp(ridgeField[i] * 1.25 + arcSpineField[i] * 0.55 + branchRidgeField[i] * 0.30 - flowAccum[i] * 0.65); const rugged = (valueNoise(x * 2.1 + 19, y * 2.1 - 23, seed + 9661, 3.2) - 0.5) * 0.040; const uplift = summit * (0.038 + Math.max(0, rugged)); if (uplift > 0) { fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 1); erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6); } } } elevation.set(fluvialElevation); // Broad alluvial/coastal/basin plains. The plain score alone is not enough; // the elevation surface must also be locally calm, otherwise every lowland // still reads as rugged terrain. Smooth only low, wet depositional cells and // leave ridges/headwaters untouched. for (let pass = 0; pass < 3 + Math.round(terrainTemplate.deposition * 2); pass++) { const nextElevation = new Float32Array(elevation); 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 lowland = clamp( coastalLowland[i] * 0.72 + basinField[i] * 0.54 + depositionalLowland[i] * 0.52 + deltaField[i] * 0.34 + alluvialFanField[i] * 0.22 + valleyField[i] * 0.34 + Math.pow(flowAccum[i], 0.58) * 0.24 - ridgeField[i] * 0.62 - Math.max(0, elevation[i] - 0.54) * 1.65 - slope[i] * 0.74 ); if (lowland <= 0.12) continue; let sum = 0; let weight = 0; for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { const nx = x + dx; const ny = y + dy; const ni = indexOf(nx, ny); if (sea[ni]) continue; const d = Math.hypot(dx, dy); if (d > 2.25) continue; const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11); const w = compatible / (1 + d); sum += elevation[ni] * w; weight += w; } } if (weight <= 0) continue; const localMean = sum / weight; const terrace = Math.round(localMean * 42) / 42; const target = lerp(localMean, terrace, 0.28); nextElevation[i] = clamp(lerp(elevation[i], target, lowland * (0.30 + terrainTemplate.deposition * 0.26)), seaLevel + 0.006, 1); if (lowland > 0.55) { depositionField[i] = clamp(depositionField[i] + lowland * (0.010 + terrainTemplate.deposition * 0.018)); erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012); } } } elevation.set(nextElevation); } // Alpine summit reinforcement. The geomorphic pipeline can otherwise erode // the whole mountain system into mid-altitude upland, especially in high- // deposition seeds. Add rugged peaks only where existing ridge fields agree, // not as a continuous stripe. 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 alpinePotential = clamp( arcSpineField[i] * 0.72 + branchRidgeField[i] * 0.54 + ridgeField[i] * 0.30 - flowAccum[i] * 0.34 - coastalLowland[i] * 0.24 - basinField[i] * 0.16 ); if (alpinePotential <= 0.38) continue; const summitNoise = 0.72 + valueNoise(x * 1.55 + 103, y * 1.55 - 89, seed + 9731, 4.7) * 0.58; const cragNoise = 0.82 + (valueNoise(x * 3.3 - 71, y * 3.3 + 47, seed + 9732, 2.2) - 0.5) * 0.42; const lift = Math.pow(alpinePotential, 1.58) * (0.060 + terrainTemplate.roughness * 0.050) * summitNoise * cragNoise; elevation[i] = clamp(elevation[i] + lift, seaLevel + 0.006, 1); if (alpinePotential > 0.54) { const target = 0.675 + Math.pow(alpinePotential, 1.28) * 0.170 + (summitNoise - 1) * 0.035; elevation[i] = clamp(lerp(elevation[i], Math.max(elevation[i], target), (alpinePotential - 0.54) * 0.92), seaLevel + 0.006, 1); } } } 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.sqrt(gx * gx + gy * gy) * 11.2); } } // Re-trim visible river paths after fluvial reshaping changes local elevation. for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = forceRiverToWater(sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.075)); for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1); for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.055); for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); const mainRivers = riverPaths .map((p, i) => ({ path: p, score: riverScores[i] })) .sort((a, b) => b.score - a.score) .slice(0, Math.min(6, riverPaths.length)) .map((x) => x.path); if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]); if (mainRivers.length === 0) { let start = null; let startScore = -INF; for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i]) continue; const score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15; if (score > startScore) { startScore = score; start = { x, y }; } } } if (start) { let goal = null; let goalDist = INF; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { if (!sea[indexOf(x, y)]) continue; const d = Math.hypot(x - start.x, y - start.y); if (d < goalDist) { goalDist = d; goal = { x, y }; } } } if (goal) { const fallbackPath = aStar(start, goal, (x, y, cx, cy) => { const i = indexOf(x, y); const ci = indexOf(cx, cy); if (sea[i]) return 0.2; const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22; const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7; return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias); }); if (fallbackPath.length > 4) { riverPaths.push(fallbackPath); mainRivers.push(fallbackPath); for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4; } } } } const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`))); const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path)); const smallStreams = streamPaths.filter((path) => path.length >= 5); prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); prefectureBorder = extractMaskBorder(prefectureMask, sea); const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); const prefectureRegionId = regionalPrefectures.regionId; const regionalDebug = regionalPrefectures.debug; const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea); 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 low = 1 - clamp((elevation[i] - 0.28) / 0.4); const flat = 1 - slope[i]; const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55 + depositionalLowland[i] * 0.34 + deltaField[i] * 0.28 + alluvialFanField[i] * 0.20; plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0)); let nearRiver = 0; for (let dy = -4; dy <= 4; dy++) { for (let dx = -4; dx <= 4; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; nearRiver = Math.max(nearRiver, river[indexOf(nx, ny)] / (1 + Math.hypot(dx, dy))); } } const fan = clamp(Math.max(alluvialFanField[i], valleyField[i] * (1 - coastalLowland[i]) * (elevation[i] > 0.34 && elevation[i] < 0.58 ? 0.9 : 0.35)) * (1 - slope[i] * 0.55)); floodplain[i] = clamp(nearRiver * plain[i] * 0.92 + coastalLowland[i] * nearRiver * 0.22 + deltaField[i] * 0.18); agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.30 + basinField[i] * 0.2 + depositionalLowland[i] * 0.24 + deltaField[i] * 0.18 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06); } } 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; let seaNear = 0; let riverNear = 0; let sheltered = 0; for (let dy = -5; dy <= 5; dy++) { for (let dx = -5; dx <= 5; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; const d = Math.hypot(dx, dy); if (sea[indexOf(nx, ny)]) seaNear += 1 / (1 + d); riverNear = Math.max(riverNear, river[indexOf(nx, ny)] / (1 + d)); } } for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { const nx = x + dx; const ny = y + dy; if (inside(nx, ny) && !sea[indexOf(nx, ny)]) sheltered += 1; } } const isDelta = (riverNear > 0.22 && coastalLowland[i] > 0.18) || deltaField[i] > 0.16; const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16; portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + deltaField[i] * 0.18 + depositionalLowland[i] * 0.08 + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16); } } for (let y = 3; y < MAP_H - 3; y++) { for (let x = 3; x < MAP_W - 3; x++) { const i = indexOf(x, y); if (sea[i]) continue; const r = river[i]; if (r < 0.2 || r > 1.85) continue; let bankPlain = 0; for (const [nx, ny] of neighbors8(x, y)) bankPlain += plain[indexOf(nx, ny)]; crossingSuitability[i] = clamp(r * 0.34 + (bankPlain / 8) * 0.54 + valleyField[i] * 0.18 - slope[i] * 0.55 - floodplain[i] * 0.06); } } for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i]) continue; const e = elevation[i]; if (e < 0.43 || e > 0.82) continue; const ewHigh = (elevation[indexOf(x - 3, y)] + elevation[indexOf(x + 3, y)]) / 2; const nsHigh = (elevation[indexOf(x, y - 3)] + elevation[indexOf(x, y + 3)]) / 2; const diagLow = Math.min( elevation[indexOf(x - 3, y - 3)], elevation[indexOf(x + 3, y + 3)], elevation[indexOf(x - 3, y + 3)], elevation[indexOf(x + 3, y - 3)] ); passSuitability[i] = clamp((Math.max(ewHigh, nsHigh) - e) * 2.2 + (e - diagLow) * 0.55 + valleyField[i] * 0.28 - ridgeField[i] * 0.18 - slope[i] * 0.2); } } 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 = Math.abs(elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]); const gy = Math.abs(elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]); const slopeBreak = clamp((gx + gy) * 3.2 + Math.max(0, slope[i] - 0.28) * 0.72); const majorRiver = clamp(Math.max(0, river[i] - 0.34) * 1.45 + Math.max(0, flowAccum[i] - 0.42) * 0.58); const basinRim = clamp(basinField[i] * Math.max(0, slope[i] - 0.16) * 1.25 + ridgeField[i] * basinField[i] * 0.32); naturalBarrierScore[i] = clamp( arcSpineField[i] * 0.80 + branchRidgeField[i] * 0.62 + ridgeField[i] * 0.54 + majorRiver * 0.62 + slopeBreak * 0.34 + basinRim * 0.36 - valleyField[i] * 0.30 - depositionalLowland[i] * 0.42 - coastalLowland[i] * 0.20 - plain[i] * 0.18 ); } } function countWaterComponents(mask, minArea = 1) { const seen = new Uint8Array(SIZE); let count = 0; for (let i = 0; i < SIZE; i++) { if (!mask[i] || seen[i]) continue; const queue = [i]; seen[i] = 1; let area = 0; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; area++; 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 (!mask[ni] || seen[ni]) continue; seen[ni] = 1; queue.push(ni); } } if (area >= minArea) count++; } return count; } function countSmallLandIslands(maxArea = 8) { const seen = new Uint8Array(SIZE); let count = 0; for (let i = 0; i < SIZE; i++) { if (sea[i] || seen[i]) continue; const queue = [i]; seen[i] = 1; let area = 0; let touchesEdge = false; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; area++; 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 (sea[ni] || seen[ni]) continue; seen[ni] = 1; queue.push(ni); } } if (!touchesEdge && area <= maxArea) count++; } return count; } const spineValues = [...arcSpineField].filter((_, i) => !sea[i]).sort((a, b) => b - a); const strongSpineSample = Math.max(1, Math.floor(spineValues.length * 0.05)); const primarySpineStrength = spineValues.slice(0, strongSpineSample).reduce((sum, value) => sum + value, 0) / strongSpineSample; const riverConnectivityRate = mainRivers.length ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && neighbors8(x, y).some(([nx, ny]) => sea[indexOf(nx, ny)] || lake[indexOf(nx, ny)]))).length / mainRivers.length : 0; const depositionLowlandArea = [...depositionalLowland].filter((value, i) => !sea[i] && value > 0.24).length; const terrainDebug = { primarySpineStrength, riverConnectivityRate, smallIslandCount: countSmallLandIslands(8), largeInlandLakeCount: countWaterComponents(Float32Array.from(lake, (value) => value ? 1 : 0), 120), depositionLowlandArea, }; return { terrainTemplate, seaLevel, elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, prefectureMask, prefectureBorder, prefectureRegionId, regionalDebug, terrainDebug, regionalPrefectureBorders, riverPaths, mainRivers, tributaryRivers, smallStreams, }; }