map/mapTerrain.v4.bak.js
2026-05-23 18:06:01 +09:00

1804 lines
78 KiB
JavaScript

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.18 + rand(seed, 41) * 0.72;
const erosion = 0.24 + rand(seed, 42) * 0.68;
const roughness = 0.34 + rand(seed, 43) * 0.62;
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.20 + rand(seed, 44) * 0.70;
const spineCount = 2 + Math.floor(rand(seed, 45) * 2);
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,
spineAngle: coastAngle + Math.PI * (0.28 + rand(seed, 46) * 0.44),
spineCurve: (rand(seed, 47) - 0.5) * 0.28,
spinePosition: (rand(seed, 48) - 0.5) * 0.56,
spineStrength: 0.56 + rand(seed, 49) * 0.32,
spineWidth: 0.034 + rand(seed, 50) * 0.036,
// v4: 個別の丸い山塊生成を主役にしない。山地は下の folded orogeny field で一括生成する。
secondaryMountainCount: 0,
secondaryMountainSize: 0.038 + rand(seed, 52) * 0.060,
secondaryMountainStrength: 0.40 + rand(seed, 53) * 0.25,
rangeBreakCount: 4 + Math.floor(rand(seed, 62) * 4),
rangeBreakWidth: 0.022 + rand(seed, 63) * 0.026,
rangeBreakStrength: 0.060 + rand(seed, 64) * 0.070,
plainNoiseSuppression: 0.34 + rand(seed, 65) * 0.22,
peakSoftStart: 0.87 + rand(seed, 66) * 0.045,
peakSoftCap: 0.982 + rand(seed, 67) * 0.014,
orographicStrength: 0.88 + rand(seed, 70) * 0.28,
orographicCoverage: 0.72 + rand(seed, 71) * 0.18,
foldDensity: 5.2 + rand(seed, 72) * 2.2,
foldSharpness: 1.65 + rand(seed, 73) * 0.85,
fluvialAggression: 1.16 + rand(seed, 74) * 0.44,
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.18 + rand(seed, 58) * 0.16,
inletStrength: 0.18 + rand(seed, 59) * 0.56,
plainWidth: sideAPlain,
},
{
penetration: 0.18 + rand(seed, 60) * 0.16,
inletStrength: 0.18 + rand(seed, 61) * 0.56,
plainWidth: sideBPlain,
},
],
deposition,
erosion,
roughness,
ridgeJaggedness,
ridgeBranchiness: 0.32 + rand(seed, 55) * 0.60,
detachedRangeCount: 0,
alpinePeakCount: 0,
};
}
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 itself with coherent long/mid waves, then apply ridge falloff.
const low = (valueNoise(along * 0.85 + ridge.seedOffset, ridge.seedOffset * 0.37, seed + 6100, 28) - 0.5) * 2;
const mid = (valueNoise(along * 1.7 - ridge.seedOffset, ridge.seedOffset * 0.23, seed + 6200, 13) - 0.5) * 2;
const sine = Math.sin(along * ridge.kinkFrequency + ridge.kinkPhase);
const curve = (ridge.curve || 0) * along * along * (along >= 0 ? 1 : -1);
const axisOffset = low * ridge.axisWobble + mid * ridge.axisWobble * 0.55 + sine * ridge.axisWobble * 0.25 + curve;
const widthNoise = 0.78 + valueNoise(along * 1.2 + ridge.seedOffset, ridge.seedOffset * 0.19, seed + 6300, 21) * ridge.widthVariation;
const localWidth = Math.max(0.006, ridge.width * widthNoise);
const jaggedPerp = perp - axisOffset;
const serration = 0.76 + valueNoise(x * 1.1 + along * 0.18, y * 1.1 + perp * 0.18, seed + ridge.seedOffset, 7) * 0.48;
return Math.exp(-(jaggedPerp * jaggedPerp) / (localWidth * localWidth)) * lengthFade * ridge.h * serration;
}
function spineFieldAt(x, y, template, spineIndex) {
const seed = template.seed || 0;
const spacing = spineIndex === 0 ? 0 : (spineIndex % 2 ? 0.30 : -0.30);
const angle = template.spineAngle + (spineIndex - 0.5) * 0.17 + (rand(seed, 700 + spineIndex) - 0.5) * 0.18;
const ridge = {
x: 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45,
y: 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45,
angle,
width: template.spineWidth * (0.82 + rand(seed, 710 + spineIndex) * 0.38),
length: 0.78 + rand(seed, 720 + spineIndex) * 0.28,
h: template.spineStrength * (0.24 + rand(seed, 730 + spineIndex) * 0.12),
curve: template.spineCurve,
axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 1.15),
kinkFrequency: 10 + rand(seed, 740 + spineIndex) * 18,
kinkPhase: rand(seed, 750 + spineIndex) * Math.PI * 2,
seedOffset: 7600 + spineIndex * 211,
widthVariation: 0.18 + template.ridgeJaggedness * 0.34,
};
return jaggedRidgeContribution(x, y, ridge, seed);
}
function buildSpineRidges(seed, template) {
const spines = [];
const branches = [];
for (let i = 0; i < template.spineCount; i++) {
const angle = template.spineAngle + (i - 0.5) * 0.17 + (rand(seed, 700 + i) - 0.5) * 0.18;
const spacing = i === 0 ? -0.18 : (i === 1 ? 0.22 : (i % 2 ? 0.42 : -0.42));
const longitudinalShift = (rand(seed, 705 + i) - 0.5) * 0.38;
const x = 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.48 + Math.cos(angle) * longitudinalShift;
const y = 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.48 + Math.sin(angle) * longitudinalShift;
spines.push({
x, y, angle,
width: template.spineWidth * (0.70 + rand(seed, 710 + i) * 0.34),
length: 0.52 + rand(seed, 720 + i) * 0.30,
h: template.spineStrength * (0.19 + rand(seed, 730 + i) * 0.10),
curve: template.spineCurve,
axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 1.15),
kinkFrequency: 10 + rand(seed, 740 + i) * 18,
kinkPhase: rand(seed, 750 + i) * Math.PI * 2,
seedOffset: 7600 + i * 211,
widthVariation: 0.18 + template.ridgeJaggedness * 0.34,
});
const branchCount = 4 + Math.floor(template.ridgeBranchiness * 6);
for (let b = 0; b < branchCount; b++) {
const along = (rand(seed, 810 + i * 31 + b) - 0.5) * 0.62;
const side = rand(seed, 820 + i * 31 + b) > 0.5 ? 1 : -1;
const branchAngle = angle + side * (0.55 + rand(seed, 830 + i * 31 + b) * 0.72);
branches.push({
x: x + Math.cos(angle) * along,
y: y + Math.sin(angle) * along,
angle: branchAngle,
width: template.spineWidth * (0.42 + rand(seed, 840 + i * 31 + b) * 0.36),
length: 0.16 + rand(seed, 850 + i * 31 + b) * 0.28,
h: template.spineStrength * (0.055 + template.ridgeBranchiness * 0.080 + rand(seed, 860 + i * 31 + b) * 0.050),
curve: template.spineCurve * 0.45,
axisWobble: template.spineWidth * (0.32 + template.ridgeJaggedness * 0.72),
kinkFrequency: 14 + rand(seed, 870 + i * 31 + b) * 20,
kinkPhase: rand(seed, 880 + i * 31 + b) * Math.PI * 2,
seedOffset: 8800 + i * 311 + b * 37,
widthVariation: 0.22 + template.ridgeJaggedness * 0.30,
});
}
}
return { spines, branches };
}
function softUpperClamp(value, start = 0.8, cap = 0.96) {
if (value <= start) return value;
const range = Math.max(0.001, cap - start);
const t = (value - start) / range;
return start + range * (1 - Math.exp(-t));
}
function elongatedFeatureContribution(x, y, feature, seed) {
const dx = x - feature.x;
const dy = y - feature.y;
const ca = Math.cos(feature.angle);
const sa = Math.sin(feature.angle);
const along = dx * ca + dy * sa;
const perp = -dx * sa + dy * ca;
const nAlong = along / Math.max(0.001, feature.length);
if (Math.abs(nAlong) > 1.35) return 0;
const alongFade = Math.exp(-nAlong * nAlong * 1.7);
const low = (valueNoise(along * 0.95 + feature.seedOffset, feature.seedOffset * 0.31, seed + 6400, 19) - 0.5) * 2;
const mid = (valueNoise(along * 1.75 - feature.seedOffset, feature.seedOffset * 0.21, seed + 6500, 9) - 0.5) * 2;
const axisOffset = low * feature.axisWobble + mid * feature.axisWobble * 0.45;
const localWidth = Math.max(0.008, feature.width * (0.84 + valueNoise(along * 1.15, feature.seedOffset, seed + 6600, 14) * feature.widthVariation));
const offsetPerp = perp - axisOffset;
return Math.exp(-(offsetPerp * offsetPerp) / (localWidth * localWidth)) * alongFade * feature.h;
}
function buildRangeBreaks(seed, template, spines) {
const rangeBreaks = [];
for (let i = 0; i < spines.length; i++) {
const spine = spines[i];
const count = Math.max(2, template.rangeBreakCount - 1 + Math.floor(rand(seed, 890 + i) * 3));
for (let b = 0; b < count; b++) {
const along = (rand(seed, 900 + i * 37 + b) - 0.5) * spine.length * 0.84;
const lateral = (rand(seed, 910 + i * 37 + b) - 0.5) * spine.width * 0.9;
rangeBreaks.push({
x: spine.x + Math.cos(spine.angle) * along + Math.cos(spine.angle + Math.PI / 2) * lateral,
y: spine.y + Math.sin(spine.angle) * along + Math.sin(spine.angle + Math.PI / 2) * lateral,
angle: spine.angle + (rand(seed, 920 + i * 37 + b) > 0.5 ? Math.PI / 2 : -Math.PI / 2) + (rand(seed, 930 + i * 37 + b) - 0.5) * 0.42,
width: template.rangeBreakWidth * (0.75 + rand(seed, 940 + i * 37 + b) * 0.75),
length: 0.12 + rand(seed, 950 + i * 37 + b) * 0.14,
h: template.rangeBreakStrength * (0.78 + rand(seed, 960 + i * 37 + b) * 0.55),
axisWobble: template.rangeBreakWidth * (0.18 + rand(seed, 970 + i * 37 + b) * 0.32),
widthVariation: 0.14 + rand(seed, 980 + i * 37 + b) * 0.24,
seedOffset: 9900 + i * 311 + b * 41,
});
}
}
return rangeBreaks;
}
function buildDetachedRanges(seed, template) {
const ranges = [];
const count = template.detachedRangeCount ?? 6;
for (let i = 0; i < count; i++) {
const quadrantX = i % 2 === 0 ? 0.24 : 0.76;
const quadrantY = Math.floor(i / 2) % 2 === 0 ? 0.24 : 0.76;
const free = rand(seed, 12000 + i) < 0.45;
const x = free ? 0.12 + rand(seed, 12010 + i) * 0.76 : quadrantX + (rand(seed, 12020 + i) - 0.5) * 0.28;
const y = free ? 0.12 + rand(seed, 12030 + i) * 0.76 : quadrantY + (rand(seed, 12040 + i) - 0.5) * 0.28;
const angle = template.spineAngle + (rand(seed, 12050 + i) - 0.5) * Math.PI * 0.95;
ranges.push({
x: clamp(x, 0.08, 0.92),
y: clamp(y, 0.08, 0.92),
angle,
width: 0.020 + rand(seed, 12060 + i) * 0.030,
length: 0.18 + rand(seed, 12070 + i) * 0.28,
h: 0.075 + rand(seed, 12080 + i) * 0.095,
curve: (rand(seed, 12090 + i) - 0.5) * 0.10,
axisWobble: 0.018 + template.ridgeJaggedness * 0.030,
kinkFrequency: 14 + rand(seed, 12100 + i) * 24,
kinkPhase: rand(seed, 12110 + i) * Math.PI * 2,
seedOffset: 12120 + i * 173,
widthVariation: 0.28 + template.ridgeJaggedness * 0.36,
});
}
return ranges;
}
function buildAlpinePeaks(seed, template, detachedRanges) {
const peaks = [];
const count = template.alpinePeakCount ?? 8;
for (let i = 0; i < count; i++) {
const attach = detachedRanges.length && rand(seed, 12300 + i) < 0.62;
const base = attach ? detachedRanges[i % detachedRanges.length] : null;
const along = base ? (rand(seed, 12310 + i) - 0.5) * base.length * 0.90 : 0;
const perp = base ? (rand(seed, 12320 + i) - 0.5) * base.width * 4.5 : 0;
const x = base ? base.x + Math.cos(base.angle) * along + Math.cos(base.angle + Math.PI / 2) * perp : 0.10 + rand(seed, 12330 + i) * 0.80;
const y = base ? base.y + Math.sin(base.angle) * along + Math.sin(base.angle + Math.PI / 2) * perp : 0.10 + rand(seed, 12340 + i) * 0.80;
peaks.push({
x: clamp(x, 0.06, 0.94),
y: clamp(y, 0.06, 0.94),
angle: base ? base.angle + (rand(seed, 12350 + i) - 0.5) * 0.9 : rand(seed, 12360 + i) * Math.PI * 2,
rx: 0.022 + rand(seed, 12370 + i) * 0.035,
ry: 0.012 + rand(seed, 12380 + i) * 0.024,
h: 0.070 + rand(seed, 12390 + i) * 0.100,
seedOffset: 12400 + i * 191,
});
}
return peaks;
}
// v4: 山塊を一つずつ置くのではなく、列島全体に折り畳み山地を一括合成する。
// 複数方向の褶曲波 + domain warp + 広域隆起で、日本風の「山がちな基盤」を作る。
function foldedOrogenyAt(px, py, seed, template, coastLower = 0) {
const baseAngle = template.spineAngle + (rand(seed, 13001) - 0.5) * 0.24;
const warpX = (fbm(px * 3.2 + 17, py * 3.2 - 31, seed + 13010) - 0.5) * 0.16;
const warpY = (fbm(px * 3.0 - 43, py * 3.0 + 19, seed + 13020) - 0.5) * 0.16;
const x = px + warpX;
const y = py + warpY;
let foldRidges = 0;
let foldMass = 0;
let crossCutValleys = 0;
const families = 4;
for (let k = 0; k < families; k++) {
const angle = baseAngle + (k - 1.5) * 0.31 + (rand(seed, 13100 + k) - 0.5) * 0.30;
const ca = Math.cos(angle);
const sa = Math.sin(angle);
const along = x * ca + y * sa;
const cross = -x * sa + y * ca;
const density = template.foldDensity * (0.70 + k * 0.14 + rand(seed, 13120 + k) * 0.18);
const phaseWarp = (valueNoise(px * 5.0 + k * 9, py * 5.0 - k * 7, seed + 13200 + k, 3.2) - 0.5) * Math.PI * 1.35;
const phase = cross * density * Math.PI * 2 + along * (1.0 + k * 0.22) + rand(seed, 13140 + k) * Math.PI * 2 + phaseWarp;
const crest = Math.pow(Math.max(0, 1 - Math.abs(Math.sin(phase))), template.foldSharpness + (k % 2) * 0.32);
const shoulder = Math.pow(0.5 + 0.5 * Math.cos(phase * 0.5 + k), 1.55);
const local = 0.82 + valueNoise(px * 2.0 + k * 13, py * 2.0 - k * 5, seed + 13300 + k, 2.2) * 0.34;
const weight = (0.15 + k * 0.026) * local;
foldRidges += crest * weight;
foldMass += (crest * 0.50 + shoulder * 0.28) * weight;
const valleyPhase = along * (density * 0.36) * Math.PI * 2 + cross * 1.45 + rand(seed, 13400 + k) * Math.PI * 2;
crossCutValleys += Math.pow(Math.max(0, 1 - Math.abs(Math.sin(valleyPhase))), 2.3) * 0.050;
}
const broadA = fbm(px * 0.95 + 23, py * 0.95 - 61, seed + 13500);
const broadB = valueNoise(px * 1.65 - 41, py * 1.65 + 17, seed + 13510, 2.0);
const tectonicEnvelope = clamp((broadA * 0.62 + broadB * 0.38 - 0.24) / 0.64);
const coastalAttenuation = lerp(1.0, 0.78, clamp(coastLower * 0.95));
const coverageFloor = template.orographicCoverage * 0.16;
const mass = clamp((foldMass * 0.98 + tectonicEnvelope * 0.42 + coverageFloor - crossCutValleys * 1.10) * coastalAttenuation);
const ridges = clamp((foldRidges * 1.62 + mass * 0.24 - crossCutValleys * 0.92) * coastalAttenuation);
const uplift = clamp((mass * 0.54 + ridges * 0.30) * template.orographicStrength);
return { uplift, ridges, valleys: clamp(crossCutValleys * 4.0) };
}
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.10 + (1 - terrainTemplate.deposition) * 0.12 + rand(seed, 13) * 0.09;
const { spines, branches } = buildSpineRidges(seed, terrainTemplate);
const detachedRanges = buildDetachedRanges(seed, terrainTemplate);
const alpinePeaks = buildAlpinePeaks(seed, terrainTemplate, detachedRanges);
const rangeBreaks = buildRangeBreaks(seed, terrainTemplate, spines);
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.275;
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);
}
const angle = nearSpine ? spine.angle + (rand(seed, 302 + i) - 0.5) * 0.75 : rand(seed, 303 + i) * Math.PI * 2;
const baseRadius = terrainTemplate.secondaryMountainSize * Math.min(MAP_W, MAP_H);
return {
x: clamp(x) * MAP_W,
y: clamp(y) * MAP_H,
angle,
rx: baseRadius * (0.95 + rand(seed, 300 + i) * 1.10),
ry: baseRadius * (0.34 + rand(seed, 301 + i) * 0.46),
h: terrainTemplate.secondaryMountainStrength * (0.11 + rand(seed, 400 + i) * 0.23),
};
});
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.62 + 180, y * 0.62 - 90, seed + 3101) - 0.5) * 13;
const warpY = (fbm(x * 0.62 - 70, y * 0.62 + 210, seed + 3201) - 0.5) * 13;
const wx = x + warpX;
const wy = y + warpY;
let mountains = 0;
for (const blob of mountainBlobs) {
const dx = wx - blob.x;
const dy = wy - blob.y;
const ca = Math.cos(blob.angle);
const sa = Math.sin(blob.angle);
const along = (dx * ca + dy * sa) / Math.max(1, blob.rx);
const perp = (-dx * sa + dy * ca) / Math.max(1, blob.ry);
const d2 = along * along + perp * perp;
const rugged = 0.82 + valueNoise(wx * 0.18 + blob.x, wy * 0.18 - blob.y, seed + 12600, 8) * 0.42;
mountains += Math.exp(-d2 * 2.55) * blob.h * rugged;
}
const px = wx / (MAP_W - 1);
const py = wy / (MAP_H - 1);
let spineRidges = 0;
for (let si = 0; si < spines.length; si++) spineRidges += jaggedRidgeContribution(px, py, spines[si], seed);
let branchRidges = 0;
for (const ridge of branches) branchRidges += jaggedRidgeContribution(px, py, ridge, seed);
let detachedRidges = 0;
for (const ridge of detachedRanges) detachedRidges += jaggedRidgeContribution(px, py, ridge, seed);
let alpineMassifs = 0;
for (const peak of alpinePeaks) {
const dx = px - peak.x;
const dy = py - peak.y;
const ca = Math.cos(peak.angle);
const sa = Math.sin(peak.angle);
const along = (dx * ca + dy * sa) / Math.max(0.002, peak.rx);
const perp = (-dx * sa + dy * ca) / Math.max(0.002, peak.ry);
const d2 = along * along + perp * perp;
const crag = 0.78 + valueNoise(px * 38 + peak.seedOffset, py * 38 - peak.seedOffset, seed + 12700, 5) * 0.52;
alpineMassifs += Math.exp(-d2 * 1.85) * peak.h * crag;
}
let rangeBreakField = 0;
for (const feature of rangeBreaks) rangeBreakField += elongatedFeatureContribution(px, py, feature, seed);
const ridges = Math.max(0, spineRidges + branchRidges + detachedRidges * 0.95 + alpineMassifs * 0.70 - rangeBreakField * 0.90);
const coast = coastPressureAt(x, y, wx, wy);
const coastLower = coast.pressure;
const folded = foldedOrogenyAt(px, py, seed, terrainTemplate, coastLower);
const orogenicUplift = folded.uplift;
const orogenicRidges = folded.ridges;
const orogenicValleys = folded.valleys;
// Four terrain-noise bands from continental structure to fine surface roughness.
const terrainLarge = fbm(wx * 0.36 + 40, wy * 0.36 - 60, seed + 710);
const terrainRegional = fbm(wx * 0.95 + 80, wy * 0.95 - 20, seed + 777);
const terrainLocal = fbm(wx * 2.05 + 17, wy * 2.05 - 31, seed + 1777);
const terrainFine = valueNoise(wx * 2.9 + 11, wy * 2.9 - 19, seed + 2444, 4.5);
const fineDissection = (Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035) * (0.68 + terrainTemplate.roughness * 0.74);
const basin = 0.1 * Math.sin((nx * 3.1 + ny * 1.7 + rand(seed, 15)) * Math.PI) - 0.045 * Math.cos((nx * 5.2 - ny * 3.6 + rand(seed, 16)) * Math.PI);
const protoHighland = clamp(orogenicUplift * 1.12 + orogenicRidges * 0.82 + spineRidges * 0.92 + branchRidges * 0.78 + detachedRidges * 0.70 + alpineMassifs * 0.70 + mountains * 0.38 - rangeBreakField * 1.80 - orogenicValleys * 0.38);
const protoLowland = clamp((1 - protoHighland) * 0.44 + coastLower * 0.24 + Math.max(0, -basin) * 0.24 + orogenicValleys * 0.26);
const plainNoiseSuppression = protoLowland * terrainTemplate.plainNoiseSuppression;
const subduedTerrainLocal = lerp(terrainLocal, 0.5, plainNoiseSuppression * 0.58);
const subduedTerrainFine = lerp(terrainFine, 0.5, plainNoiseSuppression * 0.78);
const subduedDissection = fineDissection * (1 - plainNoiseSuppression * 0.88);
const rawElevation =
0.30 * terrainLarge +
0.235 * terrainRegional +
0.105 * subduedTerrainLocal +
0.045 * subduedTerrainFine +
mountains * 0.12 +
orogenicUplift * 0.38 +
orogenicRidges * 0.13 +
spineRidges * 0.42 +
branchRidges * 0.44 +
detachedRidges * 0.26 +
alpineMassifs * 0.28 +
basin +
subduedDissection + orogenicRidges * 0.020 -
rangeBreakField * (0.44 + terrainTemplate.erosion * 0.18) -
coastLower * (coastStrength + 0.075 + terrainTemplate.deposition * 0.075) +
0.055;
const normalizedElevation = 0.5 + (rawElevation - 0.5) * 1.16;
elevation[i] = clamp(softUpperClamp(normalizedElevation, terrainTemplate.peakSoftStart, terrainTemplate.peakSoftCap));
arcSpineField[i] = clamp(orogenicRidges * 1.20 + orogenicUplift * 0.46 + spineRidges * 1.75 + detachedRidges * 1.00 + alpineMassifs * 0.80);
branchRidgeField[i] = clamp(branchRidges * 1.85 + orogenicValleys * 0.35);
ridgeField[i] = clamp(arcSpineField[i] * 0.82 + branchRidgeField[i] * 0.44 + orogenicRidges * 0.60 + orogenicUplift * 0.24 + Math.max(0, mountains - 0.10) * 0.18 + subduedDissection * 1.00 - rangeBreakField * 0.88 - orogenicValleys * 0.34);
basinField[i] = clamp(Math.max(0, -basin) * 2.2 + rangeBreakField * 1.60 + orogenicValleys * 0.82 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * (0.34 + terrainTemplate.deposition * 0.30));
moisture[i] = clamp(0.44 * fbm(wx + 400, wy - 200, seed + 333) + 0.18 * valueNoise(wx, wy, seed + 343, 11) + 0.22 * (1 - Math.abs(ny * 1.7)) + 0.28 * coastLower - Math.max(0, elevation[i] - 0.62) * 0.22);
}
}
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const coast = coastPressureAt(x, y);
const mountainToSea = ridgeField[i] * (1 - terrainTemplate.deposition) * 0.014;
const oceanSide = coast.pressure + mountainToSea > 0.10 + terrainTemplate.deposition * 0.030;
if (elevation[i] < seaLevel || oceanSide) sea[i] = 1;
if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.018 + hash2(x, y, seed + 2311) * 0.012);
}
}
// 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));
}
}
}
}
// Explicit alpine punctuation. The base ridge system defines broad relief,
// while these narrow, detached high points make several visually legible
// mountain groups instead of one round central mass.
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] || coastalLowland[i] > 0.42) continue;
const px = x / (MAP_W - 1);
const py = y / (MAP_H - 1);
let peakSignal = 0;
for (const peak of alpinePeaks) {
const dx = px - peak.x;
const dy = py - peak.y;
const ca = Math.cos(peak.angle);
const sa = Math.sin(peak.angle);
const along = (dx * ca + dy * sa) / Math.max(0.002, peak.rx);
const perp = (-dx * sa + dy * ca) / Math.max(0.002, peak.ry);
const d2 = along * along + perp * perp;
peakSignal += Math.exp(-d2 * 2.20) * peak.h;
}
if (peakSignal <= 0.026) continue;
const crag = Math.max(0, valueNoise(x * 2.4 + 73, y * 2.4 - 91, seed + 12880, 3.5) - 0.36);
const target = clamp(0.64 + peakSignal * 2.45 + crag * 0.085, seaLevel + 0.006, 0.982);
elevation[i] = Math.max(elevation[i], target);
ridgeField[i] = clamp(ridgeField[i] + peakSignal * 4.6 + crag * 0.28);
arcSpineField[i] = clamp(arcSpineField[i] + peakSignal * 3.2);
basinField[i] = Math.max(0, basinField[i] - peakSignal * 1.2);
depositionalLowland[i] = Math.max(0, depositionalLowland[i] - peakSignal * 1.5);
}
}
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.82 + moisture[i] * 0.88 + valleyField[i] * 0.78 + Math.max(0, elevation[i] - seaLevel) * 0.14;
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.91;
}
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.62 + Math.pow(flowAccum[i], 0.48) * 0.62);
}
// 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.58);
const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36;
const firstOrderPower = smoothstep((flowAccum[i] - 0.018) / 0.12);
const steepValley = clamp(terrainTemplate.fluvialAggression * firstOrderPower * flow * (0.026 + terrainTemplate.erosion * 0.046 + slope[i] * (0.105 + terrainTemplate.erosion * 0.105) + ridgeField[i] * (0.018 + terrainTemplate.erosion * 0.040)) * incisionNoise);
const lateralCut = clamp(terrainTemplate.fluvialAggression * firstOrderPower * Math.pow(flowAccum[i], 0.82) * valleyField[i] * (0.030 + terrainTemplate.erosion * 0.052));
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);
// Final orographic pass: ensure true alpine/high-mountain cells remain after
// river incision and lowland smoothing. Uplift is confined to ridge cores and
// fades out in valley floors so drainage still reads correctly.
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 ridgeCore = clamp(arcSpineField[i] * 0.74 + branchRidgeField[i] * 0.58 + ridgeField[i] * 0.42 - valleyField[i] * 0.34 - flowAccum[i] * 0.25);
const highBase = clamp((elevation[i] - 0.55) / 0.25);
const alpine = clamp(ridgeCore * 0.88 + highBase * 0.22 - coastalLowland[i] * 0.45 - depositionalLowland[i] * 0.36);
if (alpine <= 0.08) continue;
const summitTexture = Math.max(0, valueNoise(x * 2.7 + 31, y * 2.7 - 41, seed + 9771, 3.0) - 0.38);
const uplift = Math.pow(alpine, 1.55) * (0.032 + terrainTemplate.roughness * 0.040 + summitTexture * 0.032);
const summitCap = 0.955 + Math.min(0.040, ridgeCore * 0.040) + summitTexture * 0.018;
elevation[i] = clamp(elevation[i] + uplift, seaLevel + 0.006, summitCap);
ridgeField[i] = clamp(ridgeField[i] + uplift * 1.15);
erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.25);
}
}
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.24 + moisture[i] * 0.24 + ridgeField[i] * 0.035 + arcSpineField[i] * 0.035 + branchRidgeField[i] * 0.02 + flowAccum[i] * 1.05 + valleyField[i] * 0.54 + basinField[i] * 0.14 + coastalLowland[i] * 0.08 + hash2(x, y, seed + 9000) * 0.05;
if (elevation[i] > 0.30 && elevation[i] < 0.94 && moisture[i] > 0.18 && (flowAccum[i] > 0.004 || valleyField[i] > 0.045 || slope[i] > 0.20) && ridgeField[i] < 0.98) sourceCandidates.push({ x, y, score });
}
}
const sources = pickEntities(sourceCandidates, {
max: 38 + Math.floor(rand(seed, 910) * 24),
minDistance: 5,
threshold: 0.24 + rand(seed, 911) * 0.05,
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] * 1.24 -
flowAccum[i] * 1.18 -
moisture[i] * 0.22 -
coastalLowland[i] * 0.36
);
}
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.64 + path.length / 128 + flowAccum[i] * 0.92;
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.040 && flowAccum[ni] < flowAccum[i] + 0.020) 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.86 : 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] * 2.15 -
flowAccum[ni] * 1.34 -
moisture[ni] * 0.18 -
coastalLowland[ni] * 0.42 -
(river[ni] > 0 ? 0.34 : 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.50 + flowAccum[ri] * 0.68;
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 < 210; step++) {
const i = indexOf(x, y);
if (seen.has(i)) break;
seen.add(i);
path.push([x, y]);
river[i] += 0.026 + flowAccum[i] * 0.045;
if ((river[i] > 0.62 && path.length > 9) || 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.15 + Math.max(0, -drop) * 20 - Math.max(0, drop) * 1.7 - valleyField[ni] * 1.35 - flowAccum[ni] * 0.72 - moisture[ni] * 0.16 + Math.max(0, sameDirection) * 0.035 - Math.abs(dx * lastDy - dy * lastDx) * 0.024 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.065;
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: 30 + Math.floor(rand(seed, 915) * 22),
minDistance: 4,
threshold: 0.18,
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.92 + accum * 0.17);
}
}
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.095), {
max: 180 + Math.floor(rand(seed, 919) * 120),
minDistance: 2,
threshold: 0.075,
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.040) {
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) 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.79 && (valleyField[i] >= 0.13 || flowAccum[i] >= 0.030)) break;
start++;
}
return path.slice(start);
}
for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.032);
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.026);
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.46 + k / 170 + flowAccum[i] * 0.72;
}
}
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.020 + flowAccum[i] * 0.032;
}
}
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.26);
}
}
}
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] / 2.6);
const smallPower = smoothstep((r - 0.025) / 0.16);
const mediumPower = smoothstep((r - 0.20) / 0.34);
const largePower = smoothstep((r - 0.45) / 0.42);
const actionPower = clamp(smallPower * 0.18 + mediumPower * 0.52 + largePower * 0.92);
const channelCut = clamp(terrainTemplate.fluvialAggression * actionPower * Math.pow(r, 0.70) * (0.026 + terrainTemplate.erosion * 0.046 + slope[i] * (0.065 + terrainTemplate.erosion * 0.105) + ridgeField[i] * (0.012 + terrainTemplate.erosion * 0.042)));
const valleyWiden = clamp(terrainTemplate.fluvialAggression * (mediumPower * 0.35 + largePower * 0.75) * Math.pow(r, 0.86) * (0.010 + terrainTemplate.erosion * 0.024 + Math.max(0, elevation[i] - seaLevel) * (0.022 + terrainTemplate.erosion * 0.040) + valleyField[i] * (0.014 + terrainTemplate.erosion * 0.034)));
const alluvium = clamp((mediumPower * 0.32 + largePower * 0.70) * Math.pow(r, 0.86) * (coastalLowland[i] * (0.010 + terrainTemplate.deposition * 0.030) + basinField[i] * (0.007 + terrainTemplate.deposition * 0.020) + (slope[i] < 0.10 ? 0.004 + terrainTemplate.deposition * 0.012 : 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] / 2.6);
const radius = r > 0.62 ? 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 lateralPower = smoothstep((r - 0.28) / 0.45);
const carve = terrainTemplate.fluvialAggression * Math.max(0, weight) * lateralPower * (0.004 + terrainTemplate.erosion * 0.007 + r * (0.010 + terrainTemplate.erosion * 0.019)) * 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);
}
}
}
}
// 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.62) / 0.26);
const summit = high * clamp(ridgeField[i] * 1.4 - flowAccum[i] * 0.8);
const rugged = (valueNoise(x * 2.1 + 19, y * 2.1 - 23, seed + 9661, 3.2) - 0.5) * 0.035;
const uplift = summit * (0.018 + Math.max(0, rugged));
if (uplift > 0) {
fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 0.985);
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 < 4 + Math.round(terrainTemplate.deposition * 3); 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;
let localMin = 1;
let localMax = 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;
localMin = Math.min(localMin, elevation[ni]);
localMax = Math.max(localMax, elevation[ni]);
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 localRelief = localMax - localMin;
const flatBias = clamp(1 - localRelief / 0.10);
const terrace = Math.round(localMean * 42) / 42;
const target = lerp(localMean, terrace, 0.18 + flatBias * 0.24);
const flattenStrength = lowland * (0.32 + terrainTemplate.deposition * 0.24 + flatBias * 0.22);
nextElevation[i] = clamp(lerp(elevation[i], target, flattenStrength), 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);
}
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] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.028);
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.022);
for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1);
function pathKey(path) {
return path.map(([x, y]) => `${x},${y}`).join("|");
}
function buildPathCellSet(paths) {
const set = new Set();
for (const path of paths) for (const [x, y] of path) set.add(`${x},${y}`);
return set;
}
function riverPathStats(path) {
let maxRiver = 0;
let sumRiver = 0;
let maxFlow = 0;
let sumFlow = 0;
let populatedCorridor = 0;
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
maxRiver = Math.max(maxRiver, river[i]);
sumRiver += river[i];
maxFlow = Math.max(maxFlow, flowAccum[i]);
sumFlow += flowAccum[i];
populatedCorridor += plain[i] * 0.18 + valleyField[i] * 0.28 + coastalLowland[i] * 0.10 + basinField[i] * 0.08;
}
const [lx, ly] = path[path.length - 1];
const li = indexOf(lx, ly);
const outletToWater = Boolean(sea[li] || lake[li]);
const lowerReach = path.slice(Math.max(0, path.length - Math.min(path.length, 8)));
const lowerReachStrength = lowerReach.reduce((sum, [x, y]) => sum + river[indexOf(x, y)], 0) / Math.max(1, lowerReach.length);
const meanRiver = sumRiver / Math.max(1, path.length);
const meanFlow = sumFlow / Math.max(1, path.length);
const corridorMean = populatedCorridor / Math.max(1, path.length);
const score =
path.length * 0.92 +
maxRiver * 8.4 +
meanRiver * 4.4 +
maxFlow * 8.2 +
meanFlow * 2.8 +
lowerReachStrength * 3.2 +
corridorMean * 5.2 +
(outletToWater ? 5.0 : 0);
return { length: path.length, maxRiver, meanRiver, maxFlow, meanFlow, lowerReachStrength, corridorMean, outletToWater, score };
}
let rankedRivers = riverPaths
.map((path, i) => ({ path, score: riverScores[i] || 0, stats: riverPathStats(path), key: pathKey(path) }))
.filter((item) => item.path.length >= 5)
.sort((a, b) => (b.stats.score + b.score * 0.25) - (a.stats.score + a.score * 0.25));
let mainRivers = rankedRivers
.filter((item) => item.stats.length >= 8)
.slice(0, Math.min(8, rankedRivers.length))
.map((item) => item.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.55;
}
}
}
}
// v4: 急峻な地形では自然流下トレースが短く切れる seed があるため、
// 高地から海へ抜ける中〜大規模河川の骨格を数本だけ補完する。
if (mainRivers.length < 4 && sourceCandidates.length > 0) {
const usedKeys = new Set(mainRivers.map((path) => pathKey(path)));
const starts = sourceCandidates.slice()
.sort((a, b) => (b.score + elevation[indexOf(b.x, b.y)] * 0.8 + valleyField[indexOf(b.x, b.y)] * 0.6) - (a.score + elevation[indexOf(a.x, a.y)] * 0.8 + valleyField[indexOf(a.x, a.y)] * 0.6));
for (const start of starts) {
if (mainRivers.length >= 4) break;
const tooClose = mainRivers.some((path) => path.some(([px, py], k) => k % 8 === 0 && Math.hypot(px - start.x, py - start.y) < 10));
if (tooClose) continue;
const goal = nearestWaterGoal(start);
if (!goal) continue;
const path = aStar(start, goal, (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]);
return Math.max(0.22, 1 + uphill * 42 + slope[i] * 0.42 + elevation[i] * 0.32 - downhill * 2.4 - valleyField[i] * 1.65 - flowAccum[i] * 1.20 - moisture[i] * 0.18 - coastalLowland[i] * 0.38);
});
if (path.length < 9) continue;
const key = pathKey(path);
if (usedKeys.has(key)) continue;
usedKeys.add(key);
mainRivers.push(path);
riverPaths.push(path);
riverScores.push(path.length * 1.05);
for (let k = 0; k < path.length; k++) {
const [rx, ry] = path[k];
river[indexOf(rx, ry)] = Math.max(river[indexOf(rx, ry)], 0.62 + k / 180 + flowAccum[indexOf(rx, ry)] * 0.72);
}
}
}
const mainRiverCells = buildPathCellSet(mainRivers);
const mainRiverKeys = new Set(mainRivers.map((path) => pathKey(path)));
function pathTouchesMain(path) {
for (const [x, y] of path) {
if (mainRiverCells.has(`${x},${y}`)) return true;
for (const [nx, ny] of neighbors8(x, y)) {
if (mainRiverCells.has(`${nx},${ny}`)) return true;
}
}
return false;
}
rankedRivers = riverPaths
.map((path, i) => ({ path, score: riverScores[i] || 0, stats: riverPathStats(path), key: pathKey(path) }))
.filter((item) => item.path.length >= 5)
.sort((a, b) => (b.stats.score + b.score * 0.25) - (a.stats.score + a.score * 0.25));
const tributaryRivers = [];
const hiddenRiverPaths = [];
for (const item of rankedRivers) {
if (mainRiverKeys.has(item.key)) continue;
const joinsMain = pathTouchesMain(item.path);
const visibleMedium =
item.stats.score >= 18 &&
item.stats.length >= 7 &&
(joinsMain || item.stats.outletToWater || item.stats.maxRiver >= 0.95 || item.stats.lowerReachStrength >= 0.70);
if (visibleMedium) tributaryRivers.push(item.path);
else hiddenRiverPaths.push(item.path);
}
for (const path of mainRivers) {
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
river[i] = Math.max(river[i], 0.92 + k / 150 + flowAccum[i] * 0.96);
}
}
for (const path of tributaryRivers) {
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
river[i] = Math.max(river[i], 0.58 + k / 195 + flowAccum[i] * 0.62);
}
}
function traceFlowLinkedMinorStream(startX, startY, bonusSeed = 0) {
let x = startX;
let y = startY;
const path = [];
const seen = new Set();
for (let step = 0; step < 120; step++) {
const i = indexOf(x, y);
if (sea[i] || seen.has(i)) break;
seen.add(i);
path.push([x, y]);
if (path.length > 7 && river[i] > 0.42) break;
let next = flowTo[i];
if (next < 0 || next === i || sea[next]) break;
let best = next;
let bestScore = elevation[next] * 1.05 - flowAccum[next] * 0.85 - valleyField[next] * 1.20 - moisture[next] * 0.10;
const cx = x;
const cy = y;
// Micro-streams can braid into the closest descent when flowTo falls into a tiny sink.
for (const [nx, ny] of neighbors8(cx, cy)) {
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
const uphill = Math.max(0, elevation[ni] - elevation[i]);
if (uphill > 0.024 && flowAccum[ni] < flowAccum[i] + 0.006) continue;
const score = elevation[ni] * 1.05 + uphill * 16 - flowAccum[ni] * 0.82 - valleyField[ni] * 1.22 - moisture[ni] * 0.10 + (hash2(nx, ny, seed + bonusSeed + step * 19) - 0.5) * 0.035;
if (score < bestScore) {
bestScore = score;
best = ni;
}
}
if (best < 0 || best === i) break;
x = best % MAP_W;
y = Math.floor(best / MAP_W);
}
return path;
}
const minorCandidateCells = [];
for (let y = 3; y < MAP_H - 3; y += 1) {
for (let x = 3; x < MAP_W - 3; x += 1) {
const i = indexOf(x, y);
if (sea[i]) continue;
if (elevation[i] < 0.30 || elevation[i] > 0.96) continue;
const drainage = valleyField[i] * 0.52 + Math.pow(flowAccum[i], 0.48) * 0.38 + moisture[i] * 0.18 + slope[i] * 0.08 - ridgeField[i] * 0.10;
const stochastic = hash2(x, y, seed + 9340);
if (drainage > 0.085 && stochastic > 0.10) {
minorCandidateCells.push({ x, y, score: drainage + stochastic * 0.055 });
}
}
}
const minorSources = pickEntities(minorCandidateCells, {
max: 360 + Math.floor(rand(seed, 9341) * 220),
minDistance: 2,
threshold: 0.070,
seed: seed + 9342,
jitter: 0.02,
});
const derivedSmallStreams = [];
const occupiedMinorStarts = new Set();
for (const source of minorSources) {
const startKey = `${source.x},${source.y}`;
if (occupiedMinorStarts.has(startKey)) continue;
const path = traceFlowLinkedMinorStream(source.x, source.y, 11000 + source.x * 13 + source.y * 17);
if (path.length >= 3) {
derivedSmallStreams.push(path);
for (const [x, y] of path.slice(0, 4)) occupiedMinorStarts.add(`${x},${y}`);
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
river[i] = Math.max(river[i], 0.045 + Math.min(0.16, flowAccum[i] * 0.10) + Math.min(0.055, k / 1900));
}
}
}
const smallStreams = streamPaths.filter((path) => path.length >= 4)
.concat(hiddenRiverPaths.filter((path) => path.length >= 5))
.concat(derivedSmallStreams);
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,
smallStreamCount: smallStreams.length,
};
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,
};
}