From 1be3d86da04922f9ba78c5003809a06077b85ca3 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 22 May 2026 19:28:39 +0900 Subject: [PATCH 1/6] terrain update. broken but ok --- PATCH_NOTES_JP.md | 136 ++++++ app.js | 6 +- mapFeatures.js | 1083 ++++++++++++++++++++++++++++++++++++++++----- mapOutput.js | 475 +++++++++++++++++++- mapTerrain.js | 547 ++++++++++++++++++----- mapUtils.js | 6 +- renderer.js | 209 +++++++-- 7 files changed, 2208 insertions(+), 254 deletions(-) create mode 100644 PATCH_NOTES_JP.md diff --git a/PATCH_NOTES_JP.md b/PATCH_NOTES_JP.md new file mode 100644 index 0000000..8ff3f27 --- /dev/null +++ b/PATCH_NOTES_JP.md @@ -0,0 +1,136 @@ +# 修正版メモ + +## 目的 + +日本風地図ジェネレータを、将来のシームレス・パン生成に寄せるため、中央の1県だけに注目する描画を弱め、全域を同格の地図として読める方向へ変更した。 +あわせて、論理解像度を上げ、地形生成では日本列島風の複数並行脊梁山脈と大規模な沖積低地が出やすくなるよう調整した。 + +## 変更点 + +### 1. 中央県フォーカスの解除 + +- `renderer.js` + - `blendOutside()` による県外減光を停止。 + - 通常表示では `prefectureBorder` ではなく `regionalPrefectureBorders` を主境界として描画。 + - 中央県境の強調表示は `admin-debug` / `borders-debug` のみに限定。 + - 隣接県ディテールの `globalAlpha` を `0.78` から `1.0` へ変更。 + - 隣接県ラベル表示数を増加。 + - 県名ラベルを `modern` / `roads` / `development` / `landuse` でも表示。 + +### 2. 論理解像度の向上 + +- `mapUtils.js` + - `MAP_W: 172 -> 258` + - `MAP_H: 122 -> 183` + - `CELL_SIZE: 6 -> 4` + - 表示キャンバスサイズは概ね維持しつつ、地形・河川・境界の格子感を減らす構成。 + +- `mapOutput.js` + - `scaleKmPerCell: 172 / MAP_W` を追加し、スケールバーの見た目を旧スケールに近づけた。 + +### 3. 複数並行脊梁山脈 + +- `mapTerrain.js` + - `spineCount` を従来の `1〜2本` から `2〜4本` に変更。 + - `spineSpacing` を追加。 + - 脊梁山脈の向きを海岸・列島軸に沿う方向へ寄せた。 + - 本数増加に合わせて、1本ごとの幅・強度をやや抑制。 + - `buildSpineRidges()` を中心線からの並行オフセット配置に変更。 + +### 4. 大規模沖積平野 + +- `mapTerrain.js` + - 強い河川の下流部を抽出し、下流〜河口周辺に広めの堆積低地を追加。 + - `depositionalLowland`, `deltaField`, `floodplain`, `valleyField`, `basinField` を連動更新。 + - 標高面も局所的に低く・滑らかに補正し、単なる色塗りではなく地形として平野化しやすくした。 + +## 注意点 + +- 論理解像度を約2.25倍にしたため、生成時間は旧版より伸びる。 +- `prefectureMask` 自体は統計・行政デバッグ用に残している。通常描画で目立たせない方針にした。 +- まだ完全なシームレス・パン生成ではない。今回の変更は、その前段階として「中央県主役の見た目」を解除し、地形解像度と地形構造を整えたもの。 + +## 簡易確認 + +Node.js で `generateMap()` が実行できることを確認済み。 + +```bash +node --input-type=module -e "import('./mapPipeline.js').then(({generateMap})=>console.log(generateMap(114514,{slim:true}).width))" +``` + +## 2026-05-22 地形生成パイプライン再構成 + +ユーザー指摘に基づき、地形生成の考え方を「既存ノイズ地形に山脈を足す」方式から、以下の段階式に寄せた。 + +1. 全体を海面下の地形として初期化 +2. 複数の平行な脊梁山脈で地殻を隆起 +3. 海面付近にランダムな平坦面を残し、氷期の海面低下・再海進後の段丘/沖積低地の種にする +4. 大域・中域・微細ノイズを後段で加算 +5. 河川作用を粗くシミュレートし、急峻な高流量セルでは侵食、低勾配の海岸・盆地・低地では堆積を強化 +6. その後、人文地理生成へ渡す + +主な実装修正: + +- `mapTerrain.js` + - 脊梁山脈の生成を、狭い稜線 + 広い隆起帯の二層構造に変更。 + - 中央部が一枚の高原に見えないよう、山脈間の低地・盆地を明示的に残すよう修正。 + - 海岸線生成を、旧来の方向性 `coastPressure` 判定中心から、標高が海面を超えるかどうかを主とする方式へ変更。 + - 海面付近に `glacialFlatLevel` を導入し、ランダムな平坦面を形成。 + - 河川網生成前に、流量・勾配に基づく粗い侵食/堆積パスを追加。 + - 河川経路の後処理で河川が短く切れすぎる問題を緩和し、主要河川が海へ接続しやすいよう修正。 + +確認: + +- `generateMap(114514, { slim: true })` の実行を確認。 +- `terrainDebug.riverConnectivityRate` が `1` になることを確認。 +- `depositionLowlandArea` が前版より広くなり、沖積低地が出やすくなった。 + +## 2026-05-22: 不連続・非中央固定の山脈生成に修正 + +### 問題 +- 脊梁山脈が画面中央を通る連続した帯になりやすかった。 +- 広い隆起帯が強すぎ、山頂部が平坦な高原に見えた。 +- 主脊梁以外の補助山地・補助山脈が弱く、地形構造が単調だった。 + +### 修正 +- 主脊梁山脈を 2〜3 系統にし、互いの間隔を広げた。 +- `spinePosition` と各山脈の横方向オフセットを強め、中央固定を緩和した。 +- 山脈を一本の連続線ではなく、沿線方向のノイズと波で途切れる構造にした。 +- 主脊梁の broad uplift を弱め、尾根本体を細くした。 +- 尾根の蛇行量・曲率・幅変化を強めた。 +- 渥美半島・北上高地的な補助山地/補助山脈として `auxRanges` を追加した。 +- 円形山塊の数を増やし、主脊梁周辺に副次的な高地が出やすいようにした。 + +### 意図 +地図上で「太い中央帯」ではなく、 + +- 離れた複数の山地帯 +- 断続的な主稜線 +- 周辺の補助山地 +- 山地間の低地・海岸低地 + +として読めるようにする。 + +## 追加修正: 地形帯の形状を現実の山地分布に寄せる + +前回版では、山脈が周期的な波線として見え、画面中央付近に太い帯状山地が出やすかった。これは、脊梁山脈の中心線を正弦波的に揺らし、細い `arcSpineField` を描画色に強く反映していたため。 + +今回の修正では以下を行った。 + +- 脊梁山脈の正弦波的な中心線揺れを廃止。 +- 山脈の途切れは周期波ではなく、低周波ノイズによる山塊の強弱として表現。 +- 主山脈は中央固定ではなく、島の横断方向に広くオフセットするよう変更。 +- 中国山地・四国山地のように、2本の山地帯が大きく離れるケースを増加。 +- 主山脈の長さを短めにし、画面端から端まで連続する一本線になりにくくした。 +- 渥美半島・北上高地のような補助山地を、主山脈周辺の短い山塊・外帯山地として追加。 +- 山頂が平らな高原に見える原因だった広域隆起成分を弱め、山地内部に侵食状の起伏を追加。 +- 描画側では `arcSpineField` の直接発色を弱め、標高・広域山地性・陰影で山地を読ませる方式に変更。 +- 山地色をやや濃くし、低い緑地との明度差を広げた。 + +## 追加修正: alpine_bands + +- 前版で高山帯がほぼ出なくなった問題を修正。 +- 脊梁山脈・補助山地の隆起量を再調整し、浸食後にも高標高セルが残るようにした。 +- 河川作用による侵食量をやや抑え、峰・稜線部分だけに「高山帯再強化」パスを追加。 +- 高山帯が平らな台地にならないよう、標高の持ち上げには局所ノイズを混ぜている。 +- 地形色パレットを調整し、標高と ridge field が高い地点はより茶色く表示される。 diff --git a/app.js b/app.js index aeeb5c9..6335277 100644 --- a/app.js +++ b/app.js @@ -62,6 +62,9 @@ function getStats(map) { ["Castles", countText(map.castles)], ["Premodern Roads", map.premodernRoads.length], ["Minor Roads", map.minorRoads.length], + ["Prefecture", map.prefectureName || "-"], + ["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"], + ["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"], ["Prefectural Capital", map.prefecturalCapital?.name || "-"], ["Modern Cities", countText(map.modernCities)], ["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`], @@ -72,7 +75,8 @@ function getStats(map) { ["Harbor Works", (map.harborWorks || []).length], ["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length], ["Industrial Zones", countText(map.industrialZones)], - ["National Roads", map.nationalRoads.length + (map.ringRoads || []).length], + ["National Roads", `${map.nationalRoads.length} / pop cover ${Math.round((map.transportDebug?.nationalRoadPopulationCoverage || 0) * 100)}% / uncovered ${(map.transportDebug?.nationalRoadUncoveredPopulation || 0).toLocaleString()}`], + ["General Ring Roads", (map.ringRoads || []).length], ["Expressways", map.expressways.length + map.externalExpressways.length], ["External Gateways", map.externalGateways.length], ["Interchanges", countText(map.interchanges)], diff --git a/mapFeatures.js b/mapFeatures.js index 32f84de..9cded76 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -415,6 +415,249 @@ export function generateMapFeatures(seed, terrain) { return clamp(1 - Math.abs(d - 0.38) / 0.38); } + function transportTier(p) { + const pop = p?.population || 0; + if (p?.isPrefecturalCapital || p?.rank === "Prefectural Capital") return 0; + if (pop >= 900000) return 1; + if (pop >= 360000) return 2; + if (pop >= 180000) return 3; + if (pop >= 90000) return 4; + if (p?.portClass === "major") return 2; + if (p?.portClass === "regional") return 3; + if (p?.kind === "Market Town") return 4; + if (p?.kind?.includes("Castle")) return 5; + return 6; + } + + function nodeKey(p) { + return `${p.x},${p.y}`; + } + + function addUniqueNode(list, node) { + if (!node) return; + const key = nodeKey(node); + if (!list.some((p) => nodeKey(p) === key)) list.push(node); + } + + function pointLineDistanceXY(x, y, a, b) { + const vx = b.x - a.x; + const vy = b.y - a.y; + const len2 = vx * vx + vy * vy; + if (len2 <= 0.0001) return Math.hypot(x - a.x, y - a.y); + const t = clamp(((x - a.x) * vx + (y - a.y) * vy) / len2, 0, 1); + return Math.hypot(x - (a.x + vx * t), y - (a.y + vy * t)); + } + + function segmentProgressXY(x, y, a, b) { + const vx = b.x - a.x; + const vy = b.y - a.y; + const len2 = vx * vx + vy * vy; + if (len2 <= 0.0001) return 0; + return clamp(((x - a.x) * vx + (y - a.y) * vy) / len2, 0, 1); + } + + function cellTransportCorridorScore(x, y, mode = "road") { + if (!inside(x, y)) return -INF; + const i = indexOf(x, y); + if (sea[i]) return -INF; + const barrier = mountainBarrierPenalty(x, y, mode === "express" ? "express" : mode === "rail" ? "rail" : "road"); + if (barrier >= INF) return -INF; + const density = densityValue(x, y); + const midDensity = midDensityAffinity(x, y); + const lowland = plain[i] * 0.42 + basinField[i] * 0.24 + coastalLowland[i] * 0.22 + valleyField[i] * 0.34 + agriculture[i] * 0.08; + const terrainCost = slope[i] * (mode === "rail" ? 1.35 : mode === "express" ? 1.18 : 1.0) + + ridgeField[i] * 0.62 + + Math.max(0, elevation[i] - (mode === "rail" ? 0.50 : 0.56)) * 1.18 + + barrier * (mode === "rail" ? 0.0048 : mode === "express" ? 0.0036 : 0.0038); + if (mode === "rail") return density * 1.95 + lowland + coastalLowland[i] * 0.22 + valleyField[i] * 0.24 - terrainCost; + if (mode === "express") return density * 0.82 + midDensity * 1.10 + lowland * 0.62 - Math.max(0, density - 0.88) * 1.35 - terrainCost; + return density * 1.28 + midDensity * 0.28 + lowland + valleyField[i] * 0.18 - terrainCost; + } + + function sampleCorridorValue(a, b, mode = "road", samples = 14) { + let total = 0; + let count = 0; + for (let k = 1; k < samples; k++) { + const t = k / samples; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!inside(x, y)) continue; + const score = cellTransportCorridorScore(x, y, mode); + if (score <= -INF / 2) continue; + total += score; + count++; + } + return count ? total / count : -3; + } + + function pairTransportScore(a, b, mode = "road") { + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < 4) return -INF; + const demand = Math.sqrt(Math.max(0.01, transportDemand(a)) * Math.max(0.01, transportDemand(b))); + const hierarchyDelta = Math.max(0, transportTier(b) - transportTier(a)); + const corridor = sampleCorridorValue(a, b, mode); + const sameCorridor = sameCorridorAffinity(a, b); + const distancePenalty = mode === "express" ? d / 58 : mode === "rail" ? d / 48 : d / 42; + const hierarchyBonus = hierarchyDelta * (mode === "express" ? 0.16 : 0.10); + const portBonus = (a.portClass || b.portClass) ? (mode === "rail" ? 0.34 : mode === "express" ? 0.18 : 0.26) : 0; + return demand * (mode === "express" ? 1.08 : mode === "rail" ? 1.18 : 1.0) + corridor * 0.72 + sameCorridor + hierarchyBonus + portBonus - distancePenalty; + } + + function buildHierarchicalLinks(nodes, { mode = "road", maxLinks = 10, extraLinks = 3, minDistance = 10, maxDistance = 70, maxDegree = 3, seedOffset = 0 } = {}) { + const unique = []; + const seen = new Set(); + for (const node of nodes.filter(Boolean)) { + const i = indexOf(node.x, node.y); + if (!inside(node.x, node.y) || sea[i]) continue; + const key = nodeKey(node); + if (seen.has(key)) continue; + seen.add(key); + unique.push({ ...node, transportTier: transportTier(node), demand: transportDemand(node) }); + } + const ranked = unique.sort((a, b) => a.transportTier - b.transportTier || b.demand - a.demand || b.score - a.score); + const degree = new Map(); + const usedPairs = new Set(); + const links = []; + + function pairKey(a, b) { + const ak = nodeKey(a); + const bk = nodeKey(b); + return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`; + } + function tryAdd(a, b, force = false) { + if (!a || !b || nodeKey(a) === nodeKey(b)) return false; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < minDistance || d > maxDistance) return false; + const key = pairKey(a, b); + if (usedPairs.has(key)) return false; + if (!force && (getDegree(degree, a) >= maxDegree || getDegree(degree, b) >= maxDegree)) return false; + usedPairs.add(key); + incrementDegree(degree, a); + incrementDegree(degree, b); + links.push({ a, b, score: pairTransportScore(a, b, mode), distance: d }); + return true; + } + + for (let i = 1; i < ranked.length && links.length < maxLinks; i++) { + const child = ranked[i]; + const parentCandidates = ranked.slice(0, i) + .filter((parent) => transportTier(parent) <= transportTier(child) && Math.hypot(parent.x - child.x, parent.y - child.y) <= maxDistance) + .map((parent) => ({ parent, score: pairTransportScore(parent, child, mode) - getDegree(degree, parent) * 0.16 - Math.max(0, getDegree(degree, child) - 1) * 0.22 })) + .sort((a, b) => b.score - a.score); + if (parentCandidates[0]) tryAdd(parentCandidates[0].parent, child, true); + } + + const candidates = []; + for (let i = 0; i < ranked.length; i++) { + for (let j = i + 1; j < ranked.length; j++) { + const a = ranked[i]; + const b = ranked[j]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < minDistance || d > maxDistance) continue; + candidates.push({ a, b, score: pairTransportScore(a, b, mode) + hash2(a.x + b.x, a.y + b.y, seed + seedOffset + i * 31 + j * 37) * 0.05 }); + } + } + candidates.sort((a, b) => b.score - a.score); + let addedExtra = 0; + for (const c of candidates) { + if (links.length >= maxLinks || addedExtra >= extraLinks) break; + if (tryAdd(c.a, c.b)) addedExtra++; + } + return links; + } + + function pickCorridorWaypoints(a, b, mode = "road", maxCount = 2) { + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < 20) return []; + const width = mode === "express" ? 10.5 : mode === "rail" ? 8.5 : 9.5; + const minProgress = 0.18; + const maxProgress = 0.82; + const candidates = []; + const minX = Math.max(1, Math.floor(Math.min(a.x, b.x) - width - 3)); + const maxX = Math.min(MAP_W - 2, Math.ceil(Math.max(a.x, b.x) + width + 3)); + const minY = Math.max(1, Math.floor(Math.min(a.y, b.y) - width - 3)); + const maxY = Math.min(MAP_H - 2, Math.ceil(Math.max(a.y, b.y) + width + 3)); + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + const progress = segmentProgressXY(x, y, a, b); + if (progress < minProgress || progress > maxProgress) continue; + const lineD = pointLineDistanceXY(x, y, a, b); + if (lineD > width) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const cellScore = cellTransportCorridorScore(x, y, mode); + if (cellScore <= -INF / 2) continue; + const centerBias = -Math.abs(progress - 0.5) * 0.15; + const linePenalty = lineD / width * (mode === "express" ? 0.42 : 0.32); + const density = densityValue(x, y); + const densityGate = mode === "express" ? midDensityAffinity(x, y) * 0.22 : density * 0.20; + const score = cellScore + densityGate + centerBias - linePenalty + hash2(x, y, seed + 6400 + mode.length * 101) * 0.05; + candidates.push({ x, y, score, progress, kind: `${mode} corridor waypoint` }); + } + } + if (!candidates.length) return []; + const count = Math.min(maxCount, d > 62 ? 2 : 1); + return pickEntities(candidates, { + max: count, + minDistance: Math.max(7, Math.floor(d / 4.2)), + threshold: mode === "express" ? -0.42 : -0.30, + seed: seed + 6500 + Math.round(a.x * 13 + a.y * 17 + b.x * 19 + b.y * 23), + }).sort((p, q) => p.progress - q.progress); + } + + function makeDensityAwareTransportCost(baseCost, mode, guidePoints = []) { + return (x, y, cx, cy) => { + const base = baseCost(x, y, cx, cy); + if (base >= INF) return base; + const density = densityValue(x, y); + const midDensity = midDensityAffinity(x, y); + const cityDistance = distanceToNearest(modernCities, x, y); + let guidePull = 0; + for (const p of guidePoints) guidePull = Math.max(guidePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 5.8)); + if (mode === "rail") { + const lowDemandPenalty = Math.max(0, 0.08 - density) * 2.4; + return Math.max(0.30, base + lowDemandPenalty - density * 0.72 - guidePull * 0.34); + } + if (mode === "express") { + const coreAvoid = cityDistance < 2.6 ? 5.0 : cityDistance < 5.8 ? 1.4 : 0; + const lowDemandPenalty = Math.max(0, 0.10 - density) * 3.2; + return Math.max(0.42, base + lowDemandPenalty + coreAvoid - midDensity * 0.36 - Math.min(density, 0.72) * 0.18 - guidePull * 0.20); + } + const lowDemandPenalty = Math.max(0, 0.06 - density) * 1.5; + return Math.max(0.28, base + lowDemandPenalty - density * 0.44 - midDensity * 0.12 - guidePull * 0.26); + }; + } + + function routeThroughTransportCorridor(a, b, mode, baseCost, existingPaths, hubs, avoidPoints, options = {}) { + const start = routePoint(a, mode, a.x * 31 + a.y * 37 + (options.salt || 0)); + const goal = routePoint(b, mode, b.x * 31 + b.y * 37 + 17 + (options.salt || 0)); + const via = pickCorridorWaypoints(start, goal, mode, options.maxWaypoints ?? (mode === "express" ? 1 : 2)); + const terminals = [start, ...via, goal]; + const endpointSet = [start, goal, ...via]; + const guidedBaseCost = makeDensityAwareTransportCost(baseCost, mode, via); + const path = []; + for (let i = 0; i < terminals.length - 1; i++) { + const from = terminals[i]; + const to = terminals[i + 1]; + const cost = makeTransportCost( + guidedBaseCost, + [...existingPaths, path], + hubs, + endpointSet, + options.corridorRadius ?? (mode === "express" ? 5 : mode === "rail" ? 5 : 3), + options.corridorStrength ?? (mode === "express" ? 9.4 : mode === "rail" ? 8.8 : 5.8), + avoidPoints, + options.avoidRadius ?? (mode === "express" ? 8.0 : mode === "rail" ? 2.5 : 3.2), + options.avoidStrength ?? (mode === "express" ? 12.0 : mode === "rail" ? 4.2 : 5.4), + ); + const segment = aStar(from, to, cost); + if (segment.length < 2) return { path: [], via, start, goal }; + if (path.length) path.push(...segment.slice(1)); + else path.push(...segment); + } + return { path, via, start, goal }; + } + function nearPassPoint(x, y, radius = 5) { return distanceToNearest(passes, x, y) <= radius; } @@ -440,8 +683,8 @@ export function generateMapFeatures(seed, terrain) { function transportAccessPoint(node, mode = "road", salt = 0) { if (!node || nearMapEdge(node.x, node.y, 1) || node.kind === "External Gateway") return node; - const minR = mode === "express" ? 6 : mode === "rail" ? 2 : 4; - const maxR = mode === "express" ? 16 : mode === "rail" ? 6 : 10; + const minR = mode === "express" ? 6 : mode === "rail" ? 2 : 3; + const maxR = mode === "express" ? 16 : mode === "rail" ? 6 : 8; let best = null; let bestScore = -INF; for (let dy = -maxR; dy <= maxR; dy++) { @@ -515,17 +758,21 @@ export function generateMapFeatures(seed, terrain) { const railHubs = [...modernCities, ...commercialPorts]; function addRailRoute(a, b, bucket = railways) { - const start = routePoint(a, "rail", a.x * 19 + a.y * 23); - const goal = routePoint(b, "rail", b.x * 19 + b.y * 23 + 11); const existingRails = [...railways, ...branchRailways]; - const cost = makeTransportCost(railCost, existingRails, railHubs, [start, goal], 5, 10.5, townAvoidNodes, 2.4, 4.2); - const path = aStar(start, goal, cost); + const { path } = routeThroughTransportCorridor(a, b, "rail", railCost, existingRails, railHubs, townAvoidNodes, { + salt: a.x * 19 + a.y * 23 + b.x * 7 + b.y * 11, + maxWaypoints: bucket === railways ? 2 : 1, + corridorRadius: 5, + corridorStrength: bucket === railways ? 10.8 : 8.4, + avoidRadius: 2.4, + avoidStrength: 4.2, + }); const length = pathLength(path); const direct = pathEndpointDistance(path); const overlap = pathOverlapRatio(path, existingRails, 2); - const densityPurpose = averagePathField(path, populationDensity) + averagePathField(path, plain) * 0.28 + averagePathField(path, valleyField) * 0.2; + const densityPurpose = averagePathField(path, populationDensity) * 1.22 + averagePathField(path, plain) * 0.24 + averagePathField(path, valleyField) * 0.22 + averagePathField(path, coastalLowland) * 0.16; const isMain = bucket === railways; - if (path.length > 3 && direct >= (isMain ? 18 : 12) && length >= (isMain ? 22 : 14) && pathCompactness(path) < (isMain ? 3.1 : 3.4) && overlap < (isMain ? 0.30 : 0.20) && densityPurpose > (isMain ? 0.18 : 0.12)) { + if (path.length > 3 && direct >= (isMain ? 18 : 12) && length >= (isMain ? 22 : 14) && pathCompactness(path) < (isMain ? 3.25 : 3.5) && overlap < (isMain ? 0.32 : 0.22) && densityPurpose > (isMain ? 0.20 : 0.13)) { bucket.push(path); incrementDegree(railDegree, a); incrementDegree(railDegree, b); @@ -535,20 +782,38 @@ export function generateMapFeatures(seed, terrain) { } const transportCities = modernCities.filter((city) => (city.population || 0) >= 120000); - const mainRailTargets = transportCities.filter((city) => city !== capital).slice(0, 2 + Math.floor(rand(seed, 1070) * 3)); - for (const city of mainRailTargets) { - const anchor = nearestConnectable(railCore, city, railDegree, 3) || capital; - if (addRailRoute(anchor, city, railways)) railCore.push(city); - } - for (const city of modernCities.filter((city) => city !== capital && !mainRailTargets.includes(city))) { - const anchor = nearestConnectable(railCore, city, railDegree, 2) || capital; - if (anchor && rand(seed, city.x * 10 + city.y) > 0.2) { - if (addRailRoute(city, anchor, branchRailways)) railCore.push(city); + const railBackboneNodes = [capital, ...transportCities, ...commercialPorts.filter((p) => p.portClass !== "fishing")]; + const mainRailLinks = buildHierarchicalLinks(railBackboneNodes, { + mode: "rail", + maxLinks: 3 + Math.floor(rand(seed, 1070) * 3), + extraLinks: 1, + minDistance: 16, + maxDistance: 72, + maxDegree: 3, + seedOffset: 1070, + }); + for (const link of mainRailLinks) { + if (addRailRoute(link.a, link.b, railways)) { + addUniqueNode(railCore, link.a); + addUniqueNode(railCore, link.b); } } - for (const port of majorPorts.slice(0, 1 + Math.floor(rand(seed, 1071) * 2))) { - const anchor = nearestConnectable(railCore, port, railDegree, 2) || capital; - if (anchor && addRailRoute(port, anchor, branchRailways)) railCore.push(port); + + const branchRailNodes = [capital, ...railCore, ...modernCities.filter((city) => city !== capital && (city.population || 0) < 220000), ...majorPorts]; + const branchRailLinks = buildHierarchicalLinks(branchRailNodes, { + mode: "rail", + maxLinks: 5 + Math.floor(rand(seed, 1071) * 4), + extraLinks: 1, + minDistance: 12, + maxDistance: 54, + maxDegree: 2, + seedOffset: 1071, + }); + for (const link of branchRailLinks) { + if (railways.length && addRailRoute(link.a, link.b, branchRailways)) { + addUniqueNode(railCore, link.a); + addUniqueNode(railCore, link.b); + } } compactPathArray(railways, { minLength: 17, maxOverlap: 0.34, maxCount: 5 }); @@ -626,12 +891,12 @@ export function generateMapFeatures(seed, terrain) { return Math.min(0.6, (basinField[ai] + basinField[bi]) * 0.14 + (valleyField[ai] + valleyField[bi]) * 0.10 + (coastalLowland[ai] + coastalLowland[bi]) * 0.10); } - const roadTargetCandidates = [...modernCities.filter((p) => (p.population || 0) >= 90000), ...ports, ...markets, ...castles] - .map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.24 + ((p.population || 0) >= 180000 ? 0.18 : 0.05) })); + const roadTargetCandidates = [...modernCities.filter((p) => (p.population || 0) >= 110000), ...ports, ...markets, ...castles] + .map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.34 + ((p.population || 0) >= 220000 ? 0.30 : 0.05) + densityValue(p.x, p.y) * 0.20 })); const pickedRoadTargets = pickEntities(roadTargetCandidates, { - max: 8 + Math.floor(rand(seed, 1101) * 10), + max: 8 + Math.floor(rand(seed, 1101) * 9), minDistance: 9, - threshold: 0, + threshold: 0.1, seed: seed + 1100, }); const roadTargets = [ @@ -644,14 +909,20 @@ export function generateMapFeatures(seed, terrain) { const roadCore = [capital]; function addNationalRoad(a, b) { - const start = routePoint(a, "road", a.x * 31 + a.y * 37); - const goal = routePoint(b, "road", b.x * 31 + b.y * 37 + 17); const existing = [...nationalRoads, ...railways, ...branchRailways]; - const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 3, 5.8, townAvoidNodes, 3.2, 5.4)); + const { path } = routeThroughTransportCorridor(a, b, "road", roadCost, existing, roadHubs, townAvoidNodes, { + salt: a.x * 31 + a.y * 37 + b.x * 13 + b.y * 17, + maxWaypoints: 2, + corridorRadius: 3, + corridorStrength: 7.0, + avoidRadius: 2.8, + avoidStrength: 4.4, + }); const direct = pathEndpointDistance(path); const urbanPasses = modernCities.filter((city) => path.some(([x, y]) => Math.hypot(x - city.x, y - city.y) <= Math.max(6, Math.min(13, (city.urbanRadius || 8) * 0.78)))).length; - const passBonusOk = urbanPasses >= 1 || direct >= 18; - if (path.length > 3 && direct >= 12 && pathLength(path) >= 14 && pathCompactness(path) < 4.20 && pathOverlapRatio(path, existing, 2) < 0.78 && passBonusOk) { + const densityPurpose = averagePathField(path, populationDensity) * 1.28 + averagePathField(path, plain) * 0.18 + averagePathField(path, valleyField) * 0.16; + const passBonusOk = urbanPasses >= 1 || densityPurpose > 0.16 || direct >= 20; + if (path.length > 3 && direct >= 12 && pathLength(path) >= 14 && pathCompactness(path) < 4.15 && pathOverlapRatio(path, existing, 2) < 0.74 && passBonusOk) { nationalRoads.push(path); incrementDegree(roadDegree, a); incrementDegree(roadDegree, b); @@ -660,28 +931,94 @@ export function generateMapFeatures(seed, terrain) { return false; } - for (const target of roadTargets.slice(1, 8 + Math.floor(rand(seed, 1102) * 7))) { - const anchor = nearestConnectable(roadCore, target, roadDegree, 3) || capital; - if (addNationalRoad(anchor, target)) roadCore.push(target); - } - const roadLinkCandidates = []; - for (let i = 0; i < roadTargets.length; i++) { - for (let j = i + 1; j < roadTargets.length; j++) { - const a = roadTargets[i]; - const b = roadTargets[j]; - const d = Math.hypot(a.x - b.x, a.y - b.y); - if (d < 18 || d > 58) continue; - const demand = Math.sqrt(transportDemand(a) * transportDemand(b)); - roadLinkCandidates.push({ a, b, score: demand / (1 + d / 18) + sameCorridorAffinity(a, b) + hash2(a.x + b.x, a.y + b.y, seed + 1111) * 0.05 }); + function addNationalRoadRelaxed(a, b, bucket = nationalRoads) { + if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 5) return false; + const existing = [...nationalRoads, ...bucket, ...railways, ...branchRailways]; + const { path } = routeThroughTransportCorridor(a, b, "road", roadCost, existing, roadHubs, townAvoidNodes, { + salt: a.x * 47 + a.y * 53 + b.x * 59 + b.y * 61, + maxWaypoints: 2, + corridorRadius: 4, + corridorStrength: 8.2, + avoidRadius: 2.3, + avoidStrength: 3.2, + }); + const direct = pathEndpointDistance(path); + const densityPurpose = averagePathField(path, populationDensity) * 1.06 + averagePathField(path, plain) * 0.14 + averagePathField(path, valleyField) * 0.18 + averagePathField(path, coastalLowland) * 0.10; + const usefulInside = path.some(([x, y]) => prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]); + if (path.length > 3 && usefulInside && direct >= 8 && pathLength(path) >= 10 && pathCompactness(path) < 5.05 && pathOverlapRatio(path, existing, 2) < 0.86 && densityPurpose > 0.070) { + bucket.push(path); + incrementDegree(roadDegree, a); + incrementDegree(roadDegree, b); + return true; } + return false; } - roadLinkCandidates.sort((a, b) => b.score - a.score); - let extraRoadLinks = 0; - for (const link of roadLinkCandidates) { - if (extraRoadLinks >= 4) break; - if (getDegree(roadDegree, link.a) >= 4 || getDegree(roadDegree, link.b) >= 4) continue; + + function coverageRoadCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const rawBarrier = mountainBarrierPenalty(x, y, "road"); + if (rawBarrier >= INF && elevation[i] > 0.84) return INF; + const barrier = rawBarrier >= INF ? 90 + Math.max(0, elevation[i] - 0.66) * 160 + slope[i] * 34 : rawBarrier * 0.38; + const density = densityValue(x, y); + return Math.max(0.34, 1 + slope[i] * 9.8 + barrier + Math.max(0, elevation[i] - 0.58) * 4.8 + ridgeField[i] * 0.45 + (river[i] > 0.5 ? 0.75 : 0) - density * 0.86 - plain[i] * 0.20 - valleyField[i] * 0.30 - coastalLowland[i] * 0.16 + normalEdgePenalty(x, y) * 0.4 + hash2(x, y, seed + 338) * 0.04); + } + + function nationalRoadCorridorScore(path) { + if (!path || !path.length) return -1; + const density = averagePathField(path, populationDensity); + const lowland = averagePathField(path, plain); + const valley = averagePathField(path, valleyField); + const coast = averagePathField(path, coastalLowland); + const avgSlope = averagePathField(path, slope); + const avgElevation = averagePathField(path, elevation); + // Low-density valley/coastal corridors are allowed. The score is meant to + // reject truly roadless mountain/ridge alignments, not rural national roads. + return density * 0.46 + lowland * 0.24 + valley * 0.24 + coast * 0.16 - avgSlope * 0.18 - Math.max(0, avgElevation - 0.60) * 0.15; + } + + function isBackcountryNationalRoad(path, a, b) { + const corridor = nationalRoadCorridorScore(path); + const endpointWeight = nationalRoadPopulationWeight(a) + nationalRoadPopulationWeight(b); + const direct = pathEndpointDistance(path); + const valley = averagePathField(path, valleyField); + const coast = averagePathField(path, coastalLowland); + const lowland = averagePathField(path, plain); + const naturalCorridor = valley * 0.8 + coast * 0.65 + lowland * 0.55; + const endpointDensity = Math.max(densityValue(a.x, a.y), densityValue(b.x, b.y)); + const remoteEndpoint = endpointDensity < 0.10 && endpointWeight < 52000; + const longRemote = direct > 34 && corridor < 0.075 && naturalCorridor < 0.16; + return (corridor < 0.045 && naturalCorridor < 0.13) || (remoteEndpoint && corridor < 0.070 && naturalCorridor < 0.18) || longRemote; + } + + function addNationalRoadCoverageFallback(a, b, bucket = nationalRoads) { + if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 5) return false; + const existing = [...nationalRoads, ...bucket, ...railways, ...branchRailways].filter(Boolean); + const path = aStar(a, b, makeTransportCost(coverageRoadCost, existing, roadHubs, [a, b], 4, 8.2, townAvoidNodes, 2.0, 3.0)); + const direct = pathEndpointDistance(path); + const usefulInside = path.some(([x, y]) => prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]); + if (path.length > 4 && usefulInside && direct >= 7 && pathLength(path) < 150 && pathCompactness(path) < 6.9 && !isBackcountryNationalRoad(path, a, b)) { + bucket.push(path); + incrementDegree(roadDegree, a); + incrementDegree(roadDegree, b); + return true; + } + return false; + } + + const roadLinks = buildHierarchicalLinks(roadTargets, { + mode: "road", + maxLinks: 7 + Math.floor(rand(seed, 1102) * 5), + extraLinks: 3, + minDistance: 13, + maxDistance: 62, + maxDegree: 3, + seedOffset: 1102, + }); + for (const link of roadLinks) { if (addNationalRoad(link.a, link.b)) { - extraRoadLinks++; + addUniqueNode(roadCore, link.a); + addUniqueNode(roadCore, link.b); } } @@ -691,18 +1028,249 @@ export function generateMapFeatures(seed, terrain) { .filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 90000) .slice() .sort((a, b) => a.x - b.x || a.y - b.y); - for (let i = 0; i < trunkCities.length - 1; i += 2) { + for (let i = 0; i < trunkCities.length - 1; i++) { const a = trunkCities[i]; - const b = trunkCities[Math.min(trunkCities.length - 1, i + 2)]; - if (a && b && Math.hypot(a.x - b.x, a.y - b.y) >= 22 && getDegree(roadDegree, a) < 5) addNationalRoad(a, b); + const b = trunkCities[i + 1]; + const d = a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; + if (a && b && d >= 13 && d <= 58 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); + } + // Add a second, sparse north-south / inland-coastal layer so towns are not + // only chained left-to-right. This helps yellow national roads pass through + // multiple towns instead of ending as isolated spurs. + const verticalTrunkCities = trunkCities.slice().sort((a, b) => a.y - b.y || a.x - b.x); + for (let i = 0; i < verticalTrunkCities.length - 2; i += 3) { + const a = verticalTrunkCities[i]; + const b = verticalTrunkCities[Math.min(verticalTrunkCities.length - 1, i + 2)]; + const d = a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; + if (a && b && d >= 20 && d <= 62 && (a.population || 0) >= 110000 && (b.population || 0) >= 110000 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); } - const expressTargets = pickEntities(modernCities.filter((p) => p !== capital && (p.population || 0) >= 180000).map((p) => ({ ...p, score: p.score + Math.hypot(p.x - capital.x, p.y - capital.y) / 80 + 0.15 })).concat(majorPorts.map((p) => ({ ...p, score: p.score + 0.55 }))), { - max: 1 + Math.floor(rand(seed, 1120) * 3), - minDistance: 20, - threshold: 0.05, - seed: seed + 1120, - }); + function uniqueByCell(nodes) { + const seen = new Set(); + const out = []; + for (const node of nodes.filter(Boolean)) { + if (!inside(node.x, node.y) || sea[indexOf(node.x, node.y)]) continue; + const key = nodeKey(node); + if (seen.has(key)) continue; + seen.add(key); + out.push(node); + } + return out; + } + + function internalNationalRoadCellCount(paths = nationalRoads) { + const seen = new Set(); + for (const path of paths) { + for (const [x, y] of path || []) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (prefectureMask[i] && !sea[i]) seen.add(`${x},${y}`); + } + } + return seen.size; + } + + + function nationalRoadPopulationWeight(node) { + if (!node) return 0; + const pop = Math.max(0, node.population || 0); + if (pop > 0) return pop; + if (node.portClass === "major") return 180000; + if (node.portClass === "regional") return 90000; + if (node.portClass) return 35000; + if (node.kind === "Market Town" || node.kind === "Market City") return 55000; + if (node.kind?.includes("Castle")) return 45000; + return 18000; + } + + function nationalRoadPopulationCoverage(paths = nationalRoads, radius = 7.0) { + const ruralNodes = villages + .filter((v) => prefectureMask[indexOf(v.x, v.y)] && !sea[indexOf(v.x, v.y)] && (transportDemand(v) > 0.24 || settlementCluster[indexOf(v.x, v.y)] > 0.33)) + .sort((a, b) => transportDemand(b) - transportDemand(a)) + .slice(0, 18); + const nodes = uniqueByCell([ + capital, + ...modernCities.filter((city) => ((city.population || 0) >= 60000 || city.isPrefecturalCapital)), + ...ports.filter((p) => p.portClass !== "fishing"), + ...markets, + ...castleTowns, + ...ruralNodes, + ]); + let total = 0; + let covered = 0; + const uncovered = []; + for (const node of nodes) { + const weight = nationalRoadPopulationWeight(node); + if (weight <= 0) continue; + total += weight; + const d = nearestPathCellDistance(node, paths); + if (d <= radius) covered += weight; + else uncovered.push({ node, weight, distance: d, score: weight * (1 + Math.min(2.8, d / 12)) + transportDemand(node) * 48000 }); + } + uncovered.sort((a, b) => b.score - a.score); + const uncoveredPopulation = Math.max(0, total - covered); + return { ratio: total ? covered / total : 1, total, covered, uncoveredPopulation, uncovered }; + } + + // Metropolitan national roads are split by role: yellow radial roads connect + // the large city to neighbouring cities/ports; white ring roads are generated + // later as ordinary urban ring roads. + const metroRoadHubs = uniqueByCell([capital, ...modernCities.filter((city) => city !== capital && (city.population || 0) >= 240000)]) + .filter((city) => prefectureMask[indexOf(city.x, city.y)]) + .slice(0, 4); + + function addMetroRadialNationalRoads() { + let added = 0; + for (const hub of metroRoadHubs) { + const maxRadials = (hub.population || 0) >= 900000 || hub.isPrefecturalCapital ? 5 : 3; + const bySector = new Map(); + const candidates = uniqueByCell([ + ...modernCities.filter((city) => city !== hub && (city.population || 0) >= 70000), + ...ports.filter((p) => p.portClass !== "fishing"), + ...markets, + ]); + for (const node of candidates) { + if (!prefectureMask[indexOf(node.x, node.y)]) continue; + const d = Math.hypot(node.x - hub.x, node.y - hub.y); + if (d < 9 || d > 46) continue; + const angle = Math.atan2(node.y - hub.y, node.x - hub.x); + const sector = Math.floor(((angle + Math.PI) / (Math.PI * 2)) * 8); + const coveredPenalty = nearestPathCellDistance(node, nationalRoads) <= 6.2 ? 0.72 : 0; + const score = pairTransportScore(hub, node, "road") + transportDemand(node) * 0.44 + densityValue(node.x, node.y) * 0.22 - getDegree(roadDegree, node) * 0.16 - coveredPenalty; + const old = bySector.get(sector); + if (!old || score > old.score) bySector.set(sector, { node, score, d }); + } + const sectorTargets = [...bySector.values()].sort((a, b) => b.score - a.score); + let made = 0; + for (const { node } of sectorTargets) { + if (made >= maxRadials) break; + if (getDegree(roadDegree, hub) >= 10 || getDegree(roadDegree, node) >= 7) continue; + if (addNationalRoad(hub, node) || addNationalRoadRelaxed(hub, node) || addNationalRoadCoverageFallback(hub, node)) { + made++; + added++; + } + } + } + return added; + } + + function ensureInternalNationalRoadCoverage(maxAdded = 7) { + let added = 0; + const minimumCells = Math.max(72, Math.floor((MAP_W + MAP_H) * 0.58)); + const targetPopulationCoverage = 0.85; + const maxUncoveredPopulation = 50000; + const minimumNetworkPaths = 10; + const hasEnoughCells = () => internalNationalRoadCellCount() >= minimumCells && nationalRoads.length >= minimumNetworkPaths; + const coverageState = () => nationalRoadPopulationCoverage(nationalRoads, 8.5); + const hasEnoughPopulationCoverage = () => { + const state = coverageState(); + return state.ratio >= targetPopulationCoverage && state.uncoveredPopulation <= maxUncoveredPopulation; + }; + if (hasEnoughPopulationCoverage() && nationalRoads.length >= minimumNetworkPaths) return added; + + const populationNodes = uniqueByCell([ + capital, + ...modernCities.filter((city) => (city.population || 0) >= 85000), + ...ports.filter((p) => p.portClass === "major" || p.portClass === "regional"), + ...markets.filter((p) => transportDemand(p) > 0.35), + ...castleTowns.filter((p) => transportDemand(p) > 0.35), + ]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a)); + const internalNodes = uniqueByCell([ + capital, + ...modernCities.filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 85000), + ...ports.filter((p) => prefectureMask[indexOf(p.x, p.y)] && (p.portClass === "major" || p.portClass === "regional")), + ...markets.filter((p) => prefectureMask[indexOf(p.x, p.y)] && transportDemand(p) > 0.35), + ...castleTowns.filter((p) => prefectureMask[indexOf(p.x, p.y)] && transportDemand(p) > 0.35), + ]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a)); + if (populationNodes.length < 2 && internalNodes.length < 2) return added; + + function targetIsWorthNationalRoad(node) { + if (!node || node === capital) return false; + const w = nationalRoadPopulationWeight(node); + if (w >= 135000) return true; + if (node.portClass === "major" || node.portClass === "regional") return true; + if (densityValue(node.x, node.y) >= 0.18 && w >= 75000) return true; + if (w >= 18000 && transportDemand(node) >= 0.28 && (valleyField[indexOf(node.x, node.y)] > 0.20 || coastalLowland[indexOf(node.x, node.y)] > 0.18 || plain[indexOf(node.x, node.y)] > 0.36)) return true; + return false; + } + + function tryCoverageLink(a, b) { + if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 6) return false; + const before = coverageState().ratio; + const oldCount = nationalRoads.length; + if (!(addNationalRoadRelaxed(a, b) || addNationalRoadCoverageFallback(a, b))) return false; + const path = nationalRoads[nationalRoads.length - 1]; + const after = coverageState().ratio; + if (isBackcountryNationalRoad(path, a, b) && after - before < 0.025) { + nationalRoads.splice(oldCount, nationalRoads.length - oldCount); + return false; + } + return true; + } + + const currentCoverage = coverageState(); + const uncoveredPopulationTargets = currentCoverage.uncovered.map((item) => item.node).filter(targetIsWorthNationalRoad); + const internalSpanNodes = internalNodes.length >= 2 ? internalNodes : populationNodes; + const byX = internalSpanNodes.slice().sort((a, b) => a.x - b.x); + const byY = internalSpanNodes.slice().sort((a, b) => a.y - b.y); + const edgeBackstops = uniqueByCell([byX[0], byX[byX.length - 1], byY[0], byY[byY.length - 1]]) + .filter((node) => targetIsWorthNationalRoad(node) && Math.hypot(node.x - capital.x, node.y - capital.y) >= 9 && densityValue(node.x, node.y) >= 0.12) + .sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a)); + const ruralBackboneTargets = villages + .filter((v) => prefectureMask[indexOf(v.x, v.y)] && !sea[indexOf(v.x, v.y)] && nearestPathCellDistance(v, nationalRoads) > 7.0) + .filter((v) => transportDemand(v) >= 0.28 && (valleyField[indexOf(v.x, v.y)] > 0.20 || coastalLowland[indexOf(v.x, v.y)] > 0.18 || plain[indexOf(v.x, v.y)] > 0.36)) + .sort((a, b) => transportDemand(b) - transportDemand(a)) + .slice(0, 5); + const primaryTargets = uniqueByCell([ + ...uncoveredPopulationTargets.slice(0, 5), + ...edgeBackstops.slice(0, 2), + ...ruralBackboneTargets, + ...populationNodes.filter(targetIsWorthNationalRoad).slice(0, 4), + ]).filter((node) => node !== capital && Math.hypot(node.x - capital.x, node.y - capital.y) >= 7); + + for (const target of primaryTargets) { + if (added >= maxAdded || hasEnoughPopulationCoverage()) break; + if (nearestPathCellDistance(target, nationalRoads) <= 6.5) continue; + if (tryCoverageLink(capital, target)) added++; + } + + const orderedByPopulation = populationNodes + .filter((node) => targetIsWorthNationalRoad(node) && nearestPathCellDistance(node, nationalRoads) > 7.0) + .sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a)); + for (const target of orderedByPopulation) { + if (added >= maxAdded || hasEnoughPopulationCoverage()) break; + const anchor = populationNodes + .filter((node) => nodeKey(node) !== nodeKey(target) && nearestPathCellDistance(node, nationalRoads) <= 5.8) + .sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0] || capital; + const d = Math.hypot(anchor.x - target.x, anchor.y - target.y); + if (d < 7 || d > 50) continue; + if (getDegree(roadDegree, anchor) >= 7 || getDegree(roadDegree, target) >= 5) continue; + if (tryCoverageLink(anchor, target)) added++; + } + + // Cell-count backstop is deliberately weak: use it only when both network + // shape and population coverage are poor. This avoids forcing yellow roads + // into sparsely inhabited mountain or peninsula tips just to hit 100% coverage. + if (!hasEnoughCells() && coverageState().ratio < 0.80) { + for (const chain of [byX, byY]) { + if (added >= maxAdded) break; + for (let i = 0; i < chain.length - 1; i += 3) { + if (added >= maxAdded || hasEnoughCells() || hasEnoughPopulationCoverage()) break; + const a = chain[i]; + const b = chain[i + 1]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < 10 || d > 42) continue; + if (!targetIsWorthNationalRoad(a) && !targetIsWorthNationalRoad(b)) continue; + if (getDegree(roadDegree, a) >= 6 || getDegree(roadDegree, b) >= 6) continue; + if (tryCoverageLink(a, b)) added++; + } + } + } + return added; + } + + const metroRadialNationalRoadsAdded = addMetroRadialNationalRoads(); + const internalNationalRoadFallbacks = ensureInternalNationalRoadCoverage(8); const expressways = []; const expressDegree = new Map(); @@ -733,14 +1301,23 @@ export function generateMapFeatures(seed, terrain) { function addExpressway(a, b, bucket = expressways) { - const start = routePoint(a, "express", a.x * 41 + a.y * 43); - const goal = routePoint(b, "express", b.x * 41 + b.y * 43 + 29); const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways]; - let path = aStar(start, goal, makeTransportCost(expresswayCost, existing, roadHubs, [start, goal], 5, 10.8, townAvoidNodes, 8.5, 14.0)); - path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.54 && elevation[indexOf(x, y)] < 0.78, 10); - path = snapPathToExistingExpressways(path, expressways, 2.6); + let { path } = routeThroughTransportCorridor(a, b, "express", expresswayCost, existing, roadHubs, townAvoidNodes, { + salt: a.x * 41 + a.y * 43 + b.x * 19 + b.y * 29, + maxWaypoints: 1, + corridorRadius: 5, + corridorStrength: 10.8, + avoidRadius: 8.5, + avoidStrength: 14.0, + }); + path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.54 && elevation[indexOf(x, y)] < 0.78, 12); + path = snapPathToExistingExpressways(path, expressways, 2.4); const direct = pathEndpointDistance(path); - if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) { + const densityPurpose = averagePathField(path, populationDensity) * 0.8 + averagePathField(path, plain) * 0.16 + averagePathField(path, coastalLowland) * 0.12; + const turnScore = pathTurnScore(path); + const deviation = pathLateralDeviationRatio(path); + const compact = pathCompactness(path); + if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && compact < 2.28 && turnScore < 0.64 && deviation < 0.36 && pathOverlapRatio(path, existing, 2) < 0.34 && densityPurpose > 0.11) { bucket.push(path); incrementDegree(expressDegree, a); incrementDegree(expressDegree, b); @@ -749,9 +1326,21 @@ export function generateMapFeatures(seed, terrain) { return false; } - for (const target of expressTargets) { - const anchor = nearestConnectable(expressCore, target, expressDegree, 2) || capital; - if (addExpressway(anchor, target)) expressCore.push(target); + const expressNodes = [capital, ...modernCities.filter((p) => (p.population || 0) >= 220000), ...majorPorts.filter((p) => p.portClass === "major")]; + const expressLinks = buildHierarchicalLinks(expressNodes, { + mode: "express", + maxLinks: 1 + Math.floor(rand(seed, 1120) * 2), + extraLinks: rand(seed, 1121) > 0.72 ? 1 : 0, + minDistance: 26, + maxDistance: 86, + maxDegree: 2, + seedOffset: 1120, + }); + for (const link of expressLinks) { + if (addExpressway(link.a, link.b)) { + addUniqueNode(expressCore, link.a); + addUniqueNode(expressCore, link.b); + } } const ringRoads = []; @@ -885,20 +1474,20 @@ export function generateMapFeatures(seed, terrain) { return made; } - const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 130000).slice(0, 6); + const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 160000).slice(0, 5); + let metroRingRoadSegmentsAdded = 0; for (const city of mediumRingCities) { - const radius = clamp(8 + Math.sqrt(city.population || 100000) / 170, 10, 22); - addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...railways, ...branchRailways], radius); + const radius = clamp(8 + Math.sqrt(city.population || 100000) / 175, 10, 22); + metroRingRoadSegmentsAdded += addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...railways, ...branchRailways], radius); } - const largeRingCities = modernCities.filter((c) => (c.population || 0) >= 900000).slice(0, 1); + const largeRingCities = uniqueByCell([...metroRoadHubs, ...modernCities.filter((c) => (c.population || 0) >= 420000)]).slice(0, 3); for (const city of largeRingCities) { const roadRadius = clamp(10 + Math.sqrt(city.population || 400000) / 155, 13, 28); - const expressRadius = roadRadius + 3 + rand(seed, city.x * 71 + city.y * 73) * 3; const railRadius = Math.max(8, roadRadius - 4); - addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...expressways, ...railways, ...branchRailways], roadRadius); + const roadRingSegments = addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...expressways, ...railways, ...branchRailways], roadRadius); + metroRingRoadSegmentsAdded += roadRingSegments || addLooseEnvironmentalRing(city, ringRoads, roadCost, roadRadius); // Expressway rings are intentionally disabled; expressways stay as sparse interurban corridors. const railRingSegments = addEnvironmentalRing(city, "rail", ringRailways, railCost, [...railways, ...branchRailways, ...nationalRoads, ...expressways], railRadius); - // Expressway rings should be rare; do not force a fallback ring when terrain rejects it. if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius); } ringExpressways.length = 0; @@ -906,23 +1495,45 @@ export function generateMapFeatures(seed, terrain) { compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: 8 }); const gatewayCandidates = []; - for (let x = 0; x < MAP_W; x++) for (const y of [0, MAP_H - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: y === 0 ? "N" : "S", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 + coastalLowland[i] * 0.2 - Math.max(0, elevation[i] - 0.56) * 1.6 - ridgeField[i] * 0.35 }); } - for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: x === 0 ? "W" : "E", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 + coastalLowland[i] * 0.2 - Math.max(0, elevation[i] - 0.56) * 1.6 - ridgeField[i] * 0.35 }); } + for (let x = 0; x < MAP_W; x++) for (const y of [0, MAP_H - 1]) { + const i = indexOf(x, y); + if (!sea[i]) { + const density = densityValue(x, y); + gatewayCandidates.push({ + x, y, + side: y === 0 ? "N" : "S", + score: plain[i] * 0.9 + agriculture[i] * 0.35 + valleyField[i] * 0.42 + coastalLowland[i] * 0.28 + density * 0.55 + (1 - slope[i]) * 0.42 - Math.max(0, elevation[i] - 0.56) * 1.8 - ridgeField[i] * 0.42 + }); + } + } + for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) { + const i = indexOf(x, y); + if (!sea[i]) { + const density = densityValue(x, y); + gatewayCandidates.push({ + x, y, + side: x === 0 ? "W" : "E", + score: plain[i] * 0.9 + agriculture[i] * 0.35 + valleyField[i] * 0.42 + coastalLowland[i] * 0.28 + density * 0.55 + (1 - slope[i]) * 0.42 - Math.max(0, elevation[i] - 0.56) * 1.8 - ridgeField[i] * 0.42 + }); + } + } + const minExternalGatewayCount = Math.min(5, gatewayCandidates.length); + const targetGatewayCount = Math.min(gatewayCandidates.length, 4 + Math.floor(rand(seed, 1201) * 3)); let externalGateways = pickEntities(gatewayCandidates, { - max: 2 + Math.floor(rand(seed, 1201) * 3), - minDistance: 28, - threshold: 0.4, + max: targetGatewayCount, + minDistance: 20, + threshold: 0.18, seed: seed + 1201, }).map((p) => ({ ...p, kind: "External Gateway" })); - if (externalGateways.length < 2) { + if (externalGateways.length < minExternalGatewayCount) { const fallbackGateways = gatewayCandidates .slice() .sort((a, b) => b.score - a.score); for (const gate of fallbackGateways) { - if (externalGateways.some((p) => Math.hypot(p.x - gate.x, p.y - gate.y) < 30)) continue; + if (externalGateways.some((p) => Math.hypot(p.x - gate.x, p.y - gate.y) < 18)) continue; externalGateways.push({ ...gate, kind: "External Gateway" }); - if (externalGateways.length >= 2) break; + if (externalGateways.length >= minExternalGatewayCount) break; } } @@ -969,6 +1580,7 @@ export function generateMapFeatures(seed, terrain) { const externalRoads = []; const externalExpressways = []; const externalRailways = []; + const nationalRoadBranchRoads = []; function selectExternalStart(pool, gate, degreeMap, maxDegree = 2) { const sorted = pool @@ -979,29 +1591,44 @@ export function generateMapFeatures(seed, terrain) { } externalGateways.forEach((gate, idx) => { - const makeExpressLink = idx === 0 || rand(seed, 1210 + idx) > 0.4; - const roadStartRaw = selectExternalStart([...roadCore, ...modernCities, ...ports, ...markets], gate, roadDegree, 3); - const roadStart = routePoint(roadStartRaw, makeExpressLink ? "express" : "road", gate.x * 53 + gate.y * 59); - const roadExisting = [...nationalRoads, ...expressways, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways]; - const roadBaseCost = makeExpressLink ? externalExpresswayCost(gate) : externalRoadCost(gate); - let roadPath = aStar(roadStart, gate, makeTransportCost(roadBaseCost, roadExisting, roadHubs, [roadStart, gate], makeExpressLink ? 4 : 3, makeExpressLink ? 8.2 : 6.2, townAvoidNodes, makeExpressLink ? 5.4 : 3.2, makeExpressLink ? 7.8 : 5.6)); - if (makeExpressLink) roadPath = snapPathToExistingExpressways(roadPath, [...expressways, ...externalExpressways], 2.6); + // Always lay a national-road class gateway link first. Expressways are + // additional sparse corridors; they should not replace the ordinary trunk + // road connection to the neighbouring prefecture. + const roadStartRaw = selectExternalStart([...roadCore, ...modernCities, ...ports, ...markets], gate, roadDegree, 4); + const roadStart = routePoint(roadStartRaw, "road", gate.x * 53 + gate.y * 59); + const roadExisting = [...nationalRoads, ...externalRoads, ...expressways, ...externalExpressways, ...railways, ...branchRailways]; + let roadPath = aStar(roadStart, gate, makeTransportCost(externalRoadCost(gate), roadExisting, roadHubs, [roadStart, gate], 3, 7.0, townAvoidNodes, 3.2, 5.6)); if (roadPath.length > 6) { - if (makeExpressLink) { - externalExpressways.push(roadPath); - incrementDegree(expressDegree, roadStartRaw); + externalRoads.push(roadPath); + incrementDegree(roadDegree, roadStartRaw); + incrementDegree(roadDegree, gate); + addUniqueNode(roadCore, gate); + } + + const makeExpressLink = idx === 0 || idx === 1 || rand(seed, 1210 + idx) > 0.58; + if (makeExpressLink) { + const expressStartRaw = selectExternalStart([...expressCore, ...roadCore, ...modernCities, ...ports], gate, expressDegree, 3); + const expressStart = routePoint(expressStartRaw, "express", gate.x * 71 + gate.y * 73); + const expressExisting = [...expressways, ...externalExpressways, ...nationalRoads, ...externalRoads, ...railways, ...branchRailways]; + let expressPath = aStar(expressStart, gate, makeTransportCost(externalExpresswayCost(gate), expressExisting, roadHubs, [expressStart, gate], 4, 8.2, townAvoidNodes, 5.4, 7.8)); + expressPath = smoothPathByLineOfSight(expressPath, (x, y) => externalExpresswayCost(gate)(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.56 && elevation[indexOf(x, y)] < 0.80, 12); + expressPath = snapPathToExistingExpressways(expressPath, [...expressways, ...externalExpressways], 2.4); + const direct = pathEndpointDistance(expressPath); + const densityPurpose = averagePathField(expressPath, populationDensity) * 0.72 + averagePathField(expressPath, plain) * 0.14 + averagePathField(expressPath, coastalLowland) * 0.10; + const turnScore = pathTurnScore(expressPath); + const deviation = pathLateralDeviationRatio(expressPath); + if (expressPath.length > 6 && direct >= 18 && pathLength(expressPath) >= 20 && pathCompactness(expressPath) < 2.40 && turnScore < 0.66 && deviation < 0.42 && densityPurpose > 0.08) { + externalExpressways.push(expressPath); + incrementDegree(expressDegree, expressStartRaw); incrementDegree(expressDegree, gate); expressCore.push(gate); - } else { - externalRoads.push(roadPath); - incrementDegree(roadDegree, roadStartRaw); - incrementDegree(roadDegree, gate); } } + if ((idx === 0 || rand(seed, 1220 + idx) > 0.5) && modernCities.length > 0) { const railStartRaw = selectExternalStart([...railCore, ...modernCities, ...ports], gate, railDegree, 2); const railStart = routePoint(railStartRaw, "rail", gate.x * 61 + gate.y * 67); - const railExisting = [...railways, ...branchRailways, ...externalRailways, ...nationalRoads, ...expressways, ...externalExpressways]; + const railExisting = [...railways, ...branchRailways, ...externalRailways, ...nationalRoads, ...externalRoads, ...expressways, ...externalExpressways]; const railPath = aStar(railStart, gate, makeTransportCost(externalRailCost(gate), railExisting, railHubs, [railStart, gate], 4, 8.2, townAvoidNodes, 2.5, 4.4)); if (railPath.length > 6) { externalRailways.push(railPath); @@ -1011,6 +1638,181 @@ export function generateMapFeatures(seed, terrain) { } }); + function nearestOtherTrunkCell(node, ownPath, paths, minDistance = 3.5) { + let best = null; + let bestD = INF; + for (const path of paths) { + if (!path || path === ownPath) continue; + for (let k = 0; k < path.length; k += Math.max(1, Math.floor(path.length / 46))) { + const [x, y] = path[k]; + const d = Math.hypot(node.x - x, node.y - y); + if (d < bestD) { + bestD = d; + best = { x, y, d }; + } + } + } + return best && bestD >= minDistance ? best : null; + } + + function terminalIsConnectedToNationalRoad(node, ownPath = null, extraPaths = [], radius = 3.8) { + if (nearMapEdge(node.x, node.y, 4) || distanceToNearest(externalGateways, node.x, node.y) <= 4.2) return true; + const paths = [...nationalRoads, ...externalRoads, ...extraPaths]; + for (const path of paths) { + if (!path || path === ownPath) continue; + const step = Math.max(1, Math.floor(path.length / 64)); + for (let k = 0; k < path.length; k += step) { + const [x, y] = path[k]; + if (Math.hypot(node.x - x, node.y - y) <= radius) return true; + } + } + return false; + } + + function terminalImportance(node) { + let best = 0; + for (const city of modernCities) { + const d = Math.hypot(node.x - city.x, node.y - city.y); + if (d > 6.5) continue; + if (city.isPrefecturalCapital || city.rank === "Prefectural Capital") best = Math.max(best, 4); + else if ((city.population || 0) >= 180000) best = Math.max(best, 3); + else if ((city.population || 0) >= 90000) best = Math.max(best, 2); + else best = Math.max(best, 1); + } + for (const port of ports) { + const d = Math.hypot(node.x - port.x, node.y - port.y); + if (d > 6.5) continue; + if (port.portClass === "major") best = Math.max(best, 3); + else if (port.portClass === "regional") best = Math.max(best, 2); + else best = Math.max(best, 1); + } + for (const market of markets) if (Math.hypot(node.x - market.x, node.y - market.y) <= 5.5) best = Math.max(best, 1); + for (const castle of castles) if (Math.hypot(node.x - castle.x, node.y - castle.y) <= 5.5) best = Math.max(best, 1); + return best; + } + + function repairNationalRoadDeadEnds() { + const repairs = []; + const trunkPaths = () => [...nationalRoads, ...externalRoads, ...repairs]; + let repairCount = 0; + for (let pass = 0; pass < 3; pass++) { + for (const path of nationalRoads) { + if (!path || path.length < 8) continue; + const terminals = [ + { x: path[0][0], y: path[0][1] }, + { x: path[path.length - 1][0], y: path[path.length - 1][1] }, + ]; + for (const terminal of terminals) { + if (repairCount >= 28) break; + if (terminalIsConnectedToNationalRoad(terminal, path, repairs, 3.8)) continue; + const target = nearestOtherTrunkCell(terminal, path, trunkPaths(), 4.0); + if (!target || target.d > 38) continue; + const existing = [...nationalRoads, ...externalRoads, ...repairs]; + const repairPath = aStar(terminal, target, makeTransportCost(roadCost, existing, roadHubs, [terminal, target], 2, 7.4, townAvoidNodes, 2.8, 4.6)); + if (repairPath.length >= 4 && repairPath.length <= 68 && pathCompactness(repairPath) < 4.6 && pathOverlapRatio(repairPath, existing, 2) < 0.76) { + repairs.push(repairPath); + repairCount++; + } + } + } + } + nationalRoads.push(...repairs); + return repairs.length; + } + const nationalRoadDeadEndRepairs = repairNationalRoadDeadEnds(); + + function demoteUnresolvedNationalRoadBranches() { + let demoted = 0; + for (let i = nationalRoads.length - 1; i >= 0; i--) { + const path = nationalRoads[i]; + if (!path || path.length < 8) continue; + const a = { x: path[0][0], y: path[0][1] }; + const b = { x: path[path.length - 1][0], y: path[path.length - 1][1] }; + const aConnected = terminalIsConnectedToNationalRoad(a, path, [], 3.8); + const bConnected = terminalIsConnectedToNationalRoad(b, path, [], 3.8); + const deadCount = (aConnected ? 0 : 1) + (bConnected ? 0 : 1); + if (!deadCount) continue; + const aImportance = terminalImportance(a); + const bImportance = terminalImportance(b); + const importantTrunk = Math.max(aImportance, bImportance) >= 3 || (aImportance >= 2 && bImportance >= 2 && pathEndpointDistance(path) >= 24); + const looksLikeBranch = deadCount >= 2 || !importantTrunk || pathLength(path) < 34; + if (!looksLikeBranch) continue; + nationalRoads.splice(i, 1); + nationalRoadBranchRoads.push(path); + demoted++; + } + return demoted; + } + const nationalRoadBranchDemotions = demoteUnresolvedNationalRoadBranches(); + const postDemotionInternalNationalRoadFallbacks = ensureInternalNationalRoadCoverage(3); + + function terminalIsConnectedToRail(node, ownPath = null, extraPaths = [], radius = 3.8) { + if (nearMapEdge(node.x, node.y, 4) || distanceToNearest(externalGateways, node.x, node.y) <= 4.2) return true; + const paths = [...railways, ...branchRailways, ...externalRailways, ...ringRailways, ...extraPaths]; + for (const path of paths) { + if (!path || path === ownPath) continue; + const step = Math.max(1, Math.floor(path.length / 64)); + for (let k = 0; k < path.length; k += step) { + const [x, y] = path[k]; + if (Math.hypot(node.x - x, node.y - y) <= radius) return true; + } + } + return false; + } + + function railTerminalImportance(node) { + let best = 0; + for (const city of modernCities) { + const d = Math.hypot(node.x - city.x, node.y - city.y); + if (d > 6.5) continue; + if (city.isPrefecturalCapital || city.rank === "Prefectural Capital") best = Math.max(best, 4); + else if ((city.population || 0) >= 220000) best = Math.max(best, 3); + else if ((city.population || 0) >= 120000) best = Math.max(best, 2); + else best = Math.max(best, 1); + } + for (const port of ports) { + const d = Math.hypot(node.x - port.x, node.y - port.y); + if (d > 6.5) continue; + if (port.portClass === "major") best = Math.max(best, 2); + else if (port.portClass === "regional") best = Math.max(best, 1); + } + for (const station of stations) if (Math.hypot(node.x - station.x, node.y - station.y) <= 5.0) best = Math.max(best, 1); + return best; + } + + function repairRailDeadEnds() { + const repairs = []; + const currentPaths = () => [...railways, ...branchRailways, ...externalRailways, ...ringRailways, ...repairs]; + for (let pass = 0; pass < 3; pass++) { + for (const path of [...railways, ...branchRailways, ...externalRailways]) { + if (!path || path.length < 8) continue; + for (const terminal of [{ x: path[0][0], y: path[0][1] }, { x: path[path.length - 1][0], y: path[path.length - 1][1] }]) { + if (terminalIsConnectedToRail(terminal, path, repairs, 3.8)) continue; + let target = nearestOtherTrunkCell(terminal, path, currentPaths(), 4.0); + if ((!target || target.d > 48) && railTerminalImportance(terminal) >= 2) { + const candidates = [...modernCities, ...ports, ...stations] + .map((p) => ({ p, d: Math.hypot(terminal.x - p.x, terminal.y - p.y) })) + .filter(({ d }) => d >= 6 && d <= 42) + .sort((a, b) => a.d - b.d); + for (const { p } of candidates) { + const access = routePoint(p, "rail", 9400 + p.x * 17 + p.y * 19); + if (terminalIsConnectedToRail(access, path, repairs, 3.8)) { target = access; break; } + } + } + if (!target || (target.d && target.d > 52)) continue; + const existing = currentPaths(); + const repairPath = aStar(terminal, target, makeTransportCost(railCost, existing, railHubs, [terminal, target], 4, 8.4, townAvoidNodes, 2.0, 3.8)); + if (repairPath.length >= 4 && repairPath.length <= 60 && pathCompactness(repairPath) < 4.0 && pathTurnScore(repairPath) < 0.86 && pathOverlapRatio(repairPath, existing, 2) < 0.84) { + repairs.push(repairPath); + } + } + } + } + branchRailways.push(...repairs); + return repairs.length; + } + const railDeadEndRepairs = repairRailDeadEnds(); + let throughExpresswayAdded = false; function throughExpresswayCost(a, b) { return (x, y) => { @@ -1054,6 +1856,41 @@ export function generateMapFeatures(seed, terrain) { return Math.hypot(p.x - (a.x + vx * t), p.y - (a.y + vy * t)); } + function pathTurnScore(path) { + if (!path || path.length < 3) return 0; + let total = 0; + let count = 0; + for (let i = 1; i < path.length - 1; i++) { + const [x0, y0] = path[i - 1]; + const [x1, y1] = path[i]; + const [x2, y2] = path[i + 1]; + const ax = x1 - x0; + const ay = y1 - y0; + const bx = x2 - x1; + const by = y2 - y1; + const al = Math.hypot(ax, ay); + const bl = Math.hypot(bx, by); + if (al < 0.01 || bl < 0.01) continue; + const dot = clamp((ax * bx + ay * by) / (al * bl), -1, 1); + total += Math.acos(dot); + count++; + } + return count ? total / count : 0; + } + + function pathLateralDeviationRatio(path) { + if (!path || path.length < 3) return 0; + const a = { x: path[0][0], y: path[0][1] }; + const b = { x: path[path.length - 1][0], y: path[path.length - 1][1] }; + const direct = Math.max(1, Math.hypot(b.x - a.x, b.y - a.y)); + let maxDeviation = 0; + for (let i = 1; i < path.length - 1; i++) { + const p = { x: path[i][0], y: path[i][1] }; + maxDeviation = Math.max(maxDeviation, pointToSegmentDistance(p, a, b)); + } + return maxDeviation / direct; + } + function chooseThroughExpresswayVia(a, b) { const candidates = [capital, ...modernCities.filter((city) => (city.population || 0) >= 90000)]; let best = null; @@ -1110,7 +1947,10 @@ export function generateMapFeatures(seed, terrain) { path = smoothPathByLineOfSight(path, (x, y) => permissiveThroughExpresswayCost(a, b)(x, y, x, y) < INF, 12); path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8); } - if (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.42 || pathCompactness(path) > (viaUsed ? 5.6 : 3.45)) return false; + const turnScore = pathTurnScore(path); + const deviation = pathLateralDeviationRatio(path); + const compactness = pathCompactness(path); + if (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.42 || compactness > (viaUsed ? 4.10 : 3.05) || turnScore > (viaUsed ? 0.72 : 0.60) || deviation > (viaUsed ? 0.52 : 0.38)) return false; externalExpressways.push(path); incrementDegree(expressDegree, a); incrementDegree(expressDegree, b); @@ -1249,7 +2089,7 @@ export function generateMapFeatures(seed, terrain) { predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "New Town" })); - const minorRoads = []; + const minorRoads = [...nationalRoadBranchRoads]; const trunkNodes = [...markets, ...modernCities, ...stations.slice(0, 24), ...crossings.slice(0, 16)]; const roadNetInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways, ...premodernRoads], 3); @@ -1264,12 +2104,44 @@ export function generateMapFeatures(seed, terrain) { const connectedPairs = new Set(); function addMinorRoad(a, b) { const key = `${a.x},${a.y}|${b.x},${b.y}`; - if (connectedPairs.has(key)) return; + const reverseKey = `${b.x},${b.y}|${a.x},${a.y}`; + if (connectedPairs.has(key) || connectedPairs.has(reverseKey)) return; connectedPairs.add(key); const path = aStar(a, b, minorRoadCost); if (path.length > 2 && path.length < 90) minorRoads.push(path); } + function nearestRoadAccessNode(node, paths, sampleStep = 7) { + let best = null; + let bestD = INF; + for (const path of paths) { + if (!path || path.length === 0) continue; + const step = Math.max(1, Math.floor(path.length / Math.max(8, Math.ceil(path.length / sampleStep)))); + for (let k = 0; k < path.length; k += step) { + const [x, y] = path[k]; + const d = Math.hypot(node.x - x, node.y - y); + if (d < bestD) { bestD = d; best = { x, y, kind: "Road access", d }; } + } + } + return best; + } + + // Branches from the yellow national-road network are drawn as ordinary white + // roads. This keeps the national-road layer as a through-network while still + // connecting local towns, ports, castle towns, and suburban/new-town nodes. + const nationalAccessPaths = [...nationalRoads, ...externalRoads, ...ringRoads, ...premodernRoads]; + const localTownNodes = [...modernCities, ...ports, ...markets, ...castleTowns, ...satelliteCities, ...newTowns] + .filter((node, idx, arr) => idx === arr.findIndex((p) => Math.hypot(p.x - node.x, p.y - node.y) < 2.5)); + for (const town of localTownNodes) { + const nearestTrunk = nearestRoadAccessNode(town, nationalAccessPaths, 6); + if (nearestTrunk && nearestTrunk.d > 2.8 && nearestTrunk.d < 42) addMinorRoad(town, nearestTrunk); + } + const neighborTownNodes = localTownNodes.slice().sort((a, b) => (b.population || 0) - (a.population || 0)); + for (const town of neighborTownNodes.slice(0, 48)) { + const neighbor = pickEntities(neighborTownNodes.filter((p) => p !== town).map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - town.x, p.y - town.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; + if (neighbor && Math.hypot(neighbor.x - town.x, neighbor.y - town.y) < 22) addMinorRoad(town, neighbor); + } + for (const village of villages) { if (rand(seed, village.x * 13 + village.y * 17) < 0.90) { const target = pickEntities(trunkNodes.map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - village.x, p.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; @@ -1307,10 +2179,23 @@ export function generateMapFeatures(seed, terrain) { const allExpresswayPaths = [...expressways, ...externalExpressways]; const expresswayCells = allExpresswayPaths.flat(); const expresswayAverageDensity = expresswayCells.length ? expresswayCells.reduce((sum, [x, y]) => sum + densityValue(x, y), 0) / expresswayCells.length : 0; + const nationalRoadPopulationCoverageDebug = nationalRoadPopulationCoverage(nationalRoads, 8.5); const transportDebug = { requiredNodeCount: requiredTransportNodes.length, connectedRequiredNodeCount, throughExpresswayAdded, + nationalRoadDeadEndRepairs, + railDeadEndRepairs, + nationalRoadBranchDemotions, + metroRadialNationalRoadsAdded, + metroRingRoadSegmentsAdded, + internalNationalRoadFallbacks, + postDemotionInternalNationalRoadFallbacks, + nationalRoadPopulationCoverage: Number(nationalRoadPopulationCoverageDebug.ratio.toFixed(3)), + nationalRoadUncoveredPopulation: Math.round(nationalRoadPopulationCoverageDebug.uncoveredPopulation || 0), + internalNationalRoadCellCount: internalNationalRoadCellCount(), + externalGatewayCount: externalGateways.length, + externalNationalRoadCount: externalRoads.length, expresswayAverageDensity: Number(expresswayAverageDensity.toFixed(3)), expresswayPathCount: allExpresswayPaths.length, minorRoadCount: minorRoads.length, diff --git a/mapOutput.js b/mapOutput.js index e35b6cc..1477dfe 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -1,6 +1,6 @@ import { createNameDebug } from "./names.js"; import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js"; -import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; +import { aStar, applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; export function finishMapOutput({ seed, @@ -115,14 +115,396 @@ export function finishMapOutput({ return out; } + + function generatedNameStem(fullName) { + return String(fullName || "").replace(/[都道府県市町村区]$/u, ""); + } + + function terrainSettlementScore(x, y, sideBias = null) { + const i = indexOf(x, y); + if (!inside(x, y) || sea[i]) return -INF; + const edgeBias = sideBias === "north" ? (MAP_H - y) / MAP_H + : sideBias === "south" ? y / MAP_H + : sideBias === "west" ? (MAP_W - x) / MAP_W + : sideBias === "east" ? x / MAP_W + : 0; + return settlementCluster[i] * 0.55 + plain[i] * 0.42 + agriculture[i] * 0.22 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.16 + edgeBias * 0.08 - slope[i] * 0.54 - ridgeField[i] * 0.22 - Math.max(0, elevation[i] - 0.62) * 1.25; + } + + function chooseSpacedPoints(candidates, count, minDistance, seedOffset = 0) { + const selected = []; + const ordered = candidates + .map((p, n) => ({ ...p, score: (p.score || 0) + rand(seed, seedOffset + n * 17 + p.x * 5 + p.y * 7) * 0.06 })) + .sort((a, b) => b.score - a.score); + for (const p of ordered) { + if (selected.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < minDistance)) continue; + selected.push(p); + if (selected.length >= count) break; + } + return selected; + } + + function labelCollisionScore(x, y, avoidPoints) { + let nearest = 99; + for (const p of avoidPoints) { + if (!p) continue; + const d = Math.hypot(x - p.x, y - p.y); + nearest = Math.min(nearest, d); + } + return nearest; + } + + const MAX_BRIDGE_CELLS = 7; + + function pointPair(p) { + if (Array.isArray(p)) return [p[0], p[1]]; + return [p.x, p.y]; + } + + function sampledWaterRunBetween(a, b) { + if (!a || !b) return 0; + const [ax, ay] = pointPair(a); + const [bx, by] = pointPair(b); + const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) * 1.6)); + let run = 0; + let maxRun = 0; + for (let k = 0; k <= steps; k++) { + const t = k / steps; + const x = clamp(Math.round(ax + (bx - ax) * t), 0, MAP_W - 1); + const y = clamp(Math.round(ay + (by - ay) * t), 0, MAP_H - 1); + const isWater = sea[indexOf(x, y)]; + if (isWater) { + run++; + maxRun = Math.max(maxRun, run); + } else { + run = 0; + } + } + return maxRun; + } + + function pathMaxWaterRun(path) { + if (!path || path.length < 2) return 0; + let maxRun = 0; + for (let k = 1; k < path.length; k++) { + maxRun = Math.max(maxRun, sampledWaterRunBetween(path[k - 1], path[k])); + } + return maxRun; + } + + function landDetourCost(x, y) { + if (!inside(x, y)) return INF; + const i = indexOf(x, y); + if (sea[i]) return INF; + return Math.max( + 0.35, + 1 + + slope[i] * 7.6 + + Math.max(0, elevation[i] - 0.58) * 10.5 + + ridgeField[i] * 2.2 - + plain[i] * 0.45 - + valleyField[i] * 0.54 - + coastalLowland[i] * 0.38 + ); + } + + function compactOutputPath(path) { + const out = []; + let last = ""; + for (const p of path || []) { + const [x, y] = pointPair(p); + const key = `${x},${y}`; + if (key === last) continue; + last = key; + out.push([x, y]); + } + return out; + } + + function repairLongBridgeSegments(path) { + const compact = compactOutputPath(path); + if (compact.length < 2) return compact.length >= 3 ? compact : []; + const out = [compact[0]]; + for (let k = 1; k < compact.length; k++) { + const from = out[out.length - 1]; + const to = compact[k]; + if (sampledWaterRunBetween(from, to) <= MAX_BRIDGE_CELLS) { + out.push(to); + continue; + } + const detour = compactOutputPath(aStar({ x: from[0], y: from[1] }, { x: to[0], y: to[1] }, landDetourCost)); + if (detour.length >= 2 && pathMaxWaterRun(detour) <= MAX_BRIDGE_CELLS) { + out.push(...detour.slice(1)); + } else { + return []; + } + } + const repaired = compactOutputPath(out); + return repaired.length >= 3 ? repaired : []; + } + + function enforceBridgeLimitList(paths) { + return (paths || []) + .map((path) => pathMaxWaterRun(path) > MAX_BRIDGE_CELLS ? repairLongBridgeSegments(path) : compactOutputPath(path)) + .filter((path) => path && path.length >= 3 && pathMaxWaterRun(path) <= MAX_BRIDGE_CELLS); + } + + function shoreContactScore(x, y) { + let contacts = 0; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + if (!sea[indexOf(nx, ny)]) contacts++; + } + } + return contacts; + } + + function labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset, preferSea = false) { + const i = indexOf(x, y); + const nearest = labelCollisionScore(x, y, avoidPoints); + const capD = capital ? Math.hypot(x - capital.x, y - capital.y) : 24; + const fallbackD = fallback ? Math.hypot(x - fallback.x, y - fallback.y) : 0; + const terrainBias = sea[i] + ? 8 + Math.min(18, shoreContactScore(x, y)) * 0.45 + : plain[i] * 0.18 + basinField[i] * 0.10 + coastalLowland[i] * 0.10 - slope[i] * 0.08; + const clearance = Math.min(nearest, 24) * 1.55 - Math.max(0, 8 - nearest) * 5.0; + const capitalBias = capital ? -Math.max(0, capD - (preferSea ? 26 : 45)) * 0.09 : 0; + return clearance + terrainBias + capitalBias - fallbackD * 0.012 + rand(seed, seedOffset + x * 17 + y * 19) * 0.42; + } + + function pickPrefectureLabelPosition(avoidPoints, fallback, options = {}) { + const { + capital = null, + landPredicate = (x, y, i) => prefectureMask[i] && !sea[i], + areaPredicate = null, + seedOffset = 7461, + } = options; + let best = null; + let bestScore = -INF; + + // Prefer open water near the prefectural capital when it reads like a coastal prefecture label. + if (capital) { + const rMax = 30; + for (let dy = -rMax; dy <= rMax; dy += 2) { + for (let dx = -rMax; dx <= rMax; dx += 2) { + const x = clamp(Math.round(capital.x + dx), 0, MAP_W - 1); + const y = clamp(Math.round(capital.y + dy), 0, MAP_H - 1); + if (x < 6 || y < 6 || x > MAP_W - 7 || y > MAP_H - 7) continue; + const i = indexOf(x, y); + if (!sea[i]) continue; + if (areaPredicate && !areaPredicate(x, y)) continue; + const capD = Math.hypot(x - capital.x, y - capital.y); + if (capD < 5 || capD > rMax) continue; + const coastTouch = shoreContactScore(x, y); + if (coastTouch < 3 || coastTouch > 20) continue; + const score = labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset + 3000, true) + coastTouch * 0.35; + if (score > bestScore) { bestScore = score; best = { x, y, placement: "sea" }; } + } + } + if (best && bestScore >= 18) return best; + } + + best = null; + bestScore = -INF; + for (let y = 6; y < MAP_H - 6; y += 3) { + for (let x = 6; x < MAP_W - 6; x += 3) { + const i = indexOf(x, y); + if (!landPredicate(x, y, i)) continue; + const score = labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset, false); + if (score > bestScore) { bestScore = score; best = { x, y, placement: "land" }; } + } + } + if (!best || bestScore < 10) return fallback; + return best; + } + + function looseOutsidePath(a, b, salt, keepOutside = true) { + if (!a || !b) return []; + const steps = Math.max(4, Math.ceil(Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)))); + const path = []; + let lastKey = ""; + const bend = (rand(seed, 7600 + salt) - 0.5) * 5.5; + const sideways = Math.abs(a.x - b.x) > Math.abs(a.y - b.y) ? "y" : "x"; + for (let k = 0; k <= steps; k++) { + const t = k / steps; + let x = Math.round(a.x + (b.x - a.x) * t); + let y = Math.round(a.y + (b.y - a.y) * t); + const wave = Math.sin(t * Math.PI) * bend; + if (sideways === "y") y = Math.round(y + wave); + else x = Math.round(x + wave); + x = clamp(x, 0, MAP_W - 1); + y = clamp(y, 0, MAP_H - 1); + const i = indexOf(x, y); + if (sea[i]) continue; + if (keepOutside && prefectureMask[i]) continue; + const key = `${x},${y}`; + if (key === lastKey) continue; + lastKey = key; + path.push([x, y]); + } + return path.length >= 3 ? repairLongBridgeSegments(path) : []; + } + + function generatePrefectureIdentity(usedNamesForIdentity, avoidPoints) { + const stemsA = ["青", "白", "黒", "高", "奥", "新", "東", "西", "南", "北", "中", "美", "豊", "若", "真", "清", "瑞", "長", "久", "安", "阿", "葛", "榛", "碓", "那", "鹿", "宇", "志", "遠", "羽"]; + const stemsB = ["森", "川", "野", "原", "沢", "島", "浦", "浜", "海", "山", "岳", "谷", "津", "崎", "里", "畑", "橋", "瀬", "井", "沼", "丘", "郷", "城", "坂", "泊", "戸", "湊", "庄"]; + const suffixes = ["県", "県", "県", "県", "県", "府"]; + const localUsed = new Set(usedNamesForIdentity || []); + function pickGeneratedName(offset) { + const a = stemsA[Math.floor(rand(seed, 7410 + offset * 11) * stemsA.length) % stemsA.length]; + const b = stemsB[Math.floor(rand(seed, 7420 + offset * 13) * stemsB.length) % stemsB.length]; + const c = suffixes[Math.floor(rand(seed, 7430 + offset * 17) * suffixes.length) % suffixes.length]; + return `${a}${b}${c}`; + } + function pickUniqueName(offset, preferred = null) { + let candidate = preferred || pickGeneratedName(offset); + let guard = 0; + while (localUsed.has(candidate) && guard++ < 24) candidate = pickGeneratedName(offset + guard + 3); + localUsed.add(candidate); + if (usedNamesForIdentity) usedNamesForIdentity.add(candidate); + return candidate; + } + + const capital = modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]); + const capStem = generatedNameStem(capital?.name); + const prefectureSuffix = suffixes[Math.floor(rand(seed, 7433) * suffixes.length) % suffixes.length]; + const capitalBased = capStem && rand(seed, 7440) < 0.80 ? `${capStem}${prefectureSuffix}` : null; + const name = pickUniqueName(0, capitalBased); + + let sx = 0, sy = 0, n = 0; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + sx += x; sy += y; n++; + } + } + const fallback = { x: n ? sx / n : MAP_W / 2, y: n ? sy / n : MAP_H / 2 }; + const labelPoint = pickPrefectureLabelPosition(avoidPoints, fallback, { + capital, + landPredicate: (x, y, i) => prefectureMask[i] && !sea[i], + seedOffset: 7461, + }); + const label = { name, x: labelPoint.x, y: labelPoint.y, kind: "Prefecture Label", placement: labelPoint.placement || "land" }; + + const sideSamples = { north: [], south: [], west: [], east: [] }; + for (let x = 2; x < MAP_W - 2; x += 4) { + for (let y = 0; y < Math.min(13, MAP_H); y++) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.north.push({ x, y: 3 }); break; } + for (let y = MAP_H - 1; y >= Math.max(0, MAP_H - 13); y--) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.south.push({ x, y: MAP_H - 4 }); break; } + } + for (let y = 2; y < MAP_H - 2; y += 4) { + for (let x = 0; x < Math.min(13, MAP_W); x++) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.west.push({ x: 3, y }); break; } + for (let x = MAP_W - 1; x >= Math.max(0, MAP_W - 13); x--) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.east.push({ x: MAP_W - 4, y }); break; } + } + + const neighbors = []; + const sideOrder = ["north", "east", "south", "west"]; + for (let si = 0; si < sideOrder.length; si++) { + const side = sideOrder[si]; + const samples = sideSamples[side]; + if (!samples.length && rand(seed, 7500 + si) < 0.45) continue; + const nm = pickUniqueName(si + 1); + const p = samples.length ? samples[Math.floor(rand(seed, 7520 + si) * samples.length) % samples.length] : ( + side === "north" ? { x: MAP_W * 0.5, y: 3 } : side === "south" ? { x: MAP_W * 0.5, y: MAP_H - 4 } : side === "west" ? { x: 3, y: MAP_H * 0.5 } : { x: MAP_W - 4, y: MAP_H * 0.5 } + ); + neighbors.push({ name: nm, side, x: p.x, y: p.y, kind: "Neighbor Prefecture Label" }); + } + return { name, label, neighbors }; + } + + function generateNeighborPrefectureDetails(neighborLabels, usedNamesForNeighbor, nameDebugForNeighbor) { + const allCities = []; + const allAdmins = []; + const allCbds = []; + const allRoads = []; + const allRailways = []; + const details = []; + const sideBand = { + north: (x, y) => y <= Math.floor(MAP_H * 0.30), + south: (x, y) => y >= Math.ceil(MAP_H * 0.70), + west: (x, y) => x <= Math.floor(MAP_W * 0.34), + east: (x, y) => x >= Math.ceil(MAP_W * 0.66), + }; + for (let ni = 0; ni < neighborLabels.length; ni++) { + const label = neighborLabels[ni]; + const predicate = sideBand[label.side] || (() => true); + const candidates = []; + for (let y = 2; y < MAP_H - 2; y += 2) { + for (let x = 2; x < MAP_W - 2; x += 2) { + const i = indexOf(x, y); + if (prefectureMask[i] || sea[i] || !predicate(x, y)) continue; + const dToLabel = Math.hypot(x - label.x, y - label.y); + const borderAway = label.side === "north" ? y : label.side === "south" ? MAP_H - 1 - y : label.side === "west" ? x : MAP_W - 1 - x; + const score = terrainSettlementScore(x, y, label.side) - dToLabel * 0.006 + Math.min(16, borderAway) * 0.006; + if (score > 0.06) candidates.push({ x, y, score, neighborIndex: ni, neighborName: label.name, side: label.side }); + } + } + const rawCities = chooseSpacedPoints(candidates, 2 + Math.floor(rand(seed, 7700 + ni) * 3), 12, 7710 + ni * 100) + .map((p, n) => { + const rank = n === 0 ? "Neighbor Prefectural Capital" : n === 1 ? "Neighbor Regional Center" : "Neighbor City"; + const popBase = n === 0 ? 240000 : n === 1 ? 90000 : 36000; + const popSpread = n === 0 ? 620000 : n === 1 ? 220000 : 90000; + const population = Math.round((popBase + popSpread * Math.pow(clamp(p.score + rand(seed, 7730 + ni * 31 + n), 0, 1), 1.8)) / 1000) * 1000; + return { ...p, kind: rank, rank, population, urbanRadius: clamp(7 + Math.sqrt(population) / 105, 7, 21), coreRadius: clamp(2.4 + Math.sqrt(population) / 420, 2.4, 6.2), urbanWeight: 1.0 + Math.log10(Math.max(10000, population)) * 0.23, insidePrefecture: false }; + }); + const namedCities = attachIdsAndNames(rawCities, `neighborCity${ni}`, seed + ni * 100, null, nameFields, usedNamesForNeighbor, nameDebugForNeighbor); + const adminCandidates = chooseSpacedPoints(candidates.filter((p) => !namedCities.some((c) => Math.hypot(c.x - p.x, c.y - p.y) < 7)), 4 + Math.floor(rand(seed, 7760 + ni) * 5), 8, 7770 + ni * 100) + .map((p) => ({ ...p, kind: "Neighbor Municipal Center", insidePrefecture: false })); + const namedAdmins = attachIdsAndNames(adminCandidates, `neighborAdmin${ni}`, seed + ni * 131, "Neighbor Municipal Center", nameFields, usedNamesForNeighbor, nameDebugForNeighbor); + const cbds = namedCities.map((city, ci) => ({ x: city.x, y: city.y, parentId: city.id, parentName: city.name, name: `${city.name}CBD`, kind: ci === 0 ? "Neighbor Central Business District" : "Neighbor Urban Center", neighborIndex: ni, neighborName: label.name, insidePrefecture: false })); + const nodes = [...namedCities, ...namedAdmins].sort((a, b) => (b.population || 0) - (a.population || 0) || (b.score || 0) - (a.score || 0)); + const roads = []; + const rails = []; + for (let i = 1; i < nodes.length; i++) { + const target = nodes[i]; + const anchor = nodes.slice(0, i).sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0]; + const road = looseOutsidePath(anchor, target, ni * 400 + i * 17, true); + if (road.length >= 3) roads.push(road); + if (i <= 2 && rand(seed, 7790 + ni * 19 + i) > 0.30) { + const rail = looseOutsidePath(anchor, target, ni * 500 + i * 23, true); + if (rail.length >= 4) rails.push(rail); + } + } + if (nodes[0]) { + const gate = { x: label.x, y: label.y }; + const gatewayRoad = looseOutsidePath(nodes[0], gate, ni * 600 + 7, true); + if (gatewayRoad.length >= 3) roads.push(gatewayRoad); + } + const labelAvoid = [...namedCities, ...namedAdmins, ...allCities, ...allAdmins]; + const labelCandidates = candidates.length ? candidates : [{ x: label.x, y: label.y, score: 0.1 }]; + const fallbackLabel = chooseSpacedPoints(labelCandidates, 1, 1, 7810 + ni)[0] || label; + const capitalPoint = namedCities[0] || fallbackLabel; + const betterLabel = pickPrefectureLabelPosition(labelAvoid, fallbackLabel, { + capital: capitalPoint, + landPredicate: (x, y, i) => !prefectureMask[i] && !sea[i] && predicate(x, y), + areaPredicate: predicate, + seedOffset: 7810 + ni * 97, + }) || fallbackLabel; + label.x = betterLabel.x; + label.y = betterLabel.y; + label.placement = betterLabel.placement || "land"; + allCities.push(...namedCities); + allAdmins.push(...namedAdmins); + allCbds.push(...cbds); + allRoads.push(...roads); + allRailways.push(...rails); + details.push({ ...label, cities: namedCities, adminCenters: namedAdmins, centralBusinessDistricts: cbds, roads, railways: rails }); + } + return { prefectures: details, cities: allCities, adminCenters: allAdmins, centralBusinessDistricts: allCbds, roads: allRoads, railways: allRailways }; + } + // Bridge and tunnel icon systems were removed from the visual model. // Arrays remain empty for backward-compatible tests and downstream code. const bridges = []; const tunnels = []; const harborWorks = makeHarborWorks(ports); - const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); + let abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); - const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); + let preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity }; const usedNames = new Set(); const nameDebug = createNameDebug(); @@ -203,10 +585,96 @@ export function finishMapOutput({ ...externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); + const avoidForPrefectureLabel = [ + ...modernCities, + ...ports, + ...markets, + ...castles, + ...castleTowns, + ...adminCenters, + ...stations, + ...satelliteCities, + ...newTowns, + ].filter((p) => p.insidePrefecture && p.name); + const prefectureIdentity = generatePrefectureIdentity(usedNames, avoidForPrefectureLabel); + const neighborPrefectureDetails = generateNeighborPrefectureDetails(prefectureIdentity.neighbors, usedNames, nameDebug); + + const bridgeLimitedPathGroups = [ + "premodernRoads", + "minorRoads", + "nationalRoads", + "ringRoads", + "expressways", + "ringExpressways", + "icAccessRoads", + "externalRoads", + "externalExpressways", + "railways", + "branchRailways", + "ringRailways", + "externalRailways", + "abandonedRailways", + "preservedOldRoads", + ]; + premodernRoads = enforceBridgeLimitList(premodernRoads); + minorRoads = enforceBridgeLimitList(minorRoads); + nationalRoads = enforceBridgeLimitList(nationalRoads); + ringRoads = enforceBridgeLimitList(ringRoads); + expressways = enforceBridgeLimitList(expressways); + ringExpressways = enforceBridgeLimitList(ringExpressways); + icAccessRoads = enforceBridgeLimitList(icAccessRoads); + externalRoads = enforceBridgeLimitList(externalRoads); + externalExpressways = enforceBridgeLimitList(externalExpressways); + railways = enforceBridgeLimitList(railways); + branchRailways = enforceBridgeLimitList(branchRailways); + ringRailways = enforceBridgeLimitList(ringRailways); + externalRailways = enforceBridgeLimitList(externalRailways); + abandonedRailways = enforceBridgeLimitList(abandonedRailways); + preservedOldRoads = enforceBridgeLimitList(preservedOldRoads); + for (const pref of neighborPrefectureDetails.prefectures || []) { + pref.roads = enforceBridgeLimitList(pref.roads); + pref.railways = enforceBridgeLimitList(pref.railways); + } + neighborPrefectureDetails.roads = enforceBridgeLimitList(neighborPrefectureDetails.roads); + neighborPrefectureDetails.railways = enforceBridgeLimitList(neighborPrefectureDetails.railways); + const bridgeLimitDebug = { + maxBridgeCells: MAX_BRIDGE_CELLS, + maxWaterRun: Math.max( + 0, + ...[ + ...premodernRoads, + ...minorRoads, + ...nationalRoads, + ...ringRoads, + ...expressways, + ...ringExpressways, + ...icAccessRoads, + ...externalRoads, + ...externalExpressways, + ...railways, + ...branchRailways, + ...ringRailways, + ...externalRailways, + ...abandonedRailways, + ...preservedOldRoads, + ...(neighborPrefectureDetails.roads || []), + ...(neighborPrefectureDetails.railways || []), + ].map(pathMaxWaterRun) + ), + enforcedGroups: bridgeLimitedPathGroups, + }; + return applyOutputOptions({ width: MAP_W, height: MAP_H, cellSize: CELL_SIZE, + // Keep the apparent map scale close to the original 172-cell-wide version + // after increasing logical terrain resolution. + scaleKmPerCell: 172 / MAP_W, + prefectureName: prefectureIdentity.name, + prefectureLabel: prefectureIdentity.label, + neighborPrefectures: prefectureIdentity.neighbors, + neighborPrefectureDetails, terrainTemplate, seaLevel, prefectureMask, @@ -286,6 +754,7 @@ export function finishMapOutput({ smallStreams, externalGateways, transportDebug, + bridgeLimitDebug, entitiesForNames, nameDebug, }, options); diff --git a/mapTerrain.js b/mapTerrain.js index 5b47d9c..c8b8bc9 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -9,31 +9,39 @@ import { } 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 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.20 + rand(seed, 44) * 0.70; - const spineCount = rand(seed, 45) > 0.64 ? 2 : 1; + 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, - 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.66 + rand(seed, 49) * 0.44, - spineWidth: 0.060 + rand(seed, 50) * 0.050, - secondaryMountainCount: 3 + Math.floor(rand(seed, 51) * 5), - secondaryMountainSize: 0.060 + rand(seed, 52) * 0.085, - secondaryMountainStrength: 0.55 + rand(seed, 53) * 0.55, + 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, @@ -54,7 +62,7 @@ export function buildTerrainTemplate(seed) { erosion, roughness, ridgeJaggedness, - ridgeBranchiness: 0.28 + rand(seed, 55) * 0.62, + ridgeBranchiness: 0.26 + rand(seed, 55) * 0.58, }; } @@ -69,82 +77,172 @@ function jaggedRidgeContribution(x, y, ridge, seed) { 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; + // 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; - 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; + + // 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; - const spacing = spineIndex === 0 ? 0 : (spineIndex % 2 ? 0.18 : -0.18); - 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.18 + rand(seed, 730 + spineIndex) * 0.08), - 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); + 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 angle = template.spineAngle + (i - 0.5) * 0.17 + (rand(seed, 700 + i) - 0.5) * 0.18; - const spacing = i === 0 ? 0 : (i % 2 ? 0.18 : -0.18); - const x = 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45; - const y = 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45; - spines.push({ - x, y, angle, - width: template.spineWidth * (0.82 + rand(seed, 710 + i) * 0.38), - length: 0.78 + rand(seed, 720 + i) * 0.28, - h: template.spineStrength * (0.18 + rand(seed, 730 + i) * 0.08), - 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 = 3 + Math.floor(template.ridgeBranchiness * 5); + 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) * 0.62; + 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 = angle + side * (0.55 + rand(seed, 830 + i * 31 + b) * 0.72); + const branchAngle = spine.angle + side * (0.46 + rand(seed, 830 + i * 31 + b) * 0.88); branches.push({ - x: x + Math.cos(angle) * along, - y: y + Math.sin(angle) * along, + x: spine.x + Math.cos(spine.angle) * along, + y: spine.y + Math.sin(spine.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.085 + rand(seed, 860 + i * 31 + b) * 0.055), - curve: template.spineCurve * 0.45, - axisWobble: template.spineWidth * (0.32 + template.ridgeJaggedness * 0.72), - kinkFrequency: 14 + rand(seed, 870 + i * 31 + b) * 20, + 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.22 + template.ridgeJaggedness * 0.30, + 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, }); } } - return { spines, branches }; + + // 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) { @@ -187,7 +285,7 @@ export function generateTerrainAndRivers(seed) { 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 } = buildSpineRidges(seed, terrainTemplate); + const { spines, branches, auxRanges } = buildSpineRidges(seed, terrainTemplate); function coastPressureAt(x, y, wx = x, wy = y) { const nx = x / (MAP_W - 1) - 0.5; @@ -230,74 +328,130 @@ export function generateTerrainAndRivers(seed) { 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.08 + rand(seed, 400 + i) * 0.17), + 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.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 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.35) * blob.h; + mountains += Math.exp(-d * d * 2.70) * blob.h; } - 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 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); - const ridges = spineRidges + branchRidges; + for (const ridge of auxRanges) branchRidges += jaggedRidgeContribution(px, py, ridge, seed); - const coast = coastPressureAt(x, y, wx, wy); - const coastLower = coast.pressure; - // 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 rawElevation = - 0.30 * terrainLarge + - 0.235 * terrainRegional + - 0.105 * terrainLocal + - 0.055 * terrainFine + - mountains * 0.54 + - spineRidges * 0.78 + - branchRidges * 0.92 + - basin + - fineDissection - - coastLower * (coastStrength + 0.10 + terrainTemplate.deposition * 0.10) + - 0.055; + // 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; - elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26); - arcSpineField[i] = clamp(spineRidges * 3.7); - branchRidgeField[i] = clamp(branchRidges * 3.9); - ridgeField[i] = clamp(arcSpineField[i] * 0.86 + branchRidgeField[i] * 0.72 + Math.max(0, mountains - 0.10) * 0.95 + fineDissection * 2.0); - basinField[i] = clamp(Math.max(0, -basin) * 3.0 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * (0.48 + terrainTemplate.deposition * 0.42)); - 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); + // 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 coast = coastPressureAt(x, y); - const mountainToSea = ridgeField[i] * (1 - terrainTemplate.deposition) * 0.035; - const oceanSide = coast.pressure + mountainToSea > 0.56 + terrainTemplate.deposition * 0.035; - 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); + 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); } } @@ -423,6 +577,41 @@ export function generateTerrainAndRivers(seed) { } } + // 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)]; @@ -479,6 +668,34 @@ export function generateTerrainAndRivers(seed) { 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. @@ -489,8 +706,8 @@ export function generateTerrainAndRivers(seed) { 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.036 + terrainTemplate.erosion * 0.050 + slope[i] * (0.14 + terrainTemplate.erosion * 0.13) + ridgeField[i] * (0.022 + terrainTemplate.erosion * 0.044)) * incisionNoise); - const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * (0.044 + terrainTemplate.erosion * 0.064)); + 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; @@ -802,7 +1019,7 @@ export function generateTerrainAndRivers(seed) { } } - function sanitizeDownhillRiverPath(path, tolerance = 0.040) { + 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++) { @@ -810,7 +1027,7 @@ export function generateTerrainAndRivers(seed) { const [x, y] = path[k]; const pi = indexOf(px, py); const i = indexOf(x, y); - if (!sea[i] && elevation[i] > elevation[pi] + tolerance) break; + if (!sea[i] && elevation[i] > elevation[pi] + tolerance && flowAccum[i] < flowAccum[pi] + 0.025) break; out.push(path[k]); if (sea[i]) break; } @@ -823,14 +1040,14 @@ export function generateTerrainAndRivers(seed) { const [x, y] = path[start]; const i = indexOf(x, y); if (sea[i]) break; - if (elevation[i] <= 0.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) 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] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.032); + 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.026); + 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) { @@ -905,6 +1122,62 @@ export function generateTerrainAndRivers(seed) { } } + // 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); @@ -962,10 +1235,10 @@ export function generateTerrainAndRivers(seed) { 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)); + 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); @@ -1028,6 +1301,34 @@ export function generateTerrainAndRivers(seed) { 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); @@ -1039,9 +1340,9 @@ export function generateTerrainAndRivers(seed) { } // 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 = 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.022); + 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 diff --git a/mapUtils.js b/mapUtils.js index e0dff22..8d33ec1 100644 --- a/mapUtils.js +++ b/mapUtils.js @@ -1,6 +1,6 @@ -export const MAP_W = 172; -export const MAP_H = 122; -export const CELL_SIZE = 6; +export const MAP_W = 258; +export const MAP_H = 183; +export const CELL_SIZE = 4; export const SIZE = MAP_W * MAP_H; export const INF = 1e9; diff --git a/renderer.js b/renderer.js index 9c50c01..d62853f 100644 --- a/renderer.js +++ b/renderer.js @@ -261,12 +261,10 @@ function distToNearest(points, x, y, fallback = 999) { } function blendOutside(color, isInside) { - if (isInside) return color; - return [ - Math.round(color[0] * 0.8 + 50), - Math.round(color[1] * 0.8 + 50), - Math.round(color[2] * 0.8 + 50), - ]; + // Seamless-map mode: do not dim cells outside the currently named prefecture. + // The prefecture mask is still kept for statistics/admin debug, but the base + // terrain should read as one continuous region when the map is later panned. + return color; } function fieldSample(field, fx, fy) { @@ -316,14 +314,28 @@ function terrainColorContinuous(map, fx, fy, mode) { Math.round(230 + density * 10), ]; } else { - // 地形色を少し濃く(暗く)調整 + // 地形色は「細い山脈線」を直接塗らず、標高+広域山地性で読む。 + // 狭い arcSpineField を強く発色させると人工的な波線になるため、寄与を抑える。 const e = fieldSample(map.elevation, fx, fy); - if (e > 0.82) color = [210, 205, 195]; - else if (e > 0.68) color = [218, 215, 205]; - else if (e > 0.52) color = [220, 225, 210]; - else if (e > 0.34) color = [225, 230, 215]; - else if (e > 0.24) color = [230, 235, 220]; - else color = [238, 242, 228]; + const spine = map.arcSpineField ? fieldSample(map.arcSpineField, fx, fy) : 0; + const ridge = map.ridgeField ? fieldSample(map.ridgeField, fx, fy) : 0; + const rel = clamp((e - (map.seaLevel ?? 0.285) + 0.035) / 0.46); + const mountainRead = clamp(rel * 0.92 + ridge * 0.16 + spine * 0.045); + if (mountainRead > 0.86) color = [176, 166, 150]; + else if (mountainRead > 0.76) color = [192, 183, 166]; + else if (mountainRead > 0.62) color = [207, 199, 181]; + else if (mountainRead > 0.46) color = [221, 225, 204]; + else if (mountainRead > 0.29) color = [232, 238, 222]; + else color = [240, 244, 230]; + + const highRugged = clamp((ridge - 0.42) * 0.36 + (rel - 0.66) * 0.22); + if (highRugged > 0) { + color = [ + Math.round(color[0] - highRugged * 18), + Math.round(color[1] - highRugged * 15), + Math.round(color[2] - highRugged * 12), + ]; + } } return blendOutside(color, isInside); @@ -379,7 +391,7 @@ function drawBase(ctx, map, mode, continuousTerrain) { const eR = fieldSample(map.elevation, fx + 0.6, fy); const eU = fieldSample(map.elevation, fx, fy - 0.6); const eD = fieldSample(map.elevation, fx, fy + 0.6); - const shade = clamp(0.95 + (eR - eL) * 0.6 + (eD - eU) * 0.4, 0.85, 1.08); + const shade = clamp(0.965 + (eR - eL) * 1.24 + (eD - eU) * 0.86, 0.78, 1.17); const ii = (py * width + px) * 4; img.data[ii] = Math.round(r * shade); @@ -590,6 +602,136 @@ function drawLabels(ctx, points, limit = Infinity) { for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); } +function niceScaleKm(maxKm) { + const candidates = [1, 2, 5, 10, 20, 25, 50, 100, 200]; + let best = candidates[0]; + for (const c of candidates) { + if (c <= maxKm) best = c; + } + return best; +} + +function drawScaleBar(ctx, map) { + const kmPerCell = map.scaleKmPerCell || 1; + const width = MAP_W * CELL_SIZE; + const height = MAP_H * CELL_SIZE; + const maxPx = Math.min(150, width * 0.18); + const scaleKm = niceScaleKm(Math.max(1, maxPx / CELL_SIZE * kmPerCell)); + const barPx = Math.max(24, (scaleKm / kmPerCell) * CELL_SIZE); + const x = 18; + const y = height - 22; + const h = 6; + + ctx.save(); + ctx.font = '11px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + ctx.textBaseline = "bottom"; + ctx.fillStyle = "rgba(255, 255, 255, 0.72)"; + ctx.strokeStyle = "rgba(80, 80, 80, 0.34)"; + ctx.lineWidth = 1; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(x - 8, y - 22, barPx + 42, 29, 5); + else ctx.rect(x - 8, y - 22, barPx + 42, 29); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "rgba(60, 60, 60, 0.95)"; + ctx.fillRect(x, y - h, barPx / 2, h); + ctx.fillStyle = "rgba(245, 245, 245, 0.95)"; + ctx.fillRect(x + barPx / 2, y - h, barPx / 2, h); + ctx.strokeStyle = "rgba(55, 55, 55, 0.95)"; + ctx.lineWidth = 1.2; + ctx.strokeRect(x, y - h, barPx, h); + ctx.beginPath(); + ctx.moveTo(x, y - h - 3); + ctx.lineTo(x, y + 3); + ctx.moveTo(x + barPx / 2, y - h); + ctx.lineTo(x + barPx / 2, y + 2); + ctx.moveTo(x + barPx, y - h - 3); + ctx.lineTo(x + barPx, y + 3); + ctx.stroke(); + + ctx.fillStyle = "rgba(45, 45, 45, 0.95)"; + ctx.fillText(`${scaleKm} km`, x + barPx + 8, y + 2); + ctx.restore(); +} + + + +function drawNeighborPrefectureDetails(ctx, map, mode = "all") { + const details = map?.neighborPrefectureDetails; + if (!details || !details.prefectures?.length) return; + const showRoads = ["roads", "all", "development"].includes(mode); + const showModern = ["modern", "all", "development", "landuse", "roads", "admin", "admin-debug", "borders-debug"].includes(mode); + const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); + if (!showRoads && !showModern && !showAdmin) return; + + ctx.save(); + ctx.globalAlpha = 1.0; + if (showModern) { + for (const city of details.cities || []) { + const r = Math.max(6, Math.min(22, (city.urbanRadius || 8) * CELL_SIZE * 0.48)); + ctx.beginPath(); + ctx.arc(city.x * CELL_SIZE + CELL_SIZE / 2, city.y * CELL_SIZE + CELL_SIZE / 2, r, 0, Math.PI * 2); + ctx.fillStyle = "rgba(226, 216, 202, 0.52)"; + ctx.fill(); + } + for (const cbd of details.centralBusinessDistricts || []) { + ctx.fillStyle = "rgba(222, 168, 145, 0.68)"; + ctx.fillRect(cbd.x * CELL_SIZE - 1.5, cbd.y * CELL_SIZE - 1.5, CELL_SIZE + 3, CELL_SIZE + 3); + } + } + if (showRoads) { + for (const path of details.roads || []) drawPath(ctx, path, "rgba(190, 174, 118, 0.92)", 3.2); + for (const path of details.roads || []) drawPath(ctx, path, "rgba(248, 226, 122, 0.96)", 1.65); + } + if (showModern || showRoads) { + for (const path of details.railways || []) drawPath(ctx, path, "rgba(255,255,255,0.74)", 2.8); + for (const path of details.railways || []) drawRailway(ctx, path, "rgba(76, 76, 76, 0.86)", 1.15, 4.0, 6.0); + } + if (showModern || showAdmin) { + for (const p of details.adminCenters || []) dot(ctx, p, 2.0, "rgba(250,250,245,0.82)", "rgba(90,90,90,0.66)"); + for (const p of details.cities || []) { + const r = p.rank === "Neighbor Prefectural Capital" ? 5.4 : 3.8; + dot(ctx, p, r, "rgba(226, 104, 104, 0.86)", "rgba(255,255,255,0.78)"); + if (p.rank === "Neighbor Prefectural Capital") dot(ctx, p, r + 4.6, "rgba(255,255,255,0.05)", "rgba(225,95,95,0.55)"); + } + } + if (["all", "admin", "modern", "roads", "admin-debug", "borders-debug"].includes(mode)) { + ctx.globalAlpha = 0.72; + drawLabels(ctx, [...(details.cities || []), ...(showAdmin ? (details.adminCenters || []) : [])], showAdmin ? 64 : 36); + } + ctx.restore(); +} + +function drawPrefectureLabels(ctx, map, mode = "all") { + if (!map?.prefectureLabel && !(map?.neighborPrefectures || []).length) return; + const width = MAP_W * CELL_SIZE; + const height = MAP_H * CELL_SIZE; + const shouldShow = ["all", "modern", "roads", "development", "landuse", "admin", "admin-debug", "borders-debug"].includes(mode); + if (!shouldShow) return; + + ctx.save(); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.lineJoin = "round"; + + const drawPrefLabel = (label, isMain = false) => { + if (!label?.name) return; + const x = clamp(label.x * CELL_SIZE, 44, width - 44); + const y = clamp(label.y * CELL_SIZE - (isMain ? 14 : 8), 28, height - 34); + ctx.font = `${isMain ? 700 : 700} ${isMain ? 22 : 20}px "Hiragino Sans", "Yu Gothic", system-ui, sans-serif`; + ctx.lineWidth = isMain ? 5.2 : 4.8; + ctx.strokeStyle = "rgba(255, 255, 255, 0.72)"; + ctx.fillStyle = "rgba(75, 62, 90, 0.64)"; + ctx.strokeText(label.name, x, y); + ctx.fillText(label.name, x, y); + }; + + drawPrefLabel(map.prefectureLabel, true); + for (const label of map.neighborPrefectures || []) drawPrefLabel(label, false); + ctx.restore(); +} + export function drawMap(canvas, map, options) { const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -607,8 +749,8 @@ export function drawMap(canvas, map, options) { drawBase(ctx, map, mode, true); drawUrbanAreas(ctx, map, mode); const coastSegments = getCoastlineSegments(map); - drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06 }); + drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06 }); // 2. Rivers const waterBlue = "rgba(160, 205, 240, 1)"; @@ -621,22 +763,36 @@ export function drawMap(canvas, map, options) { const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode); const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); + drawNeighborPrefectureDetails(ctx, map, mode); + // 3. Borders if (showAdmin && map.adminBorders) { - drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); - drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, map.adminBorders, "rgba(145, 145, 145, 0.82)", 1.15, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); } if (mode === "admin-debug" || mode === "borders-debug") { drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`); if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true); for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)"); - if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } - - drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - if (!showFeatures) return; + // Draw all generated prefecture borders with the same weight. The old main + // prefecture border is shown only in debug modes so the normal view no longer + // reads as a single highlighted prefecture surrounded by context. + const prefectureBorderSegments = map.regionalPrefectureBorders?.length ? map.regionalPrefectureBorders : map.prefectureBorder; + if (prefectureBorderSegments?.length) { + drawVectorSegments(ctx, prefectureBorderSegments, "rgba(255, 255, 255, 0.82)", 4.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, prefectureBorderSegments, "rgba(110, 90, 120, 0.86)", 1.7, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + } + if ((mode === "admin-debug" || mode === "borders-debug") && map.prefectureBorder?.length) { + drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + } + if (showLabels) drawPrefectureLabels(ctx, map, mode); + + if (!showFeatures) { + drawScaleBar(ctx, map); + return; + } // 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways. if (showHistory) { @@ -646,8 +802,8 @@ export function drawMap(canvas, map, options) { for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35); } if (showRoads) { + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.62)", 2.8); for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); } if (showModern || showRoads) { @@ -668,8 +824,8 @@ export function drawMap(canvas, map, options) { for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false); } if (showRoads) { + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.96)", 1.25, false); for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); } if (showModern || showRoads) { @@ -700,10 +856,12 @@ export function drawMap(canvas, map, options) { if (showLabels) { if (mode === "admin") { drawLabels(ctx, map.adminCenters || [], Infinity); + drawScaleBar(ctx, map); return; } if (mode === "admin-debug" || mode === "borders-debug") { drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity); + drawScaleBar(ctx, map); return; } const important = [ @@ -713,4 +871,5 @@ export function drawMap(canvas, map, options) { ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); drawLabels(ctx, important, 60); } + drawScaleBar(ctx, map); } From 8e1ec7b9ac539d5fbbeccee2339d70227b50b077 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sat, 23 May 2026 18:06:01 +0900 Subject: [PATCH 2/6] terrain tweak? --- PATCH_NOTES_JP.md | 136 --- mapOutput.js | 479 +--------- mapPipeline.js | 4 +- mapTerrain.js | 1978 +++++++++++++++++++++++++++++++----------- mapTerrain.v4.bak.js | 1804 ++++++++++++++++++++++++++++++++++++++ names.js | 4 +- renderer.js | 356 ++++---- 7 files changed, 3428 insertions(+), 1333 deletions(-) delete mode 100644 PATCH_NOTES_JP.md create mode 100644 mapTerrain.v4.bak.js diff --git a/PATCH_NOTES_JP.md b/PATCH_NOTES_JP.md deleted file mode 100644 index 8ff3f27..0000000 --- a/PATCH_NOTES_JP.md +++ /dev/null @@ -1,136 +0,0 @@ -# 修正版メモ - -## 目的 - -日本風地図ジェネレータを、将来のシームレス・パン生成に寄せるため、中央の1県だけに注目する描画を弱め、全域を同格の地図として読める方向へ変更した。 -あわせて、論理解像度を上げ、地形生成では日本列島風の複数並行脊梁山脈と大規模な沖積低地が出やすくなるよう調整した。 - -## 変更点 - -### 1. 中央県フォーカスの解除 - -- `renderer.js` - - `blendOutside()` による県外減光を停止。 - - 通常表示では `prefectureBorder` ではなく `regionalPrefectureBorders` を主境界として描画。 - - 中央県境の強調表示は `admin-debug` / `borders-debug` のみに限定。 - - 隣接県ディテールの `globalAlpha` を `0.78` から `1.0` へ変更。 - - 隣接県ラベル表示数を増加。 - - 県名ラベルを `modern` / `roads` / `development` / `landuse` でも表示。 - -### 2. 論理解像度の向上 - -- `mapUtils.js` - - `MAP_W: 172 -> 258` - - `MAP_H: 122 -> 183` - - `CELL_SIZE: 6 -> 4` - - 表示キャンバスサイズは概ね維持しつつ、地形・河川・境界の格子感を減らす構成。 - -- `mapOutput.js` - - `scaleKmPerCell: 172 / MAP_W` を追加し、スケールバーの見た目を旧スケールに近づけた。 - -### 3. 複数並行脊梁山脈 - -- `mapTerrain.js` - - `spineCount` を従来の `1〜2本` から `2〜4本` に変更。 - - `spineSpacing` を追加。 - - 脊梁山脈の向きを海岸・列島軸に沿う方向へ寄せた。 - - 本数増加に合わせて、1本ごとの幅・強度をやや抑制。 - - `buildSpineRidges()` を中心線からの並行オフセット配置に変更。 - -### 4. 大規模沖積平野 - -- `mapTerrain.js` - - 強い河川の下流部を抽出し、下流〜河口周辺に広めの堆積低地を追加。 - - `depositionalLowland`, `deltaField`, `floodplain`, `valleyField`, `basinField` を連動更新。 - - 標高面も局所的に低く・滑らかに補正し、単なる色塗りではなく地形として平野化しやすくした。 - -## 注意点 - -- 論理解像度を約2.25倍にしたため、生成時間は旧版より伸びる。 -- `prefectureMask` 自体は統計・行政デバッグ用に残している。通常描画で目立たせない方針にした。 -- まだ完全なシームレス・パン生成ではない。今回の変更は、その前段階として「中央県主役の見た目」を解除し、地形解像度と地形構造を整えたもの。 - -## 簡易確認 - -Node.js で `generateMap()` が実行できることを確認済み。 - -```bash -node --input-type=module -e "import('./mapPipeline.js').then(({generateMap})=>console.log(generateMap(114514,{slim:true}).width))" -``` - -## 2026-05-22 地形生成パイプライン再構成 - -ユーザー指摘に基づき、地形生成の考え方を「既存ノイズ地形に山脈を足す」方式から、以下の段階式に寄せた。 - -1. 全体を海面下の地形として初期化 -2. 複数の平行な脊梁山脈で地殻を隆起 -3. 海面付近にランダムな平坦面を残し、氷期の海面低下・再海進後の段丘/沖積低地の種にする -4. 大域・中域・微細ノイズを後段で加算 -5. 河川作用を粗くシミュレートし、急峻な高流量セルでは侵食、低勾配の海岸・盆地・低地では堆積を強化 -6. その後、人文地理生成へ渡す - -主な実装修正: - -- `mapTerrain.js` - - 脊梁山脈の生成を、狭い稜線 + 広い隆起帯の二層構造に変更。 - - 中央部が一枚の高原に見えないよう、山脈間の低地・盆地を明示的に残すよう修正。 - - 海岸線生成を、旧来の方向性 `coastPressure` 判定中心から、標高が海面を超えるかどうかを主とする方式へ変更。 - - 海面付近に `glacialFlatLevel` を導入し、ランダムな平坦面を形成。 - - 河川網生成前に、流量・勾配に基づく粗い侵食/堆積パスを追加。 - - 河川経路の後処理で河川が短く切れすぎる問題を緩和し、主要河川が海へ接続しやすいよう修正。 - -確認: - -- `generateMap(114514, { slim: true })` の実行を確認。 -- `terrainDebug.riverConnectivityRate` が `1` になることを確認。 -- `depositionLowlandArea` が前版より広くなり、沖積低地が出やすくなった。 - -## 2026-05-22: 不連続・非中央固定の山脈生成に修正 - -### 問題 -- 脊梁山脈が画面中央を通る連続した帯になりやすかった。 -- 広い隆起帯が強すぎ、山頂部が平坦な高原に見えた。 -- 主脊梁以外の補助山地・補助山脈が弱く、地形構造が単調だった。 - -### 修正 -- 主脊梁山脈を 2〜3 系統にし、互いの間隔を広げた。 -- `spinePosition` と各山脈の横方向オフセットを強め、中央固定を緩和した。 -- 山脈を一本の連続線ではなく、沿線方向のノイズと波で途切れる構造にした。 -- 主脊梁の broad uplift を弱め、尾根本体を細くした。 -- 尾根の蛇行量・曲率・幅変化を強めた。 -- 渥美半島・北上高地的な補助山地/補助山脈として `auxRanges` を追加した。 -- 円形山塊の数を増やし、主脊梁周辺に副次的な高地が出やすいようにした。 - -### 意図 -地図上で「太い中央帯」ではなく、 - -- 離れた複数の山地帯 -- 断続的な主稜線 -- 周辺の補助山地 -- 山地間の低地・海岸低地 - -として読めるようにする。 - -## 追加修正: 地形帯の形状を現実の山地分布に寄せる - -前回版では、山脈が周期的な波線として見え、画面中央付近に太い帯状山地が出やすかった。これは、脊梁山脈の中心線を正弦波的に揺らし、細い `arcSpineField` を描画色に強く反映していたため。 - -今回の修正では以下を行った。 - -- 脊梁山脈の正弦波的な中心線揺れを廃止。 -- 山脈の途切れは周期波ではなく、低周波ノイズによる山塊の強弱として表現。 -- 主山脈は中央固定ではなく、島の横断方向に広くオフセットするよう変更。 -- 中国山地・四国山地のように、2本の山地帯が大きく離れるケースを増加。 -- 主山脈の長さを短めにし、画面端から端まで連続する一本線になりにくくした。 -- 渥美半島・北上高地のような補助山地を、主山脈周辺の短い山塊・外帯山地として追加。 -- 山頂が平らな高原に見える原因だった広域隆起成分を弱め、山地内部に侵食状の起伏を追加。 -- 描画側では `arcSpineField` の直接発色を弱め、標高・広域山地性・陰影で山地を読ませる方式に変更。 -- 山地色をやや濃くし、低い緑地との明度差を広げた。 - -## 追加修正: alpine_bands - -- 前版で高山帯がほぼ出なくなった問題を修正。 -- 脊梁山脈・補助山地の隆起量を再調整し、浸食後にも高標高セルが残るようにした。 -- 河川作用による侵食量をやや抑え、峰・稜線部分だけに「高山帯再強化」パスを追加。 -- 高山帯が平らな台地にならないよう、標高の持ち上げには局所ノイズを混ぜている。 -- 地形色パレットを調整し、標高と ridge field が高い地点はより茶色く表示される。 diff --git a/mapOutput.js b/mapOutput.js index 1477dfe..1d8c319 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -1,6 +1,6 @@ import { createNameDebug } from "./names.js"; import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js"; -import { aStar, applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; +import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; export function finishMapOutput({ seed, @@ -24,6 +24,8 @@ export function finishMapOutput({ settlementCluster, ridgeField, valleyField, + visibleRavineField, + surfaceTextureField, basinField, coastalLowland, flowAccum, @@ -115,396 +117,14 @@ export function finishMapOutput({ return out; } - - function generatedNameStem(fullName) { - return String(fullName || "").replace(/[都道府県市町村区]$/u, ""); - } - - function terrainSettlementScore(x, y, sideBias = null) { - const i = indexOf(x, y); - if (!inside(x, y) || sea[i]) return -INF; - const edgeBias = sideBias === "north" ? (MAP_H - y) / MAP_H - : sideBias === "south" ? y / MAP_H - : sideBias === "west" ? (MAP_W - x) / MAP_W - : sideBias === "east" ? x / MAP_W - : 0; - return settlementCluster[i] * 0.55 + plain[i] * 0.42 + agriculture[i] * 0.22 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.16 + edgeBias * 0.08 - slope[i] * 0.54 - ridgeField[i] * 0.22 - Math.max(0, elevation[i] - 0.62) * 1.25; - } - - function chooseSpacedPoints(candidates, count, minDistance, seedOffset = 0) { - const selected = []; - const ordered = candidates - .map((p, n) => ({ ...p, score: (p.score || 0) + rand(seed, seedOffset + n * 17 + p.x * 5 + p.y * 7) * 0.06 })) - .sort((a, b) => b.score - a.score); - for (const p of ordered) { - if (selected.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < minDistance)) continue; - selected.push(p); - if (selected.length >= count) break; - } - return selected; - } - - function labelCollisionScore(x, y, avoidPoints) { - let nearest = 99; - for (const p of avoidPoints) { - if (!p) continue; - const d = Math.hypot(x - p.x, y - p.y); - nearest = Math.min(nearest, d); - } - return nearest; - } - - const MAX_BRIDGE_CELLS = 7; - - function pointPair(p) { - if (Array.isArray(p)) return [p[0], p[1]]; - return [p.x, p.y]; - } - - function sampledWaterRunBetween(a, b) { - if (!a || !b) return 0; - const [ax, ay] = pointPair(a); - const [bx, by] = pointPair(b); - const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) * 1.6)); - let run = 0; - let maxRun = 0; - for (let k = 0; k <= steps; k++) { - const t = k / steps; - const x = clamp(Math.round(ax + (bx - ax) * t), 0, MAP_W - 1); - const y = clamp(Math.round(ay + (by - ay) * t), 0, MAP_H - 1); - const isWater = sea[indexOf(x, y)]; - if (isWater) { - run++; - maxRun = Math.max(maxRun, run); - } else { - run = 0; - } - } - return maxRun; - } - - function pathMaxWaterRun(path) { - if (!path || path.length < 2) return 0; - let maxRun = 0; - for (let k = 1; k < path.length; k++) { - maxRun = Math.max(maxRun, sampledWaterRunBetween(path[k - 1], path[k])); - } - return maxRun; - } - - function landDetourCost(x, y) { - if (!inside(x, y)) return INF; - const i = indexOf(x, y); - if (sea[i]) return INF; - return Math.max( - 0.35, - 1 + - slope[i] * 7.6 + - Math.max(0, elevation[i] - 0.58) * 10.5 + - ridgeField[i] * 2.2 - - plain[i] * 0.45 - - valleyField[i] * 0.54 - - coastalLowland[i] * 0.38 - ); - } - - function compactOutputPath(path) { - const out = []; - let last = ""; - for (const p of path || []) { - const [x, y] = pointPair(p); - const key = `${x},${y}`; - if (key === last) continue; - last = key; - out.push([x, y]); - } - return out; - } - - function repairLongBridgeSegments(path) { - const compact = compactOutputPath(path); - if (compact.length < 2) return compact.length >= 3 ? compact : []; - const out = [compact[0]]; - for (let k = 1; k < compact.length; k++) { - const from = out[out.length - 1]; - const to = compact[k]; - if (sampledWaterRunBetween(from, to) <= MAX_BRIDGE_CELLS) { - out.push(to); - continue; - } - const detour = compactOutputPath(aStar({ x: from[0], y: from[1] }, { x: to[0], y: to[1] }, landDetourCost)); - if (detour.length >= 2 && pathMaxWaterRun(detour) <= MAX_BRIDGE_CELLS) { - out.push(...detour.slice(1)); - } else { - return []; - } - } - const repaired = compactOutputPath(out); - return repaired.length >= 3 ? repaired : []; - } - - function enforceBridgeLimitList(paths) { - return (paths || []) - .map((path) => pathMaxWaterRun(path) > MAX_BRIDGE_CELLS ? repairLongBridgeSegments(path) : compactOutputPath(path)) - .filter((path) => path && path.length >= 3 && pathMaxWaterRun(path) <= MAX_BRIDGE_CELLS); - } - - function shoreContactScore(x, y) { - let contacts = 0; - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) { - if (!dx && !dy) continue; - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - if (!sea[indexOf(nx, ny)]) contacts++; - } - } - return contacts; - } - - function labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset, preferSea = false) { - const i = indexOf(x, y); - const nearest = labelCollisionScore(x, y, avoidPoints); - const capD = capital ? Math.hypot(x - capital.x, y - capital.y) : 24; - const fallbackD = fallback ? Math.hypot(x - fallback.x, y - fallback.y) : 0; - const terrainBias = sea[i] - ? 8 + Math.min(18, shoreContactScore(x, y)) * 0.45 - : plain[i] * 0.18 + basinField[i] * 0.10 + coastalLowland[i] * 0.10 - slope[i] * 0.08; - const clearance = Math.min(nearest, 24) * 1.55 - Math.max(0, 8 - nearest) * 5.0; - const capitalBias = capital ? -Math.max(0, capD - (preferSea ? 26 : 45)) * 0.09 : 0; - return clearance + terrainBias + capitalBias - fallbackD * 0.012 + rand(seed, seedOffset + x * 17 + y * 19) * 0.42; - } - - function pickPrefectureLabelPosition(avoidPoints, fallback, options = {}) { - const { - capital = null, - landPredicate = (x, y, i) => prefectureMask[i] && !sea[i], - areaPredicate = null, - seedOffset = 7461, - } = options; - let best = null; - let bestScore = -INF; - - // Prefer open water near the prefectural capital when it reads like a coastal prefecture label. - if (capital) { - const rMax = 30; - for (let dy = -rMax; dy <= rMax; dy += 2) { - for (let dx = -rMax; dx <= rMax; dx += 2) { - const x = clamp(Math.round(capital.x + dx), 0, MAP_W - 1); - const y = clamp(Math.round(capital.y + dy), 0, MAP_H - 1); - if (x < 6 || y < 6 || x > MAP_W - 7 || y > MAP_H - 7) continue; - const i = indexOf(x, y); - if (!sea[i]) continue; - if (areaPredicate && !areaPredicate(x, y)) continue; - const capD = Math.hypot(x - capital.x, y - capital.y); - if (capD < 5 || capD > rMax) continue; - const coastTouch = shoreContactScore(x, y); - if (coastTouch < 3 || coastTouch > 20) continue; - const score = labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset + 3000, true) + coastTouch * 0.35; - if (score > bestScore) { bestScore = score; best = { x, y, placement: "sea" }; } - } - } - if (best && bestScore >= 18) return best; - } - - best = null; - bestScore = -INF; - for (let y = 6; y < MAP_H - 6; y += 3) { - for (let x = 6; x < MAP_W - 6; x += 3) { - const i = indexOf(x, y); - if (!landPredicate(x, y, i)) continue; - const score = labelCandidateScore(x, y, avoidPoints, fallback, capital, seedOffset, false); - if (score > bestScore) { bestScore = score; best = { x, y, placement: "land" }; } - } - } - if (!best || bestScore < 10) return fallback; - return best; - } - - function looseOutsidePath(a, b, salt, keepOutside = true) { - if (!a || !b) return []; - const steps = Math.max(4, Math.ceil(Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)))); - const path = []; - let lastKey = ""; - const bend = (rand(seed, 7600 + salt) - 0.5) * 5.5; - const sideways = Math.abs(a.x - b.x) > Math.abs(a.y - b.y) ? "y" : "x"; - for (let k = 0; k <= steps; k++) { - const t = k / steps; - let x = Math.round(a.x + (b.x - a.x) * t); - let y = Math.round(a.y + (b.y - a.y) * t); - const wave = Math.sin(t * Math.PI) * bend; - if (sideways === "y") y = Math.round(y + wave); - else x = Math.round(x + wave); - x = clamp(x, 0, MAP_W - 1); - y = clamp(y, 0, MAP_H - 1); - const i = indexOf(x, y); - if (sea[i]) continue; - if (keepOutside && prefectureMask[i]) continue; - const key = `${x},${y}`; - if (key === lastKey) continue; - lastKey = key; - path.push([x, y]); - } - return path.length >= 3 ? repairLongBridgeSegments(path) : []; - } - - function generatePrefectureIdentity(usedNamesForIdentity, avoidPoints) { - const stemsA = ["青", "白", "黒", "高", "奥", "新", "東", "西", "南", "北", "中", "美", "豊", "若", "真", "清", "瑞", "長", "久", "安", "阿", "葛", "榛", "碓", "那", "鹿", "宇", "志", "遠", "羽"]; - const stemsB = ["森", "川", "野", "原", "沢", "島", "浦", "浜", "海", "山", "岳", "谷", "津", "崎", "里", "畑", "橋", "瀬", "井", "沼", "丘", "郷", "城", "坂", "泊", "戸", "湊", "庄"]; - const suffixes = ["県", "県", "県", "県", "県", "府"]; - const localUsed = new Set(usedNamesForIdentity || []); - function pickGeneratedName(offset) { - const a = stemsA[Math.floor(rand(seed, 7410 + offset * 11) * stemsA.length) % stemsA.length]; - const b = stemsB[Math.floor(rand(seed, 7420 + offset * 13) * stemsB.length) % stemsB.length]; - const c = suffixes[Math.floor(rand(seed, 7430 + offset * 17) * suffixes.length) % suffixes.length]; - return `${a}${b}${c}`; - } - function pickUniqueName(offset, preferred = null) { - let candidate = preferred || pickGeneratedName(offset); - let guard = 0; - while (localUsed.has(candidate) && guard++ < 24) candidate = pickGeneratedName(offset + guard + 3); - localUsed.add(candidate); - if (usedNamesForIdentity) usedNamesForIdentity.add(candidate); - return candidate; - } - - const capital = modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]); - const capStem = generatedNameStem(capital?.name); - const prefectureSuffix = suffixes[Math.floor(rand(seed, 7433) * suffixes.length) % suffixes.length]; - const capitalBased = capStem && rand(seed, 7440) < 0.80 ? `${capStem}${prefectureSuffix}` : null; - const name = pickUniqueName(0, capitalBased); - - let sx = 0, sy = 0, n = 0; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - sx += x; sy += y; n++; - } - } - const fallback = { x: n ? sx / n : MAP_W / 2, y: n ? sy / n : MAP_H / 2 }; - const labelPoint = pickPrefectureLabelPosition(avoidPoints, fallback, { - capital, - landPredicate: (x, y, i) => prefectureMask[i] && !sea[i], - seedOffset: 7461, - }); - const label = { name, x: labelPoint.x, y: labelPoint.y, kind: "Prefecture Label", placement: labelPoint.placement || "land" }; - - const sideSamples = { north: [], south: [], west: [], east: [] }; - for (let x = 2; x < MAP_W - 2; x += 4) { - for (let y = 0; y < Math.min(13, MAP_H); y++) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.north.push({ x, y: 3 }); break; } - for (let y = MAP_H - 1; y >= Math.max(0, MAP_H - 13); y--) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.south.push({ x, y: MAP_H - 4 }); break; } - } - for (let y = 2; y < MAP_H - 2; y += 4) { - for (let x = 0; x < Math.min(13, MAP_W); x++) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.west.push({ x: 3, y }); break; } - for (let x = MAP_W - 1; x >= Math.max(0, MAP_W - 13); x--) if (inside(x, y) && !prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]) { sideSamples.east.push({ x: MAP_W - 4, y }); break; } - } - - const neighbors = []; - const sideOrder = ["north", "east", "south", "west"]; - for (let si = 0; si < sideOrder.length; si++) { - const side = sideOrder[si]; - const samples = sideSamples[side]; - if (!samples.length && rand(seed, 7500 + si) < 0.45) continue; - const nm = pickUniqueName(si + 1); - const p = samples.length ? samples[Math.floor(rand(seed, 7520 + si) * samples.length) % samples.length] : ( - side === "north" ? { x: MAP_W * 0.5, y: 3 } : side === "south" ? { x: MAP_W * 0.5, y: MAP_H - 4 } : side === "west" ? { x: 3, y: MAP_H * 0.5 } : { x: MAP_W - 4, y: MAP_H * 0.5 } - ); - neighbors.push({ name: nm, side, x: p.x, y: p.y, kind: "Neighbor Prefecture Label" }); - } - return { name, label, neighbors }; - } - - function generateNeighborPrefectureDetails(neighborLabels, usedNamesForNeighbor, nameDebugForNeighbor) { - const allCities = []; - const allAdmins = []; - const allCbds = []; - const allRoads = []; - const allRailways = []; - const details = []; - const sideBand = { - north: (x, y) => y <= Math.floor(MAP_H * 0.30), - south: (x, y) => y >= Math.ceil(MAP_H * 0.70), - west: (x, y) => x <= Math.floor(MAP_W * 0.34), - east: (x, y) => x >= Math.ceil(MAP_W * 0.66), - }; - for (let ni = 0; ni < neighborLabels.length; ni++) { - const label = neighborLabels[ni]; - const predicate = sideBand[label.side] || (() => true); - const candidates = []; - for (let y = 2; y < MAP_H - 2; y += 2) { - for (let x = 2; x < MAP_W - 2; x += 2) { - const i = indexOf(x, y); - if (prefectureMask[i] || sea[i] || !predicate(x, y)) continue; - const dToLabel = Math.hypot(x - label.x, y - label.y); - const borderAway = label.side === "north" ? y : label.side === "south" ? MAP_H - 1 - y : label.side === "west" ? x : MAP_W - 1 - x; - const score = terrainSettlementScore(x, y, label.side) - dToLabel * 0.006 + Math.min(16, borderAway) * 0.006; - if (score > 0.06) candidates.push({ x, y, score, neighborIndex: ni, neighborName: label.name, side: label.side }); - } - } - const rawCities = chooseSpacedPoints(candidates, 2 + Math.floor(rand(seed, 7700 + ni) * 3), 12, 7710 + ni * 100) - .map((p, n) => { - const rank = n === 0 ? "Neighbor Prefectural Capital" : n === 1 ? "Neighbor Regional Center" : "Neighbor City"; - const popBase = n === 0 ? 240000 : n === 1 ? 90000 : 36000; - const popSpread = n === 0 ? 620000 : n === 1 ? 220000 : 90000; - const population = Math.round((popBase + popSpread * Math.pow(clamp(p.score + rand(seed, 7730 + ni * 31 + n), 0, 1), 1.8)) / 1000) * 1000; - return { ...p, kind: rank, rank, population, urbanRadius: clamp(7 + Math.sqrt(population) / 105, 7, 21), coreRadius: clamp(2.4 + Math.sqrt(population) / 420, 2.4, 6.2), urbanWeight: 1.0 + Math.log10(Math.max(10000, population)) * 0.23, insidePrefecture: false }; - }); - const namedCities = attachIdsAndNames(rawCities, `neighborCity${ni}`, seed + ni * 100, null, nameFields, usedNamesForNeighbor, nameDebugForNeighbor); - const adminCandidates = chooseSpacedPoints(candidates.filter((p) => !namedCities.some((c) => Math.hypot(c.x - p.x, c.y - p.y) < 7)), 4 + Math.floor(rand(seed, 7760 + ni) * 5), 8, 7770 + ni * 100) - .map((p) => ({ ...p, kind: "Neighbor Municipal Center", insidePrefecture: false })); - const namedAdmins = attachIdsAndNames(adminCandidates, `neighborAdmin${ni}`, seed + ni * 131, "Neighbor Municipal Center", nameFields, usedNamesForNeighbor, nameDebugForNeighbor); - const cbds = namedCities.map((city, ci) => ({ x: city.x, y: city.y, parentId: city.id, parentName: city.name, name: `${city.name}CBD`, kind: ci === 0 ? "Neighbor Central Business District" : "Neighbor Urban Center", neighborIndex: ni, neighborName: label.name, insidePrefecture: false })); - const nodes = [...namedCities, ...namedAdmins].sort((a, b) => (b.population || 0) - (a.population || 0) || (b.score || 0) - (a.score || 0)); - const roads = []; - const rails = []; - for (let i = 1; i < nodes.length; i++) { - const target = nodes[i]; - const anchor = nodes.slice(0, i).sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0]; - const road = looseOutsidePath(anchor, target, ni * 400 + i * 17, true); - if (road.length >= 3) roads.push(road); - if (i <= 2 && rand(seed, 7790 + ni * 19 + i) > 0.30) { - const rail = looseOutsidePath(anchor, target, ni * 500 + i * 23, true); - if (rail.length >= 4) rails.push(rail); - } - } - if (nodes[0]) { - const gate = { x: label.x, y: label.y }; - const gatewayRoad = looseOutsidePath(nodes[0], gate, ni * 600 + 7, true); - if (gatewayRoad.length >= 3) roads.push(gatewayRoad); - } - const labelAvoid = [...namedCities, ...namedAdmins, ...allCities, ...allAdmins]; - const labelCandidates = candidates.length ? candidates : [{ x: label.x, y: label.y, score: 0.1 }]; - const fallbackLabel = chooseSpacedPoints(labelCandidates, 1, 1, 7810 + ni)[0] || label; - const capitalPoint = namedCities[0] || fallbackLabel; - const betterLabel = pickPrefectureLabelPosition(labelAvoid, fallbackLabel, { - capital: capitalPoint, - landPredicate: (x, y, i) => !prefectureMask[i] && !sea[i] && predicate(x, y), - areaPredicate: predicate, - seedOffset: 7810 + ni * 97, - }) || fallbackLabel; - label.x = betterLabel.x; - label.y = betterLabel.y; - label.placement = betterLabel.placement || "land"; - allCities.push(...namedCities); - allAdmins.push(...namedAdmins); - allCbds.push(...cbds); - allRoads.push(...roads); - allRailways.push(...rails); - details.push({ ...label, cities: namedCities, adminCenters: namedAdmins, centralBusinessDistricts: cbds, roads, railways: rails }); - } - return { prefectures: details, cities: allCities, adminCenters: allAdmins, centralBusinessDistricts: allCbds, roads: allRoads, railways: allRailways }; - } - // Bridge and tunnel icon systems were removed from the visual model. // Arrays remain empty for backward-compatible tests and downstream code. const bridges = []; const tunnels = []; const harborWorks = makeHarborWorks(ports); - let abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); + const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); - let preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); + const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity }; const usedNames = new Set(); const nameDebug = createNameDebug(); @@ -585,96 +205,10 @@ export function finishMapOutput({ ...externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - const avoidForPrefectureLabel = [ - ...modernCities, - ...ports, - ...markets, - ...castles, - ...castleTowns, - ...adminCenters, - ...stations, - ...satelliteCities, - ...newTowns, - ].filter((p) => p.insidePrefecture && p.name); - const prefectureIdentity = generatePrefectureIdentity(usedNames, avoidForPrefectureLabel); - const neighborPrefectureDetails = generateNeighborPrefectureDetails(prefectureIdentity.neighbors, usedNames, nameDebug); - - const bridgeLimitedPathGroups = [ - "premodernRoads", - "minorRoads", - "nationalRoads", - "ringRoads", - "expressways", - "ringExpressways", - "icAccessRoads", - "externalRoads", - "externalExpressways", - "railways", - "branchRailways", - "ringRailways", - "externalRailways", - "abandonedRailways", - "preservedOldRoads", - ]; - premodernRoads = enforceBridgeLimitList(premodernRoads); - minorRoads = enforceBridgeLimitList(minorRoads); - nationalRoads = enforceBridgeLimitList(nationalRoads); - ringRoads = enforceBridgeLimitList(ringRoads); - expressways = enforceBridgeLimitList(expressways); - ringExpressways = enforceBridgeLimitList(ringExpressways); - icAccessRoads = enforceBridgeLimitList(icAccessRoads); - externalRoads = enforceBridgeLimitList(externalRoads); - externalExpressways = enforceBridgeLimitList(externalExpressways); - railways = enforceBridgeLimitList(railways); - branchRailways = enforceBridgeLimitList(branchRailways); - ringRailways = enforceBridgeLimitList(ringRailways); - externalRailways = enforceBridgeLimitList(externalRailways); - abandonedRailways = enforceBridgeLimitList(abandonedRailways); - preservedOldRoads = enforceBridgeLimitList(preservedOldRoads); - for (const pref of neighborPrefectureDetails.prefectures || []) { - pref.roads = enforceBridgeLimitList(pref.roads); - pref.railways = enforceBridgeLimitList(pref.railways); - } - neighborPrefectureDetails.roads = enforceBridgeLimitList(neighborPrefectureDetails.roads); - neighborPrefectureDetails.railways = enforceBridgeLimitList(neighborPrefectureDetails.railways); - const bridgeLimitDebug = { - maxBridgeCells: MAX_BRIDGE_CELLS, - maxWaterRun: Math.max( - 0, - ...[ - ...premodernRoads, - ...minorRoads, - ...nationalRoads, - ...ringRoads, - ...expressways, - ...ringExpressways, - ...icAccessRoads, - ...externalRoads, - ...externalExpressways, - ...railways, - ...branchRailways, - ...ringRailways, - ...externalRailways, - ...abandonedRailways, - ...preservedOldRoads, - ...(neighborPrefectureDetails.roads || []), - ...(neighborPrefectureDetails.railways || []), - ].map(pathMaxWaterRun) - ), - enforcedGroups: bridgeLimitedPathGroups, - }; - return applyOutputOptions({ width: MAP_W, height: MAP_H, cellSize: CELL_SIZE, - // Keep the apparent map scale close to the original 172-cell-wide version - // after increasing logical terrain resolution. - scaleKmPerCell: 172 / MAP_W, - prefectureName: prefectureIdentity.name, - prefectureLabel: prefectureIdentity.label, - neighborPrefectures: prefectureIdentity.neighbors, - neighborPrefectureDetails, terrainTemplate, seaLevel, prefectureMask, @@ -696,6 +230,8 @@ export function finishMapOutput({ settlementCluster, ridgeField, valleyField, + visibleRavineField, + surfaceTextureField, basinField, coastalLowland, flowAccum, @@ -754,7 +290,6 @@ export function finishMapOutput({ smallStreams, externalGateways, transportDebug, - bridgeLimitDebug, entitiesForNames, nameDebug, }, options); diff --git a/mapPipeline.js b/mapPipeline.js index 519e435..841334f 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -25,6 +25,8 @@ export function generateMap(seedInput = 114514, options = {}) { agriculture, ridgeField, valleyField, + visibleRavineField, + surfaceTextureField, basinField, coastalLowland, flowAccum, @@ -65,7 +67,7 @@ export function generateMap(seedInput = 114514, options = {}) { return finishMapOutput({ seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, - elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, + elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity, railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, diff --git a/mapTerrain.js b/mapTerrain.js index c8b8bc9..ef6e25f 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -9,51 +9,84 @@ import { } 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 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.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 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; + const spineAngle = coastAngle + Math.PI * (0.28 + rand(seed, 46) * 0.44); + const spineCurve = (rand(seed, 47) - 0.5) * 0.28; + const spinePosition = (rand(seed, 48) - 0.5) * 0.56; + const backboneLongShift = (rand(seed, 68) - 0.5) * 0.34; + const backboneCenterX = clamp(0.5 + Math.cos(spineAngle + Math.PI / 2) * spinePosition * 0.36 + Math.cos(spineAngle) * backboneLongShift, 0.18, 0.82); + const backboneCenterY = clamp(0.5 + Math.sin(spineAngle + Math.PI / 2) * spinePosition * 0.36 + Math.sin(spineAngle) * backboneLongShift, 0.18, 0.82); + const backboneLength = 0.50 + rand(seed, 69) * 0.28; + const backboneWidth = 0.12 + rand(seed, 83) * 0.12; + const backboneScratchCount = 34 + Math.floor(rand(seed, 84) * 30); 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), + spineAngle, + spineCurve, + spinePosition, + backboneCenterX, + backboneCenterY, + backboneLength, + backboneWidth, + backboneScratchCount, + 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.905 + rand(seed, 66) * 0.030, + peakSoftCap: 1.010 + rand(seed, 67) * 0.020, + 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.50 + rand(seed, 74) * 0.48, + // 粗い格子でも山肌の複雑さが出るよう、 + // 細谷の「見え」は手続きノイズ寄りにし、明示的な小流路本数は抑える。 + drainageDensity: 0.56 + rand(seed, 75) * 0.22, + gullyIncision: 0.18 + rand(seed, 76) * 0.10, + dendriticTexture: 0.72 + rand(seed, 77) * 0.24, + macroNoiseStrength: 0.018 + rand(seed, 85) * 0.014, + macroNoiseScale: 0.028 + rand(seed, 86) * 0.018, + globalNoiseStrength: 0.010 + rand(seed, 87) * 0.010, + headwaterGullyCount: 0, + alpineMicroRelief: 0.018 + rand(seed, 79) * 0.022, + basinOutletCount: 10 + Math.floor(rand(seed, 80) * 8), + meanderStrength: 0.18 + rand(seed, 81) * 0.16, + terrainNoisePatchiness: 0.42 + rand(seed, 82) * 0.28, 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, + penetration: 0.18 + rand(seed, 58) * 0.16, inletStrength: 0.18 + rand(seed, 59) * 0.56, plainWidth: sideAPlain, }, { - penetration: 0.24 + rand(seed, 60) * 0.24, + penetration: 0.18 + rand(seed, 60) * 0.16, inletStrength: 0.18 + rand(seed, 61) * 0.56, plainWidth: sideBPlain, }, @@ -62,7 +95,9 @@ export function buildTerrainTemplate(seed) { erosion, roughness, ridgeJaggedness, - ridgeBranchiness: 0.26 + rand(seed, 55) * 0.58, + ridgeBranchiness: 0.44 + rand(seed, 55) * 0.66, + detachedRangeCount: 0, + alpinePeakCount: 0, }; } @@ -77,172 +112,435 @@ function jaggedRidgeContribution(x, y, ridge, seed) { 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; + // 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; - - // 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, - }; + 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; - 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); - } - } + 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 = []; - const auxRanges = []; - for (let i = 0; i < template.spineCount; i++) { - const spine = makePrimarySpine(seed, template, i); - spines.push(spine); + const centerX = template.backboneCenterX ?? 0.5; + const centerY = template.backboneCenterY ?? 0.5; + const baseAngle = template.spineAngle; + const axisCos = Math.cos(baseAngle); + const axisSin = Math.sin(baseAngle); + const crossCos = Math.cos(baseAngle + Math.PI / 2); + const crossSin = Math.sin(baseAngle + Math.PI / 2); + const corridorLength = template.backboneLength ?? 0.62; + const corridorWidth = template.backboneWidth ?? 0.16; - 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, + for (let i = 0; i < template.spineCount; i++) { + const along = ((i / Math.max(1, template.spineCount - 1)) - 0.5) * corridorLength * 0.48 + (rand(seed, 705 + i) - 0.5) * corridorLength * 0.12; + const cross = (rand(seed, 706 + i) - 0.5) * corridorWidth * 0.45; + const angle = baseAngle + (rand(seed, 700 + i) - 0.5) * 0.16; + const x = clamp(centerX + axisCos * along + crossCos * cross, 0.06, 0.94); + const y = clamp(centerY + axisSin * along + crossSin * cross, 0.06, 0.94); + spines.push({ + x, y, angle, + width: template.spineWidth * (0.52 + rand(seed, 710 + i) * 0.28), + length: corridorLength * (0.58 + rand(seed, 720 + i) * 0.18), + h: template.spineStrength * (0.11 + rand(seed, 730 + i) * 0.08), + curve: template.spineCurve, + axisWobble: template.spineWidth * (0.42 + template.ridgeJaggedness * 0.95), + kinkFrequency: 10 + rand(seed, 740 + i) * 16, + kinkPhase: rand(seed, 750 + i) * Math.PI * 2, + seedOffset: 7600 + i * 211, + widthVariation: 0.18 + template.ridgeJaggedness * 0.34, + }); + } + + const scratchCount = template.backboneScratchCount ?? (36 + Math.floor(template.ridgeBranchiness * 30)); + for (let b = 0; b < scratchCount; b++) { + const centerBias = ((rand(seed, 810 + b) + rand(seed, 811 + b)) * 0.5 - 0.5) * 2; + const edgeBias = (rand(seed, 812 + b) - 0.5) * 2; + const along = centerBias * corridorLength * 0.82; + const corridorT = clamp(1 - Math.abs(centerBias)); + const lateralSpread = corridorWidth * (0.48 + corridorT * 0.72); + const cross = edgeBias * lateralSpread; + const radialT = clamp(1 - Math.abs(edgeBias)); + const density = clamp(corridorT * 0.72 + radialT * 0.28); + const x = clamp(centerX + axisCos * along + crossCos * cross, 0.04, 0.96); + const y = clamp(centerY + axisSin * along + crossSin * cross, 0.04, 0.96); + const branchAngle = baseAngle + (rand(seed, 830 + b) - 0.5) * 0.92 + edgeBias * 0.20; + branches.push({ + x, + y, + angle: branchAngle, + width: template.spineWidth * (0.16 + rand(seed, 840 + b) * 0.16 + density * 0.08), + length: 0.07 + rand(seed, 850 + b) * 0.13 + density * 0.06, + h: template.spineStrength * (0.018 + density * 0.066 + rand(seed, 860 + b) * 0.018), + curve: template.spineCurve * 0.22, + axisWobble: template.spineWidth * (0.22 + template.ridgeJaggedness * 0.58), + kinkFrequency: 15 + rand(seed, 870 + b) * 22, + kinkPhase: rand(seed, 880 + b) * Math.PI * 2, + seedOffset: 8800 + b * 37, + widthVariation: 0.16 + template.ridgeJaggedness * 0.20, + }); + } + return { spines, branches }; +} + + +function softUpperClamp(value, start = 0.8, cap = 0.96) { + if (value <= start) return value; + if (value <= cap) { + const t = clamp((value - start) / Math.max(0.001, cap - start)); + // 肩へ向かって緩やかに圧縮するが、cap 未満ではなるべく差を残す。 + return lerp(value, start + (cap - start) * (1 - Math.pow(1 - t, 1.18)), 0.16); + } + const overflow = value - cap; + // cap 超過分も少し残して、山頂が一様な平頂面にならないようにする。 + return cap + overflow * (0.28 / (1 + overflow * 4.2)); +} + +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; +} - // 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, + +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: 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, + 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 { spines, branches, auxRanges }; + 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; +} + +// v5: 「全域を海底として初期化し、海底から広域隆起で山地を生やす」ための造山場。 +// 周期的な褶曲波はワッフル状の縞を作るため廃止し、広い帯状隆起・不均質な断層谷・ +// 連続した尾根核を domain-warp 付きで合成する。 +function foldedOrogenyAt(px, py, seed, template, coastLower = 0) { + const baseAngle = template.spineAngle + (rand(seed, 13001) - 0.5) * 0.28; + const warpX = (fbm(px * 2.0 + 17, py * 2.0 - 31, seed + 13010) - 0.5) * 0.20; + const warpY = (fbm(px * 2.1 - 43, py * 2.1 + 19, seed + 13020) - 0.5) * 0.20; + const x = px + warpX; + const y = py + warpY; + + let beltMass = 0; + let ridgeCores = 0; + let structuralValleys = 0; + const beltCount = 3; + + for (let k = 0; k < beltCount; k++) { + const angle = baseAngle + (k - 1) * 0.24 + (rand(seed, 13100 + k) - 0.5) * 0.26; + const ca = Math.cos(angle); + const sa = Math.sin(angle); + const along = x * ca + y * sa; + const cross = -x * sa + y * ca; + const offset = (rand(seed, 13120 + k) - 0.5) * 0.38 + (k - 1) * 0.10; + const axisWarp = (fbm(px * 1.65 + k * 11, py * 1.65 - k * 7, seed + 13200 + k) - 0.5) * (0.15 + template.ridgeJaggedness * 0.08); + const lengthWarp = (valueNoise(px * 1.3 - k * 17, py * 1.3 + k * 13, seed + 13250 + k, 2.4) - 0.5) * 0.34; + const localCross = cross - offset - axisWarp; + const localAlong = along + lengthWarp; + const width = 0.24 + rand(seed, 13300 + k) * 0.14; + const coreWidth = 0.058 + rand(seed, 13320 + k) * 0.046; + const lengthFade = smoothstep(1.38 - Math.abs(localAlong - 0.52)); + const broad = Math.exp(-(localCross * localCross) / (width * width)) * lengthFade; + const core = Math.exp(-(localCross * localCross) / (coreWidth * coreWidth)) * lengthFade; + const broken = 0.72 + valueNoise(px * 2.2 + k * 21, py * 2.2 - k * 15, seed + 13400 + k, 2.1) * 0.46; + const rugged = 0.74 + fbm(px * 4.3 + k * 19, py * 4.3 - k * 23, seed + 13480 + k) * 0.54; + beltMass += broad * (0.285 + k * 0.040) * broken; + ridgeCores += core * (0.340 + k * 0.030) * rugged; + + // 河川が後で選びやすい弱線。周期的な縞ではなく、ノイズで途切れる断層・構造谷として扱う。 + const valleyNoise = Math.max(0, valueNoise(px * 5.2 + k * 31, py * 5.2 - k * 27, seed + 13540 + k, 3.2) - 0.55); + const transverse = Math.exp(-((localAlong - (0.26 + rand(seed, 13600 + k) * 0.52)) ** 2) / 0.030); + structuralValleys += broad * (valleyNoise * 0.18 + transverse * 0.035); + } + + const broadA = fbm(px * 0.80 + 23, py * 0.80 - 61, seed + 13700); + const broadB = valueNoise(px * 1.35 - 41, py * 1.35 + 17, seed + 13710, 2.4); + const continentalPulse = clamp((broadA * 0.58 + broadB * 0.42 - 0.22) / 0.66); + const edgeDistance = Math.min(px, py, 1 - px, 1 - py); + const edgeDrowning = 1 - smoothstep(edgeDistance / 0.16); + const marineLoss = edgeDrowning * (0.22 + template.deposition * 0.10) + coastLower * 0.035; + + const mass = clamp(beltMass * 0.95 + continentalPulse * 0.40 + template.orographicCoverage * 0.12 - structuralValleys * 0.78 - marineLoss * 0.18); + const ridges = clamp(ridgeCores * 1.18 + mass * 0.20 - structuralValleys * 0.64); + const uplift = clamp((mass * 0.62 + ridges * 0.34) * template.orographicStrength); + return { uplift, ridges, valleys: clamp(structuralValleys * 5.0) }; +} + + +// v6: 谷状の細かな侵食テクスチャ。流路網のない場所も「沢に削られた山肌」に見せる。 +// 高周波ノイズをそのまま標高に足すのではなく、ノイズ場の細い等値線だけを抽出して +// 傾斜・標高・湿潤度でマスクする。 +function dendriticRavineTexture(x, y, seed) { + const warpX = (fbm(x * 0.72 + 113, y * 0.72 - 71, seed + 15010) - 0.5) * 9.5; + const warpY = (fbm(x * 0.74 - 59, y * 0.74 + 97, seed + 15020) - 0.5) * 9.5; + const wx = x + warpX; + const wy = y + warpY; + + const n1 = valueNoise(wx * 1.15 + 31, wy * 1.15 - 47, seed + 15100, 8.0); + const n2 = valueNoise(wx * 2.05 - 19, wy * 2.05 + 23, seed + 15110, 4.8); + const n3 = valueNoise(wx * 3.65 + 71, wy * 3.65 - 11, seed + 15120, 3.0); + const line1 = Math.pow(clamp(1 - Math.abs(n1 - 0.50) * 6.1), 3.4); + const line2 = Math.pow(clamp(1 - Math.abs(n2 - 0.47) * 7.6), 3.0); + const line3 = Math.pow(clamp(1 - Math.abs(n3 - 0.52) * 9.0), 2.6); + + const join = valueNoise(wx * 0.55 + 7, wy * 0.55 - 5, seed + 15140, 11.0); + return clamp(line1 * 0.48 + line1 * line2 * 0.44 + line2 * line3 * 0.26 + line3 * Math.max(0, join - 0.58) * 0.36); +} + +function addPathIncision(field, path, strength, radius = 1) { + if (!path || path.length < 2) return; + for (let k = 0; k < path.length; k++) { + const [px, py] = path[k]; + const downstream = k / Math.max(1, path.length - 1); + const local = strength * (0.72 + downstream * 0.44); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = px + dx; + const ny = py + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (d > radius + 0.15) continue; + const w = d < 0.001 ? 1 : Math.max(0, 1 - d / (radius + 0.35)) * 0.52; + const i = indexOf(nx, ny); + field[i] = clamp(field[i] + local * w); + } + } + } +} + +// v7: line-only carving is too thin and looks like a texture overlay. This helper +// converts traced drainage paths into a distance field with a V-shaped cross-section: +// small headwater gullies stay narrow, while longer/merged channels open a slightly +// wider valley floor. The visual result is produced by terrain change, not by merely +// drawing more blue river lines. +function addValleyDistanceInfluence(incisionField, floorField, path, strength, radius = 2, floorRadius = 0.55) { + if (!path || path.length < 2) return; + for (let k = 0; k < path.length; k++) { + const [px, py] = path[k]; + const downstream = k / Math.max(1, path.length - 1); + const localRadius = Math.max(1.1, radius * (0.68 + downstream * 0.56)); + const localStrength = strength * (0.62 + downstream * 0.70); + const r = Math.ceil(localRadius + 1.2); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const nx = px + dx; + const ny = py + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (d > localRadius + 1.0) continue; + const i = indexOf(nx, ny); + const vShape = Math.pow(clamp(1 - d / (localRadius + 0.55)), 1.55); + const floor = smoothstep((floorRadius + 0.25 - d) / Math.max(0.35, floorRadius + 0.25)); + incisionField[i] = clamp(incisionField[i] + localStrength * vShape * 0.48); + floorField[i] = clamp(floorField[i] + localStrength * floor * 0.42); + } + } + } +} + +function applyAlpineMicroRelief(elevation, sea, slope, ridgeField, valleyField, coastalLowland, seaLevel, seed, terrainTemplate, surfaceTextureField = null) { + 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 highland = clamp((elevation[i] - (seaLevel + 0.12)) / 0.36); + const alpineMask = clamp((elevation[i] - (seaLevel + 0.20)) / 0.28) * clamp(slope[i] * 1.15 + ridgeField[i] * 0.44 - valleyField[i] * 0.26) * (1 - coastalLowland[i] * 0.75); + const ruggedMask = clamp(highland * (0.28 + slope[i] * 0.95 + ridgeField[i] * 0.38 - valleyField[i] * 0.16)); + const patch = clamp(0.35 + (fbm(x * 0.055 + 80, y * 0.055 - 34, seed + 18018) - 0.5) * 1.45 + terrainTemplate.terrainNoisePatchiness * 0.22); + const activeMask = Math.max(alpineMask, ruggedMask * 0.56) * (0.52 + patch * 0.48); + if (activeMask <= 0.02) continue; + const warpX = x + (fbm(x * 0.18 + 24, y * 0.18 - 17, seed + 18021) - 0.5) * 5.5; + const warpY = y + (fbm(x * 0.18 - 37, y * 0.18 + 13, seed + 18022) - 0.5) * 5.5; + const coarse = (valueNoise(warpX * 0.72, warpY * 0.72, seed + 18023, 4.8) - 0.5) * 2; + const medium = (valueNoise(warpX * 1.18 - 11, warpY * 1.18 + 19, seed + 18024, 6.7) - 0.5) * 2; + const fine = (valueNoise(warpX * 1.95 + 17, warpY * 1.95 - 9, seed + 18025, 9.4) - 0.5) * 2; + const ridged = 1 - Math.abs((valueNoise(warpX * 1.36 - 7, warpY * 1.36 + 21, seed + 18026, 6.2) - 0.5) * 2); + const cellular = (fbm(warpX * 0.52 + 9, warpY * 0.52 - 4, seed + 18027) - 0.5) * 2; + const perturb = (coarse * 0.42 + medium * 0.30 + fine * 0.18 + (ridged - 0.5) * 0.88 + cellular * 0.22) * terrainTemplate.alpineMicroRelief * activeMask; + elevation[i] = clamp(elevation[i] + perturb, seaLevel + 0.006, 0.998); + if (surfaceTextureField) { + const textureSignal = clamp(Math.abs(perturb) * 44 + ridged * activeMask * 0.40 + Math.abs(cellular) * activeMask * 0.24 + patch * activeMask * 0.16); + surfaceTextureField[i] = clamp(surfaceTextureField[i] + textureSignal); + } + if (perturb < 0) valleyField[i] = clamp(valleyField[i] + (-perturb) * activeMask * 4.5); + } + } +} + + +function breakHighPlateaus(elevation, sea, slope, ridgeField, valleyField, flowAccum, coastalLowland, seaLevel, seed, terrainTemplate, visibleRavineField = null, surfaceTextureField = null) { + 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 high = clamp((elevation[i] - (seaLevel + 0.18)) / 0.28); + const flat = clamp((0.26 - slope[i]) * 5.2); + const mountain = clamp(ridgeField[i] * 0.78 + high * 0.62 - valleyField[i] * 0.16) * (1 - coastalLowland[i] * 0.82); + const active = high * flat * mountain; + if (active <= 0.02) continue; + + const warpX = x + (fbm(x * 0.11 + 13, y * 0.11 - 17, seed + 18101) - 0.5) * 4.6; + const warpY = y + (fbm(x * 0.11 - 29, y * 0.11 + 7, seed + 18102) - 0.5) * 4.6; + const dend = dendriticRavineTexture(warpX * 1.05, warpY * 1.05, seed + 18103); + const broad = Math.abs((fbm(warpX * 0.16 + 4, warpY * 0.16 - 9, seed + 18104) - 0.5) * 2); + const summit = Math.max(0, valueNoise(warpX * 1.85 + 11, warpY * 1.85 - 23, seed + 18105, 4.7) - 0.57); + const drainage = Math.pow(flowAccum[i], 0.50); + const carve = active * (0.0038 + dend * 0.0062 + broad * 0.0026 + drainage * 0.0032) * (0.92 + terrainTemplate.dendriticTexture * 0.30); + const bump = active * summit * (0.0028 + terrainTemplate.roughness * 0.0022); + elevation[i] = clamp(elevation[i] - carve + bump, seaLevel + 0.006, 0.998); + valleyField[i] = clamp(valleyField[i] + carve * 18.0); + ridgeField[i] = clamp(ridgeField[i] + bump * 8.0); + if (visibleRavineField) visibleRavineField[i] = clamp(visibleRavineField[i] + carve * 8.6); + if (surfaceTextureField) surfaceTextureField[i] = clamp(surfaceTextureField[i] + active * (0.16 + dend * 0.22 + broad * 0.10)); + } + } +} + +function carveOutletChannel(elevation, sea, lake, river, valleyField, basinField, flowAccum, seaLevel, startIndex, targetIndex, seed, bonusSeed = 0) { + const start = { x: startIndex % MAP_W, y: Math.floor(startIndex / MAP_W) }; + const goal = { x: targetIndex % MAP_W, y: Math.floor(targetIndex / MAP_W) }; + const path = aStar(start, goal, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.12; + const uphill = Math.max(0, elevation[i] - elevation[ci]); + return Math.max(0.16, 0.55 + uphill * 80 + Math.max(0, elevation[i] - seaLevel) * 0.10 - flowAccum[i] * 0.65 - valleyField[i] * 0.38 - basinField[i] * 0.12 + (hash2(x, y, seed + bonusSeed) - 0.5) * 0.05); + }); + if (path.length < 2) return []; + const startElev = elevation[startIndex]; + const targetElev = sea[targetIndex] ? seaLevel - 0.002 : Math.min(startElev - 0.010, elevation[targetIndex]); + for (let k = 0; k < path.length; k++) { + const [x, y] = path[k]; + const i = indexOf(x, y); + const t = k / Math.max(1, path.length - 1); + const base = lerp(startElev - 0.001, targetElev + 0.004, t); + const wiggle = (hash2(x, y, seed + 22000 + bonusSeed) - 0.5) * 0.0025; + const floorLimit = seaLevel + 0.020 + basinField[i] * 0.014; + elevation[i] = Math.min(elevation[i], Math.max(floorLimit, base + wiggle)); + valleyField[i] = clamp(valleyField[i] + 0.20 + (1 - t) * 0.12); + basinField[i] = Math.max(0, basinField[i] - 0.10); + river[i] = Math.max(river[i], 0.18 + flowAccum[i] * 0.42 + t * 0.10); + if (lake[i] && k < path.length - 1) lake[i] = 0; + if (!sea[i]) { + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const d = Math.hypot(nx - x, ny - y); + const widen = Math.max(0, 0.010 - d * 0.003); + if (widen > 0) { + elevation[ni] = Math.min(elevation[ni], Math.max(seaLevel + 0.020, elevation[i] + 0.008 + d * 0.004)); + valleyField[ni] = clamp(valleyField[ni] + widen * 12); + } + } + } + } + return path; } export function generateTerrainAndRivers(seed) { @@ -284,8 +582,11 @@ export function generateTerrainAndRivers(seed) { 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); + 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; @@ -300,7 +601,7 @@ export function generateTerrainAndRivers(seed) { return { sideA, sideB, pressure: Math.max(sideA, sideB), signedAxis: axis }; } - const seaLevel = 0.285; + const seaLevel = 0.275; const mountainBlobs = Array.from({ length: terrainTemplate.secondaryMountainCount }, (_, i) => { const spine = spines[i % spines.length]; @@ -324,134 +625,146 @@ export function generateTerrainAndRivers(seed) { 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, - 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), + 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), }; }); - // 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 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; - 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; + 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; - let broadSpineUplift = 0; - for (const spine of spines) { - spineRidges += jaggedRidgeContribution(px, py, spine, seed); - broadSpineUplift += broadRidgeContribution(px, py, spine, seed, 4.6, 0.135); - } + 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); - 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); + 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); - 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); + 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; + // v5 terrain lifecycle: + // 1) 全セルを海面下の海底として置く。 + // 2) 造山帯・広域隆起・尾根核を海底から持ち上げる。 + // 3) この後の流路計算で谷・扇状地・沖積平野を作る。 + const terrainLarge = fbm(wx * 0.30 + 40, wy * 0.30 - 60, seed + 710); + const terrainRegional = fbm(wx * 0.72 + 80, wy * 0.72 - 20, seed + 777); + const terrainLocal = fbm(wx * 1.55 + 17, wy * 1.55 - 31, seed + 1777); + const terrainFine = valueNoise(wx * 2.15 + 11, wy * 2.15 - 19, seed + 2444, 5.5); + const fineDissection = (Math.abs(terrainLocal - 0.5) * 0.050 + Math.abs(terrainFine - 0.5) * 0.024) * (0.62 + terrainTemplate.roughness * 0.58); + const edgeDistance = Math.min(x, y, MAP_W - 1 - x, MAP_H - 1 - y) / Math.min(MAP_W, MAP_H); + const deepEdge = 1 - smoothstep(edgeDistance / 0.15); + const backboneDX = px - terrainTemplate.backboneCenterX; + const backboneDY = py - terrainTemplate.backboneCenterY; + const backboneAlong = (backboneDX * Math.cos(terrainTemplate.spineAngle) + backboneDY * Math.sin(terrainTemplate.spineAngle)) / Math.max(0.001, terrainTemplate.backboneLength); + const backboneCross = (-backboneDX * Math.sin(terrainTemplate.spineAngle) + backboneDY * Math.cos(terrainTemplate.spineAngle)) / Math.max(0.001, terrainTemplate.backboneWidth); + const backboneCore = clamp(1 - Math.sqrt(backboneAlong * backboneAlong + backboneCross * backboneCross)); + const basin = 0.040 * (terrainRegional - 0.5) + 0.030 * (terrainLarge - 0.5) + (backboneCore - 0.5) * 0.010; + const protoHighland = clamp(orogenicUplift * 1.20 + orogenicRidges * 0.72 + spineRidges * 0.98 + branchRidges * 0.76 + detachedRidges * 0.66 + alpineMassifs * 0.72 + mountains * 0.24 - rangeBreakField * 1.35 - orogenicValleys * 0.42 + backboneCore * 0.10); + // 海域は画面端の一律沈降ではなく、海岸圧・低地性・非山地性から開く。 + const marineOpening = clamp( + coastLower * (0.95 + terrainTemplate.deposition * 0.35) + + Math.max(0, 0.42 - protoHighland) * 0.22 + + Math.max(0, -basin) * 0.20 - + backboneCore * 0.10 + ); + const protoLowland = clamp((1 - protoHighland) * 0.40 + marineOpening * 0.28 + Math.max(0, -basin) * 0.34 + orogenicValleys * 0.34); + const plainNoiseSuppression = protoLowland * terrainTemplate.plainNoiseSuppression; + const subduedTerrainLocal = lerp(terrainLocal, 0.5, plainNoiseSuppression * 0.70); + const subduedTerrainFine = lerp(terrainFine, 0.5, plainNoiseSuppression * 0.82); + const subduedDissection = fineDissection * (1 - plainNoiseSuppression * 0.86); + const seafloor = seaLevel - + (0.188 + (1 - terrainLarge) * 0.045 + (1 - terrainRegional) * 0.034 + coastLower * (0.070 + terrainTemplate.deposition * 0.035) + marineOpening * 0.045 + terrainTemplate.deposition * 0.016); + const platformEmergence = (0.050 + terrainTemplate.orographicCoverage * 0.032) * (1 - marineOpening * 0.62); + const broadEmergence = Math.pow(protoHighland, 1.02) * (0.305 + terrainTemplate.orographicCoverage * 0.130); + const ridgeEmergence = + orogenicUplift * 0.245 + + orogenicRidges * 0.225 + + spineRidges * 0.520 + + branchRidges * 0.430 + + detachedRidges * 0.310 + + alpineMassifs * 0.360 + + mountains * 0.080; + const shelfDepression = Math.max(0, -basin) * 0.032 + marineOpening * (0.070 + terrainTemplate.deposition * 0.030); + const rawElevation = + seafloor + + platformEmergence + + broadEmergence + + ridgeEmergence + + (terrainLarge - 0.5) * 0.055 + + (terrainRegional - 0.5) * 0.040 + + (subduedTerrainLocal - 0.5) * 0.036 + + (subduedTerrainFine - 0.5) * 0.014 + + subduedDissection + + basin - + shelfDepression - + rangeBreakField * (0.155 + terrainTemplate.erosion * 0.080) - + orogenicValleys * (0.035 + terrainTemplate.erosion * 0.045); + + elevation[i] = clamp(softUpperClamp(rawElevation, terrainTemplate.peakSoftStart, terrainTemplate.peakSoftCap)); + arcSpineField[i] = clamp(orogenicRidges * 1.05 + orogenicUplift * 0.58 + spineRidges * 1.65 + detachedRidges * 0.98 + alpineMassifs * 0.88); + branchRidgeField[i] = clamp(branchRidges * 1.70 + orogenicValleys * 0.18); + ridgeField[i] = clamp(arcSpineField[i] * 0.78 + branchRidgeField[i] * 0.42 + orogenicRidges * 0.48 + orogenicUplift * 0.25 + subduedDissection * 0.85 - rangeBreakField * 0.72 - orogenicValleys * 0.36); + basinField[i] = clamp(Math.max(0, -basin) * 2.6 + rangeBreakField * 1.25 + orogenicValleys * 1.00 + Math.max(0, seaLevel + 0.085 - elevation[i]) * (1.10 + terrainTemplate.deposition * 0.65)); + coastalLowland[i] = 0; + 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.12 * deepEdge + 0.18 * (1 - clamp((elevation[i] - seaLevel) / 0.35)) - 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 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); + // v5: 海岸線は後から切るのではなく、海底からの隆起量が海面を超えた場所だけを陸にする。 + if (elevation[i] < seaLevel) sea[i] = 1; + if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.014 + hash2(x, y, seed + 2311) * 0.010); } } @@ -547,16 +860,40 @@ export function generateTerrainAndRivers(seed) { } } - // Align coastal elevation with the sea mask. This prevents artificial one-cell cliffs - // when the directional coastline cuts through a high terrain cell. + // Seed ごとの過剰な平坦化・過剰な高原化を抑える救済正規化。 + // 海面を超えた陸だけを対象に、90/97 パーセンタイルを「山がちな島弧」の範囲へ寄せる。 + const emergedElevations = []; + for (let i = 0; i < SIZE; i++) if (!sea[i]) emergedElevations.push(elevation[i]); + emergedElevations.sort((a, b) => a - b); + if (emergedElevations.length > 100) { + const q = (p) => emergedElevations[Math.max(0, Math.min(emergedElevations.length - 1, Math.floor((emergedElevations.length - 1) * p)))]; + const q65 = q(0.65); + const q90 = q(0.90); + const q97 = q(0.97); + const targetQ90 = 0.680 + terrainTemplate.roughness * 0.070; + const targetQ97 = 0.840 + terrainTemplate.roughness * 0.095; + const scale97 = clamp((targetQ97 - seaLevel) / Math.max(0.045, q97 - seaLevel), 0.52, 1.50); + const shift90 = clamp(targetQ90 - q90, -0.30, 0.22); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const highShoulder = smoothstep((elevation[i] - q65) / Math.max(0.045, q97 - q65)); + const ridgeBoost = clamp(ridgeField[i] * 0.42 + arcSpineField[i] * 0.28 - valleyField[i] * 0.18 - coastalLowland[i] * 0.15); + let adjusted = seaLevel + (elevation[i] - seaLevel) * lerp(1, scale97, highShoulder * 0.92 + ridgeBoost * 0.22); + adjusted += shift90 * highShoulder * (0.48 + ridgeBoost * 0.30); + elevation[i] = clamp(softUpperClamp(adjusted, 0.895, targetQ97 + 0.070), seaLevel + 0.006, 0.992); + } + } + + // 海岸は「切断面」ではなく沈降・波食・堆積で丸める。 + // 強い尾根が海へ落ちる場所は残し、低い場所だけを浜堤・海岸低地へ寄せる。 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++) { + for (let dy = -8; dy <= 8; dy++) { + for (let dx = -8; dx <= 8; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny) || !sea[indexOf(nx, ny)]) continue; @@ -564,52 +901,49 @@ export function generateTerrainAndRivers(seed) { 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)); + if (nearestSea <= 8) { + const marineInfluence = smoothstep((8 - nearestSea) / 8); + const ridgeResistance = smoothstep((ridgeField[i] - 0.24) / 0.42); + const coastalShelf = seaLevel + 0.015 + nearestSea * (0.015 + terrainTemplate.deposition * 0.008) + Math.max(0, fbm(x * 1.1, y * 1.1, seed + 2350) - 0.5) * (0.010 + terrainTemplate.coastRoughness * 0.012); + const lowlandBlend = marineInfluence * (1 - ridgeResistance) * (0.42 + terrainTemplate.deposition * 0.30); + if (elevation[i] > coastalShelf) elevation[i] = lerp(elevation[i], coastalShelf, lowlandBlend); + if (nearestOcean <= 8) { + const plainReach = clamp(5.2 + terrainTemplate.deposition * 7.0, 5.5, 10.5); + coastalLowland[i] = clamp((1 - nearestOcean / plainReach) * (0.70 + terrainTemplate.deposition * 0.55) * (1 - ridgeField[i] * 0.62)); } } } } - // 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); - } + // 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.092, seaLevel + 0.006, 0.992); + 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); } - elevation.set(nextElevation); } for (let y = 1; y < MAP_H - 1; y++) { @@ -650,14 +984,14 @@ export function generateTerrainAndRivers(seed) { 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; + 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.82; + 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]); @@ -665,36 +999,247 @@ export function generateTerrainAndRivers(seed) { 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); + if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.62 + Math.pow(flowAccum[i], 0.48) * 0.62); } - // 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++) { + function recomputeDrainageFields({ reinforceValleys = false } = {}) { + flowTo.fill(-1); + flowAccum.fill(0); + const order = []; + 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.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); + let low = i; + let best = elevation[i] + 0.010 * hash2(x, y, seed + 18440); + 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 drainageAttraction = valleyField[ni] * 0.020 + erosionField[ni] * 0.030 + gullyIncisionField?.[ni] * 0.020; + const directed = ev + 0.007 * hash2(nx, ny, seed + 18441) - drainageAttraction; + 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; + if (reinforceValleys) { + valleyField[i] = clamp(valleyField[i] * 0.72 + hollow * 7.8 + Math.max(0, 0.055 - relief) * Math.max(0, elevation[i] - seaLevel - 0.10) * 1.2); + basinField[i] = clamp(basinField[i] + hollow * 1.8 + (relief < 0.040 && elevation[i] < 0.58 ? 0.10 : 0)); + } + flowAccum[i] = 0.72 + moisture[i] * 0.72 + valleyField[i] * 0.72 + Math.max(0, elevation[i] - seaLevel) * 0.10; + order.push(i); } } - elevation.set(nextElevation); + order.sort((a, b) => elevation[b] - elevation[a]); + for (const i of order) { + const to = flowTo[i]; + if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.93; + } + let maxAccum = 0; + for (let i = 0; i < SIZE; i++) if (!sea[i]) maxAccum = Math.max(maxAccum, flowAccum[i]); + if (maxAccum > 0) { + for (let i = 0; i < SIZE; i++) if (!sea[i]) flowAccum[i] = clamp(flowAccum[i] / maxAccum); + } + } + + // v6/v7: 可視河川だけでなく、山地の無数の沢・ガリーを先に掘る。 + // これを描画用の川には使わず、地形侵食だけに使うことで「日本的な谷密度」を出す。 + function traceErosionGully(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 < 92; step++) { + const i = indexOf(x, y); + if (seen.has(i) || sea[i]) break; + seen.add(i); + path.push([x, y]); + if (elevation[i] < seaLevel + 0.055 && path.length > 8) break; + let best = null; + let bestValue = INF; + const preferred = flowTo[i]; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (seen.has(ni)) continue; + const dx = nx - x; + const dy = ny - y; + const drop = elevation[i] - elevation[ni]; + const uphill = Math.max(0, -drop); + if (!sea[ni] && uphill > 0.035 + flowAccum[i] * 0.10) continue; + const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; + const bend = Math.abs(dx * lastDy - dy * lastDx); + const preferredBonus = ni === preferred ? 0.80 : 0; + const value = + elevation[ni] * 0.96 + + uphill * 30.0 - + Math.max(0, drop) * 2.9 - + valleyField[ni] * 1.15 - + Math.pow(flowAccum[ni], 0.55) * 1.05 - + moisture[ni] * 0.22 - + preferredBonus - + Math.max(0, sameDirection) * 0.045 + + bend * 0.030 + + (hash2(nx, ny, seed + bonusSeed + step * 31) - 0.5) * 0.055; + 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 gullyIncisionField = new Float32Array(SIZE); + const visibleRavineField = new Float32Array(SIZE); + const surfaceTextureField = new Float32Array(SIZE); + const gullyCandidates = []; + 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 highland = clamp((elevation[i] - (seaLevel + 0.075)) / 0.42); + const highFlat = highland * clamp(1 - slope[i] * 2.7) * clamp((elevation[i] - (seaLevel + 0.170)) / 0.34) * (1 - coastalLowland[i] * 0.70); + const relief = clamp(slope[i] * 1.10 + ridgeField[i] * 0.34 + valleyField[i] * 0.22 + highFlat * 0.86); + const drainage = clamp(Math.pow(flowAccum[i], 0.36) * 0.74 + moisture[i] * 0.26 + dendriticRavineTexture(x, y, seed) * 0.18 + highFlat * 0.58); + const score = highland * relief * drainage + highFlat * 0.44 + hash2(x, y, seed + 15200) * 0.10; + if (score > 0.13) gullyCandidates.push({ x, y, score }); + } + } + // 明示的な細流路大量生成は粗い格子では効きにくく、過剰掘削の原因にもなる。 + // 細かな山肌表現は renderer 側の手続きノイズへ移し、ここでは実際の小河川は掘らない。 + const gullySources = []; + const erosionGullyPaths = []; + const valleyNetworkIncision = new Float32Array(SIZE); + const valleyFloorField = new Float32Array(SIZE); + for (const source of gullySources) { + const path = traceErosionGully(source.x, source.y, 15250 + source.x * 17 + source.y * 23); + if (path.length < 5) continue; + erosionGullyPaths.push(path); + const startIndex = indexOf(source.x, source.y); + const highFlat = clamp(1 - slope[startIndex] * 2.4) * clamp((elevation[startIndex] - (seaLevel + 0.16)) / 0.35); + const strength = terrainTemplate.gullyIncision * clamp(0.016 + slope[startIndex] * 0.026 + Math.pow(flowAccum[startIndex], 0.42) * 0.020 + valleyField[startIndex] * 0.010 + highFlat * 0.018); + addPathIncision(gullyIncisionField, path, strength, path.length > 22 ? 2 : 1); + const valleyRadius = path.length > 54 ? 3.2 : path.length > 28 ? 2.4 : 1.7; + addValleyDistanceInfluence(valleyNetworkIncision, valleyFloorField, path, strength * (0.55 + highFlat * 0.55), valleyRadius, path.length > 42 ? 0.80 : 0.48); + addPathIncision(visibleRavineField, path, strength * (8.4 + highFlat * 3.4), path.length > 34 ? 2 : 1); + addValleyDistanceInfluence(visibleRavineField, valleyFloorField, path, strength * (2.2 + highFlat * 1.8), Math.max(1.5, valleyRadius * 0.72), 0.30); + } + + + // v10 の明示的な支谷トレースは削除。粗い解像度では可視変化が薄く、 + // 処理だけが増えるため、支谷表現は後段の手続きノイズに集約する。 + + // v11: red-box style dissected relief should not be a local accident. + // Spread that branchy mountain texture across the whole mountainous massif, + // while still letting real drainage control the strongest valleys. + const massifDissectionField = new Float32Array(SIZE); + 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 highland = clamp((elevation[i] - (seaLevel + 0.055)) / 0.46); + const mountainMask = highland * clamp(slope[i] * 1.35 + ridgeField[i] * 0.42 + arcSpineField[i] * 0.24 + branchRidgeField[i] * 0.18 - basinField[i] * 0.22) * (1 - coastalLowland[i] * 0.70); + if (mountainMask <= 0.04) continue; + const highFlat = clamp((elevation[i] - (seaLevel + 0.16)) / 0.34) * clamp(1 - slope[i] * 2.7) * mountainMask; + const warpX = x + (fbm(x * 0.075 + 17, y * 0.075 - 29, seed + 16201) - 0.5) * 7.8; + const warpY = y + (fbm(x * 0.075 - 53, y * 0.075 + 11, seed + 16202) - 0.5) * 7.8; + const ravA = dendriticRavineTexture(warpX * 0.70, warpY * 0.70, seed + 16203); + const ravB = dendriticRavineTexture(warpX * 1.02 + 37, warpY * 1.02 - 19, seed + 16204); + const coarse = Math.abs((fbm(warpX * 0.085 + 21, warpY * 0.085 - 8, seed + 16205) - 0.5) * 2); + const patch = clamp(0.64 + (fbm(x * 0.028 + 80, y * 0.028 - 41, seed + 16206) - 0.5) * 0.78); + const branchiness = clamp(ravA * 0.64 + ravB * 0.42 + coarse * 0.18); + const dissection = mountainMask * patch * (branchiness * (1.00 + highFlat * 0.72) + highFlat * 0.26); + if (dissection <= 0.03) continue; + massifDissectionField[i] = dissection; + visibleRavineField[i] = clamp(visibleRavineField[i] + dissection * (0.34 + highFlat * 0.18)); + surfaceTextureField[i] = clamp(surfaceTextureField[i] + dissection * (0.40 + highFlat * 0.32) + coarse * mountainMask * 0.08); + valleyField[i] = clamp(valleyField[i] + dissection * 0.22); + } + } + + + // Coarse grids cannot resolve endless tiny tributaries. Here the “fine + // ravines” are not just a paint/shading overlay: they are folded into the DEM + // itself as a small alternating cut/crest signal, then slope is recomputed. + // This keeps the detail persistent for rivers, labels and all later terrain uses. + const proceduralRavineField = new Float32Array(SIZE); + const proceduralReliefField = new Float32Array(SIZE); + 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 highland = clamp((elevation[i] - (seaLevel + 0.07)) / 0.42); + const mountainMask = highland * clamp(slope[i] * 0.84 + ridgeField[i] * 0.48 + arcSpineField[i] * 0.22 - basinField[i] * 0.20) * (1 - coastalLowland[i] * 0.78); + if (mountainMask <= 0.025) continue; + const warpX = x + (fbm(x * 0.10 + 91, y * 0.10 - 47, seed + 16601) - 0.5) * 7.8; + const warpY = y + (fbm(x * 0.10 - 33, y * 0.10 + 28, seed + 16602) - 0.5) * 7.8; + const ravA = dendriticRavineTexture(warpX * 0.74, warpY * 0.74, seed + 16603); + const ravB = dendriticRavineTexture(warpX * 1.18 + 23, warpY * 1.18 - 41, seed + 16604); + const ridged = 1 - Math.abs((valueNoise(warpX * 0.92 + 17, warpY * 0.92 - 9, seed + 16605, 6.2) - 0.5) * 2); + const patch = clamp(0.66 + (fbm(x * 0.038 + 80, y * 0.038 - 51, seed + 16606) - 0.5) * 0.88); + const micro = clamp((ravA * 0.70 + ravB * 0.46 + ridged * 0.22) * patch * mountainMask); + if (micro <= 0.025) continue; + + const crestNoise = Math.max(0, ridged - 0.45) * mountainMask * patch; + const cut = micro * (0.024 + highland * 0.024 + terrainTemplate.roughness * 0.012 + terrainTemplate.macroNoiseStrength * 0.20); + const crest = crestNoise * (0.008 + highland * 0.010 + terrainTemplate.globalNoiseStrength * 0.18); + const relief = crest - cut; + proceduralRavineField[i] = micro; + proceduralReliefField[i] = relief; + elevation[i] = clamp(elevation[i] + relief, seaLevel + 0.006, 0.998); + visibleRavineField[i] = clamp(visibleRavineField[i] + micro * 0.66); + surfaceTextureField[i] = clamp(surfaceTextureField[i] + micro * 0.78 + Math.max(0, crestNoise) * 0.20); + valleyField[i] = clamp(valleyField[i] + micro * 0.20); + erosionField[i] = clamp(erosionField[i] + cut * 0.40); + } + } + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const upland = clamp((elevation[i] - seaLevel) / 0.56); + const macroA = (fbm(x * terrainTemplate.macroNoiseScale + 301, y * terrainTemplate.macroNoiseScale - 119, seed + 16701) - 0.5) * 2; + const macroB = (valueNoise(x * (terrainTemplate.macroNoiseScale * 1.9) - 87, y * (terrainTemplate.macroNoiseScale * 1.9) + 63, seed + 16702, 10.5) - 0.5) * 2; + const globalA = (fbm(x * 0.016 + 57, y * 0.016 - 21, seed + 16703) - 0.5) * 2; + const globalB = (valueNoise(x * 0.030 + 19, y * 0.030 - 44, seed + 16704, 14.0) - 0.5) * 2; + const macroMask = clamp(0.30 + upland * 0.78 - coastalLowland[i] * 0.44); + const macroRelief = (macroA * terrainTemplate.macroNoiseStrength + macroB * terrainTemplate.macroNoiseStrength * 0.62 + globalA * terrainTemplate.globalNoiseStrength + globalB * terrainTemplate.globalNoiseStrength * 0.58) * macroMask; + elevation[i] = clamp(elevation[i] + macroRelief, seaLevel + 0.006, 0.998); + visibleRavineField[i] = clamp(visibleRavineField[i] + Math.abs(macroRelief) * 6.4 * upland); + surfaceTextureField[i] = clamp(surfaceTextureField[i] + Math.abs(macroRelief) * 9.0 * macroMask); + valleyField[i] = clamp(valleyField[i] + Math.max(0, -macroRelief) * 2.4); + ridgeField[i] = clamp(ridgeField[i] + Math.max(0, macroRelief) * 1.9); + } + } + + 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); + } } - 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 @@ -704,19 +1249,68 @@ export function generateTerrainAndRivers(seed) { 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 flow = Math.pow(flowAccum[i], 0.58); 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; + const firstOrderPower = smoothstep((flowAccum[i] - 0.010) / 0.095); + const highlandMask = clamp((elevation[i] - (seaLevel + 0.060)) / 0.44); + const ravineTexture = dendriticRavineTexture(x, y, seed); + const highFlat = highlandMask * clamp(1 - slope[i] * 2.9) * clamp((elevation[i] - (seaLevel + 0.170)) / 0.34) * (1 - coastalLowland[i] * 0.72); + const basinProtection = clamp(basinField[i] * (1 - slope[i] * 2.2) * (1 - ridgeField[i] * 0.65)); + const textureMask = clamp(highlandMask * (slope[i] * 1.40 + ridgeField[i] * 0.32 + valleyField[i] * 0.26 + highFlat * 0.82) * (0.40 + moisture[i] * 0.78)); + const syntheticRavine = proceduralRavineField[i]; + const textureCut = clamp(terrainTemplate.dendriticTexture * ravineTexture * textureMask * (0.0016 + terrainTemplate.erosion * 0.0022 + slope[i] * 0.006 + highFlat * 0.003)); + const gullyCut = clamp(gullyIncisionField[i] * (0.34 + slope[i] * 0.76 + highlandMask * 0.24 + highFlat * 0.26)); + const networkCut = clamp(valleyNetworkIncision[i] * (0.18 + highlandMask * 0.22 + slope[i] * 0.24)); + const massifDissection = massifDissectionField[i]; + const visualRavine = clamp(visibleRavineField[i] * (0.16 + highlandMask * 0.32 + slope[i] * 0.18) + massifDissection * (0.10 + highFlat * 0.08) + syntheticRavine * 0.26); + const plateauCut = clamp(highFlat * (ravineTexture * 0.002 + valleyNetworkIncision[i] * 0.06 + Math.pow(flowAccum[i], 0.52) * 0.0018 + massifDissection * 0.004 + syntheticRavine * 0.003) + visualRavine * 0.004); + const sourceProtection = clamp((0.060 - flowAccum[i]) / 0.060) * clamp((elevation[i] - (seaLevel + 0.08)) / 0.40); + const fluvialCore = terrainTemplate.fluvialAggression * firstOrderPower * flow * (0.018 + terrainTemplate.erosion * 0.028 + slope[i] * (0.050 + terrainTemplate.erosion * 0.050) + ridgeField[i] * (0.008 + terrainTemplate.erosion * 0.014)) * incisionNoise; + let steepValley = clamp((fluvialCore + textureCut + gullyCut + networkCut + plateauCut) * (1 - basinProtection * 0.70)); + let lateralCut = clamp((terrainTemplate.fluvialAggression * firstOrderPower * Math.pow(flowAccum[i], 0.76) * valleyField[i] * (0.014 + terrainTemplate.erosion * 0.024) + valleyFloorField[i] * (0.050 + highlandMask * 0.08) + gullyIncisionField[i] * 0.06) * (1 - basinProtection * 0.82)); + if (sourceProtection > 0) { + const protect = 1 - sourceProtection * 0.82; + steepValley *= protect; + lateralCut *= 1 - sourceProtection * 0.74; + } + const lowSettling = clamp(flow * (coastalLowland[i] * (0.018 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.018 + terrainTemplate.deposition * 0.040) + (elevation[i] < 0.40 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - slope[i] * 0.82) * (1 - ridgeField[i] * 0.45) + basinProtection * (0.006 + terrainTemplate.deposition * 0.012)); + erosionField[i] = steepValley + lateralCut + networkCut * 0.20; 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); + depositionalLowland[i] = clamp(lowSettling * 6.5 + basinField[i] * terrainTemplate.deposition * 0.28 + coastalLowland[i] * terrainTemplate.deposition * 0.34 + valleyFloorField[i] * 0.18); + valleyField[i] = clamp(valleyField[i] + ravineTexture * textureMask * 0.22 + gullyIncisionField[i] * 1.8 + valleyNetworkIncision[i] * 1.5 + visibleRavineField[i] * 1.30 + massifDissection * 0.80 + syntheticRavine * 0.42 + valleyFloorField[i] * 1.0 + steepValley * 1.4); + surfaceTextureField[i] = clamp(surfaceTextureField[i] + ravineTexture * textureMask * 0.30 + visibleRavineField[i] * 0.66 + massifDissection * 0.72 + syntheticRavine * 0.74 + gullyIncisionField[i] * 1.2); + const basinFloorGuard = seaLevel + 0.010 + basinProtection * 0.030 + coastalLowland[i] * 0.010; + const headwaterGuard = elevation[i] - (0.010 + slope[i] * 0.014 + highlandMask * 0.010 + Math.pow(flowAccum[i], 0.60) * 0.028); + const carved = elevation[i] - steepValley - lateralCut + lowSettling * (0.72 + basinProtection * 0.35); + shapedElevation[i] = clamp(Math.max(basinFloorGuard, sourceProtection > 0 ? headwaterGuard : seaLevel + 0.006, carved), 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 dissectionLock = clamp(valleyNetworkIncision[i] * 2.6 + gullyIncisionField[i] * 2.4 + erosionField[i] * 2.0); + const ridgeCore = clamp(arcSpineField[i] * 0.74 + branchRidgeField[i] * 0.58 + ridgeField[i] * 0.42 - valleyField[i] * 0.38 - flowAccum[i] * 0.28 - dissectionLock * 0.18); + const highBase = clamp((elevation[i] - 0.55) / 0.25); + const alpine = clamp(ridgeCore * 0.88 + highBase * 0.18 - coastalLowland[i] * 0.45 - depositionalLowland[i] * 0.36 - dissectionLock * 0.20); + 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.970 + Math.min(0.045, ridgeCore * 0.042) + summitTexture * 0.020; + 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); + } + } + + applyAlpineMicroRelief(elevation, sea, slope, ridgeField, valleyField, coastalLowland, seaLevel, seed, terrainTemplate, surfaceTextureField); + breakHighPlateaus(elevation, sea, slope, ridgeField, valleyField, flowAccum, coastalLowland, seaLevel, seed, terrainTemplate, visibleRavineField, surfaceTextureField); + for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); @@ -724,25 +1318,98 @@ export function generateTerrainAndRivers(seed) { 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); + visibleRavineField[i] = clamp(visibleRavineField[i] + Math.pow(flowAccum[i], 0.56) * clamp(slope[i] * 1.5 + valleyField[i] * 0.28) * 0.24); + valleyField[i] = clamp(valleyField[i] + visibleRavineField[i] * 0.42 + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06); basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6); } } + // v7: after actual incision, recompute flow direction/accumulation so the + // visible river network follows the carved valleys rather than the pre-erosion surface. + recomputeDrainageFields({ reinforceValleys: true }); + + // v8: breach the most prominent enclosed basins so盆地 often acquire an outlet + // instead of remaining as unexplained closed depressions. + const sinkCandidates = []; + 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; + if (flowTo[i] >= 0 && flowTo[i] !== i) continue; + const basinScore = basinField[i] * 1.15 + valleyField[i] * 0.55 + flowAccum[i] * 0.38 + (lake[i] ? 0.16 : 0) - coastalLowland[i] * 0.30; + if (basinScore > 0.24) sinkCandidates.push({ i, x, y, score: basinScore }); + } + } + sinkCandidates.sort((a, b) => b.score - a.score); + const usedOutletStarts = new Set(); + for (const candidate of sinkCandidates.slice(0, terrainTemplate.basinOutletCount)) { + const startIndex = candidate.i; + const startKey = `${candidate.x},${candidate.y}`; + if (usedOutletStarts.has(startKey)) continue; + let targetIndex = -1; + let bestScore = INF; + for (let j = 0; j < SIZE; j++) { + if (j === startIndex) continue; + if (sea[j]) { + const d = Math.hypot((j % MAP_W) - candidate.x, Math.floor(j / MAP_W) - candidate.y); + const score = d * 0.48 - 7.5; + if (score < bestScore) { bestScore = score; targetIndex = j; } + continue; + } + const elevDelta = elevation[j] - elevation[startIndex]; + if (elevDelta > 0.050) continue; + if (flowAccum[j] < Math.max(0.08, flowAccum[startIndex] + 0.010) && basinField[j] < 0.18) continue; + const dx = (j % MAP_W) - candidate.x; + const dy = Math.floor(j / MAP_W) - candidate.y; + const d = Math.hypot(dx, dy); + const score = d * 0.34 + Math.max(0, elevDelta) * 120 - flowAccum[j] * 18 - valleyField[j] * 4 - (lake[j] ? 1.5 : 0); + if (score < bestScore) { bestScore = score; targetIndex = j; } + } + if (targetIndex < 0) continue; + const outletPath = carveOutletChannel(elevation, sea, lake, river, valleyField, basinField, flowAccum, seaLevel, startIndex, targetIndex, seed, 22800 + candidate.x * 17 + candidate.y * 31); + if (outletPath.length > 2) { + for (const [px, py] of outletPath.slice(0, 6)) usedOutletStarts.add(`${px},${py}`); + } + } + recomputeDrainageFields({ reinforceValleys: true }); + 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 gullyHint = gullyIncisionField[i] * 1.8 + dendriticRavineTexture(x, y, seed) * 0.12; + const score = elevation[i] * 0.22 + moisture[i] * 0.25 + ridgeField[i] * 0.030 + arcSpineField[i] * 0.030 + branchRidgeField[i] * 0.018 + flowAccum[i] * 1.12 + valleyField[i] * 0.58 + gullyHint * 0.42 + basinField[i] * 0.12 + coastalLowland[i] * 0.07 + hash2(x, y, seed + 9000) * 0.05; + if (elevation[i] > 0.30 && elevation[i] < 0.94 && moisture[i] > 0.16 && (flowAccum[i] > 0.003 || valleyField[i] > 0.035 || gullyIncisionField[i] > 0.006 || slope[i] > 0.18) && ridgeField[i] < 0.98) sourceCandidates.push({ x, y, score }); } } + const majorRiverCandidates = sourceCandidates + .filter((p) => { + const i = indexOf(p.x, p.y); + return elevation[i] > 0.24 && elevation[i] < 0.80 && moisture[i] > 0.18 && (flowAccum[i] > 0.070 || valleyField[i] > 0.18); + }) + .map((p) => { + const i = indexOf(p.x, p.y); + const inland = Math.min(p.x, p.y, MAP_W - 1 - p.x, MAP_H - 1 - p.y) / Math.min(MAP_W, MAP_H); + return { + ...p, + score: p.score + flowAccum[i] * 1.55 + valleyField[i] * 0.60 + inland * 0.55 - Math.abs(elevation[i] - 0.50) * 0.16, + }; + }); + const majorRiverDesired = rand(seed, 9120) < 0.30 ? 0 : (rand(seed, 9121) < 0.24 ? 2 : 1); + const majorSources = pickEntities(majorRiverCandidates, { + max: majorRiverDesired, + minDistance: 12, + threshold: 0.54, + seed: seed + 9122, + jitter: 0.01, + }); + const sources = pickEntities(sourceCandidates, { - max: 20 + Math.floor(rand(seed, 910) * 28), - minDistance: 8, - threshold: 0.53 + rand(seed, 911) * 0.11, + max: 48 + Math.floor(rand(seed, 910) * 30), + minDistance: 4, + threshold: 0.22 + rand(seed, 911) * 0.05, seed, }); @@ -770,7 +1437,7 @@ export function generateTerrainAndRivers(seed) { 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; + if (!sea[i] && uphill > 0.070 && flowAccum[i] < flowAccum[ci] + 0.010) return INF; return Math.max( 0.18, 1 + @@ -778,10 +1445,10 @@ export function generateTerrainAndRivers(seed) { 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 + valleyField[i] * 1.24 - + flowAccum[i] * 1.18 - + moisture[i] * 0.22 - + coastalLowland[i] * 0.36 ); } @@ -795,7 +1462,7 @@ export function generateTerrainAndRivers(seed) { 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; + if (!sea[i] && elevation[i] > Math.max(startElevation + 0.160, elevation[ci] + 0.090)) return INF; return riverRouteCost(x, y, cx, cy); }); if (tail.length <= 2) return path; @@ -832,7 +1499,7 @@ export function generateTerrainAndRivers(seed) { if (seen.has(i)) break; seen.add(i); path.push([x, y]); - river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55; + river[i] += 0.64 + path.length / 128 + flowAccum[i] * 0.92; accum += river[i] + flowAccum[i]; if (sea[i]) break; @@ -847,7 +1514,7 @@ export function generateTerrainAndRivers(seed) { 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; + 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)) { @@ -856,12 +1523,16 @@ export function generateTerrainAndRivers(seed) { } 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 lowlandMeander = clamp((1 - slope[i] * 2.4) * (0.35 + valleyField[i] * 0.60 + Math.pow(flowAccum[i], 0.35) * 0.42)); + const straightPenalty = Math.max(0, sameDirection) * (0.12 + terrainTemplate.meanderStrength * (0.42 + lowlandMeander * 0.35)); + const turnPenalty = sameDirection < -0.62 ? 0.10 : 0; + const sideSwing = dx * lastDy - dy * lastDx; + const bendMag = Math.abs(sideSwing); + const meanderWave = Math.sin((path.length + bonusSeed * 0.011) * (0.44 + terrainTemplate.meanderStrength * 0.28) + hash2(startX, startY, seed + bonusSeed) * Math.PI * 2); + const targetBend = Math.sign(meanderWave); + const meanderBias = targetBend !== 0 ? Math.max(0, sideSwing * targetBend) * (0.055 + terrainTemplate.meanderStrength * 0.110 + lowlandMeander * 0.070) : 0; + const antiStraight = bendMag * (0.024 + terrainTemplate.meanderStrength * 0.070 + lowlandMeander * 0.035); + const flowBonus = ni === preferred ? (0.36 + flowAccum[ni] * 0.18) : 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 = @@ -869,17 +1540,18 @@ export function generateTerrainAndRivers(seed) { 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) - + valleyField[ni] * (2.00 + lowlandMeander * 0.28) - + flowAccum[ni] * (1.22 + lowlandMeander * 0.12) - + moisture[ni] * 0.18 - + coastalLowland[ni] * (0.38 + lowlandMeander * 0.22) - + (river[ni] > 0 ? 0.34 : 0) - flowBonus + slope[ni] * 0.04 + straightPenalty + turnPenalty + - junctionPenalty * 1.35 - - meander + + junctionPenalty * 1.10 - + meanderBias - + antiStraight - noise - (sea[ni] ? 0.6 : 0); @@ -899,7 +1571,7 @@ export function generateTerrainAndRivers(seed) { 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; + river[ri] += 0.50 + flowAccum[ri] * 0.68; accum += river[ri] + flowAccum[ri]; } } @@ -913,13 +1585,13 @@ export function generateTerrainAndRivers(seed) { let lastDy = 0; const path = []; const seen = new Set(); - for (let step = 0; step < 160; step++) { + 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.12 + flowAccum[i] * 0.18; - if ((river[i] > 0.48 && path.length > 5) || sea[i]) break; + 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)) { @@ -928,7 +1600,12 @@ export function generateTerrainAndRivers(seed) { 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; + const swing = dx * lastDy - dy * lastDx; + const lowlandMeander = clamp((1 - slope[i] * 2.0) * (0.28 + valleyField[i] * 0.80 + Math.pow(flowAccum[i], 0.35) * 0.38)); + const meanderWave = Math.sin((step + bonusSeed * 0.009) * (0.52 + terrainTemplate.meanderStrength * 0.25) + hash2(startX, startY, seed + 23123) * Math.PI * 2); + const targetSwing = Math.sign(meanderWave); + const lateralBonus = targetSwing !== 0 ? Math.max(0, swing * targetSwing) * (0.038 + terrainTemplate.meanderStrength * 0.070 + lowlandMeander * 0.060) : 0; + const value = elevation[ni] * 1.10 + Math.max(0, -drop) * 17.5 - Math.max(0, drop) * 1.52 - valleyField[ni] * (1.42 + lowlandMeander * 0.18) - flowAccum[ni] * (0.74 + lowlandMeander * 0.06) - moisture[ni] * 0.16 + Math.max(0, sameDirection) * (0.062 + lowlandMeander * 0.03) - Math.abs(swing) * 0.016 - lateralBonus + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.090; if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; } } if (!best) break; @@ -941,7 +1618,16 @@ export function generateTerrainAndRivers(seed) { } const riverPaths = []; + const majorRiverPathSet = new Set(); const riverScores = []; + for (const source of majorSources) { + const { path, accum } = traceRiverPath(source.x, source.y, 2000 + source.x * 13 + source.y * 19); + if (path.length > 14) { + riverPaths.push(path); + majorRiverPathSet.add(path); + riverScores.push(path.length * 1.35 + accum * 0.32); + } + } for (const source of sources) { const { path, accum } = traceRiverPath(source.x, source.y, 0); if (path.length > 6) { @@ -954,9 +1640,9 @@ export function generateTerrainAndRivers(seed) { 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, + max: 42 + Math.floor(rand(seed, 915) * 28), + minDistance: 3, + threshold: 0.16, seed: seed + 916, jitter: 0.02, }); @@ -964,17 +1650,17 @@ export function generateTerrainAndRivers(seed) { 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); + 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.18), { - max: 22 + Math.floor(rand(seed, 919) * 20), - minDistance: 4, - threshold: 0.18, + .filter((p) => p.score > 0.095), { + max: 82 + Math.floor(rand(seed, 919) * 52), + minDistance: 1.5, + threshold: 0.062, seed: seed + 919, jitter: 0.015, }); @@ -1019,7 +1705,7 @@ export function generateTerrainAndRivers(seed) { } } - function sanitizeDownhillRiverPath(path, tolerance = 0.075) { + 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++) { @@ -1027,7 +1713,7 @@ export function generateTerrainAndRivers(seed) { 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; + if (!sea[i] && elevation[i] > elevation[pi] + tolerance) break; out.push(path[k]); if (sea[i]) break; } @@ -1040,28 +1726,29 @@ export function generateTerrainAndRivers(seed) { 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; + 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] = forceRiverToWater(sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.075)); + for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.055); 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 = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.040); 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) { + const major = majorRiverPathSet.has(path); 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; + river[i] += major ? (0.72 + k / 118 + flowAccum[i] * 0.98) : (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.11 + flowAccum[i] * 0.18; + river[i] += 0.020 + flowAccum[i] * 0.032; } } @@ -1071,7 +1758,7 @@ export function generateTerrainAndRivers(seed) { 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); + expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.26); } } } @@ -1084,99 +1771,31 @@ export function generateTerrainAndRivers(seed) { 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)); + 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(mediumPower * 0.20 + largePower * 0.44); + const headwaterProtect = clamp((0.54 - r) / 0.54) * clamp((elevation[i] - (seaLevel + 0.08)) / 0.42); + const highlandProtect = clamp((elevation[i] - 0.54) / 0.34) * clamp((0.58 - flowAccum[i]) / 0.58); + const localWallProtect = clamp((slope[i] - 0.22) * 1.8) * clamp((elevation[i] - 0.54) / 0.34); + const cutLimiter = 1 - clamp(headwaterProtect * 0.96 + highlandProtect * 0.76 + localWallProtect * 0.55); + const channelCutRaw = terrainTemplate.fluvialAggression * actionPower * Math.pow(r, 0.82) * (0.0045 + terrainTemplate.erosion * 0.0055 + slope[i] * (0.006 + terrainTemplate.erosion * 0.008) + ridgeField[i] * (0.0015 + terrainTemplate.erosion * 0.0025)); + const valleyWidenRaw = terrainTemplate.fluvialAggression * (mediumPower * 0.08 + largePower * 0.18) * Math.pow(r, 0.92) * (0.0015 + terrainTemplate.erosion * 0.0030 + Math.max(0, elevation[i] - seaLevel) * (0.0018 + terrainTemplate.erosion * 0.0030) + valleyField[i] * (0.0015 + terrainTemplate.erosion * 0.0030)); + const maxRiverCut = 0.0018 + mediumPower * 0.0032 + largePower * 0.0065 + Math.pow(flowAccum[i], 0.65) * 0.0045; + const channelCut = Math.min(maxRiverCut, clamp(channelCutRaw * cutLimiter)); + const valleyWiden = Math.min(maxRiverCut * 0.65, clamp(valleyWidenRaw * cutLimiter)); + 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); + valleyField[i] = clamp(valleyField[i] + r * 0.28 + channelCut * 3.0); 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 - ); - } - } - } - } + // No lateral terrain carving from traced river lines. Side-valley complexity is + // already present in the DEM through proceduralReliefField/proceduralRavineField. // Template-driven deposition is limited to plausible low-energy places: // river mouths, basin floors, coastal plains, and slope breaks below ridges. @@ -1235,24 +1854,53 @@ export function generateTerrainAndRivers(seed) { 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)); + 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, 1); + fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 0.985); erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6); } } } + // Guard against one-cell over-incision: a river cell next to a 0.8-0.9 ridge + // must not collapse to near sea level just because a routed channel crossed it. + const guardedFluvialElevation = new Float32Array(fluvialElevation); + 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.10) continue; + let highNeighbor = fluvialElevation[i]; + let meanNeighbor = 0; + let nCount = 0; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + highNeighbor = Math.max(highNeighbor, fluvialElevation[ni]); + meanNeighbor += fluvialElevation[ni]; + nCount++; + } + meanNeighbor /= Math.max(1, nCount); + const maxAllowedDrop = 0.18 + clamp(river[i] / 2.6) * 0.06 + coastalLowland[i] * 0.08 + basinField[i] * 0.04; + const floorFromWall = highNeighbor - maxAllowedDrop; + const floorFromMean = meanNeighbor - 0.13; + if (highNeighbor > 0.64 && highNeighbor - fluvialElevation[i] > 0.26) { + guardedFluvialElevation[i] = Math.max(fluvialElevation[i], Math.min(highNeighbor - 0.08, Math.max(floorFromWall, floorFromMean))); + erosionField[i] = Math.max(0, erosionField[i] * 0.55); + } + } + } + fluvialElevation.set(guardedFluvialElevation); + 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++) { + 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++) { @@ -1264,8 +1912,9 @@ export function generateTerrainAndRivers(seed) { depositionalLowland[i] * 0.52 + deltaField[i] * 0.34 + alluvialFanField[i] * 0.22 + - valleyField[i] * 0.34 + - Math.pow(flowAccum[i], 0.58) * 0.24 - + valleyField[i] * 0.18 + + Math.pow(flowAccum[i], 0.58) * 0.20 - + gullyIncisionField[i] * 3.0 - ridgeField[i] * 0.62 - Math.max(0, elevation[i] - 0.54) * 1.65 - slope[i] * 0.74 @@ -1273,6 +1922,8 @@ export function generateTerrainAndRivers(seed) { 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; @@ -1281,6 +1932,8 @@ export function generateTerrainAndRivers(seed) { 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; @@ -1289,9 +1942,12 @@ export function generateTerrainAndRivers(seed) { } 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.28); - nextElevation[i] = clamp(lerp(elevation[i], target, lowland * (0.30 + terrainTemplate.deposition * 0.26)), seaLevel + 0.006, 1); + 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); @@ -1301,34 +1957,6 @@ export function generateTerrainAndRivers(seed) { 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); @@ -1340,16 +1968,65 @@ export function generateTerrainAndRivers(seed) { } // 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 = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.052); 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 = 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); - 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); + 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) { @@ -1391,15 +2068,253 @@ export function generateTerrainAndRivers(seed) { if (fallbackPath.length > 4) { riverPaths.push(fallbackPath); mainRivers.push(fallbackPath); - for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4; + for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.55; } } } } - 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); + // 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); + } + } + } + + // v5: 最終的に主河川が内陸で途切れる場合は、海または湖まで河口部を補完する。 + // これは地形生成後の河川作用を明示的に効かせ、山地から海への侵食軸を保証するため。 + for (let r = 0; r < mainRivers.length; r++) { + const path = mainRivers[r]; + if (!path || path.length < 2) continue; + const connected = path.some(([x, y], k) => k > path.length * 0.45 && neighbors8(x, y).some(([nx, ny]) => sea[indexOf(nx, ny)] || lake[indexOf(nx, ny)])); + if (connected) continue; + const forced = forceRiverToWater(path); + if (forced.length > path.length) { + mainRivers[r] = forced; + riverPaths.push(forced); + riverScores.push(forced.length * 1.12); + for (let k = path.length; k < forced.length; k++) { + const [rx, ry] = forced[k]; + const ri = indexOf(rx, ry); + river[ri] = Math.max(river[ri], 0.70 + k / 180 + flowAccum[ri] * 0.70); + valleyField[ri] = clamp(valleyField[ri] + 0.18); + } + } + } + + 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: 90 + Math.floor(rand(seed, 9341) * 60), + 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); + + for (const path of smallStreams) { + if (!path || path.length < 3) continue; + const strength = path.length > 24 ? 0.020 : 0.013; + addPathIncision(visibleRavineField, path, strength, path.length > 18 ? 2 : 1); + } + + // v9: keep enclosed盆地 as habitable basins rather than over-incised pits. + 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 basinFloor = clamp(basinField[i] * (1 - slope[i] * 3.0) * (1 - coastalLowland[i] * 0.7) * (1 - Math.min(1, river[i] * 0.7))); + if (basinFloor <= 0.16) continue; + elevation[i] = clamp(elevation[i] + basinFloor * 0.020, seaLevel + 0.008, 1); + depositionalLowland[i] = clamp(depositionalLowland[i] + basinFloor * 0.12); + plain[i] = clamp(plain[i] + basinFloor * 0.10); + } + } + // Final one-cell canyon guard. Apply it to all land cells, not only river + // cells, because a traced channel or earlier basin operation can leave a 0.3 + // cell directly beside a 0.9 ridge. The guard preserves broad basins/coasts but + // prevents single-cell cliff trenches. + for (let pass = 0; pass < 4; pass++) { + const guarded = new Float32Array(elevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let highNeighbor = elevation[i]; + let meanNeighbor = 0; + let nCount = 0; + let seaTouch = 0; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (sea[ni]) { seaTouch++; continue; } + highNeighbor = Math.max(highNeighbor, elevation[ni]); + meanNeighbor += elevation[ni]; + nCount++; + } + meanNeighbor /= Math.max(1, nCount); + if (highNeighbor > 0.64 && highNeighbor - elevation[i] > 0.22) { + const coastalAllowance = coastalLowland[i] * 0.10 + (seaTouch ? 0.08 : 0); + const basinAllowance = basinField[i] * 0.055; + const riverAllowance = clamp(river[i] / 2.8) * 0.035; + const allowedDrop = 0.19 + coastalAllowance + basinAllowance + riverAllowance; + guarded[i] = Math.max(elevation[i], Math.max(highNeighbor - allowedDrop, meanNeighbor - 0.085)); + valleyField[i] = clamp(valleyField[i] * 0.86); + erosionField[i] = Math.max(0, erosionField[i] * 0.40); + } + } + } + elevation.set(guarded); + } + + 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); + } + } prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); prefectureBorder = extractMaskBorder(prefectureMask, sea); @@ -1585,6 +2500,9 @@ export function generateTerrainAndRivers(seed) { smallIslandCount: countSmallLandIslands(8), largeInlandLakeCount: countWaterComponents(Float32Array.from(lake, (value) => value ? 1 : 0), 120), depositionLowlandArea, + smallStreamCount: smallStreams.length, + erosionGullyCount: erosionGullyPaths.length, + branchRavineCount: 0, }; return { @@ -1602,6 +2520,8 @@ export function generateTerrainAndRivers(seed) { agriculture, ridgeField, valleyField, + visibleRavineField, + surfaceTextureField, basinField, coastalLowland, flowAccum, diff --git a/mapTerrain.v4.bak.js b/mapTerrain.v4.bak.js new file mode 100644 index 0000000..cfeb1a0 --- /dev/null +++ b/mapTerrain.v4.bak.js @@ -0,0 +1,1804 @@ +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, + }; +} diff --git a/names.js b/names.js index 399aa87..1de3b02 100644 --- a/names.js +++ b/names.js @@ -30,11 +30,11 @@ export const NAME_KANJI_POOLS = { "池", "沼", "泉", "井", "滝", "梅", "沢", "澤", "谷", "津", "水", "清", "渡", "橋", "堀", - "溝", "浦", "洲" + "溝", "浦" ], coastalTerrain: [ - "津", "浦", "ヶ浦", "津", "崎", + "津", "浦", "津", "崎", "島", "磯", "潟", "湊", "津", "州", "洲", "瀬", "砂", "潮", "塩", "汐", "泊", "江", "浦", "灘", "入", diff --git a/renderer.js b/renderer.js index d62853f..22d4186 100644 --- a/renderer.js +++ b/renderer.js @@ -1,4 +1,4 @@ -import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js"; +import { CELL_SIZE, MAP_H, MAP_W, clamp, fbm, indexOf, valueNoise } from "./mapUtils.js"; const segmentVectorCache = new WeakMap(); @@ -261,10 +261,12 @@ function distToNearest(points, x, y, fallback = 999) { } function blendOutside(color, isInside) { - // Seamless-map mode: do not dim cells outside the currently named prefecture. - // The prefecture mask is still kept for statistics/admin debug, but the base - // terrain should read as one continuous region when the map is later panned. - return color; + if (isInside) return color; + return [ + Math.round(color[0] * 0.8 + 50), + Math.round(color[1] * 0.8 + 50), + Math.round(color[2] * 0.8 + 50), + ]; } function fieldSample(field, fx, fy) { @@ -285,6 +287,23 @@ function fieldSample(field, fx, fy) { return ((a * (1 - tx) + b * tx) * (1 - ty)) + ((c * (1 - tx) + d * tx) * ty); } +function interpolateColorStops(value, stops) { + if (value <= stops[0][0]) return stops[0][1].slice(); + for (let i = 1; i < stops.length; i++) { + const [v, c] = stops[i]; + const [pv, pc] = stops[i - 1]; + if (value <= v) { + const t = clamp((value - pv) / Math.max(0.0001, v - pv)); + return [ + Math.round(pc[0] + (c[0] - pc[0]) * t), + Math.round(pc[1] + (c[1] - pc[1]) * t), + Math.round(pc[2] + (c[2] - pc[2]) * t), + ]; + } + } + return stops[stops.length - 1][1].slice(); +} + function terrainColorContinuous(map, fx, fy, mode) { const i = sampleCellIndex(fx, fy); const isInside = Boolean(map.prefectureMask[i]); @@ -314,39 +333,75 @@ function terrainColorContinuous(map, fx, fy, mode) { Math.round(230 + density * 10), ]; } else { - // 地形色は「細い山脈線」を直接塗らず、標高+広域山地性で読む。 - // 狭い arcSpineField を強く発色させると人工的な波線になるため、寄与を抑える。 + // 地形の基底色は標高のみに従わせる。 + // 谷や微地形の見え方は陰影側で制御し、谷底だけが不自然に茶色化しないようにする。 const e = fieldSample(map.elevation, fx, fy); - const spine = map.arcSpineField ? fieldSample(map.arcSpineField, fx, fy) : 0; - const ridge = map.ridgeField ? fieldSample(map.ridgeField, fx, fy) : 0; - const rel = clamp((e - (map.seaLevel ?? 0.285) + 0.035) / 0.46); - const mountainRead = clamp(rel * 0.92 + ridge * 0.16 + spine * 0.045); - if (mountainRead > 0.86) color = [176, 166, 150]; - else if (mountainRead > 0.76) color = [192, 183, 166]; - else if (mountainRead > 0.62) color = [207, 199, 181]; - else if (mountainRead > 0.46) color = [221, 225, 204]; - else if (mountainRead > 0.29) color = [232, 238, 222]; - else color = [240, 244, 230]; - - const highRugged = clamp((ridge - 0.42) * 0.36 + (rel - 0.66) * 0.22); - if (highRugged > 0) { - color = [ - Math.round(color[0] - highRugged * 18), - Math.round(color[1] - highRugged * 15), - Math.round(color[2] - highRugged * 12), - ]; - } + color = interpolateColorStops(clamp(e), [ + [0.20, [231, 236, 223]], + [0.30, [223, 231, 214]], + [0.40, [213, 223, 201]], + [0.50, [204, 215, 188]], + [0.58, [195, 207, 173]], + [0.65, [185, 196, 158]], + [0.71, [177, 181, 141]], + [0.76, [169, 164, 125]], + [0.81, [157, 145, 105]], + [0.86, [144, 128, 89]], + [0.91, [130, 111, 79]], + [0.95, [118, 103, 89]], + [0.985, [146, 141, 133]], + [1.00, [183, 179, 171]], + ]); } return blendOutside(color, isInside); } +function terrainShadeContinuous(map, fx, fy) { + const eL = fieldSample(map.elevation, fx - 0.6, fy); + const eR = fieldSample(map.elevation, fx + 0.6, fy); + const eU = fieldSample(map.elevation, fx, fy - 0.6); + const eD = fieldSample(map.elevation, fx, fy + 0.6); + + // x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。 + // 光源は北西上空(日本の地形表現で一般的な見え方)。 + const dzdx = (eR - eL) / 1.2; + const dzdy = (eD - eU) / 1.2; + const nx = -dzdx * 2.5; + const ny = -dzdy * 2.5; + const nz = 1.0; + const nLen = Math.hypot(nx, ny, nz) || 1; + + const lx = -0.5; + const ly = -0.5; + const lz = 0.7071067811865476; + const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.5 + 0.5); + + const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0; + const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0; + const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0; + const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0; + const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.75, fy) : 0; + const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.75, fy) : 0; + const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.75) : 0; + const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.75) : 0; + const ravineRelief = (rvL - rvR) * 0.16 + (rvU - rvD) * 0.12; + + // 谷底の低傾斜面では陰影を少し圧縮し、標高色がそのまま見えるようにする。 + const valleyFloor = clamp((valley - 0.16) * 1.8) * clamp((0.28 - slope) * 4.5); + let shade = 0.76 + hill * 0.32 + ravineRelief - ravine * 0.08 - tex * 0.028; + if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.52); + else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.20); + + return clamp(shade, 0.66, 1.13); +} + function discreteColor(map, x, y, mode) { const i = indexOf(x, y); let color; if (map.sea[i]) { - color = [170, 218, 255]; + color = [160, 205, 239]; } else if (mode === "landuse") { const colors = { 0: [242, 248, 238], @@ -386,12 +441,7 @@ function drawBase(ctx, map, mode, continuousTerrain) { for (let px = 0; px < width; px++) { const fx = px / CELL_SIZE; const [r, g, b] = terrainColorContinuous(map, fx, fy, mode === "all" ? "terrain" : mode); - - const eL = fieldSample(map.elevation, fx - 0.6, fy); - const eR = fieldSample(map.elevation, fx + 0.6, fy); - const eU = fieldSample(map.elevation, fx, fy - 0.6); - const eD = fieldSample(map.elevation, fx, fy + 0.6); - const shade = clamp(0.965 + (eR - eL) * 1.24 + (eD - eU) * 0.86, 0.78, 1.17); + const shade = terrainShadeContinuous(map, fx, fy); const ii = (py * width + px) * 4; img.data[ii] = Math.round(r * shade); @@ -419,6 +469,28 @@ function drawBase(ctx, map, mode, continuousTerrain) { ctx.putImageData(img, 0, 0); } +function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) { + if (!path || path.length < 2) return; + ctx.save(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + for (let k = 0; k < path.length - 1; k++) { + const [x1, y1] = path[k]; + const [x2, y2] = path[k + 1]; + const i1 = indexOf(x1, y1); + const i2 = indexOf(x2, y2); + const strength = Math.max((map.river?.[i1] || 0) + (map.flowAccum?.[i1] || 0) * 0.95, (map.river?.[i2] || 0) + (map.flowAccum?.[i2] || 0) * 0.95); + ctx.strokeStyle = color; + ctx.globalAlpha = alpha; + ctx.lineWidth = widthFn(strength, k / Math.max(1, path.length - 1)); + ctx.beginPath(); + ctx.moveTo(x1 * CELL_SIZE + CELL_SIZE / 2, y1 * CELL_SIZE + CELL_SIZE / 2); + ctx.lineTo(x2 * CELL_SIZE + CELL_SIZE / 2, y2 * CELL_SIZE + CELL_SIZE / 2); + ctx.stroke(); + } + ctx.restore(); +} + function drawPath(ctx, path, color, width, dashed = false) { const points = vectorPath(path); if (points.length < 2) return; @@ -602,136 +674,6 @@ function drawLabels(ctx, points, limit = Infinity) { for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); } -function niceScaleKm(maxKm) { - const candidates = [1, 2, 5, 10, 20, 25, 50, 100, 200]; - let best = candidates[0]; - for (const c of candidates) { - if (c <= maxKm) best = c; - } - return best; -} - -function drawScaleBar(ctx, map) { - const kmPerCell = map.scaleKmPerCell || 1; - const width = MAP_W * CELL_SIZE; - const height = MAP_H * CELL_SIZE; - const maxPx = Math.min(150, width * 0.18); - const scaleKm = niceScaleKm(Math.max(1, maxPx / CELL_SIZE * kmPerCell)); - const barPx = Math.max(24, (scaleKm / kmPerCell) * CELL_SIZE); - const x = 18; - const y = height - 22; - const h = 6; - - ctx.save(); - ctx.font = '11px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - ctx.textBaseline = "bottom"; - ctx.fillStyle = "rgba(255, 255, 255, 0.72)"; - ctx.strokeStyle = "rgba(80, 80, 80, 0.34)"; - ctx.lineWidth = 1; - ctx.beginPath(); - if (ctx.roundRect) ctx.roundRect(x - 8, y - 22, barPx + 42, 29, 5); - else ctx.rect(x - 8, y - 22, barPx + 42, 29); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = "rgba(60, 60, 60, 0.95)"; - ctx.fillRect(x, y - h, barPx / 2, h); - ctx.fillStyle = "rgba(245, 245, 245, 0.95)"; - ctx.fillRect(x + barPx / 2, y - h, barPx / 2, h); - ctx.strokeStyle = "rgba(55, 55, 55, 0.95)"; - ctx.lineWidth = 1.2; - ctx.strokeRect(x, y - h, barPx, h); - ctx.beginPath(); - ctx.moveTo(x, y - h - 3); - ctx.lineTo(x, y + 3); - ctx.moveTo(x + barPx / 2, y - h); - ctx.lineTo(x + barPx / 2, y + 2); - ctx.moveTo(x + barPx, y - h - 3); - ctx.lineTo(x + barPx, y + 3); - ctx.stroke(); - - ctx.fillStyle = "rgba(45, 45, 45, 0.95)"; - ctx.fillText(`${scaleKm} km`, x + barPx + 8, y + 2); - ctx.restore(); -} - - - -function drawNeighborPrefectureDetails(ctx, map, mode = "all") { - const details = map?.neighborPrefectureDetails; - if (!details || !details.prefectures?.length) return; - const showRoads = ["roads", "all", "development"].includes(mode); - const showModern = ["modern", "all", "development", "landuse", "roads", "admin", "admin-debug", "borders-debug"].includes(mode); - const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); - if (!showRoads && !showModern && !showAdmin) return; - - ctx.save(); - ctx.globalAlpha = 1.0; - if (showModern) { - for (const city of details.cities || []) { - const r = Math.max(6, Math.min(22, (city.urbanRadius || 8) * CELL_SIZE * 0.48)); - ctx.beginPath(); - ctx.arc(city.x * CELL_SIZE + CELL_SIZE / 2, city.y * CELL_SIZE + CELL_SIZE / 2, r, 0, Math.PI * 2); - ctx.fillStyle = "rgba(226, 216, 202, 0.52)"; - ctx.fill(); - } - for (const cbd of details.centralBusinessDistricts || []) { - ctx.fillStyle = "rgba(222, 168, 145, 0.68)"; - ctx.fillRect(cbd.x * CELL_SIZE - 1.5, cbd.y * CELL_SIZE - 1.5, CELL_SIZE + 3, CELL_SIZE + 3); - } - } - if (showRoads) { - for (const path of details.roads || []) drawPath(ctx, path, "rgba(190, 174, 118, 0.92)", 3.2); - for (const path of details.roads || []) drawPath(ctx, path, "rgba(248, 226, 122, 0.96)", 1.65); - } - if (showModern || showRoads) { - for (const path of details.railways || []) drawPath(ctx, path, "rgba(255,255,255,0.74)", 2.8); - for (const path of details.railways || []) drawRailway(ctx, path, "rgba(76, 76, 76, 0.86)", 1.15, 4.0, 6.0); - } - if (showModern || showAdmin) { - for (const p of details.adminCenters || []) dot(ctx, p, 2.0, "rgba(250,250,245,0.82)", "rgba(90,90,90,0.66)"); - for (const p of details.cities || []) { - const r = p.rank === "Neighbor Prefectural Capital" ? 5.4 : 3.8; - dot(ctx, p, r, "rgba(226, 104, 104, 0.86)", "rgba(255,255,255,0.78)"); - if (p.rank === "Neighbor Prefectural Capital") dot(ctx, p, r + 4.6, "rgba(255,255,255,0.05)", "rgba(225,95,95,0.55)"); - } - } - if (["all", "admin", "modern", "roads", "admin-debug", "borders-debug"].includes(mode)) { - ctx.globalAlpha = 0.72; - drawLabels(ctx, [...(details.cities || []), ...(showAdmin ? (details.adminCenters || []) : [])], showAdmin ? 64 : 36); - } - ctx.restore(); -} - -function drawPrefectureLabels(ctx, map, mode = "all") { - if (!map?.prefectureLabel && !(map?.neighborPrefectures || []).length) return; - const width = MAP_W * CELL_SIZE; - const height = MAP_H * CELL_SIZE; - const shouldShow = ["all", "modern", "roads", "development", "landuse", "admin", "admin-debug", "borders-debug"].includes(mode); - if (!shouldShow) return; - - ctx.save(); - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.lineJoin = "round"; - - const drawPrefLabel = (label, isMain = false) => { - if (!label?.name) return; - const x = clamp(label.x * CELL_SIZE, 44, width - 44); - const y = clamp(label.y * CELL_SIZE - (isMain ? 14 : 8), 28, height - 34); - ctx.font = `${isMain ? 700 : 700} ${isMain ? 22 : 20}px "Hiragino Sans", "Yu Gothic", system-ui, sans-serif`; - ctx.lineWidth = isMain ? 5.2 : 4.8; - ctx.strokeStyle = "rgba(255, 255, 255, 0.72)"; - ctx.fillStyle = "rgba(75, 62, 90, 0.64)"; - ctx.strokeText(label.name, x, y); - ctx.fillText(label.name, x, y); - }; - - drawPrefLabel(map.prefectureLabel, true); - for (const label of map.neighborPrefectures || []) drawPrefLabel(label, false); - ctx.restore(); -} - export function drawMap(canvas, map, options) { const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -749,13 +691,58 @@ export function drawMap(canvas, map, options) { drawBase(ctx, map, mode, true); drawUrbanAreas(ctx, map, mode); const coastSegments = getCoastlineSegments(map); - drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06 }); - drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06 }); + drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); // 2. Rivers const waterBlue = "rgba(160, 205, 240, 1)"; - for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(160, 205, 240, 0.8)", 1.5); - for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.5); + const mediumBlue = "rgba(160, 205, 240, 0.88)"; + const riverStrengthForPath = (path) => { + if (!path || path.length === 0) return 0; + let peak = 0; + let tail = 0; + const tailStart = Math.max(0, path.length - Math.min(path.length, 8)); + let tailCount = 0; + for (let k = 0; k < path.length; k++) { + const [x, y] = path[k]; + const i = indexOf(x, y); + const strength = (map.river?.[i] || 0) + (map.flowAccum?.[i] || 0) * 0.75; + peak = Math.max(peak, strength); + if (k >= tailStart) { + tail += strength; + tailCount++; + } + } + return Math.max(peak, tail / Math.max(1, tailCount)); + }; + // Draw a dendritic river network. Width is intentionally separated by + // river order: small streams are hairline/low-alpha, tributaries are thin, + // and only trunk rivers get a modestly wider stroke. + for (const path of map.smallStreams || []) { + const strength = riverStrengthForPath(path); + if ((path?.length || 0) < 5 || strength < 0.045) continue; + drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34); + } + for (const path of map.tributaryRivers || []) { + const strength = riverStrengthForPath(path); + if ((path?.length || 0) < 9 || strength < 0.45) continue; + drawRiverPath(ctx, map, path, mediumBlue, (s, t) => { + const downstreamBoost = 0.92 + t * 0.18; + if (s > 1.65) return 1.35 * downstreamBoost; + if (s > 0.95) return 1.12 * downstreamBoost; + return 0.94 * downstreamBoost; + }, 0.92); + } + for (const path of map.mainRivers || []) { + const strength = riverStrengthForPath(path); + if ((path?.length || 0) < 9) continue; + drawRiverPath(ctx, map, path, waterBlue, (s, t) => { + const downstreamBoost = 0.96 + t * 0.24; + if (s > 2.35) return 2.15 * downstreamBoost; + if (s > 1.45) return 1.86 * downstreamBoost; + return 1.55 * downstreamBoost; + }, 1.0); + } const showHistory = ["history", "all", "terrain"].includes(mode); const showModern = ["modern", "all", "development", "landuse", "roads", "admin-debug", "borders-debug"].includes(mode); @@ -763,36 +750,22 @@ export function drawMap(canvas, map, options) { const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode); const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); - drawNeighborPrefectureDetails(ctx, map, mode); - // 3. Borders if (showAdmin && map.adminBorders) { - drawVectorSegments(ctx, map.adminBorders, "rgba(145, 145, 145, 0.82)", 1.15, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); } if (mode === "admin-debug" || mode === "borders-debug") { drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`); if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true); for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)"); + if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } + + drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - // Draw all generated prefecture borders with the same weight. The old main - // prefecture border is shown only in debug modes so the normal view no longer - // reads as a single highlighted prefecture surrounded by context. - const prefectureBorderSegments = map.regionalPrefectureBorders?.length ? map.regionalPrefectureBorders : map.prefectureBorder; - if (prefectureBorderSegments?.length) { - drawVectorSegments(ctx, prefectureBorderSegments, "rgba(255, 255, 255, 0.82)", 4.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - drawVectorSegments(ctx, prefectureBorderSegments, "rgba(110, 90, 120, 0.86)", 1.7, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - } - if ((mode === "admin-debug" || mode === "borders-debug") && map.prefectureBorder?.length) { - drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); - } - if (showLabels) drawPrefectureLabels(ctx, map, mode); - - if (!showFeatures) { - drawScaleBar(ctx, map); - return; - } + if (!showFeatures) return; // 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways. if (showHistory) { @@ -802,8 +775,8 @@ export function drawMap(canvas, map, options) { for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35); } if (showRoads) { - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.62)", 2.8); for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); } if (showModern || showRoads) { @@ -824,8 +797,8 @@ export function drawMap(canvas, map, options) { for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false); } if (showRoads) { - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.96)", 1.25, false); for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); } if (showModern || showRoads) { @@ -856,12 +829,10 @@ export function drawMap(canvas, map, options) { if (showLabels) { if (mode === "admin") { drawLabels(ctx, map.adminCenters || [], Infinity); - drawScaleBar(ctx, map); return; } if (mode === "admin-debug" || mode === "borders-debug") { drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity); - drawScaleBar(ctx, map); return; } const important = [ @@ -871,5 +842,4 @@ export function drawMap(canvas, map, options) { ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); drawLabels(ctx, important, 60); } - drawScaleBar(ctx, map); } From 84ad22f7af228114821180010a0634741171e205 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sat, 23 May 2026 20:06:29 +0900 Subject: [PATCH 3/6] smaller teerrain system --- mapTerrain.js | 3137 +++++++++++-------------------------------------- 1 file changed, 694 insertions(+), 2443 deletions(-) diff --git a/mapTerrain.js b/mapTerrain.js index ef6e25f..89bbb9e 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -1,6 +1,5 @@ -import { INF, MAP_H, MAP_W, SIZE, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, pickEntities, rand, smoothstep, valueNoise } from "./mapUtils.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, pickEntities, rand, smoothstep, valueNoise, xyOf } from "./mapUtils.js"; import { - aStar, extractMaskBorder, extractRegionBorderSegments, generateRegionalPrefectures, @@ -8,2501 +7,753 @@ import { 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; - const spineAngle = coastAngle + Math.PI * (0.28 + rand(seed, 46) * 0.44); - const spineCurve = (rand(seed, 47) - 0.5) * 0.28; - const spinePosition = (rand(seed, 48) - 0.5) * 0.56; - const backboneLongShift = (rand(seed, 68) - 0.5) * 0.34; - const backboneCenterX = clamp(0.5 + Math.cos(spineAngle + Math.PI / 2) * spinePosition * 0.36 + Math.cos(spineAngle) * backboneLongShift, 0.18, 0.82); - const backboneCenterY = clamp(0.5 + Math.sin(spineAngle + Math.PI / 2) * spinePosition * 0.36 + Math.sin(spineAngle) * backboneLongShift, 0.18, 0.82); - const backboneLength = 0.50 + rand(seed, 69) * 0.28; - const backboneWidth = 0.12 + rand(seed, 83) * 0.12; - const backboneScratchCount = 34 + Math.floor(rand(seed, 84) * 30); +const ASPECT = MAP_W / MAP_H; +const SQRT2 = Math.SQRT2; +function normalizeCoord(x, y) { return { - seed, - spineCount, - spineAngle, - spineCurve, - spinePosition, - backboneCenterX, - backboneCenterY, - backboneLength, - backboneWidth, - backboneScratchCount, - 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.905 + rand(seed, 66) * 0.030, - peakSoftCap: 1.010 + rand(seed, 67) * 0.020, - 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.50 + rand(seed, 74) * 0.48, - // 粗い格子でも山肌の複雑さが出るよう、 - // 細谷の「見え」は手続きノイズ寄りにし、明示的な小流路本数は抑える。 - drainageDensity: 0.56 + rand(seed, 75) * 0.22, - gullyIncision: 0.18 + rand(seed, 76) * 0.10, - dendriticTexture: 0.72 + rand(seed, 77) * 0.24, - macroNoiseStrength: 0.018 + rand(seed, 85) * 0.014, - macroNoiseScale: 0.028 + rand(seed, 86) * 0.018, - globalNoiseStrength: 0.010 + rand(seed, 87) * 0.010, - headwaterGullyCount: 0, - alpineMicroRelief: 0.018 + rand(seed, 79) * 0.022, - basinOutletCount: 10 + Math.floor(rand(seed, 80) * 8), - meanderStrength: 0.18 + rand(seed, 81) * 0.16, - terrainNoisePatchiness: 0.42 + rand(seed, 82) * 0.28, - 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.44 + rand(seed, 55) * 0.66, - detachedRangeCount: 0, - alpinePeakCount: 0, + px: (x + 0.5) / MAP_W, + py: (y + 0.5) / MAP_H, }; } -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 distNorm(ax, ay, bx, by) { + const dx = (ax - bx) * ASPECT; + const dy = ay - by; + return Math.hypot(dx, dy); } -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 rotate(dx, dy, angle) { + const c = Math.cos(angle); + const s = Math.sin(angle); + return { u: dx * c + dy * s, v: -dx * s + dy * c }; } -function buildSpineRidges(seed, template) { - const spines = []; - const branches = []; - const centerX = template.backboneCenterX ?? 0.5; - const centerY = template.backboneCenterY ?? 0.5; - const baseAngle = template.spineAngle; - const axisCos = Math.cos(baseAngle); - const axisSin = Math.sin(baseAngle); - const crossCos = Math.cos(baseAngle + Math.PI / 2); - const crossSin = Math.sin(baseAngle + Math.PI / 2); - const corridorLength = template.backboneLength ?? 0.62; - const corridorWidth = template.backboneWidth ?? 0.16; - - for (let i = 0; i < template.spineCount; i++) { - const along = ((i / Math.max(1, template.spineCount - 1)) - 0.5) * corridorLength * 0.48 + (rand(seed, 705 + i) - 0.5) * corridorLength * 0.12; - const cross = (rand(seed, 706 + i) - 0.5) * corridorWidth * 0.45; - const angle = baseAngle + (rand(seed, 700 + i) - 0.5) * 0.16; - const x = clamp(centerX + axisCos * along + crossCos * cross, 0.06, 0.94); - const y = clamp(centerY + axisSin * along + crossSin * cross, 0.06, 0.94); - spines.push({ - x, y, angle, - width: template.spineWidth * (0.52 + rand(seed, 710 + i) * 0.28), - length: corridorLength * (0.58 + rand(seed, 720 + i) * 0.18), - h: template.spineStrength * (0.11 + rand(seed, 730 + i) * 0.08), - curve: template.spineCurve, - axisWobble: template.spineWidth * (0.42 + template.ridgeJaggedness * 0.95), - kinkFrequency: 10 + rand(seed, 740 + i) * 16, - kinkPhase: rand(seed, 750 + i) * Math.PI * 2, - seedOffset: 7600 + i * 211, - widthVariation: 0.18 + template.ridgeJaggedness * 0.34, - }); - } - - const scratchCount = template.backboneScratchCount ?? (36 + Math.floor(template.ridgeBranchiness * 30)); - for (let b = 0; b < scratchCount; b++) { - const centerBias = ((rand(seed, 810 + b) + rand(seed, 811 + b)) * 0.5 - 0.5) * 2; - const edgeBias = (rand(seed, 812 + b) - 0.5) * 2; - const along = centerBias * corridorLength * 0.82; - const corridorT = clamp(1 - Math.abs(centerBias)); - const lateralSpread = corridorWidth * (0.48 + corridorT * 0.72); - const cross = edgeBias * lateralSpread; - const radialT = clamp(1 - Math.abs(edgeBias)); - const density = clamp(corridorT * 0.72 + radialT * 0.28); - const x = clamp(centerX + axisCos * along + crossCos * cross, 0.04, 0.96); - const y = clamp(centerY + axisSin * along + crossSin * cross, 0.04, 0.96); - const branchAngle = baseAngle + (rand(seed, 830 + b) - 0.5) * 0.92 + edgeBias * 0.20; - branches.push({ - x, - y, - angle: branchAngle, - width: template.spineWidth * (0.16 + rand(seed, 840 + b) * 0.16 + density * 0.08), - length: 0.07 + rand(seed, 850 + b) * 0.13 + density * 0.06, - h: template.spineStrength * (0.018 + density * 0.066 + rand(seed, 860 + b) * 0.018), - curve: template.spineCurve * 0.22, - axisWobble: template.spineWidth * (0.22 + template.ridgeJaggedness * 0.58), - kinkFrequency: 15 + rand(seed, 870 + b) * 22, - kinkPhase: rand(seed, 880 + b) * Math.PI * 2, - seedOffset: 8800 + b * 37, - widthVariation: 0.16 + template.ridgeJaggedness * 0.20, - }); - } - return { spines, branches }; +function quantile(values, q) { + const arr = Array.from(values).filter(Number.isFinite).sort((a, b) => a - b); + if (!arr.length) return 0; + const p = clamp(q) * (arr.length - 1); + const i = Math.floor(p); + const f = p - i; + return lerp(arr[i], arr[Math.min(arr.length - 1, i + 1)], f); } - -function softUpperClamp(value, start = 0.8, cap = 0.96) { - if (value <= start) return value; - if (value <= cap) { - const t = clamp((value - start) / Math.max(0.001, cap - start)); - // 肩へ向かって緩やかに圧縮するが、cap 未満ではなるべく差を残す。 - return lerp(value, start + (cap - start) * (1 - Math.pow(1 - t, 1.18)), 0.16); - } - const overflow = value - cap; - // cap 超過分も少し残して、山頂が一様な平頂面にならないようにする。 - return cap + overflow * (0.28 / (1 + overflow * 4.2)); -} - -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; -} - -// v5: 「全域を海底として初期化し、海底から広域隆起で山地を生やす」ための造山場。 -// 周期的な褶曲波はワッフル状の縞を作るため廃止し、広い帯状隆起・不均質な断層谷・ -// 連続した尾根核を domain-warp 付きで合成する。 -function foldedOrogenyAt(px, py, seed, template, coastLower = 0) { - const baseAngle = template.spineAngle + (rand(seed, 13001) - 0.5) * 0.28; - const warpX = (fbm(px * 2.0 + 17, py * 2.0 - 31, seed + 13010) - 0.5) * 0.20; - const warpY = (fbm(px * 2.1 - 43, py * 2.1 + 19, seed + 13020) - 0.5) * 0.20; - const x = px + warpX; - const y = py + warpY; - - let beltMass = 0; - let ridgeCores = 0; - let structuralValleys = 0; - const beltCount = 3; - - for (let k = 0; k < beltCount; k++) { - const angle = baseAngle + (k - 1) * 0.24 + (rand(seed, 13100 + k) - 0.5) * 0.26; - const ca = Math.cos(angle); - const sa = Math.sin(angle); - const along = x * ca + y * sa; - const cross = -x * sa + y * ca; - const offset = (rand(seed, 13120 + k) - 0.5) * 0.38 + (k - 1) * 0.10; - const axisWarp = (fbm(px * 1.65 + k * 11, py * 1.65 - k * 7, seed + 13200 + k) - 0.5) * (0.15 + template.ridgeJaggedness * 0.08); - const lengthWarp = (valueNoise(px * 1.3 - k * 17, py * 1.3 + k * 13, seed + 13250 + k, 2.4) - 0.5) * 0.34; - const localCross = cross - offset - axisWarp; - const localAlong = along + lengthWarp; - const width = 0.24 + rand(seed, 13300 + k) * 0.14; - const coreWidth = 0.058 + rand(seed, 13320 + k) * 0.046; - const lengthFade = smoothstep(1.38 - Math.abs(localAlong - 0.52)); - const broad = Math.exp(-(localCross * localCross) / (width * width)) * lengthFade; - const core = Math.exp(-(localCross * localCross) / (coreWidth * coreWidth)) * lengthFade; - const broken = 0.72 + valueNoise(px * 2.2 + k * 21, py * 2.2 - k * 15, seed + 13400 + k, 2.1) * 0.46; - const rugged = 0.74 + fbm(px * 4.3 + k * 19, py * 4.3 - k * 23, seed + 13480 + k) * 0.54; - beltMass += broad * (0.285 + k * 0.040) * broken; - ridgeCores += core * (0.340 + k * 0.030) * rugged; - - // 河川が後で選びやすい弱線。周期的な縞ではなく、ノイズで途切れる断層・構造谷として扱う。 - const valleyNoise = Math.max(0, valueNoise(px * 5.2 + k * 31, py * 5.2 - k * 27, seed + 13540 + k, 3.2) - 0.55); - const transverse = Math.exp(-((localAlong - (0.26 + rand(seed, 13600 + k) * 0.52)) ** 2) / 0.030); - structuralValleys += broad * (valleyNoise * 0.18 + transverse * 0.035); - } - - const broadA = fbm(px * 0.80 + 23, py * 0.80 - 61, seed + 13700); - const broadB = valueNoise(px * 1.35 - 41, py * 1.35 + 17, seed + 13710, 2.4); - const continentalPulse = clamp((broadA * 0.58 + broadB * 0.42 - 0.22) / 0.66); - const edgeDistance = Math.min(px, py, 1 - px, 1 - py); - const edgeDrowning = 1 - smoothstep(edgeDistance / 0.16); - const marineLoss = edgeDrowning * (0.22 + template.deposition * 0.10) + coastLower * 0.035; - - const mass = clamp(beltMass * 0.95 + continentalPulse * 0.40 + template.orographicCoverage * 0.12 - structuralValleys * 0.78 - marineLoss * 0.18); - const ridges = clamp(ridgeCores * 1.18 + mass * 0.20 - structuralValleys * 0.64); - const uplift = clamp((mass * 0.62 + ridges * 0.34) * template.orographicStrength); - return { uplift, ridges, valleys: clamp(structuralValleys * 5.0) }; -} - - -// v6: 谷状の細かな侵食テクスチャ。流路網のない場所も「沢に削られた山肌」に見せる。 -// 高周波ノイズをそのまま標高に足すのではなく、ノイズ場の細い等値線だけを抽出して -// 傾斜・標高・湿潤度でマスクする。 -function dendriticRavineTexture(x, y, seed) { - const warpX = (fbm(x * 0.72 + 113, y * 0.72 - 71, seed + 15010) - 0.5) * 9.5; - const warpY = (fbm(x * 0.74 - 59, y * 0.74 + 97, seed + 15020) - 0.5) * 9.5; - const wx = x + warpX; - const wy = y + warpY; - - const n1 = valueNoise(wx * 1.15 + 31, wy * 1.15 - 47, seed + 15100, 8.0); - const n2 = valueNoise(wx * 2.05 - 19, wy * 2.05 + 23, seed + 15110, 4.8); - const n3 = valueNoise(wx * 3.65 + 71, wy * 3.65 - 11, seed + 15120, 3.0); - const line1 = Math.pow(clamp(1 - Math.abs(n1 - 0.50) * 6.1), 3.4); - const line2 = Math.pow(clamp(1 - Math.abs(n2 - 0.47) * 7.6), 3.0); - const line3 = Math.pow(clamp(1 - Math.abs(n3 - 0.52) * 9.0), 2.6); - - const join = valueNoise(wx * 0.55 + 7, wy * 0.55 - 5, seed + 15140, 11.0); - return clamp(line1 * 0.48 + line1 * line2 * 0.44 + line2 * line3 * 0.26 + line3 * Math.max(0, join - 0.58) * 0.36); -} - -function addPathIncision(field, path, strength, radius = 1) { - if (!path || path.length < 2) return; - for (let k = 0; k < path.length; k++) { - const [px, py] = path[k]; - const downstream = k / Math.max(1, path.length - 1); - const local = strength * (0.72 + downstream * 0.44); - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const nx = px + dx; - const ny = py + dy; - if (!inside(nx, ny)) continue; - const d = Math.hypot(dx, dy); - if (d > radius + 0.15) continue; - const w = d < 0.001 ? 1 : Math.max(0, 1 - d / (radius + 0.35)) * 0.52; - const i = indexOf(nx, ny); - field[i] = clamp(field[i] + local * w); - } +function forDisk(cx, cy, radius, fn) { + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = cx + dx; + const y = cy + dy; + if (!inside(x, y)) continue; + const d = Math.hypot(dx, dy); + if (d <= radius) fn(x, y, d); } } } -// v7: line-only carving is too thin and looks like a texture overlay. This helper -// converts traced drainage paths into a distance field with a V-shaped cross-section: -// small headwater gullies stay narrow, while longer/merged channels open a slightly -// wider valley floor. The visual result is produced by terrain change, not by merely -// drawing more blue river lines. -function addValleyDistanceInfluence(incisionField, floorField, path, strength, radius = 2, floorRadius = 0.55) { - if (!path || path.length < 2) return; - for (let k = 0; k < path.length; k++) { - const [px, py] = path[k]; - const downstream = k / Math.max(1, path.length - 1); - const localRadius = Math.max(1.1, radius * (0.68 + downstream * 0.56)); - const localStrength = strength * (0.62 + downstream * 0.70); - const r = Math.ceil(localRadius + 1.2); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const nx = px + dx; - const ny = py + dy; - if (!inside(nx, ny)) continue; - const d = Math.hypot(dx, dy); - if (d > localRadius + 1.0) continue; - const i = indexOf(nx, ny); - const vShape = Math.pow(clamp(1 - d / (localRadius + 0.55)), 1.55); - const floor = smoothstep((floorRadius + 0.25 - d) / Math.max(0.35, floorRadius + 0.25)); - incisionField[i] = clamp(incisionField[i] + localStrength * vShape * 0.48); - floorField[i] = clamp(floorField[i] + localStrength * floor * 0.42); - } - } - } -} - -function applyAlpineMicroRelief(elevation, sea, slope, ridgeField, valleyField, coastalLowland, seaLevel, seed, terrainTemplate, surfaceTextureField = null) { - 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 highland = clamp((elevation[i] - (seaLevel + 0.12)) / 0.36); - const alpineMask = clamp((elevation[i] - (seaLevel + 0.20)) / 0.28) * clamp(slope[i] * 1.15 + ridgeField[i] * 0.44 - valleyField[i] * 0.26) * (1 - coastalLowland[i] * 0.75); - const ruggedMask = clamp(highland * (0.28 + slope[i] * 0.95 + ridgeField[i] * 0.38 - valleyField[i] * 0.16)); - const patch = clamp(0.35 + (fbm(x * 0.055 + 80, y * 0.055 - 34, seed + 18018) - 0.5) * 1.45 + terrainTemplate.terrainNoisePatchiness * 0.22); - const activeMask = Math.max(alpineMask, ruggedMask * 0.56) * (0.52 + patch * 0.48); - if (activeMask <= 0.02) continue; - const warpX = x + (fbm(x * 0.18 + 24, y * 0.18 - 17, seed + 18021) - 0.5) * 5.5; - const warpY = y + (fbm(x * 0.18 - 37, y * 0.18 + 13, seed + 18022) - 0.5) * 5.5; - const coarse = (valueNoise(warpX * 0.72, warpY * 0.72, seed + 18023, 4.8) - 0.5) * 2; - const medium = (valueNoise(warpX * 1.18 - 11, warpY * 1.18 + 19, seed + 18024, 6.7) - 0.5) * 2; - const fine = (valueNoise(warpX * 1.95 + 17, warpY * 1.95 - 9, seed + 18025, 9.4) - 0.5) * 2; - const ridged = 1 - Math.abs((valueNoise(warpX * 1.36 - 7, warpY * 1.36 + 21, seed + 18026, 6.2) - 0.5) * 2); - const cellular = (fbm(warpX * 0.52 + 9, warpY * 0.52 - 4, seed + 18027) - 0.5) * 2; - const perturb = (coarse * 0.42 + medium * 0.30 + fine * 0.18 + (ridged - 0.5) * 0.88 + cellular * 0.22) * terrainTemplate.alpineMicroRelief * activeMask; - elevation[i] = clamp(elevation[i] + perturb, seaLevel + 0.006, 0.998); - if (surfaceTextureField) { - const textureSignal = clamp(Math.abs(perturb) * 44 + ridged * activeMask * 0.40 + Math.abs(cellular) * activeMask * 0.24 + patch * activeMask * 0.16); - surfaceTextureField[i] = clamp(surfaceTextureField[i] + textureSignal); - } - if (perturb < 0) valleyField[i] = clamp(valleyField[i] + (-perturb) * activeMask * 4.5); - } - } -} - - -function breakHighPlateaus(elevation, sea, slope, ridgeField, valleyField, flowAccum, coastalLowland, seaLevel, seed, terrainTemplate, visibleRavineField = null, surfaceTextureField = null) { - 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 high = clamp((elevation[i] - (seaLevel + 0.18)) / 0.28); - const flat = clamp((0.26 - slope[i]) * 5.2); - const mountain = clamp(ridgeField[i] * 0.78 + high * 0.62 - valleyField[i] * 0.16) * (1 - coastalLowland[i] * 0.82); - const active = high * flat * mountain; - if (active <= 0.02) continue; - - const warpX = x + (fbm(x * 0.11 + 13, y * 0.11 - 17, seed + 18101) - 0.5) * 4.6; - const warpY = y + (fbm(x * 0.11 - 29, y * 0.11 + 7, seed + 18102) - 0.5) * 4.6; - const dend = dendriticRavineTexture(warpX * 1.05, warpY * 1.05, seed + 18103); - const broad = Math.abs((fbm(warpX * 0.16 + 4, warpY * 0.16 - 9, seed + 18104) - 0.5) * 2); - const summit = Math.max(0, valueNoise(warpX * 1.85 + 11, warpY * 1.85 - 23, seed + 18105, 4.7) - 0.57); - const drainage = Math.pow(flowAccum[i], 0.50); - const carve = active * (0.0038 + dend * 0.0062 + broad * 0.0026 + drainage * 0.0032) * (0.92 + terrainTemplate.dendriticTexture * 0.30); - const bump = active * summit * (0.0028 + terrainTemplate.roughness * 0.0022); - elevation[i] = clamp(elevation[i] - carve + bump, seaLevel + 0.006, 0.998); - valleyField[i] = clamp(valleyField[i] + carve * 18.0); - ridgeField[i] = clamp(ridgeField[i] + bump * 8.0); - if (visibleRavineField) visibleRavineField[i] = clamp(visibleRavineField[i] + carve * 8.6); - if (surfaceTextureField) surfaceTextureField[i] = clamp(surfaceTextureField[i] + active * (0.16 + dend * 0.22 + broad * 0.10)); - } - } -} - -function carveOutletChannel(elevation, sea, lake, river, valleyField, basinField, flowAccum, seaLevel, startIndex, targetIndex, seed, bonusSeed = 0) { - const start = { x: startIndex % MAP_W, y: Math.floor(startIndex / MAP_W) }; - const goal = { x: targetIndex % MAP_W, y: Math.floor(targetIndex / MAP_W) }; - const path = aStar(start, goal, (x, y, cx, cy) => { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (sea[i]) return 0.12; - const uphill = Math.max(0, elevation[i] - elevation[ci]); - return Math.max(0.16, 0.55 + uphill * 80 + Math.max(0, elevation[i] - seaLevel) * 0.10 - flowAccum[i] * 0.65 - valleyField[i] * 0.38 - basinField[i] * 0.12 + (hash2(x, y, seed + bonusSeed) - 0.5) * 0.05); - }); - if (path.length < 2) return []; - const startElev = elevation[startIndex]; - const targetElev = sea[targetIndex] ? seaLevel - 0.002 : Math.min(startElev - 0.010, elevation[targetIndex]); - for (let k = 0; k < path.length; k++) { - const [x, y] = path[k]; - const i = indexOf(x, y); - const t = k / Math.max(1, path.length - 1); - const base = lerp(startElev - 0.001, targetElev + 0.004, t); - const wiggle = (hash2(x, y, seed + 22000 + bonusSeed) - 0.5) * 0.0025; - const floorLimit = seaLevel + 0.020 + basinField[i] * 0.014; - elevation[i] = Math.min(elevation[i], Math.max(floorLimit, base + wiggle)); - valleyField[i] = clamp(valleyField[i] + 0.20 + (1 - t) * 0.12); - basinField[i] = Math.max(0, basinField[i] - 0.10); - river[i] = Math.max(river[i], 0.18 + flowAccum[i] * 0.42 + t * 0.10); - if (lake[i] && k < path.length - 1) lake[i] = 0; - if (!sea[i]) { +function largestComponent(mask, allowEdgePreference = false) { + const seen = new Uint8Array(SIZE); + let best = []; + let bestScore = -1; + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) continue; + const queue = [i]; + const cells = []; + let touchesEdge = false; + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesEdge = true; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); - if (sea[ni]) continue; - const d = Math.hypot(nx - x, ny - y); - const widen = Math.max(0, 0.010 - d * 0.003); - if (widen > 0) { - elevation[ni] = Math.min(elevation[ni], Math.max(seaLevel + 0.020, elevation[i] + 0.008 + d * 0.004)); - valleyField[ni] = clamp(valleyField[ni] + widen * 12); - } + if (!mask[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); } } + const score = cells.length + (allowEdgePreference && touchesEdge ? SIZE : 0); + if (score > bestScore) { + bestScore = score; + best = cells; + } + } + const out = new Uint8Array(SIZE); + for (const i of best) out[i] = 1; + return out; +} + +function distanceField(sourceMask, maxDistance = 999) { + const dist = new Float32Array(SIZE); + dist.fill(maxDistance); + const heap = new MinHeap(); + for (let i = 0; i < SIZE; i++) { + if (!sourceMask[i]) continue; + dist[i] = 0; + heap.push({ i, f: 0 }); + } + while (heap.length) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const x = cur.i % MAP_W; + const y = Math.floor(cur.i / MAP_W); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const step = (nx !== x && ny !== y) ? SQRT2 : 1; + const nd = cur.f + step; + if (nd >= dist[ni] || nd > maxDistance) continue; + dist[ni] = nd; + heap.push({ i: ni, f: nd }); + } + } + return dist; +} + +function ridgeContribution(px, py, ridge, seed) { + const dx = (px - ridge.x) * ASPECT; + const dy = py - ridge.y; + const { u, v } = rotate(dx, dy, ridge.angle); + const half = ridge.length * 0.5; + const along = Math.abs(u / Math.max(0.001, half)); + if (along >= 1.22) return 0; + const taper = smoothstep(1 - clamp((along - 0.68) / 0.54)); + const wobble = (valueNoise((u + ridge.phase) * 720, (py + ridge.phase) * 720, seed + ridge.seedOffset, 14) - 0.5) * ridge.width * ridge.wobble; + const cross = Math.abs(v + wobble); + const core = Math.exp(-Math.pow(cross / Math.max(0.0008, ridge.width), 2.0)); + const serration = 0.82 + 0.36 * valueNoise((px + ridge.phase) * 900, (py - ridge.phase) * 900, seed + ridge.seedOffset + 71, 7.5); + return ridge.height * core * taper * serration; +} + +function ellipticalMask(px, py, system) { + const dx = (px - system.x) * ASPECT; + const dy = py - system.y; + const { u, v } = rotate(dx, dy, system.angle); + const a = Math.max(0.01, system.length * 0.5); + const b = Math.max(0.01, system.width * 0.5); + const r = Math.sqrt((u / a) ** 2 + (v / b) ** 2); + return clamp(1 - smoothstep((r - 0.55) / 0.65)); +} + +function sampleInsideUnitDisk(seed, n) { + const r = Math.sqrt(rand(seed, n)); + const a = rand(seed, n + 1) * Math.PI * 2; + return { x: Math.cos(a) * r, y: Math.sin(a) * r, r }; +} + +export function buildTerrainTemplate(seed) { + const mountainModeRoll = rand(seed, 12); + const mountainMode = mountainModeRoll < 0.48 ? "range" : mountainModeRoll < 0.80 ? "mixed" : "massif"; + const mountainMassifness = mountainMode === "massif" ? 0.72 + rand(seed, 13) * 0.24 : mountainMode === "mixed" ? 0.34 + rand(seed, 14) * 0.36 : rand(seed, 15) * 0.24; + const coastAngle = rand(seed, 21) * Math.PI * 2; + const twoSidedCoast = rand(seed, 22) < 0.36; + const seaRatio = 0.14 + rand(seed, 23) * 0.16; + const mountainAngle = coastAngle + Math.PI * (0.26 + rand(seed, 24) * 0.48); + const baseHeight = 0.52 + rand(seed, 25) * 0.46; + const primaryLength = lerp(0.70 + rand(seed, 26) * 0.22, 0.38 + rand(seed, 27) * 0.20, mountainMassifness); + const primaryWidth = lerp(0.15 + rand(seed, 28) * 0.13, 0.36 + rand(seed, 29) * 0.20, mountainMassifness); + const scratchCount = Math.round(lerp(26 + rand(seed, 30) * 22, 18 + rand(seed, 31) * 18, mountainMassifness)); + // 脊梁山脈そのものを複数箇所に置く。旧版の secondary は主山脈の周囲に寄りすぎ、 + // 画面上では「単一の山塊」に見えやすかったため、独立した major system として扱う。 + const mountainSystemCount = 14 + Math.floor(rand(seed, 32) * 3); // 14〜16 + return { + seed, + seaRatio, + coastAngle, + twoSidedCoast, + coastNoise: 0.045 + rand(seed, 33) * 0.045, + mountainMode, + mountainMassifness, + mountainAngle, + mountainBaseHeight: baseHeight, + mountainDensity: 0.62 + rand(seed, 34) * 0.35, + primaryMountain: { + x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.28, 0.22, 0.78), + y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.28, 0.22, 0.78), + angle: mountainAngle, + length: primaryLength, + width: primaryWidth, + height: baseHeight, + scratchCount, + massifness: mountainMassifness, + }, + mountainSystemCount, + secondaryCount: mountainSystemCount - 1, + macroNoiseScale: 0.020 + rand(seed, 37) * 0.030, + macroNoiseStrength: 0.028 + rand(seed, 38) * 0.025, + scratchNoiseStrength: 0.018 + rand(seed, 39) * 0.022, + roughness: 0.40 + rand(seed, 40) * 0.50, + erosion: 0.34 + rand(seed, 41) * 0.48, + deposition: 0.28 + rand(seed, 42) * 0.56, + riverRichness: 0.72 + rand(seed, 43) * 0.60, + bigRiverChance: 0.34 + rand(seed, 44) * 0.34, + plainBias: 0.32 + rand(seed, 45) * 0.46, + }; +} + +function buildMountainSystems(template, seed) { + const systems = []; + const targetCount = Math.max(8, template.mountainSystemCount ?? 15); + const baseAngle = template.mountainAngle; + + // 複数の脊梁山脈システムを、画面中央ではなくマップ全域に分散配置する。 + // 5x3 / 4x4 に近い粗い格子へ jitter を入れ、さらに farthest-candidate で + // 既存システムから離れた候補を選ぶ。これにより「中央に単一山塊」化しにくくする。 + const cols = targetCount >= 14 ? 5 : 4; + const rows = Math.ceil(targetCount / cols); + const cellOrder = Array.from({ length: cols * rows }, (_, i) => i) + .map((v) => ({ v, key: rand(seed, 1000 + v * 17) })) + .sort((a, b) => a.key - b.key) + .map((o) => o.v); + + function gridCandidate(k, attempt) { + const cell = cellOrder[(k + attempt * 7) % cellOrder.length]; + const cx = cell % cols; + const cy = Math.floor(cell / cols); + const jitterX = (rand(seed, 1100 + k * 101 + attempt * 13) - 0.5) * 0.62; + const jitterY = (rand(seed, 1200 + k * 101 + attempt * 13) - 0.5) * 0.62; + const x = clamp((cx + 0.5 + jitterX) / cols, 0.055, 0.945); + const y = clamp((cy + 0.5 + jitterY) / rows, 0.055, 0.945); + const localTurn = (rand(seed, 1300 + k * 101 + attempt) - 0.5) * Math.PI * 0.92; + const diagonalBias = (cx / Math.max(1, cols - 1) - 0.5 + (cy / Math.max(1, rows - 1) - 0.5) * 0.35) * 0.16; + return { + x, + y, + angle: baseAngle + localTurn + diagonalBias, + }; + } + + function randomCandidate(k, attempt) { + return { + x: clamp(0.055 + rand(seed, 2000 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945), + y: clamp(0.055 + rand(seed, 2100 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945), + angle: baseAngle + (rand(seed, 2200 + k * 137 + attempt) - 0.5) * Math.PI * 1.05, + }; + } + + function candidateAt(k, attempt) { + return attempt < 5 ? gridCandidate(k, attempt) : randomCandidate(k, attempt); + } + + for (let k = 0; k < targetCount; k++) { + let best = candidateAt(k, 0); + let bestScore = -INF; + for (let attempt = 0; attempt < 18; attempt++) { + const c = candidateAt(k, attempt); + let minD = 999; + for (const s of systems) minD = Math.min(minD, distNorm(c.x, c.y, s.x, s.y)); + // 中央集中を避けるため、中心距離を少し加点する。ただし端に張り付きすぎないよう edge も見る。 + const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y); + const centerD = distNorm(c.x, c.y, 0.5, 0.5); + const score = + minD * 1.25 + + centerD * 0.18 + + Math.min(edgeD, 0.16) * 0.22 + + rand(seed, 2300 + k * 101 + attempt) * 0.04; + if (score > bestScore) { bestScore = score; best = c; } + } + + const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.50); + const isMassif = m > 0.58; + const major = k < 4 || rand(seed, 2500 + k) > 0.68; + const length = lerp( + major ? 0.30 + rand(seed, 2600 + k) * 0.22 : 0.20 + rand(seed, 2610 + k) * 0.16, + major ? 0.22 + rand(seed, 2620 + k) * 0.14 : 0.16 + rand(seed, 2630 + k) * 0.12, + m + ); + const width = lerp( + major ? 0.055 + rand(seed, 2700 + k) * 0.060 : 0.040 + rand(seed, 2710 + k) * 0.045, + major ? 0.120 + rand(seed, 2720 + k) * 0.090 : 0.085 + rand(seed, 2730 + k) * 0.070, + m + ); + const height = template.mountainBaseHeight * ( + major + ? 0.34 + rand(seed, 2800 + k) * 0.24 + : 0.20 + rand(seed, 2810 + k) * 0.18 + ); + const scratchCount = Math.round(lerp( + major ? 10 + rand(seed, 2900 + k) * 10 : 6 + rand(seed, 2910 + k) * 7, + isMassif ? 8 + rand(seed, 2920 + k) * 9 : 6 + rand(seed, 2930 + k) * 7, + m + )); + + systems.push({ + x: best.x, + y: best.y, + angle: best.angle, + length, + width, + height, + scratchCount, + massifness: m, + role: major ? (k < 4 ? "primary" : "major") : "minor", + }); + } + + return systems; +} + +function buildScratchRidges(system, seed, systemId) { + const ridges = []; + const count = Math.max(6, Math.round(system.scratchCount)); + for (let i = 0; i < count; i++) { + const p = sampleInsideUnitDisk(seed + systemId * 10000, 2000 + i * 7); + const density = clamp(1 - p.r * 0.78); + const localAngle = system.massifness > 0.55 + ? system.angle + (rand(seed, 2100 + i + systemId * 331) - 0.5) * Math.PI * 1.45 + : system.angle + (rand(seed, 2100 + i + systemId * 331) - 0.5) * (0.36 + system.massifness * 0.80); + const along = p.x * system.length * 0.45; + const cross = p.y * system.width * 0.45; + const x = clamp(system.x + Math.cos(system.angle) * along / ASPECT + Math.cos(system.angle + Math.PI / 2) * cross / ASPECT, 0.03, 0.97); + const y = clamp(system.y + Math.sin(system.angle) * along + Math.sin(system.angle + Math.PI / 2) * cross, 0.03, 0.97); + const len = lerp(system.length * (0.18 + rand(seed, 2200 + i) * 0.20), system.width * (0.32 + rand(seed, 2200 + i) * 0.30), system.massifness); + const width = lerp(0.010 + rand(seed, 2300 + i) * 0.012, 0.018 + rand(seed, 2300 + i) * 0.020, system.massifness) * (0.80 + density * 0.60); + const height = system.height * (0.040 + density * 0.095 + rand(seed, 2400 + i) * 0.035); + ridges.push({ + x, y, + angle: localAngle, + length: len, + width, + height, + wobble: 1.2 + rand(seed, 2500 + i) * 2.0, + phase: rand(seed, 2600 + i) * 10, + seedOffset: 2700 + systemId * 997 + i * 37, + density, + systemId, + }); + } + return ridges; +} + +function computeCoastLower(px, py, template, seed) { + const axis = (px - 0.5) * Math.cos(template.coastAngle) * ASPECT + (py - 0.5) * Math.sin(template.coastAngle); + const wave = (fbm(px * 220, py * 220, seed + 300) - 0.5) * template.coastNoise; + const bay = (valueNoise(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055; + const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26); + const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0; + const pressure = Math.max(sideA, sideB); + return { pressure, signedAxis: axis }; +} + +function recomputeSlope(elevation, sea, slope) { + slope.fill(0); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[i] = clamp(Math.hypot(gx, gy) * 8.2); + } + } +} + +function classifyWater(elevation, seaLevel, sea, ocean, lake) { + sea.fill(0); ocean.fill(0); lake.fill(0); + const water = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) water[i] = elevation[i] <= seaLevel ? 1 : 0; + const oceanMask = largestComponent(water, true); + const seen = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (!water[i] || seen[i]) continue; + const queue = [i]; + const cells = []; + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!water[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + const isOcean = cells.some((ci) => oceanMask[ci]); + if (isOcean || cells.length >= 22) { + for (const ci of cells) { + sea[ci] = 1; + if (isOcean) ocean[ci] = 1; + else lake[ci] = 1; + } + } else { + for (const ci of cells) elevation[ci] = seaLevel + 0.010; + } + } +} + +function priorityFloodFlow(elevation, sea, flowTo, filled) { + flowTo.fill(-1); + filled.set(elevation); + const visited = new Uint8Array(SIZE); + const heap = new MinHeap(); + let seedCount = 0; + for (let i = 0; i < SIZE; i++) { + if (sea[i]) { + visited[i] = 1; + heap.push({ i, f: filled[i] }); + seedCount++; + } + } + if (seedCount === 0) { + for (let i = 0; i < SIZE; i++) { + const x = i % MAP_W; + const y = Math.floor(i / MAP_W); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) { + visited[i] = 1; + heap.push({ i, f: filled[i] }); + } + } + } + while (heap.length) { + const cur = heap.pop(); + if (!cur) continue; + const cx = cur.i % MAP_W; + const cy = Math.floor(cur.i / MAP_W); + for (const [nx, ny] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (visited[ni]) continue; + visited[ni] = 1; + if (filled[ni] < filled[cur.i] + 0.00002) filled[ni] = filled[cur.i] + 0.00002; + heap.push({ i: ni, f: filled[ni] }); + } + } + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let best = -1; + let bestScore = filled[i]; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const stepPenalty = (nx !== x && ny !== y) ? 0.000015 : 0; + const score = filled[ni] + stepPenalty + hash2(nx, ny, 9000) * 0.000002; + if (score < bestScore - 0.000001 || sea[ni]) { + bestScore = score; + best = ni; + if (sea[ni]) break; + } + } + flowTo[i] = best; + } + } +} + +function computeFlowAccumulation(sea, flowTo, filled, flowAccum) { + const area = new Float32Array(SIZE); + const order = []; + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + area[i] = 1; + order.push(i); + } + order.sort((a, b) => filled[b] - filled[a]); + for (const i of order) { + const to = flowTo[i]; + if (to >= 0 && !sea[to]) area[to] += area[i]; + } + let maxArea = 1; + for (let i = 0; i < SIZE; i++) if (!sea[i]) maxArea = Math.max(maxArea, area[i]); + for (let i = 0; i < SIZE; i++) flowAccum[i] = sea[i] ? 0 : clamp(Math.pow(area[i] / maxArea, 0.42)); + return area; +} + +function traceFlowPath(start, sea, flowTo, maxSteps = 900) { + const path = []; + const seen = new Set(); + let i = start; + for (let step = 0; step < maxSteps && i >= 0 && !seen.has(i); step++) { + seen.add(i); + const x = i % MAP_W; + const y = Math.floor(i / MAP_W); + path.push([x, y]); + if (sea[i]) break; + const next = flowTo[i]; + if (next < 0 || next === i) break; + i = next; } return path; } -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; - // v5 terrain lifecycle: - // 1) 全セルを海面下の海底として置く。 - // 2) 造山帯・広域隆起・尾根核を海底から持ち上げる。 - // 3) この後の流路計算で谷・扇状地・沖積平野を作る。 - const terrainLarge = fbm(wx * 0.30 + 40, wy * 0.30 - 60, seed + 710); - const terrainRegional = fbm(wx * 0.72 + 80, wy * 0.72 - 20, seed + 777); - const terrainLocal = fbm(wx * 1.55 + 17, wy * 1.55 - 31, seed + 1777); - const terrainFine = valueNoise(wx * 2.15 + 11, wy * 2.15 - 19, seed + 2444, 5.5); - const fineDissection = (Math.abs(terrainLocal - 0.5) * 0.050 + Math.abs(terrainFine - 0.5) * 0.024) * (0.62 + terrainTemplate.roughness * 0.58); - const edgeDistance = Math.min(x, y, MAP_W - 1 - x, MAP_H - 1 - y) / Math.min(MAP_W, MAP_H); - const deepEdge = 1 - smoothstep(edgeDistance / 0.15); - const backboneDX = px - terrainTemplate.backboneCenterX; - const backboneDY = py - terrainTemplate.backboneCenterY; - const backboneAlong = (backboneDX * Math.cos(terrainTemplate.spineAngle) + backboneDY * Math.sin(terrainTemplate.spineAngle)) / Math.max(0.001, terrainTemplate.backboneLength); - const backboneCross = (-backboneDX * Math.sin(terrainTemplate.spineAngle) + backboneDY * Math.cos(terrainTemplate.spineAngle)) / Math.max(0.001, terrainTemplate.backboneWidth); - const backboneCore = clamp(1 - Math.sqrt(backboneAlong * backboneAlong + backboneCross * backboneCross)); - const basin = 0.040 * (terrainRegional - 0.5) + 0.030 * (terrainLarge - 0.5) + (backboneCore - 0.5) * 0.010; - const protoHighland = clamp(orogenicUplift * 1.20 + orogenicRidges * 0.72 + spineRidges * 0.98 + branchRidges * 0.76 + detachedRidges * 0.66 + alpineMassifs * 0.72 + mountains * 0.24 - rangeBreakField * 1.35 - orogenicValleys * 0.42 + backboneCore * 0.10); - // 海域は画面端の一律沈降ではなく、海岸圧・低地性・非山地性から開く。 - const marineOpening = clamp( - coastLower * (0.95 + terrainTemplate.deposition * 0.35) + - Math.max(0, 0.42 - protoHighland) * 0.22 + - Math.max(0, -basin) * 0.20 - - backboneCore * 0.10 - ); - const protoLowland = clamp((1 - protoHighland) * 0.40 + marineOpening * 0.28 + Math.max(0, -basin) * 0.34 + orogenicValleys * 0.34); - const plainNoiseSuppression = protoLowland * terrainTemplate.plainNoiseSuppression; - const subduedTerrainLocal = lerp(terrainLocal, 0.5, plainNoiseSuppression * 0.70); - const subduedTerrainFine = lerp(terrainFine, 0.5, plainNoiseSuppression * 0.82); - const subduedDissection = fineDissection * (1 - plainNoiseSuppression * 0.86); - const seafloor = seaLevel - - (0.188 + (1 - terrainLarge) * 0.045 + (1 - terrainRegional) * 0.034 + coastLower * (0.070 + terrainTemplate.deposition * 0.035) + marineOpening * 0.045 + terrainTemplate.deposition * 0.016); - const platformEmergence = (0.050 + terrainTemplate.orographicCoverage * 0.032) * (1 - marineOpening * 0.62); - const broadEmergence = Math.pow(protoHighland, 1.02) * (0.305 + terrainTemplate.orographicCoverage * 0.130); - const ridgeEmergence = - orogenicUplift * 0.245 + - orogenicRidges * 0.225 + - spineRidges * 0.520 + - branchRidges * 0.430 + - detachedRidges * 0.310 + - alpineMassifs * 0.360 + - mountains * 0.080; - const shelfDepression = Math.max(0, -basin) * 0.032 + marineOpening * (0.070 + terrainTemplate.deposition * 0.030); - const rawElevation = - seafloor + - platformEmergence + - broadEmergence + - ridgeEmergence + - (terrainLarge - 0.5) * 0.055 + - (terrainRegional - 0.5) * 0.040 + - (subduedTerrainLocal - 0.5) * 0.036 + - (subduedTerrainFine - 0.5) * 0.014 + - subduedDissection + - basin - - shelfDepression - - rangeBreakField * (0.155 + terrainTemplate.erosion * 0.080) - - orogenicValleys * (0.035 + terrainTemplate.erosion * 0.045); - - elevation[i] = clamp(softUpperClamp(rawElevation, terrainTemplate.peakSoftStart, terrainTemplate.peakSoftCap)); - arcSpineField[i] = clamp(orogenicRidges * 1.05 + orogenicUplift * 0.58 + spineRidges * 1.65 + detachedRidges * 0.98 + alpineMassifs * 0.88); - branchRidgeField[i] = clamp(branchRidges * 1.70 + orogenicValleys * 0.18); - ridgeField[i] = clamp(arcSpineField[i] * 0.78 + branchRidgeField[i] * 0.42 + orogenicRidges * 0.48 + orogenicUplift * 0.25 + subduedDissection * 0.85 - rangeBreakField * 0.72 - orogenicValleys * 0.36); - basinField[i] = clamp(Math.max(0, -basin) * 2.6 + rangeBreakField * 1.25 + orogenicValleys * 1.00 + Math.max(0, seaLevel + 0.085 - elevation[i]) * (1.10 + terrainTemplate.deposition * 0.65)); - coastalLowland[i] = 0; - 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.12 * deepEdge + 0.18 * (1 - clamp((elevation[i] - seaLevel) / 0.35)) - 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); - // v5: 海岸線は後から切るのではなく、海底からの隆起量が海面を超えた場所だけを陸にする。 - if (elevation[i] < seaLevel) sea[i] = 1; - if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.014 + 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); - } - } - - // Seed ごとの過剰な平坦化・過剰な高原化を抑える救済正規化。 - // 海面を超えた陸だけを対象に、90/97 パーセンタイルを「山がちな島弧」の範囲へ寄せる。 - const emergedElevations = []; - for (let i = 0; i < SIZE; i++) if (!sea[i]) emergedElevations.push(elevation[i]); - emergedElevations.sort((a, b) => a - b); - if (emergedElevations.length > 100) { - const q = (p) => emergedElevations[Math.max(0, Math.min(emergedElevations.length - 1, Math.floor((emergedElevations.length - 1) * p)))]; - const q65 = q(0.65); - const q90 = q(0.90); - const q97 = q(0.97); - const targetQ90 = 0.680 + terrainTemplate.roughness * 0.070; - const targetQ97 = 0.840 + terrainTemplate.roughness * 0.095; - const scale97 = clamp((targetQ97 - seaLevel) / Math.max(0.045, q97 - seaLevel), 0.52, 1.50); - const shift90 = clamp(targetQ90 - q90, -0.30, 0.22); - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - const highShoulder = smoothstep((elevation[i] - q65) / Math.max(0.045, q97 - q65)); - const ridgeBoost = clamp(ridgeField[i] * 0.42 + arcSpineField[i] * 0.28 - valleyField[i] * 0.18 - coastalLowland[i] * 0.15); - let adjusted = seaLevel + (elevation[i] - seaLevel) * lerp(1, scale97, highShoulder * 0.92 + ridgeBoost * 0.22); - adjusted += shift90 * highShoulder * (0.48 + ridgeBoost * 0.30); - elevation[i] = clamp(softUpperClamp(adjusted, 0.895, targetQ97 + 0.070), seaLevel + 0.006, 0.992); - } - } - - // 海岸は「切断面」ではなく沈降・波食・堆積で丸める。 - // 強い尾根が海へ落ちる場所は残し、低い場所だけを浜堤・海岸低地へ寄せる。 - 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 = -8; dy <= 8; dy++) { - for (let dx = -8; dx <= 8; 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 <= 8) { - const marineInfluence = smoothstep((8 - nearestSea) / 8); - const ridgeResistance = smoothstep((ridgeField[i] - 0.24) / 0.42); - const coastalShelf = seaLevel + 0.015 + nearestSea * (0.015 + terrainTemplate.deposition * 0.008) + Math.max(0, fbm(x * 1.1, y * 1.1, seed + 2350) - 0.5) * (0.010 + terrainTemplate.coastRoughness * 0.012); - const lowlandBlend = marineInfluence * (1 - ridgeResistance) * (0.42 + terrainTemplate.deposition * 0.30); - if (elevation[i] > coastalShelf) elevation[i] = lerp(elevation[i], coastalShelf, lowlandBlend); - if (nearestOcean <= 8) { - const plainReach = clamp(5.2 + terrainTemplate.deposition * 7.0, 5.5, 10.5); - coastalLowland[i] = clamp((1 - nearestOcean / plainReach) * (0.70 + terrainTemplate.deposition * 0.55) * (1 - ridgeField[i] * 0.62)); - } - } - } - } - - // 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.092, seaLevel + 0.006, 0.992); - 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); - } - - function recomputeDrainageFields({ reinforceValleys = false } = {}) { - flowTo.fill(-1); - flowAccum.fill(0); - const order = []; - 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.010 * hash2(x, y, seed + 18440); - 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 drainageAttraction = valleyField[ni] * 0.020 + erosionField[ni] * 0.030 + gullyIncisionField?.[ni] * 0.020; - const directed = ev + 0.007 * hash2(nx, ny, seed + 18441) - drainageAttraction; - 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; - if (reinforceValleys) { - valleyField[i] = clamp(valleyField[i] * 0.72 + hollow * 7.8 + Math.max(0, 0.055 - relief) * Math.max(0, elevation[i] - seaLevel - 0.10) * 1.2); - basinField[i] = clamp(basinField[i] + hollow * 1.8 + (relief < 0.040 && elevation[i] < 0.58 ? 0.10 : 0)); - } - flowAccum[i] = 0.72 + moisture[i] * 0.72 + valleyField[i] * 0.72 + Math.max(0, elevation[i] - seaLevel) * 0.10; - order.push(i); - } - } - order.sort((a, b) => elevation[b] - elevation[a]); - for (const i of order) { - const to = flowTo[i]; - if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.93; - } - let maxAccum = 0; - for (let i = 0; i < SIZE; i++) if (!sea[i]) maxAccum = Math.max(maxAccum, flowAccum[i]); - if (maxAccum > 0) { - for (let i = 0; i < SIZE; i++) if (!sea[i]) flowAccum[i] = clamp(flowAccum[i] / maxAccum); - } - } - - // v6/v7: 可視河川だけでなく、山地の無数の沢・ガリーを先に掘る。 - // これを描画用の川には使わず、地形侵食だけに使うことで「日本的な谷密度」を出す。 - function traceErosionGully(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 < 92; step++) { - const i = indexOf(x, y); - if (seen.has(i) || sea[i]) break; - seen.add(i); - path.push([x, y]); - if (elevation[i] < seaLevel + 0.055 && path.length > 8) break; - let best = null; - let bestValue = INF; - const preferred = flowTo[i]; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (seen.has(ni)) continue; - const dx = nx - x; - const dy = ny - y; - const drop = elevation[i] - elevation[ni]; - const uphill = Math.max(0, -drop); - if (!sea[ni] && uphill > 0.035 + flowAccum[i] * 0.10) continue; - const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; - const bend = Math.abs(dx * lastDy - dy * lastDx); - const preferredBonus = ni === preferred ? 0.80 : 0; - const value = - elevation[ni] * 0.96 + - uphill * 30.0 - - Math.max(0, drop) * 2.9 - - valleyField[ni] * 1.15 - - Math.pow(flowAccum[ni], 0.55) * 1.05 - - moisture[ni] * 0.22 - - preferredBonus - - Math.max(0, sameDirection) * 0.045 + - bend * 0.030 + - (hash2(nx, ny, seed + bonusSeed + step * 31) - 0.5) * 0.055; - 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 gullyIncisionField = new Float32Array(SIZE); - const visibleRavineField = new Float32Array(SIZE); - const surfaceTextureField = new Float32Array(SIZE); - const gullyCandidates = []; - 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 highland = clamp((elevation[i] - (seaLevel + 0.075)) / 0.42); - const highFlat = highland * clamp(1 - slope[i] * 2.7) * clamp((elevation[i] - (seaLevel + 0.170)) / 0.34) * (1 - coastalLowland[i] * 0.70); - const relief = clamp(slope[i] * 1.10 + ridgeField[i] * 0.34 + valleyField[i] * 0.22 + highFlat * 0.86); - const drainage = clamp(Math.pow(flowAccum[i], 0.36) * 0.74 + moisture[i] * 0.26 + dendriticRavineTexture(x, y, seed) * 0.18 + highFlat * 0.58); - const score = highland * relief * drainage + highFlat * 0.44 + hash2(x, y, seed + 15200) * 0.10; - if (score > 0.13) gullyCandidates.push({ x, y, score }); - } - } - // 明示的な細流路大量生成は粗い格子では効きにくく、過剰掘削の原因にもなる。 - // 細かな山肌表現は renderer 側の手続きノイズへ移し、ここでは実際の小河川は掘らない。 - const gullySources = []; - const erosionGullyPaths = []; - const valleyNetworkIncision = new Float32Array(SIZE); - const valleyFloorField = new Float32Array(SIZE); - for (const source of gullySources) { - const path = traceErosionGully(source.x, source.y, 15250 + source.x * 17 + source.y * 23); - if (path.length < 5) continue; - erosionGullyPaths.push(path); - const startIndex = indexOf(source.x, source.y); - const highFlat = clamp(1 - slope[startIndex] * 2.4) * clamp((elevation[startIndex] - (seaLevel + 0.16)) / 0.35); - const strength = terrainTemplate.gullyIncision * clamp(0.016 + slope[startIndex] * 0.026 + Math.pow(flowAccum[startIndex], 0.42) * 0.020 + valleyField[startIndex] * 0.010 + highFlat * 0.018); - addPathIncision(gullyIncisionField, path, strength, path.length > 22 ? 2 : 1); - const valleyRadius = path.length > 54 ? 3.2 : path.length > 28 ? 2.4 : 1.7; - addValleyDistanceInfluence(valleyNetworkIncision, valleyFloorField, path, strength * (0.55 + highFlat * 0.55), valleyRadius, path.length > 42 ? 0.80 : 0.48); - addPathIncision(visibleRavineField, path, strength * (8.4 + highFlat * 3.4), path.length > 34 ? 2 : 1); - addValleyDistanceInfluence(visibleRavineField, valleyFloorField, path, strength * (2.2 + highFlat * 1.8), Math.max(1.5, valleyRadius * 0.72), 0.30); - } - - - // v10 の明示的な支谷トレースは削除。粗い解像度では可視変化が薄く、 - // 処理だけが増えるため、支谷表現は後段の手続きノイズに集約する。 - - // v11: red-box style dissected relief should not be a local accident. - // Spread that branchy mountain texture across the whole mountainous massif, - // while still letting real drainage control the strongest valleys. - const massifDissectionField = new Float32Array(SIZE); - 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 highland = clamp((elevation[i] - (seaLevel + 0.055)) / 0.46); - const mountainMask = highland * clamp(slope[i] * 1.35 + ridgeField[i] * 0.42 + arcSpineField[i] * 0.24 + branchRidgeField[i] * 0.18 - basinField[i] * 0.22) * (1 - coastalLowland[i] * 0.70); - if (mountainMask <= 0.04) continue; - const highFlat = clamp((elevation[i] - (seaLevel + 0.16)) / 0.34) * clamp(1 - slope[i] * 2.7) * mountainMask; - const warpX = x + (fbm(x * 0.075 + 17, y * 0.075 - 29, seed + 16201) - 0.5) * 7.8; - const warpY = y + (fbm(x * 0.075 - 53, y * 0.075 + 11, seed + 16202) - 0.5) * 7.8; - const ravA = dendriticRavineTexture(warpX * 0.70, warpY * 0.70, seed + 16203); - const ravB = dendriticRavineTexture(warpX * 1.02 + 37, warpY * 1.02 - 19, seed + 16204); - const coarse = Math.abs((fbm(warpX * 0.085 + 21, warpY * 0.085 - 8, seed + 16205) - 0.5) * 2); - const patch = clamp(0.64 + (fbm(x * 0.028 + 80, y * 0.028 - 41, seed + 16206) - 0.5) * 0.78); - const branchiness = clamp(ravA * 0.64 + ravB * 0.42 + coarse * 0.18); - const dissection = mountainMask * patch * (branchiness * (1.00 + highFlat * 0.72) + highFlat * 0.26); - if (dissection <= 0.03) continue; - massifDissectionField[i] = dissection; - visibleRavineField[i] = clamp(visibleRavineField[i] + dissection * (0.34 + highFlat * 0.18)); - surfaceTextureField[i] = clamp(surfaceTextureField[i] + dissection * (0.40 + highFlat * 0.32) + coarse * mountainMask * 0.08); - valleyField[i] = clamp(valleyField[i] + dissection * 0.22); - } - } - - - // Coarse grids cannot resolve endless tiny tributaries. Here the “fine - // ravines” are not just a paint/shading overlay: they are folded into the DEM - // itself as a small alternating cut/crest signal, then slope is recomputed. - // This keeps the detail persistent for rivers, labels and all later terrain uses. - const proceduralRavineField = new Float32Array(SIZE); - const proceduralReliefField = new Float32Array(SIZE); - 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 highland = clamp((elevation[i] - (seaLevel + 0.07)) / 0.42); - const mountainMask = highland * clamp(slope[i] * 0.84 + ridgeField[i] * 0.48 + arcSpineField[i] * 0.22 - basinField[i] * 0.20) * (1 - coastalLowland[i] * 0.78); - if (mountainMask <= 0.025) continue; - const warpX = x + (fbm(x * 0.10 + 91, y * 0.10 - 47, seed + 16601) - 0.5) * 7.8; - const warpY = y + (fbm(x * 0.10 - 33, y * 0.10 + 28, seed + 16602) - 0.5) * 7.8; - const ravA = dendriticRavineTexture(warpX * 0.74, warpY * 0.74, seed + 16603); - const ravB = dendriticRavineTexture(warpX * 1.18 + 23, warpY * 1.18 - 41, seed + 16604); - const ridged = 1 - Math.abs((valueNoise(warpX * 0.92 + 17, warpY * 0.92 - 9, seed + 16605, 6.2) - 0.5) * 2); - const patch = clamp(0.66 + (fbm(x * 0.038 + 80, y * 0.038 - 51, seed + 16606) - 0.5) * 0.88); - const micro = clamp((ravA * 0.70 + ravB * 0.46 + ridged * 0.22) * patch * mountainMask); - if (micro <= 0.025) continue; - - const crestNoise = Math.max(0, ridged - 0.45) * mountainMask * patch; - const cut = micro * (0.024 + highland * 0.024 + terrainTemplate.roughness * 0.012 + terrainTemplate.macroNoiseStrength * 0.20); - const crest = crestNoise * (0.008 + highland * 0.010 + terrainTemplate.globalNoiseStrength * 0.18); - const relief = crest - cut; - proceduralRavineField[i] = micro; - proceduralReliefField[i] = relief; - elevation[i] = clamp(elevation[i] + relief, seaLevel + 0.006, 0.998); - visibleRavineField[i] = clamp(visibleRavineField[i] + micro * 0.66); - surfaceTextureField[i] = clamp(surfaceTextureField[i] + micro * 0.78 + Math.max(0, crestNoise) * 0.20); - valleyField[i] = clamp(valleyField[i] + micro * 0.20); - erosionField[i] = clamp(erosionField[i] + cut * 0.40); - } - } - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const upland = clamp((elevation[i] - seaLevel) / 0.56); - const macroA = (fbm(x * terrainTemplate.macroNoiseScale + 301, y * terrainTemplate.macroNoiseScale - 119, seed + 16701) - 0.5) * 2; - const macroB = (valueNoise(x * (terrainTemplate.macroNoiseScale * 1.9) - 87, y * (terrainTemplate.macroNoiseScale * 1.9) + 63, seed + 16702, 10.5) - 0.5) * 2; - const globalA = (fbm(x * 0.016 + 57, y * 0.016 - 21, seed + 16703) - 0.5) * 2; - const globalB = (valueNoise(x * 0.030 + 19, y * 0.030 - 44, seed + 16704, 14.0) - 0.5) * 2; - const macroMask = clamp(0.30 + upland * 0.78 - coastalLowland[i] * 0.44); - const macroRelief = (macroA * terrainTemplate.macroNoiseStrength + macroB * terrainTemplate.macroNoiseStrength * 0.62 + globalA * terrainTemplate.globalNoiseStrength + globalB * terrainTemplate.globalNoiseStrength * 0.58) * macroMask; - elevation[i] = clamp(elevation[i] + macroRelief, seaLevel + 0.006, 0.998); - visibleRavineField[i] = clamp(visibleRavineField[i] + Math.abs(macroRelief) * 6.4 * upland); - surfaceTextureField[i] = clamp(surfaceTextureField[i] + Math.abs(macroRelief) * 9.0 * macroMask); - valleyField[i] = clamp(valleyField[i] + Math.max(0, -macroRelief) * 2.4); - ridgeField[i] = clamp(ridgeField[i] + Math.max(0, macroRelief) * 1.9); - } - } - - 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); - } - } - - // 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.010) / 0.095); - const highlandMask = clamp((elevation[i] - (seaLevel + 0.060)) / 0.44); - const ravineTexture = dendriticRavineTexture(x, y, seed); - const highFlat = highlandMask * clamp(1 - slope[i] * 2.9) * clamp((elevation[i] - (seaLevel + 0.170)) / 0.34) * (1 - coastalLowland[i] * 0.72); - const basinProtection = clamp(basinField[i] * (1 - slope[i] * 2.2) * (1 - ridgeField[i] * 0.65)); - const textureMask = clamp(highlandMask * (slope[i] * 1.40 + ridgeField[i] * 0.32 + valleyField[i] * 0.26 + highFlat * 0.82) * (0.40 + moisture[i] * 0.78)); - const syntheticRavine = proceduralRavineField[i]; - const textureCut = clamp(terrainTemplate.dendriticTexture * ravineTexture * textureMask * (0.0016 + terrainTemplate.erosion * 0.0022 + slope[i] * 0.006 + highFlat * 0.003)); - const gullyCut = clamp(gullyIncisionField[i] * (0.34 + slope[i] * 0.76 + highlandMask * 0.24 + highFlat * 0.26)); - const networkCut = clamp(valleyNetworkIncision[i] * (0.18 + highlandMask * 0.22 + slope[i] * 0.24)); - const massifDissection = massifDissectionField[i]; - const visualRavine = clamp(visibleRavineField[i] * (0.16 + highlandMask * 0.32 + slope[i] * 0.18) + massifDissection * (0.10 + highFlat * 0.08) + syntheticRavine * 0.26); - const plateauCut = clamp(highFlat * (ravineTexture * 0.002 + valleyNetworkIncision[i] * 0.06 + Math.pow(flowAccum[i], 0.52) * 0.0018 + massifDissection * 0.004 + syntheticRavine * 0.003) + visualRavine * 0.004); - const sourceProtection = clamp((0.060 - flowAccum[i]) / 0.060) * clamp((elevation[i] - (seaLevel + 0.08)) / 0.40); - const fluvialCore = terrainTemplate.fluvialAggression * firstOrderPower * flow * (0.018 + terrainTemplate.erosion * 0.028 + slope[i] * (0.050 + terrainTemplate.erosion * 0.050) + ridgeField[i] * (0.008 + terrainTemplate.erosion * 0.014)) * incisionNoise; - let steepValley = clamp((fluvialCore + textureCut + gullyCut + networkCut + plateauCut) * (1 - basinProtection * 0.70)); - let lateralCut = clamp((terrainTemplate.fluvialAggression * firstOrderPower * Math.pow(flowAccum[i], 0.76) * valleyField[i] * (0.014 + terrainTemplate.erosion * 0.024) + valleyFloorField[i] * (0.050 + highlandMask * 0.08) + gullyIncisionField[i] * 0.06) * (1 - basinProtection * 0.82)); - if (sourceProtection > 0) { - const protect = 1 - sourceProtection * 0.82; - steepValley *= protect; - lateralCut *= 1 - sourceProtection * 0.74; - } - const lowSettling = clamp(flow * (coastalLowland[i] * (0.018 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.018 + terrainTemplate.deposition * 0.040) + (elevation[i] < 0.40 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - slope[i] * 0.82) * (1 - ridgeField[i] * 0.45) + basinProtection * (0.006 + terrainTemplate.deposition * 0.012)); - erosionField[i] = steepValley + lateralCut + networkCut * 0.20; - depositionField[i] = lowSettling; - depositionalLowland[i] = clamp(lowSettling * 6.5 + basinField[i] * terrainTemplate.deposition * 0.28 + coastalLowland[i] * terrainTemplate.deposition * 0.34 + valleyFloorField[i] * 0.18); - valleyField[i] = clamp(valleyField[i] + ravineTexture * textureMask * 0.22 + gullyIncisionField[i] * 1.8 + valleyNetworkIncision[i] * 1.5 + visibleRavineField[i] * 1.30 + massifDissection * 0.80 + syntheticRavine * 0.42 + valleyFloorField[i] * 1.0 + steepValley * 1.4); - surfaceTextureField[i] = clamp(surfaceTextureField[i] + ravineTexture * textureMask * 0.30 + visibleRavineField[i] * 0.66 + massifDissection * 0.72 + syntheticRavine * 0.74 + gullyIncisionField[i] * 1.2); - const basinFloorGuard = seaLevel + 0.010 + basinProtection * 0.030 + coastalLowland[i] * 0.010; - const headwaterGuard = elevation[i] - (0.010 + slope[i] * 0.014 + highlandMask * 0.010 + Math.pow(flowAccum[i], 0.60) * 0.028); - const carved = elevation[i] - steepValley - lateralCut + lowSettling * (0.72 + basinProtection * 0.35); - shapedElevation[i] = clamp(Math.max(basinFloorGuard, sourceProtection > 0 ? headwaterGuard : seaLevel + 0.006, carved), 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 dissectionLock = clamp(valleyNetworkIncision[i] * 2.6 + gullyIncisionField[i] * 2.4 + erosionField[i] * 2.0); - const ridgeCore = clamp(arcSpineField[i] * 0.74 + branchRidgeField[i] * 0.58 + ridgeField[i] * 0.42 - valleyField[i] * 0.38 - flowAccum[i] * 0.28 - dissectionLock * 0.18); - const highBase = clamp((elevation[i] - 0.55) / 0.25); - const alpine = clamp(ridgeCore * 0.88 + highBase * 0.18 - coastalLowland[i] * 0.45 - depositionalLowland[i] * 0.36 - dissectionLock * 0.20); - 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.970 + Math.min(0.045, ridgeCore * 0.042) + summitTexture * 0.020; - 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); - } - } - - applyAlpineMicroRelief(elevation, sea, slope, ridgeField, valleyField, coastalLowland, seaLevel, seed, terrainTemplate, surfaceTextureField); - breakHighPlateaus(elevation, sea, slope, ridgeField, valleyField, flowAccum, coastalLowland, seaLevel, seed, terrainTemplate, visibleRavineField, surfaceTextureField); - - 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); - visibleRavineField[i] = clamp(visibleRavineField[i] + Math.pow(flowAccum[i], 0.56) * clamp(slope[i] * 1.5 + valleyField[i] * 0.28) * 0.24); - valleyField[i] = clamp(valleyField[i] + visibleRavineField[i] * 0.42 + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06); - basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6); - } - } - - // v7: after actual incision, recompute flow direction/accumulation so the - // visible river network follows the carved valleys rather than the pre-erosion surface. - recomputeDrainageFields({ reinforceValleys: true }); - - // v8: breach the most prominent enclosed basins so盆地 often acquire an outlet - // instead of remaining as unexplained closed depressions. - const sinkCandidates = []; - 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; - if (flowTo[i] >= 0 && flowTo[i] !== i) continue; - const basinScore = basinField[i] * 1.15 + valleyField[i] * 0.55 + flowAccum[i] * 0.38 + (lake[i] ? 0.16 : 0) - coastalLowland[i] * 0.30; - if (basinScore > 0.24) sinkCandidates.push({ i, x, y, score: basinScore }); - } - } - sinkCandidates.sort((a, b) => b.score - a.score); - const usedOutletStarts = new Set(); - for (const candidate of sinkCandidates.slice(0, terrainTemplate.basinOutletCount)) { - const startIndex = candidate.i; - const startKey = `${candidate.x},${candidate.y}`; - if (usedOutletStarts.has(startKey)) continue; - let targetIndex = -1; - let bestScore = INF; - for (let j = 0; j < SIZE; j++) { - if (j === startIndex) continue; - if (sea[j]) { - const d = Math.hypot((j % MAP_W) - candidate.x, Math.floor(j / MAP_W) - candidate.y); - const score = d * 0.48 - 7.5; - if (score < bestScore) { bestScore = score; targetIndex = j; } - continue; - } - const elevDelta = elevation[j] - elevation[startIndex]; - if (elevDelta > 0.050) continue; - if (flowAccum[j] < Math.max(0.08, flowAccum[startIndex] + 0.010) && basinField[j] < 0.18) continue; - const dx = (j % MAP_W) - candidate.x; - const dy = Math.floor(j / MAP_W) - candidate.y; - const d = Math.hypot(dx, dy); - const score = d * 0.34 + Math.max(0, elevDelta) * 120 - flowAccum[j] * 18 - valleyField[j] * 4 - (lake[j] ? 1.5 : 0); - if (score < bestScore) { bestScore = score; targetIndex = j; } - } - if (targetIndex < 0) continue; - const outletPath = carveOutletChannel(elevation, sea, lake, river, valleyField, basinField, flowAccum, seaLevel, startIndex, targetIndex, seed, 22800 + candidate.x * 17 + candidate.y * 31); - if (outletPath.length > 2) { - for (const [px, py] of outletPath.slice(0, 6)) usedOutletStarts.add(`${px},${py}`); - } - } - recomputeDrainageFields({ reinforceValleys: true }); - - 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 gullyHint = gullyIncisionField[i] * 1.8 + dendriticRavineTexture(x, y, seed) * 0.12; - const score = elevation[i] * 0.22 + moisture[i] * 0.25 + ridgeField[i] * 0.030 + arcSpineField[i] * 0.030 + branchRidgeField[i] * 0.018 + flowAccum[i] * 1.12 + valleyField[i] * 0.58 + gullyHint * 0.42 + basinField[i] * 0.12 + coastalLowland[i] * 0.07 + hash2(x, y, seed + 9000) * 0.05; - if (elevation[i] > 0.30 && elevation[i] < 0.94 && moisture[i] > 0.16 && (flowAccum[i] > 0.003 || valleyField[i] > 0.035 || gullyIncisionField[i] > 0.006 || slope[i] > 0.18) && ridgeField[i] < 0.98) sourceCandidates.push({ x, y, score }); - } - } - - const majorRiverCandidates = sourceCandidates - .filter((p) => { - const i = indexOf(p.x, p.y); - return elevation[i] > 0.24 && elevation[i] < 0.80 && moisture[i] > 0.18 && (flowAccum[i] > 0.070 || valleyField[i] > 0.18); - }) - .map((p) => { - const i = indexOf(p.x, p.y); - const inland = Math.min(p.x, p.y, MAP_W - 1 - p.x, MAP_H - 1 - p.y) / Math.min(MAP_W, MAP_H); - return { - ...p, - score: p.score + flowAccum[i] * 1.55 + valleyField[i] * 0.60 + inland * 0.55 - Math.abs(elevation[i] - 0.50) * 0.16, - }; - }); - const majorRiverDesired = rand(seed, 9120) < 0.30 ? 0 : (rand(seed, 9121) < 0.24 ? 2 : 1); - const majorSources = pickEntities(majorRiverCandidates, { - max: majorRiverDesired, - minDistance: 12, - threshold: 0.54, - seed: seed + 9122, - jitter: 0.01, - }); - - const sources = pickEntities(sourceCandidates, { - max: 48 + Math.floor(rand(seed, 910) * 30), - minDistance: 4, - threshold: 0.22 + 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.070 && flowAccum[i] < flowAccum[ci] + 0.010) 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.160, elevation[ci] + 0.090)) 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 lowlandMeander = clamp((1 - slope[i] * 2.4) * (0.35 + valleyField[i] * 0.60 + Math.pow(flowAccum[i], 0.35) * 0.42)); - const straightPenalty = Math.max(0, sameDirection) * (0.12 + terrainTemplate.meanderStrength * (0.42 + lowlandMeander * 0.35)); - const turnPenalty = sameDirection < -0.62 ? 0.10 : 0; - const sideSwing = dx * lastDy - dy * lastDx; - const bendMag = Math.abs(sideSwing); - const meanderWave = Math.sin((path.length + bonusSeed * 0.011) * (0.44 + terrainTemplate.meanderStrength * 0.28) + hash2(startX, startY, seed + bonusSeed) * Math.PI * 2); - const targetBend = Math.sign(meanderWave); - const meanderBias = targetBend !== 0 ? Math.max(0, sideSwing * targetBend) * (0.055 + terrainTemplate.meanderStrength * 0.110 + lowlandMeander * 0.070) : 0; - const antiStraight = bendMag * (0.024 + terrainTemplate.meanderStrength * 0.070 + lowlandMeander * 0.035); - const flowBonus = ni === preferred ? (0.36 + flowAccum[ni] * 0.18) : 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.00 + lowlandMeander * 0.28) - - flowAccum[ni] * (1.22 + lowlandMeander * 0.12) - - moisture[ni] * 0.18 - - coastalLowland[ni] * (0.38 + lowlandMeander * 0.22) - - (river[ni] > 0 ? 0.34 : 0) - - flowBonus + - slope[ni] * 0.04 + - straightPenalty + - turnPenalty + - junctionPenalty * 1.10 - - meanderBias - - antiStraight - - 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 swing = dx * lastDy - dy * lastDx; - const lowlandMeander = clamp((1 - slope[i] * 2.0) * (0.28 + valleyField[i] * 0.80 + Math.pow(flowAccum[i], 0.35) * 0.38)); - const meanderWave = Math.sin((step + bonusSeed * 0.009) * (0.52 + terrainTemplate.meanderStrength * 0.25) + hash2(startX, startY, seed + 23123) * Math.PI * 2); - const targetSwing = Math.sign(meanderWave); - const lateralBonus = targetSwing !== 0 ? Math.max(0, swing * targetSwing) * (0.038 + terrainTemplate.meanderStrength * 0.070 + lowlandMeander * 0.060) : 0; - const value = elevation[ni] * 1.10 + Math.max(0, -drop) * 17.5 - Math.max(0, drop) * 1.52 - valleyField[ni] * (1.42 + lowlandMeander * 0.18) - flowAccum[ni] * (0.74 + lowlandMeander * 0.06) - moisture[ni] * 0.16 + Math.max(0, sameDirection) * (0.062 + lowlandMeander * 0.03) - Math.abs(swing) * 0.016 - lateralBonus + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.090; - 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 majorRiverPathSet = new Set(); - const riverScores = []; - for (const source of majorSources) { - const { path, accum } = traceRiverPath(source.x, source.y, 2000 + source.x * 13 + source.y * 19); - if (path.length > 14) { - riverPaths.push(path); - majorRiverPathSet.add(path); - riverScores.push(path.length * 1.35 + accum * 0.32); - } - } - 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: 42 + Math.floor(rand(seed, 915) * 28), - minDistance: 3, - threshold: 0.16, - 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: 82 + Math.floor(rand(seed, 919) * 52), - minDistance: 1.5, - threshold: 0.062, - 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.055); - 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.040); - for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); +function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField) { river.fill(0); + const candidates = []; + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const f = flowAccum[i]; + const high = clamp((elevation[i] - 0.36) * 2.2); + const wet = valueNoise(x * 1.7, y * 1.7, seed + 12000, 24); + const score = f * 0.80 + high * 0.25 + wet * 0.16 - slope[i] * 0.10; + if (score > 0.24) candidates.push({ x, y, score }); + } + } + const desired = 44 + Math.floor(template.riverRichness * 28); + const sources = pickEntities(candidates, { max: desired, minDistance: 6, threshold: 0.26, seed: seed + 12100, jitter: 0.035 }); + const riverPaths = []; + for (const s of sources) { + const path = traceFlowPath(indexOf(s.x, s.y), sea, flowTo); + if (path.length >= 8 && path.some(([x, y], k) => k > 5 && (sea[indexOf(x, y)] || lake[indexOf(x, y)]))) riverPaths.push(path); + else if (path.length >= 14) riverPaths.push(path); + } + + const longPaths = riverPaths + .map((path) => { + let maxFlow = 0; + let meanFlow = 0; + for (const [x, y] of path) { + const f = flowAccum[indexOf(x, y)]; + maxFlow = Math.max(maxFlow, f); + meanFlow += f; + } + meanFlow /= Math.max(1, path.length); + return { path, score: path.length * 0.75 + maxFlow * 90 + meanFlow * 35 }; + }) + .sort((a, b) => b.score - a.score); + const mainCount = Math.min(longPaths.length, template.bigRiverChance > 0.56 ? 4 : 3); + const mainSet = new Set(longPaths.slice(0, mainCount).map((p) => p.path)); + + const riverThreshold = 0.26 - template.riverRichness * 0.035 - (template.bigRiverChance > 0.56 ? 0.030 : 0); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const f = flowAccum[i]; + if (f > riverThreshold) river[i] = clamp((f - riverThreshold) / (0.55 - riverThreshold)); + } for (const path of riverPaths) { - const major = majorRiverPathSet.has(path); + const main = mainSet.has(path); for (let k = 0; k < path.length; k++) { const [x, y] = path[k]; - const i = indexOf(x, y); - river[i] += major ? (0.72 + k / 118 + flowAccum[i] * 0.98) : (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(mediumPower * 0.20 + largePower * 0.44); - const headwaterProtect = clamp((0.54 - r) / 0.54) * clamp((elevation[i] - (seaLevel + 0.08)) / 0.42); - const highlandProtect = clamp((elevation[i] - 0.54) / 0.34) * clamp((0.58 - flowAccum[i]) / 0.58); - const localWallProtect = clamp((slope[i] - 0.22) * 1.8) * clamp((elevation[i] - 0.54) / 0.34); - const cutLimiter = 1 - clamp(headwaterProtect * 0.96 + highlandProtect * 0.76 + localWallProtect * 0.55); - const channelCutRaw = terrainTemplate.fluvialAggression * actionPower * Math.pow(r, 0.82) * (0.0045 + terrainTemplate.erosion * 0.0055 + slope[i] * (0.006 + terrainTemplate.erosion * 0.008) + ridgeField[i] * (0.0015 + terrainTemplate.erosion * 0.0025)); - const valleyWidenRaw = terrainTemplate.fluvialAggression * (mediumPower * 0.08 + largePower * 0.18) * Math.pow(r, 0.92) * (0.0015 + terrainTemplate.erosion * 0.0030 + Math.max(0, elevation[i] - seaLevel) * (0.0018 + terrainTemplate.erosion * 0.0030) + valleyField[i] * (0.0015 + terrainTemplate.erosion * 0.0030)); - const maxRiverCut = 0.0018 + mediumPower * 0.0032 + largePower * 0.0065 + Math.pow(flowAccum[i], 0.65) * 0.0045; - const channelCut = Math.min(maxRiverCut, clamp(channelCutRaw * cutLimiter)); - const valleyWiden = Math.min(maxRiverCut * 0.65, clamp(valleyWidenRaw * cutLimiter)); - 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.28 + channelCut * 3.0); - basinField[i] = clamp(basinField[i] + alluvium * 3.2); - } - } - // No lateral terrain carving from traced river lines. Side-valley complexity is - // already present in the DEM through proceduralReliefField/proceduralRavineField. - - // 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); - } + const downstream = k / Math.max(1, path.length - 1); + const boost = main ? 0.54 + downstream * 0.42 : 0.30 + downstream * 0.22; + river[i] = clamp(Math.max(river[i], boost + flowAccum[i] * (main ? 0.70 : 0.42))); } } - // Guard against one-cell over-incision: a river cell next to a 0.8-0.9 ridge - // must not collapse to near sea level just because a routed channel crossed it. - const guardedFluvialElevation = new Float32Array(fluvialElevation); - 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.10) continue; - let highNeighbor = fluvialElevation[i]; - let meanNeighbor = 0; - let nCount = 0; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (sea[ni]) continue; - highNeighbor = Math.max(highNeighbor, fluvialElevation[ni]); - meanNeighbor += fluvialElevation[ni]; - nCount++; - } - meanNeighbor /= Math.max(1, nCount); - const maxAllowedDrop = 0.18 + clamp(river[i] / 2.6) * 0.06 + coastalLowland[i] * 0.08 + basinField[i] * 0.04; - const floorFromWall = highNeighbor - maxAllowedDrop; - const floorFromMean = meanNeighbor - 0.13; - if (highNeighbor > 0.64 && highNeighbor - fluvialElevation[i] > 0.26) { - guardedFluvialElevation[i] = Math.max(fluvialElevation[i], Math.min(highNeighbor - 0.08, Math.max(floorFromWall, floorFromMean))); - erosionField[i] = Math.max(0, erosionField[i] * 0.55); - } - } + const riverCells = []; + for (let i = 0; i < SIZE; i++) if (!sea[i] && river[i] > 0.12) riverCells.push(i); + for (const i of riverCells) { + const x = i % MAP_W; + const y = Math.floor(i / MAP_W); + const strength = river[i]; + const width = 1.15 + clamp((strength - 0.35) * 2.2) * 1.65; + const depth = (0.006 + strength * (0.014 + template.erosion * 0.018) + flowAccum[i] * 0.018) * (0.65 + clamp((elevation[i] - 0.28) * 1.4) * 0.55); + forDisk(x, y, width, (nx, ny, d) => { + const ni = indexOf(nx, ny); + if (sea[ni]) return; + const profile = Math.pow(Math.max(0, 1 - d / Math.max(0.1, width)), 2.15); + const sideGuard = d === 0 ? 1 : 0.38; + const cut = depth * profile * sideGuard; + const floor = 0.075; + elevation[ni] = Math.max(floor, elevation[ni] - cut); + erosionField[ni] = clamp(erosionField[ni] + cut * 9.0); + }); } - fluvialElevation.set(guardedFluvialElevation); - elevation.set(fluvialElevation); + const sortedPaths = longPaths.map((p) => p.path); + const mainRivers = sortedPaths.filter((p) => mainSet.has(p)).slice(0, mainCount); + const tributaryRivers = sortedPaths.filter((p) => !mainSet.has(p)).slice(0, 24); + const smallStreams = sortedPaths.slice(mainCount + 8, mainCount + 58); + return { riverPaths: sortedPaths.slice(0, 80), mainRivers, tributaryRivers, smallStreams }; +} - // 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.18 + - Math.pow(flowAccum[i], 0.58) * 0.20 - - gullyIncisionField[i] * 3.0 - - 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); - } +function deriveFields(seed, template, fields, seaLevel) { + const { + elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, + ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, + coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, + branchRidgeField, depositionalLowland, alluvialFanField, deltaField, + naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, + } = fields; + + recomputeSlope(elevation, sea, slope); + const waterDist = distanceField(sea, 80); + const riverMask = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) if (river[i] > 0.18) riverMask[i] = 1; + const riverDist = distanceField(riverMask, 40); for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const 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.052); - 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); - } - } - } - - // v5: 最終的に主河川が内陸で途切れる場合は、海または湖まで河口部を補完する。 - // これは地形生成後の河川作用を明示的に効かせ、山地から海への侵食軸を保証するため。 - for (let r = 0; r < mainRivers.length; r++) { - const path = mainRivers[r]; - if (!path || path.length < 2) continue; - const connected = path.some(([x, y], k) => k > path.length * 0.45 && neighbors8(x, y).some(([nx, ny]) => sea[indexOf(nx, ny)] || lake[indexOf(nx, ny)])); - if (connected) continue; - const forced = forceRiverToWater(path); - if (forced.length > path.length) { - mainRivers[r] = forced; - riverPaths.push(forced); - riverScores.push(forced.length * 1.12); - for (let k = path.length; k < forced.length; k++) { - const [rx, ry] = forced[k]; - const ri = indexOf(rx, ry); - river[ri] = Math.max(river[ri], 0.70 + k / 180 + flowAccum[ri] * 0.70); - valleyField[ri] = clamp(valleyField[ri] + 0.18); - } - } - } - - 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: 90 + Math.floor(rand(seed, 9341) * 60), - 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); - - for (const path of smallStreams) { - if (!path || path.length < 3) continue; - const strength = path.length > 24 ? 0.020 : 0.013; - addPathIncision(visibleRavineField, path, strength, path.length > 18 ? 2 : 1); - } - - // v9: keep enclosed盆地 as habitable basins rather than over-incised pits. - 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 basinFloor = clamp(basinField[i] * (1 - slope[i] * 3.0) * (1 - coastalLowland[i] * 0.7) * (1 - Math.min(1, river[i] * 0.7))); - if (basinFloor <= 0.16) continue; - elevation[i] = clamp(elevation[i] + basinFloor * 0.020, seaLevel + 0.008, 1); - depositionalLowland[i] = clamp(depositionalLowland[i] + basinFloor * 0.12); - plain[i] = clamp(plain[i] + basinFloor * 0.10); - } - } - // Final one-cell canyon guard. Apply it to all land cells, not only river - // cells, because a traced channel or earlier basin operation can leave a 0.3 - // cell directly beside a 0.9 ridge. The guard preserves broad basins/coasts but - // prevents single-cell cliff trenches. - for (let pass = 0; pass < 4; pass++) { - const guarded = new Float32Array(elevation); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - let highNeighbor = elevation[i]; - let meanNeighbor = 0; - let nCount = 0; - let seaTouch = 0; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (sea[ni]) { seaTouch++; continue; } - highNeighbor = Math.max(highNeighbor, elevation[ni]); - meanNeighbor += elevation[ni]; - nCount++; - } - meanNeighbor /= Math.max(1, nCount); - if (highNeighbor > 0.64 && highNeighbor - elevation[i] > 0.22) { - const coastalAllowance = coastalLowland[i] * 0.10 + (seaTouch ? 0.08 : 0); - const basinAllowance = basinField[i] * 0.055; - const riverAllowance = clamp(river[i] / 2.8) * 0.035; - const allowedDrop = 0.19 + coastalAllowance + basinAllowance + riverAllowance; - guarded[i] = Math.max(elevation[i], Math.max(highNeighbor - allowedDrop, meanNeighbor - 0.085)); - valleyField[i] = clamp(valleyField[i] * 0.86); - erosionField[i] = Math.max(0, erosionField[i] * 0.40); - } - } - } - elevation.set(guarded); - } - - 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); - } - } - - 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); + const mean4 = (elevation[indexOf(x - 1, y)] + elevation[indexOf(x + 1, y)] + elevation[indexOf(x, y - 1)] + elevation[indexOf(x, y + 1)]) * 0.25; + const relief = e - mean4; + const coast = clamp((24 - waterDist[i]) / 24) * clamp((0.43 - e) * 2.4) * clamp((0.42 - slope[i]) * 2.4); + coastalLowland[i] = coast; + const riverNear = clamp((8 - riverDist[i]) / 8); + const valley = clamp(flowAccum[i] * 0.82 + river[i] * 0.72 + Math.max(0, -relief) * 10.0 + riverNear * 0.38 - slope[i] * 0.22); + valleyField[i] = clamp(Math.max(valleyField[i] * 0.30, valley)); + const ridge = clamp(arcSpineField[i] * 0.76 + branchRidgeField[i] * 0.86 + Math.max(0, relief) * 9.0 + slope[i] * 0.30 + Math.max(0, e - 0.54) * 0.88 - valleyField[i] * 0.34); + ridgeField[i] = clamp(Math.max(ridgeField[i] * 0.30, ridge)); + basinField[i] = clamp((0.42 - slope[i]) * 1.55 + Math.max(0, -relief) * 5.0 + clamp((0.48 - e) * 1.35) - coast * 0.40 - river[i] * 0.32); + const low = clamp((0.58 - e) * 1.55); + const lowSlope = clamp((0.34 - slope[i]) * 2.8); + const riverGate = clamp(riverNear * 0.72 + flowAccum[i] * 0.82 + coast * 0.70 + basinField[i] * 0.20 - ridgeField[i] * 0.40); + plain[i] = clamp(low * lowSlope * (0.18 + template.plainBias * 0.42 + riverGate * 0.92)); + floodplain[i] = clamp(lowSlope * riverNear * (0.35 + flowAccum[i] * 0.82 + river[i] * 0.52)); + deltaField[i] = clamp(coast * river[i] * 1.4 + coast * flowAccum[i] * 0.72); + alluvialFanField[i] = clamp(riverNear * clamp(slope[i] * 2.5) * clamp((0.58 - e) * 1.7) * clamp(ridgeField[i] * 0.8 + arcSpineField[i] * 0.4)); + depositionalLowland[i] = clamp(floodplain[i] * 0.54 + deltaField[i] * 0.66 + alluvialFanField[i] * 0.42 + coast * 0.28 + basinField[i] * 0.18); + depositionField[i] = clamp(depositionalLowland[i] * (0.25 + template.deposition * 0.30)); + agriculture[i] = clamp(plain[i] * 0.72 + floodplain[i] * 0.42 + depositionalLowland[i] * 0.36 - slope[i] * 0.20); + visibleRavineField[i] = clamp(visibleRavineField[i] + valleyField[i] * 0.22 + erosionField[i] * 0.35); + surfaceTextureField[i] = clamp(surfaceTextureField[i] + slope[i] * 0.26 + visibleRavineField[i] * 0.38 + Math.max(0, relief) * 2.2); + naturalBarrierScore[i] = clamp(ridgeField[i] * 0.82 + slope[i] * 0.44 + river[i] * 0.42 + Math.max(0, e - 0.58) * 0.34 - plain[i] * 0.24); + portSuitability[i] = clamp(coast * (1 - slope[i]) * (0.50 + plain[i] * 0.32) - ridgeField[i] * 0.20); + crossingSuitability[i] = clamp((1 - slope[i]) * 0.38 + plain[i] * 0.34 + floodplain[i] * 0.24 - river[i] * 0.20 - ridgeField[i] * 0.28); + passSuitability[i] = clamp(slope[i] * 0.30 + valleyField[i] * 0.34 + clamp((0.75 - ridgeField[i]) * 0.8) + plain[i] * 0.18); + moisture[i] = clamp(0.30 + (1 - waterDist[i] / 65) * 0.36 + valleyField[i] * 0.22 + riverNear * 0.26 - Math.max(0, e - 0.55) * 0.36 + (fbm(x * 1.6, y * 1.6, seed + 15000) - 0.5) * 0.18); } } + for (let i = 0; i < SIZE; i++) { + if (!sea[i]) continue; + moisture[i] = 1; + slope[i] = 0; + river[i] = 0; + ridgeField[i] = 0; + valleyField[i] = 0; + plain[i] = 0; + agriculture[i] = 0; + coastalLowland[i] = 0; + naturalBarrierScore[i] = 0; + } +} - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { +function enforceLandGradient(elevation, sea, seaLevel) { + // Keep extreme cliffs rare without flattening normal mountain relief. + for (let pass = 0; pass < 2; pass++) { + const next = new Float32Array(elevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let minN = elevation[i]; + let maxN = elevation[i]; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + minN = Math.min(minN, elevation[ni]); + maxN = Math.max(maxN, elevation[ni]); + } + const range = maxN - minN; + if (range > 0.34) next[i] = lerp(elevation[i], (elevation[i] + minN + maxN) / 3, 0.22); + next[i] = Math.max(next[i], seaLevel + 0.006); + } + } + elevation.set(next); + } +} + +export function generateTerrainAndRivers(seed) { + const fields = createMapFields(); + fields.visibleRavineField = new Float32Array(SIZE); + fields.surfaceTextureField = new Float32Array(SIZE); + const { + elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, + agriculture, ridgeField, valleyField, visibleRavineField, surfaceTextureField, + basinField, coastalLowland, flowAccum, erosionField, depositionField, + arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, + deltaField, naturalBarrierScore, flowTo, portSuitability, crossingSuitability, + passSuitability, + } = fields; + + const terrainTemplate = buildTerrainTemplate(seed); + const systems = buildMountainSystems(terrainTemplate, seed); + const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id)); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); - 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); - } + const { px, py } = normalizeCoord(x, y); + const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23; + const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16; + const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed); + let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (0.22 + terrainTemplate.deposition * 0.040); + let mountainMaskMax = 0; + for (let s = 0; s < systems.length; s++) { + const system = systems[s]; + const mask = ellipticalMask(px, py, system); + mountainMaskMax = Math.max(mountainMaskMax, mask); + const broad = Math.pow(mask, lerp(2.0, 1.35, system.massifness)) * system.height * lerp(0.13, 0.25, system.massifness); + e += broad; + arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72)); } - 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); - } + for (const ridge of allRidges) { + const r = ridgeContribution(px, py, ridge, seed); + if (r <= 0) continue; + e += r; + branchRidgeField[i] = clamp(branchRidgeField[i] + r * 5.0); + arcSpineField[i] = clamp(Math.max(arcSpineField[i], r * 4.6)); } - if (!touchesEdge && area <= maxArea) count++; + const macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2; + const global = (valueNoise(x * 0.23, y * 0.23, seed + 501, 38) - 0.5) * 2; + const scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2; + e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80); + e += global * 0.020; + e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax; + elevation[i] = clamp(e, 0.025, 1.08); + visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40); + surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46); + valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18); + moisture[i] = clamp(0.45 + coastPressure * 0.28 - elevation[i] * 0.20 + (fbm(x * 1.1, y * 1.1, seed + 503) - 0.5) * 0.16); } - 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; + let seaLevel = quantile(elevation, terrainTemplate.seaRatio); + seaLevel = clamp(seaLevel, 0.20, 0.47); + classifyWater(elevation, seaLevel, sea, ocean, lake); + recomputeSlope(elevation, sea, slope); + + const filled = new Float32Array(SIZE); + priorityFloodFlow(elevation, sea, flowTo, filled); + computeFlowAccumulation(sea, flowTo, filled, flowAccum); + const { riverPaths, mainRivers, tributaryRivers, smallStreams } = buildRiverNetwork(seed, terrainTemplate, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField); + enforceLandGradient(elevation, sea, seaLevel); + deriveFields(seed, terrainTemplate, fields, seaLevel); + + const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); + const regional = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); + const prefectureRegionId = regional.regionId; + const regionalDebug = regional.debug; + const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea); + const prefectureBorder = extractMaskBorder(prefectureMask, sea); + + let landCount = 0; + let waterCount = 0; + let mountainCount = 0; + let plainCount = 0; + let primarySpineStrength = 0; + let spineSamples = 0; + for (let i = 0; i < SIZE; i++) { + if (sea[i]) { waterCount++; continue; } + landCount++; + if (elevation[i] > 0.60 || ridgeField[i] > 0.58) mountainCount++; + if (plain[i] > 0.36) plainCount++; + if (arcSpineField[i] > 0.55) { primarySpineStrength += arcSpineField[i]; spineSamples++; } + } + primarySpineStrength /= Math.max(1, spineSamples); const terrainDebug = { primarySpineStrength, - riverConnectivityRate, - smallIslandCount: countSmallLandIslands(8), - largeInlandLakeCount: countWaterComponents(Float32Array.from(lake, (value) => value ? 1 : 0), 120), - depositionLowlandArea, + riverConnectivityRate: mainRivers.length ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && sea[indexOf(x, y)])).length / mainRivers.length : 0, + smallIslandCount: 0, + largeInlandLakeCount: lake.reduce((a, v) => a + v, 0) > 120 ? 1 : 0, + depositionLowlandArea: depositionalLowland.reduce((a, v, i) => a + (!sea[i] && v > 0.24 ? 1 : 0), 0), smallStreamCount: smallStreams.length, - erosionGullyCount: erosionGullyPaths.length, + erosionGullyCount: 0, branchRavineCount: 0, + simpleTerrainSystem: true, + seaRatio: waterCount / SIZE, + landCount, + mountainRatio: mountainCount / Math.max(1, landCount), + plainRatio: plainCount / Math.max(1, landCount), + mountainSystemCount: systems.length, }; return { From 1ea8ba1701094567a53000fe7cc4dd81c5cabffa Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sun, 24 May 2026 17:38:51 +0900 Subject: [PATCH 4/6] town tweak --- adminRegions.js | 505 ++++++++++- adminRegions.notrace.js | 1776 +++++++++++++++++++++++++++++++++++++++ app.js | 17 +- landuseCodes.js | 37 + mapAdminStage.js | 192 ++++- mapAdminStage.nolog.js | 1064 +++++++++++++++++++++++ mapFeatures.js | 1157 +++++++++++++++++++++---- mapGeneratorHelpers.js | 23 +- mapOutput.js | 54 +- mapPipeline.js | 3 +- mapTerrain.js | 469 +++++++++-- renderer.js | 79 +- 12 files changed, 5001 insertions(+), 375 deletions(-) create mode 100644 adminRegions.notrace.js create mode 100644 landuseCodes.js create mode 100644 mapAdminStage.nolog.js diff --git a/adminRegions.js b/adminRegions.js index b823245..877c1cf 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -558,20 +558,22 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB); if (!bothUrban && !bothLivingCorridor) return false; } - const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72; + const majorRiverEdge = Math.max(river[a], river[b]) > 0.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68; const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) && ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34); - const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.42 && !majorRiverEdge; - const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62; + const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge; + const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50; return barrier < threshold && (!majorRiverEdge || urbanEdge); } function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) { let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0; let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0; + let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0; for (const i of unit.cells) { const [x, y] = xyOf(i); sx += x; sy += y; pop += populationDensity[i]; + minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse); ridgeExposure += ridgeField[i]; coastalExposure += coastalLowland[i]; @@ -584,6 +586,13 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField unit.area = area; unit.x = sx / Math.max(1, area); unit.y = sy / Math.max(1, area); + unit.minX = area ? minX : 0; + unit.minY = area ? minY : 0; + unit.maxX = area ? maxX : 0; + unit.maxY = area ? maxY : 0; + unit.width = area ? maxX - minX + 1 : 0; + unit.height = area ? maxY - minY + 1 : 0; + unit.elongation = Math.max(unit.width, unit.height) / Math.max(1, Math.min(unit.width, unit.height)); unit.population = pop; unit.urbanWeight = urbanWeight / Math.max(1, area); unit.ridgeExposure = ridgeExposure / Math.max(1, area); @@ -596,13 +605,25 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField } function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) { - if (!unit || unit.area < 28 || unit.lowlandFitness < 0.24 || unit.mountainFitness > 0.72) return null; - const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields; - let first = -1, second = -1, bestA = -INF, bestB = -INF; + if (!unit || unit.area < 24) return null; + const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields; + const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20))); + + let first = -1; + let second = -1; + let bestA = -INF; + let bestB = -INF; + const width = unit.width || (unit.maxX - unit.minX + 1) || 1; + const height = unit.height || (unit.maxY - unit.minY + 1) || 1; + const horizontal = width >= height; + const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65; + for (const i of unit.cells) { const [x, y] = xyOf(i); const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - const score = low + populationDensity[i] * 0.22 + hashSeededTie(x, y, seed) * 0.04; + const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0); + const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0; + const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10; if (score > bestA) { bestA = score; first = i; } } if (first < 0) return null; @@ -610,36 +631,54 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) { for (const i of unit.cells) { const [x, y] = xyOf(i); const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + const axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0; const d = Math.hypot(x - fx, y - fy); - const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20; + const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08; if (score > bestB) { bestB = score; second = i; } } if (second < 0 || second === first) return null; const cellSet = new Set(unit.cells); - const localOwner = new Map([[first, 0], [second, 1]]); - const queue = [first, second]; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - const owner = localOwner.get(cur); - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors4(x, y)) { + const owner = new Int8Array(SIZE); + owner.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + for (const [source, sourceOwner] of [[first, 0], [second, 1]]) { + owner[source] = sourceOwner; + dist[source] = 0; + heap.push({ i: source, f: 0, owner: sourceOwner }); + } + + while (heap.length > 0) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [x, y] = xyOf(cur.i); + for (const [nx, ny, step] of neighbors4(x, y)) { const ni = indexOf(nx, ny); - if (!cellSet.has(ni) || localOwner.has(ni)) continue; - localOwner.set(ni, owner); - queue.push(ni); + if (!cellSet.has(ni)) continue; + const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; + const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32; + const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25; + const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12)); + const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step; + const nd = cur.f + stepCost; + if (nd < dist[ni]) { + dist[ni] = nd; + owner[ni] = cur.owner; + heap.push({ i: ni, f: nd, owner: cur.owner }); + } } } - for (const ci of unit.cells) if (!localOwner.has(ci)) { - const [x, y] = xyOf(ci); - const d0 = Math.hypot(x - fx, y - fy); - const [sx, sy] = xyOf(second); - const d1 = Math.hypot(x - sx, y - sy); - localOwner.set(ci, d0 <= d1 ? 0 : 1); + + const aCells = []; + const bCells = []; + for (const ci of unit.cells) { + if (owner[ci] === 1) bCells.push(ci); + else aCells.push(ci); } - const aCells = [], bCells = []; - for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci); - if (aCells.length < 10 || bCells.length < 10) return null; + if (aCells.length < minPart || bCells.length < minPart) return null; + unit.cells = aCells; const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() }; for (const ci of bCells) compartmentId[ci] = newId; @@ -663,7 +702,369 @@ function naturalGroupKey(unit) { return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; } + +function collectLandComponents(prefectureMask, sea) { + const seen = new Uint8Array(SIZE); + const components = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !prefectureMask[i] || sea[i]) continue; + const cells = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push(cells); + } + return components; +} + +function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) { + const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8; + const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + const mountain = mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse); + const stableInterior = clamp(1 - (naturalBarrierScore[i] || 0)); + const settlement = clamp(populationDensity[i] * 0.65 + (klassUrban ? 0.24 : 0)); + const streamCorridor = clamp(valleyField[i] * 0.28 + river[i] * 0.08); + const mountainInterior = clamp(mountain * 0.45 + stableInterior * 0.28 - ridgeField[i] * 0.22); + return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10; +} + +function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) { + const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields; + const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); + const seeds = []; + const seedComponentId = []; + const minCellsPerUnit = 9; + let remainingTarget = Math.max(1, Math.min(targetCount || Math.round(totalArea / 42), Math.floor(totalArea / minCellsPerUnit))); + + const sortedComponents = landComponents + .map((cells, componentIndex) => ({ cells, componentIndex, area: cells.length })) + .sort((a, b) => b.area - a.area); + + for (let componentOrder = 0; componentOrder < sortedComponents.length; componentOrder++) { + const { cells, componentIndex, area } = sortedComponents[componentOrder]; + if (area <= 0) continue; + const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea)); + let localTarget = Math.max(1, proportional); + localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit))); + if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget)); + remainingTarget -= localTarget; + + const candidates = cells + .map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) })) + .sort((a, b) => b.score - a.score); + const localSeeds = []; + const idealSpacing = Math.sqrt(area / Math.max(1, localTarget)); + const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34]; + for (const factor of spacingPasses) { + const minDist = Math.max(2.2, idealSpacing * factor); + for (const candidate of candidates) { + if (localSeeds.length >= localTarget) break; + const [x, y] = xyOf(candidate.i); + let ok = true; + for (const existing of localSeeds) { + const [ex, ey] = xyOf(existing); + if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; } + } + if (ok) localSeeds.push(candidate.i); + } + if (localSeeds.length >= localTarget) break; + } + for (const i of localSeeds) { + seeds.push(i); + seedComponentId.push(componentIndex); + } + } + + if (seeds.length === 0 && landComponents[0]?.length) { + seeds.push(landComponents[0][0]); + seedComponentId.push(0); + } + return { seeds, seedComponentId }; +} + +function naturalStepCost(a, b, cellClass, fields) { + const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields; + const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5; + const ridge = Math.max(ridgeField[a], ridgeField[b]); + const riverEdge = Math.max(river[a], river[b]); + const flowEdge = Math.max(flowAccum?.[a] || 0, flowAccum?.[b] || 0); + const majorRiverCrossing = riverEdge > 0.44 || flowEdge > 0.55; + const elevationBreak = Math.abs(elevation[a] - elevation[b]); + const slopeBreak = Math.max(slope[a], slope[b]); + const classBreak = cellClass[a] !== cellClass[b] ? 0.34 : -0.08; + const bothUrban = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.30) && + ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.30); + const lowlandContinuity = Math.min( + (plain?.[a] || 0) + (agriculture?.[a] || 0) * 0.35 + basinField[a] * 0.25 + coastalLowland[a] * 0.20, + (plain?.[b] || 0) + (agriculture?.[b] || 0) * 0.35 + basinField[b] * 0.25 + coastalLowland[b] * 0.20 + ); + const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45); + const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0)); + const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0; + return Math.max(0.16, + 0.72 + + barrier * 5.1 + + ridge * 0.82 + + elevationBreak * 3.0 + + slopeBreak * 0.56 + + riverPenalty + + classBreak - + corridorBonus + ); +} + +function buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields) { + const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields; + let maxId = -1; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] > maxId) maxId = compartmentId[i]; + const units = Array.from({ length: maxId + 1 }, (_, id) => ({ id, cells: [], centerIds: [], adjacent: new Map(), area: 0 })); + for (let i = 0; i < SIZE; i++) { + const id = compartmentId[i]; + if (id >= 0 && units[id]) units[id].cells.push(i); + } + for (const unit of units) { + if (!unit.cells.length) { unit.area = 0; continue; } + const counts = new Map(); + for (const ci of unit.cells) counts.set(cellClass[ci], (counts.get(cellClass[ci]) || 0) + 1); + let klass = -1, best = -1; + for (const [k, count] of counts) if (count > best) { best = count; klass = k; } + unit.classId = klass; + unit.dominantLandscapeClass = klass; + refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + let riverExposure = 0; + for (const ci of unit.cells) riverExposure += river[ci] + (fields.flowAccum?.[ci] || 0) * 0.45; + unit.riverExposure = riverExposure / Math.max(1, unit.area); + } + return units; +} + + +function splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea) { + const queue = []; + for (const unit of [...compartments]) { + if (!unit || unit.area === 0 || !unit.cells?.length) continue; + const unitCellSet = new Set(unit.cells); + const seen = new Set(); + const components = []; + for (const start of unit.cells) { + if (seen.has(start) || compartmentId[start] !== unit.id) continue; + const cells = []; + queue.length = 0; + queue.push(start); + seen.add(start); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni] || seen.has(ni) || compartmentId[ni] !== unit.id || !unitCellSet.has(ni)) continue; + seen.add(ni); + queue.push(ni); + } + } + components.push(cells); + } + if (components.length <= 1) continue; + components.sort((a, b) => b.length - a.length); + unit.cells = components[0]; + for (const extra of components.slice(1)) { + const newId = compartments.length; + for (const ci of extra) compartmentId[ci] = newId; + compartments.push({ id: newId, cells: extra, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }); + } + } +} + +function renumberCompartments(compartmentId, compartments, prefectureMask, sea) { + const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length); + const idMap = new Map(); + active.forEach((unit, newId) => idMap.set(unit.id, newId)); + for (let i = 0; i < SIZE; i++) { + const id = compartmentId[i]; + if (!prefectureMask[i] || sea[i]) compartmentId[i] = -1; + else if (idMap.has(id)) compartmentId[i] = idMap.get(id); + } + active.forEach((unit, newId) => { unit.id = newId; unit.centerIds = []; }); + return active; +} + +function refreshAllCompartmentStats(compartments, fields) { + const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, river, flowAccum } = fields; + for (const unit of compartments) { + if (!unit || unit.area === 0 || !unit.cells?.length) continue; + refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + let riverExposure = 0; + const counts = new Map(); + for (const ci of unit.cells) { + riverExposure += river[ci] + (flowAccum?.[ci] || 0) * 0.45; + if (unit._cellClass) counts.set(unit._cellClass[ci], (counts.get(unit._cellClass[ci]) || 0) + 1); + } + unit.riverExposure = riverExposure / Math.max(1, unit.area); + } +} + +function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed) { + if (!unit || unit.area < 20) return null; + const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum, cellClass } = fields; + const minPart = Math.max(7, Math.min(30, Math.floor(unit.area * 0.18))); + let cx = unit.x || 0, cy = unit.y || 0; + let first = -1, second = -1, bestA = -INF, bestB = -INF; + const elongated = (unit.elongation || 1) > 2.3; + const horizontal = (unit.width || 0) >= (unit.height || 0); + for (const i of unit.cells) { + const [x, y] = xyOf(i); + const centerDist = Math.hypot(x - cx, y - cy); + const axis = elongated ? Math.abs((horizontal ? x - cx : y - cy)) / Math.max(1, horizontal ? unit.width : unit.height) : 0; + const interior = 1 - (naturalBarrierScore[i] || 0); + const score = centerDist * 0.13 + axis * 1.1 + interior * 0.35 + hashSeededTie(x, y, seed) * 0.08 - ridgeField[i] * 0.10; + if (score > bestA) { bestA = score; first = i; } + } + if (first < 0) return null; + const [fx, fy] = xyOf(first); + for (const i of unit.cells) { + const [x, y] = xyOf(i); + const d = Math.hypot(x - fx, y - fy); + const interior = 1 - (naturalBarrierScore[i] || 0); + const score = d * 0.20 + interior * 0.38 + hashSeededTie(x, y, seed + 31) * 0.08 - ridgeField[i] * 0.08; + if (score > bestB) { bestB = score; second = i; } + } + if (second < 0 || second === first) return null; + + const cellSet = new Set(unit.cells); + const owner = new Int8Array(SIZE); + owner.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + for (const [source, sourceOwner] of [[first, 0], [second, 1]]) { + owner[source] = sourceOwner; + dist[source] = 0; + heap.push({ i: source, f: 0, owner: sourceOwner }); + } + while (heap.length > 0) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [x, y] = xyOf(cur.i); + for (const [nx, ny, step] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!cellSet.has(ni)) continue; + const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; + if (nd < dist[ni]) { + dist[ni] = nd; + owner[ni] = cur.owner; + heap.push({ i: ni, f: nd, owner: cur.owner }); + } + } + } + const aCells = [], bCells = []; + for (const ci of unit.cells) (owner[ci] === 1 ? bCells : aCells).push(ci); + if (aCells.length < minPart || bCells.length < minPart) return null; + unit.cells = aCells; + for (const ci of bCells) compartmentId[ci] = newId; + const newUnit = { id: newId, cells: bCells, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }; + refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + return newUnit; +} + +function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { + const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); + const cellClass = new Int16Array(SIZE); + cellClass.fill(-1); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); + const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass }; + const landComponents = collectLandComponents(prefectureMask, sea); + const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); + const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360); + const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8))); + const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0); + const compartmentId = new Int32Array(SIZE); + compartmentId.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + seeds.forEach((i, id) => { + compartmentId[i] = id; + dist[i] = 0; + heap.push({ i, f: 0, id }); + }); + while (heap.length > 0) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [x, y] = xyOf(cur.i); + for (const [nx, ny, step] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; + if (nd < dist[ni]) { + dist[ni] = nd; + compartmentId[ni] = cur.id; + heap.push({ i: ni, f: nd, id: cur.id }); + } + } + } + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0; + + let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + mergeTinyLandscapeUnits(compartmentId, compartments, 9); + splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); + compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); + refreshAllCompartmentStats(compartments, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + + const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55)); + let guard = Math.max(80, targetCount * 3); + while (guard-- > 0) { + let active = compartments.filter((unit) => unit && unit.area > 0); + const needMore = active.length < targetCount; + const worst = active + .filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2)) + .sort((a, b) => { + const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2; + const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2; + return sb - sa; + })[0]; + if (!worst) break; + const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97); + if (!newUnit) { + worst._splitRejected = (worst._splitRejected || 0) + 1; + if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1); + if (!needMore) break; + continue; + } + compartments.push(newUnit); + if (compartments.filter((unit) => unit && unit.area > 0).length >= targetCount && newUnit.area <= maxNaturalCompartmentArea) { + const stillBad = compartments.some((unit) => unit && unit.area >= 20 && ( + unit.area > maxNaturalCompartmentArea * 1.35 || + ((unit.elongation || 1) > 4.2 && unit.area > 28) + )); + if (!stillBad) break; + } + } + + splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); + compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); + refreshAllCompartmentStats(compartments, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + return { compartmentId, compartments, naturalBarrierScore }; +} + export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { + return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options); const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); const compartmentId = new Int32Array(SIZE); compartmentId.fill(-1); @@ -679,6 +1080,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, const startClass = cellClass[i]; const cells = []; let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0; + let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0; queue.length = 0; queue.push(i); compartmentId[i] = id; @@ -687,6 +1089,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, const [x, y] = xyOf(cur); cells.push(cur); sx += x; sy += y; pop += populationDensity[cur]; + minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse); ridgeExposure += ridgeField[cur]; riverExposure += river[cur] + flowAccum[cur] * 0.45; @@ -711,6 +1114,13 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, y: sy / area, classId: startClass, dominantLandscapeClass: startClass, + minX, + minY, + maxX, + maxY, + width: maxX - minX + 1, + height: maxY - minY + 1, + elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)), population: pop, urbanWeight: urbanWeight / area, ridgeExposure: ridgeExposure / area, @@ -730,22 +1140,47 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); const targetCount = options.targetCompartmentCount || 0; if (targetCount > 0) { - const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore }; - let guard = targetCount * 3; + const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum }; + const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0); + const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65)); + const splitScore = (unit) => { + const elongated = Math.max(0, (unit.elongation || 1) - 2.1); + const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea); + const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35; + return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20; + }; + let guard = Math.max(targetCount * 4, 80); while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) { const candidates = compartments - .filter((unit) => unit.area > 0 && unit.lowlandFitness > 0.24 && unit.mountainFitness < 0.74 && unit.area >= 28) - .sort((a, b) => (b.area * (0.45 + b.lowlandFitness) - b.mountainFitness * 80) - (a.area * (0.45 + a.lowlandFitness) - a.mountainFitness * 80)); + .filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8)) + .sort((a, b) => splitScore(b) - splitScore(a)); const target = candidates[0]; if (!target) break; const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard); if (!newUnit) { - target.lowlandFitness = 0; + target._splitRejected = (target._splitRejected || 0) + 1; + target.elongation = Math.max(1, (target.elongation || 1) * 0.72); + if (target._splitRejected > 2) target.area = target.cells.length; continue; } compartments.push(newUnit); if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); } + + guard = Math.max(targetCount * 2, 60); + while (guard-- > 0) { + const target = compartments + .filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85))) + .sort((a, b) => splitScore(b) - splitScore(a))[0]; + if (!target) break; + const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991); + if (!newUnit) { + target.elongation = Math.max(1, (target.elongation || 1) * 0.70); + break; + } + compartments.push(newUnit); + if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + } rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); } return { compartmentId, compartments, naturalBarrierScore }; @@ -1081,6 +1516,12 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e ...relationMetrics, compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea), averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, + maxCompartmentArea: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.area || 0)) : 0, + maxCompartmentElongation: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.elongation || 1)) : 1, + worstNaturalCompartments: activeCompartments + .map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) })) + .sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area)))) + .slice(0, 8), finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), voronoiLikeRateBefore: 0, voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), diff --git a/adminRegions.notrace.js b/adminRegions.notrace.js new file mode 100644 index 0000000..877c1cf --- /dev/null +++ b/adminRegions.notrace.js @@ -0,0 +1,1776 @@ +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } from "./mapUtils.js"; + +function neighbors8(x, y) { + const out = []; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]); + } + } + return out; +} + +function neighbors4(x, y) { + const out = []; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny)) out.push([nx, ny, 1]); + } + return out; +} + +export function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) { + const adminId = new Int16Array(SIZE); + adminId.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + + centers.forEach((center, regionId) => { + const i = indexOf(center.x, center.y); + dist[i] = 0; + adminId[i] = regionId; + heap.push({ i, f: 0, regionId }); + }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 12) { + const current = heap.pop(); + if (!current) continue; + const curIndex = current.i; + const curRegion = adminId[curIndex]; + if (curRegion < 0 || current.f > dist[curIndex] + 1e-5) continue; + + const [cx, cy] = xyOf(curIndex); + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + + const ridgeBarrier = Math.max(ridgeField[ni], ridgeField[curIndex]); + const riverBarrier = Math.max(river[ni], river[curIndex]); + const highDivide = Math.max(elevation[ni], elevation[curIndex]); + const watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0); + const ridgePenalty = Math.max(0, highDivide - 0.36) * 16.0 + Math.abs(elevation[ni] - elevation[curIndex]) * 12.4 + watershedBarrier; + const slopePenalty = slope[ni] * 10.6; + const valleyBarrier = valleyField[ni] > 0.50 ? valleyField[ni] * (riverBarrier > 0.16 ? 7.2 : 2.6) : 0; + const riverPenalty = riverBarrier > 0.7 ? 22.0 : riverBarrier > 0.42 ? 14.8 : riverBarrier > 0.22 ? 7.4 : riverBarrier > 0.12 ? 2.2 : 0; + const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.65 : 0; + const valleyLocalityBonus = valleyField[ni] * 0.16; + const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty + valleyBarrier - valleyLocalityBonus - urbanContinuityBonus) * step; + const nextDist = dist[curIndex] + stepCost; + + if (nextDist < dist[ni]) { + dist[ni] = nextDist; + adminId[ni] = curRegion; + heap.push({ i: ni, f: nextDist, regionId: curRegion }); + } + } + } + + return adminId; +} + +function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) { + return clamp(weightedScore([ + [ridgeField[i], 3.15], + [river[i], 2.45], + [valleyField[i], 0.62], + [slope[i], 1.06], + [Math.max(0, elevation[i] - 0.5), 1.18], + ])); +} + +export function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 5) { + let current = new Int16Array(adminId); + for (let pass = 0; pass < passes; pass++) { + const next = new Int16Array(current); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + const own = current[i]; + if (!prefectureMask[i] || sea[i] || own < 0) continue; + const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.24; + const barrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField); + if (barrier > 0.62 || urbanCell) continue; + + const counts = new Map(); + let ownCount = 0; + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const id = current[ni]; + if (id < 0) continue; + const weight = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField) > 0.72 ? 0.45 : 1; + counts.set(id, (counts.get(id) || 0) + weight); + if (id === own) ownCount += weight; + } + let bestId = own; + let best = ownCount; + for (const [id, score] of counts) if (score > best) { best = score; bestId = id; } + if (bestId !== own && (best >= 4.2 || ownCount <= 2.1)) next[i] = bestId; + } + } + current = next; + } + adminId.set(current); +} + +export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 360) { + const seen = new Uint8Array(SIZE); + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !prefectureMask[i] || sea[i]) continue; + const isUrbanStart = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.20; + if (!isUrbanStart) continue; + const component = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + component.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + const isUrban = (landuse[ni] >= 2 && landuse[ni] <= 4) || landuse[ni] === 7 || landuse[ni] === 8 || populationDensity[ni] > 0.20; + if (!isUrban) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (component.length === 0 || component.length > maxCells) continue; + const counts = new Map(); + for (const ci of component) { + const id = adminId[ci]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + populationDensity[ci]); + } + let bestId = -1; + let best = -1; + for (const [id, score] of counts) if (score > best) { best = score; bestId = id; } + if (bestId >= 0) for (const ci of component) adminId[ci] = bestId; + } +} + +export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) { + const area = new Map(); + const pop = new Map(); + const adjacency = new Map(); + const cityMunicipalities = new Set(); + for (const city of modernCities || []) { + if (!inside(city.x, city.y)) continue; + const id = adminId[indexOf(city.x, city.y)]; + if (id < 0) continue; + if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id); + } + for (const point of options.protectedPoints || []) { + if (!point || !inside(point.x, point.y)) continue; + const id = adminId[indexOf(point.x, point.y)]; + if (id >= 0) cityMunicipalities.add(id); + } + const satelliteByAdmin = new Map(); + for (const sat of options.satelliteCities || []) { + if (!sat || !inside(sat.x, sat.y)) continue; + const id = adminId[indexOf(sat.x, sat.y)]; + if (id < 0) continue; + if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []); + satelliteByAdmin.get(id).push(sat); + } + const satelliteStats = options.satelliteStats || null; + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const id = adminId[i]; + if (id < 0) continue; + area.set(id, (area.get(id) || 0) + 1); + pop.set(id, (pop.get(id) || 0) + populationDensity[i]); + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other < 0 || other === id) continue; + const key = id < other ? `${id}:${other}` : `${other}:${id}`; + adjacency.set(key, (adjacency.get(key) || 0) + 1); + } + } + } + + const mergeTarget = new Map(); + for (const [id, cells] of area) { + const score = cells + (pop.get(id) || 0) * 16; + if (cells >= minArea || cityMunicipalities.has(id)) continue; + const satellites = satelliteByAdmin.get(id) || []; + const protectedSatellite = satellites.some((sat) => { + const minSatelliteArea = sat.satelliteMinArea || options.satelliteMinArea || 110; + return sat.municipalityClass === "independentSatelliteMunicipality" && ( + cells >= minSatelliteArea || + (sat.population || 0) >= (options.satelliteIndependentPopulationThreshold || 60000) || + (sat.distinctUrbanComponentArea || 0) >= 80 || + sat.separatedByBarrier + ); + }); + if (protectedSatellite) continue; + let bestNeighbor = -1; + let bestScore = -1; + for (const [key, border] of adjacency) { + const [a, b] = key.split(":").map(Number); + if (a !== id && b !== id) continue; + const other = a === id ? b : a; + const parentBias = satellites.some((sat) => inside(sat.parentX ?? -1, sat.parentY ?? -1) && adminId[indexOf(sat.parentX, sat.parentY)] === other) ? 26 : 0; + const ruralBias = satellites.some((sat) => sat.municipalityClass === "smallTownAttachedToRuralMunicipality") ? Math.min(12, (area.get(other) || 0) * 0.01) : 0; + const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24 + parentBias + ruralBias; + if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; } + } + if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) { + mergeTarget.set(id, bestNeighbor); + if (satelliteStats && satellites.length) { + satelliteStats.satelliteMunicipalitiesMerged += satellites.length; + for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor; + } + } + } + if (mergeTarget.size === 0) return; + for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]); +} + +export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxIslandCells = 220) { + const protectedByAdmin = new Map(); + for (const p of [...adminCenters, ...protectedPoints]) { + if (!p || !inside(p.x, p.y)) continue; + const id = adminId[indexOf(p.x, p.y)]; + if (id < 0) continue; + if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set()); + protectedByAdmin.get(id).add(indexOf(p.x, p.y)); + } + + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); + + const globalSeen = new Uint8Array(SIZE); + const queue = []; + for (const id of ids) { + const components = []; + for (let i = 0; i < SIZE; i++) { + if (globalSeen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; + const comp = []; + let hasProtected = protectedByAdmin.get(id)?.has(i) || false; + queue.length = 0; + queue.push(i); + globalSeen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + if (protectedByAdmin.get(id)?.has(cur)) hasProtected = true; + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (globalSeen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; + globalSeen[ni] = 1; + queue.push(ni); + } + } + components.push({ cells: comp, hasProtected }); + } + if (components.length <= 1) continue; + components.sort((a, b) => (b.hasProtected ? 1000000 : 0) + b.cells.length - ((a.hasProtected ? 1000000 : 0) + a.cells.length)); + for (const component of components.slice(1)) { + const mainSize = components[0].cells.length; + if (component.hasProtected && component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.42) continue; + if (component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.36) continue; + const counts = new Map(); + for (const ci of component.cells) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1); + } + } + let target = -1; + let best = -1; + for (const [other, count] of counts) if (count > best) { best = count; target = other; } + if (target >= 0) for (const ci of component.cells) adminId[ci] = target; + } + } +} + +export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) { + const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse); + const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75); + const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18); + const ridgeDivide = clamp(ridgeField[i] * 1.55 + Math.max(0, elevation[i] - 0.54) * ridgeField[i] * 0.95); + const slopeBreak = clamp(slope[i] * 0.58 + Math.max(0, slope[i] - 0.32) * 0.68); + const highGround = Math.max(0, elevation[i] - 0.56) * 0.22; + const valleyFloorPenalty = valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); + return clamp(ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72); +} + +function urbanBoundaryPenalty(i, populationDensity, landuse) { + const lu = landuse[i]; + const core = lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0; + return clamp(core + populationDensity[i] * 1.35); +} + +function isAdminBoundaryCell(labels, prefectureMask, sea, x, y, useEight = true) { + const i = indexOf(x, y); + const own = labels[i]; + if (!prefectureMask[i] || sea[i] || own < 0) return false; + const neighbors = useEight ? neighbors8(x, y) : neighbors4(x, y); + for (const [nx, ny] of neighbors) { + const ni = indexOf(nx, ny); + if (prefectureMask[ni] && !sea[ni] && labels[ni] >= 0 && labels[ni] !== own) return true; + } + return false; +} + +function buildBoundaryBand(labels, prefectureMask, sea, radius = 5) { + const band = new Uint8Array(SIZE); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + if (!isAdminBoundaryCell(labels, prefectureMask, sea, x, y, true)) continue; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (Math.hypot(dx, dy) > radius) continue; + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (prefectureMask[ni] && !sea[ni]) band[ni] = 1; + } + } + } + } + return band; +} + +function buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], populationDensity, landuse) { + const protectedMask = new Uint8Array(SIZE); + function protectDisk(p, radius) { + if (!p || !inside(p.x, p.y)) return; + const owner = adminId[indexOf(p.x, p.y)]; + if (owner < 0) return; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.hypot(dx, dy) > radius) continue; + const x = p.x + dx; + const y = p.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (prefectureMask[i] && !sea[i] && adminId[i] === owner) protectedMask[i] = 1; + } + } + } + for (const center of adminCenters) protectDisk(center, 2.2); + for (const p of protectedPoints || []) protectDisk(p, p.population ? clamp(1.6 + Math.sqrt(p.population) / 520, 2.1, 6.0) : p.portClass ? 2.0 : 1.7); + for (let i = 0; i < SIZE; i++) { + if (prefectureMask[i] && !sea[i] && (landuse[i] === 3 || populationDensity[i] > 0.72)) protectedMask[i] = 1; + } + return protectedMask; +} + +function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, populationDensity, landuse, river, valleyField) { + const [x, y] = xyOf(i); + const oldId = labels[i]; + let energy = centerDist[candidateId]?.[i] ?? 0; + let same4 = 0; + let diff4 = 0; + let diagDiff = 0; + + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const neighborId = labels[ni]; + if (neighborId < 0) continue; + const isCardinal = nx === x || ny === y; + const differs = neighborId !== candidateId; + if (isCardinal) { + if (differs) { + diff4++; + const boundaryTarget = (targetScore[i] + targetScore[ni]) * 0.5; + const urbanCut = (urbanBoundaryPenalty(i, populationDensity, landuse) + urbanBoundaryPenalty(ni, populationDensity, landuse)) * 0.5; + const minorValley = (valleyField[i] + valleyField[ni]) * 0.5 > 0.34 && Math.max(river[i], river[ni]) < 0.30 ? 0.72 : 0; + const dHere = centerDist[candidateId]?.[i] ?? 99; + const dThere = centerDist[neighborId]?.[i] ?? 99; + const weakBisectorPenalty = Math.abs(dHere - dThere) < 4.0 && boundaryTarget < 0.42 ? 0.62 : 0; + energy += 2.15 - boundaryTarget * 1.55 + urbanCut * 3.0 + minorValley + weakBisectorPenalty; + } else same4++; + } else if (differs) diagDiff++; + } + + if (same4 === 0) energy += 5.2; + if (same4 === 1) energy += 1.7; + if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25; + if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42; + if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) { + const drift = centerDist[candidateId][i] - centerDist[oldId][i]; + if (drift > 0) energy += Math.min(0.9, drift * 0.012); + } + return energy; +} + +function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) { + const fields = []; + for (const id of adminIds) { + const center = adminCenters[id]; + const field = new Float32Array(SIZE); + if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)] || !prefectureMask[indexOf(center.x, center.y)]) field.fill(24); + else { + for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) field[indexOf(x, y)] = Math.hypot(x - center.x, y - center.y); + } + fields[id] = field; + } + return fields; +} + +function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) { + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); + const queue = []; + + for (const id of ids) { + const seen = new Uint8Array(SIZE); + const components = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; + const cells = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push(cells); + } + if (components.length <= 1) continue; + + const centerIndex = adminCenters[id] && inside(adminCenters[id].x, adminCenters[id].y) ? indexOf(adminCenters[id].x, adminCenters[id].y) : -1; + let keepIndex = centerIndex >= 0 ? components.findIndex((cells) => cells.includes(centerIndex)) : -1; + if (keepIndex < 0) { + let bestSize = -1; + for (let c = 0; c < components.length; c++) if (components[c].length > bestSize) { bestSize = components[c].length; keepIndex = c; } + } + + for (let c = 0; c < components.length; c++) { + if (c === keepIndex) continue; + const counts = new Map(); + for (const ci of components[c]) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other < 0 || other === id) continue; + const terrainFit = targetScore ? targetScore[ci] * 0.18 : 0; + const urbanFit = populationDensity && landuse ? (1 - urbanBoundaryPenalty(ci, populationDensity, landuse)) * 0.08 : 0; + counts.set(other, (counts.get(other) || 0) + 1 + terrainFit + urbanFit); + } + } + let target = -1; + let best = -1; + for (const [other, score] of counts) if (score > best) { best = score; target = other; } + if (target >= 0) for (const ci of components[c]) adminId[ci] = target; + } + } +} + +function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { + if (landuse[i] === 3 || populationDensity[i] > 0.70) return 1; + if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.24) return 2; + if (landuse[i] === 5 || landuse[i] === 6) return 3; + if ((river[i] > 0.50 && flowAccum[i] > 0.34) || flowAccum[i] > 0.68) return 4; + if (coastalLowland[i] > 0.42 && elevation[i] < 0.44) return 5; + if (basinField[i] > 0.38 && plain[i] > 0.26) return 6; + if (valleyField[i] > 0.42 && ridgeField[i] < 0.55) return 7; + if (ridgeField[i] > 0.54 || (ridgeField[i] > 0.40 && elevation[i] > 0.54)) return 8; + if (elevation[i] > 0.62 || slope[i] > 0.42) return 9; + if (landuse[i] === 0 || agriculture[i] > 0.45 || plain[i] > 0.48) return 10; + return 11; +} + +export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) { + const score = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i]) continue; + const [x, y] = xyOf(i); + let coastEdge = 0; + for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1; + const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0; + const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82); + const ridgeDivide = clamp(ridgeField[i] * 1.65 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.05); + const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.55) * ridgeField[i] * 1.4 + slope[i] * ridgeField[i] * 0.8); + const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.32) * Math.max(0, slope[i] - 0.18) * 1.15 + Math.max(0, ridgeField[i] - 0.34) * basinField[i] * 0.62) : 0; + const foothillBreak = clamp(Math.max(0, slope[i] - 0.30) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.48)) * 0.82); + const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18); + score[i] = clamp( + ridgeDivide * 0.92 + + crest * 0.72 + + majorRiver * 0.86 + + basinRim * 0.54 + + foothillBreak * 0.48 + + coastEdge * 0.34 + + terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 - + livingCorridor * 0.50 - + urbanContinuity * 0.72 + ); + } + return score; +} + +function lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) { + const lowRelief = clamp((0.68 - elevation[i]) * 1.25) + clamp((0.36 - slope[i]) * 1.45) + clamp((0.48 - ridgeField[i]) * 1.10); + const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.42 : landuse[i] === 5 || landuse[i] === 6 ? 0.20 : 0; + return clamp( + lowRelief * 0.30 + + (plain?.[i] || 0) * 0.30 + + (agriculture?.[i] || 0) * 0.16 + + basinField[i] * 0.24 + + coastalLowland[i] * 0.24 + + valleyField[i] * 0.10 + + populationDensity[i] * 0.34 + + landuseFit + ); +} + +function mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse) { + const settled = populationDensity[i] * 0.85 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.35 : 0); + return clamp(elevation[i] * 0.38 + slope[i] * 0.32 + ridgeField[i] * 0.42 - settled); +} + +function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAccum, valleyField, populationDensity, landuse) { + if (classA !== classB) { + const bothUrban = classA <= 3 && classB <= 3; + const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB); + if (!bothUrban && !bothLivingCorridor) return false; + } + const majorRiverEdge = Math.max(river[a], river[b]) > 0.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68; + const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) && + ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34); + const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge; + const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50; + return barrier < threshold && (!majorRiverEdge || urbanEdge); +} + +function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) { + let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0; + let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0; + let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0; + for (const i of unit.cells) { + const [x, y] = xyOf(i); + sx += x; sy += y; pop += populationDensity[i]; + minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse); + ridgeExposure += ridgeField[i]; + coastalExposure += coastalLowland[i]; + basinIdentity += basinField[i]; + valleyIdentity += valleyField[i]; + lowlandFitness += lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + mountainFitness += mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse); + } + const area = unit.cells.length; + unit.area = area; + unit.x = sx / Math.max(1, area); + unit.y = sy / Math.max(1, area); + unit.minX = area ? minX : 0; + unit.minY = area ? minY : 0; + unit.maxX = area ? maxX : 0; + unit.maxY = area ? maxY : 0; + unit.width = area ? maxX - minX + 1 : 0; + unit.height = area ? maxY - minY + 1 : 0; + unit.elongation = Math.max(unit.width, unit.height) / Math.max(1, Math.min(unit.width, unit.height)); + unit.population = pop; + unit.urbanWeight = urbanWeight / Math.max(1, area); + unit.ridgeExposure = ridgeExposure / Math.max(1, area); + unit.riverExposure = riverExposure / Math.max(1, area); + unit.coastalExposure = coastalExposure / Math.max(1, area); + unit.basinIdentity = basinIdentity / Math.max(1, area); + unit.valleyIdentity = valleyIdentity / Math.max(1, area); + unit.lowlandFitness = lowlandFitness / Math.max(1, area); + unit.mountainFitness = mountainFitness / Math.max(1, area); +} + +function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) { + if (!unit || unit.area < 24) return null; + const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields; + const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20))); + + let first = -1; + let second = -1; + let bestA = -INF; + let bestB = -INF; + const width = unit.width || (unit.maxX - unit.minX + 1) || 1; + const height = unit.height || (unit.maxY - unit.minY + 1) || 1; + const horizontal = width >= height; + const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65; + + for (const i of unit.cells) { + const [x, y] = xyOf(i); + const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0); + const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0; + const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10; + if (score > bestA) { bestA = score; first = i; } + } + if (first < 0) return null; + const [fx, fy] = xyOf(first); + for (const i of unit.cells) { + const [x, y] = xyOf(i); + const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + const axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0; + const d = Math.hypot(x - fx, y - fy); + const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08; + if (score > bestB) { bestB = score; second = i; } + } + if (second < 0 || second === first) return null; + + const cellSet = new Set(unit.cells); + const owner = new Int8Array(SIZE); + owner.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + for (const [source, sourceOwner] of [[first, 0], [second, 1]]) { + owner[source] = sourceOwner; + dist[source] = 0; + heap.push({ i: source, f: 0, owner: sourceOwner }); + } + + while (heap.length > 0) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [x, y] = xyOf(cur.i); + for (const [nx, ny, step] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!cellSet.has(ni)) continue; + const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; + const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32; + const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25; + const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12)); + const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step; + const nd = cur.f + stepCost; + if (nd < dist[ni]) { + dist[ni] = nd; + owner[ni] = cur.owner; + heap.push({ i: ni, f: nd, owner: cur.owner }); + } + } + } + + const aCells = []; + const bCells = []; + for (const ci of unit.cells) { + if (owner[ci] === 1) bCells.push(ci); + else aCells.push(ci); + } + if (aCells.length < minPart || bCells.length < minPart) return null; + + unit.cells = aCells; + const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() }; + for (const ci of bCells) compartmentId[ci] = newId; + refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + return newUnit; +} + +function hashSeededTie(x, y, seed) { + let h = Math.imul((x | 0) ^ (seed | 0), 1597334677) ^ Math.imul((y | 0) ^ ((seed >>> 1) | 0), 3812015801); + h = (h ^ (h >>> 15)) >>> 0; + return h / 4294967295; +} + +function naturalGroupKey(unit) { + if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`; + if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`; + if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`; + if (unit.classId === 7) return `valley:${Math.round((unit.x + unit.y) / 12)}`; + if (unit.classId === 8 || unit.classId === 9) return `mountain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; + return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; +} + + +function collectLandComponents(prefectureMask, sea) { + const seen = new Uint8Array(SIZE); + const components = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !prefectureMask[i] || sea[i]) continue; + const cells = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push(cells); + } + return components; +} + +function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) { + const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8; + const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + const mountain = mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse); + const stableInterior = clamp(1 - (naturalBarrierScore[i] || 0)); + const settlement = clamp(populationDensity[i] * 0.65 + (klassUrban ? 0.24 : 0)); + const streamCorridor = clamp(valleyField[i] * 0.28 + river[i] * 0.08); + const mountainInterior = clamp(mountain * 0.45 + stableInterior * 0.28 - ridgeField[i] * 0.22); + return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10; +} + +function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) { + const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields; + const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); + const seeds = []; + const seedComponentId = []; + const minCellsPerUnit = 9; + let remainingTarget = Math.max(1, Math.min(targetCount || Math.round(totalArea / 42), Math.floor(totalArea / minCellsPerUnit))); + + const sortedComponents = landComponents + .map((cells, componentIndex) => ({ cells, componentIndex, area: cells.length })) + .sort((a, b) => b.area - a.area); + + for (let componentOrder = 0; componentOrder < sortedComponents.length; componentOrder++) { + const { cells, componentIndex, area } = sortedComponents[componentOrder]; + if (area <= 0) continue; + const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea)); + let localTarget = Math.max(1, proportional); + localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit))); + if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget)); + remainingTarget -= localTarget; + + const candidates = cells + .map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) })) + .sort((a, b) => b.score - a.score); + const localSeeds = []; + const idealSpacing = Math.sqrt(area / Math.max(1, localTarget)); + const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34]; + for (const factor of spacingPasses) { + const minDist = Math.max(2.2, idealSpacing * factor); + for (const candidate of candidates) { + if (localSeeds.length >= localTarget) break; + const [x, y] = xyOf(candidate.i); + let ok = true; + for (const existing of localSeeds) { + const [ex, ey] = xyOf(existing); + if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; } + } + if (ok) localSeeds.push(candidate.i); + } + if (localSeeds.length >= localTarget) break; + } + for (const i of localSeeds) { + seeds.push(i); + seedComponentId.push(componentIndex); + } + } + + if (seeds.length === 0 && landComponents[0]?.length) { + seeds.push(landComponents[0][0]); + seedComponentId.push(0); + } + return { seeds, seedComponentId }; +} + +function naturalStepCost(a, b, cellClass, fields) { + const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields; + const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5; + const ridge = Math.max(ridgeField[a], ridgeField[b]); + const riverEdge = Math.max(river[a], river[b]); + const flowEdge = Math.max(flowAccum?.[a] || 0, flowAccum?.[b] || 0); + const majorRiverCrossing = riverEdge > 0.44 || flowEdge > 0.55; + const elevationBreak = Math.abs(elevation[a] - elevation[b]); + const slopeBreak = Math.max(slope[a], slope[b]); + const classBreak = cellClass[a] !== cellClass[b] ? 0.34 : -0.08; + const bothUrban = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.30) && + ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.30); + const lowlandContinuity = Math.min( + (plain?.[a] || 0) + (agriculture?.[a] || 0) * 0.35 + basinField[a] * 0.25 + coastalLowland[a] * 0.20, + (plain?.[b] || 0) + (agriculture?.[b] || 0) * 0.35 + basinField[b] * 0.25 + coastalLowland[b] * 0.20 + ); + const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45); + const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0)); + const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0; + return Math.max(0.16, + 0.72 + + barrier * 5.1 + + ridge * 0.82 + + elevationBreak * 3.0 + + slopeBreak * 0.56 + + riverPenalty + + classBreak - + corridorBonus + ); +} + +function buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields) { + const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields; + let maxId = -1; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] > maxId) maxId = compartmentId[i]; + const units = Array.from({ length: maxId + 1 }, (_, id) => ({ id, cells: [], centerIds: [], adjacent: new Map(), area: 0 })); + for (let i = 0; i < SIZE; i++) { + const id = compartmentId[i]; + if (id >= 0 && units[id]) units[id].cells.push(i); + } + for (const unit of units) { + if (!unit.cells.length) { unit.area = 0; continue; } + const counts = new Map(); + for (const ci of unit.cells) counts.set(cellClass[ci], (counts.get(cellClass[ci]) || 0) + 1); + let klass = -1, best = -1; + for (const [k, count] of counts) if (count > best) { best = count; klass = k; } + unit.classId = klass; + unit.dominantLandscapeClass = klass; + refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + let riverExposure = 0; + for (const ci of unit.cells) riverExposure += river[ci] + (fields.flowAccum?.[ci] || 0) * 0.45; + unit.riverExposure = riverExposure / Math.max(1, unit.area); + } + return units; +} + + +function splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea) { + const queue = []; + for (const unit of [...compartments]) { + if (!unit || unit.area === 0 || !unit.cells?.length) continue; + const unitCellSet = new Set(unit.cells); + const seen = new Set(); + const components = []; + for (const start of unit.cells) { + if (seen.has(start) || compartmentId[start] !== unit.id) continue; + const cells = []; + queue.length = 0; + queue.push(start); + seen.add(start); + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni] || seen.has(ni) || compartmentId[ni] !== unit.id || !unitCellSet.has(ni)) continue; + seen.add(ni); + queue.push(ni); + } + } + components.push(cells); + } + if (components.length <= 1) continue; + components.sort((a, b) => b.length - a.length); + unit.cells = components[0]; + for (const extra of components.slice(1)) { + const newId = compartments.length; + for (const ci of extra) compartmentId[ci] = newId; + compartments.push({ id: newId, cells: extra, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }); + } + } +} + +function renumberCompartments(compartmentId, compartments, prefectureMask, sea) { + const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length); + const idMap = new Map(); + active.forEach((unit, newId) => idMap.set(unit.id, newId)); + for (let i = 0; i < SIZE; i++) { + const id = compartmentId[i]; + if (!prefectureMask[i] || sea[i]) compartmentId[i] = -1; + else if (idMap.has(id)) compartmentId[i] = idMap.get(id); + } + active.forEach((unit, newId) => { unit.id = newId; unit.centerIds = []; }); + return active; +} + +function refreshAllCompartmentStats(compartments, fields) { + const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, river, flowAccum } = fields; + for (const unit of compartments) { + if (!unit || unit.area === 0 || !unit.cells?.length) continue; + refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + let riverExposure = 0; + const counts = new Map(); + for (const ci of unit.cells) { + riverExposure += river[ci] + (flowAccum?.[ci] || 0) * 0.45; + if (unit._cellClass) counts.set(unit._cellClass[ci], (counts.get(unit._cellClass[ci]) || 0) + 1); + } + unit.riverExposure = riverExposure / Math.max(1, unit.area); + } +} + +function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed) { + if (!unit || unit.area < 20) return null; + const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum, cellClass } = fields; + const minPart = Math.max(7, Math.min(30, Math.floor(unit.area * 0.18))); + let cx = unit.x || 0, cy = unit.y || 0; + let first = -1, second = -1, bestA = -INF, bestB = -INF; + const elongated = (unit.elongation || 1) > 2.3; + const horizontal = (unit.width || 0) >= (unit.height || 0); + for (const i of unit.cells) { + const [x, y] = xyOf(i); + const centerDist = Math.hypot(x - cx, y - cy); + const axis = elongated ? Math.abs((horizontal ? x - cx : y - cy)) / Math.max(1, horizontal ? unit.width : unit.height) : 0; + const interior = 1 - (naturalBarrierScore[i] || 0); + const score = centerDist * 0.13 + axis * 1.1 + interior * 0.35 + hashSeededTie(x, y, seed) * 0.08 - ridgeField[i] * 0.10; + if (score > bestA) { bestA = score; first = i; } + } + if (first < 0) return null; + const [fx, fy] = xyOf(first); + for (const i of unit.cells) { + const [x, y] = xyOf(i); + const d = Math.hypot(x - fx, y - fy); + const interior = 1 - (naturalBarrierScore[i] || 0); + const score = d * 0.20 + interior * 0.38 + hashSeededTie(x, y, seed + 31) * 0.08 - ridgeField[i] * 0.08; + if (score > bestB) { bestB = score; second = i; } + } + if (second < 0 || second === first) return null; + + const cellSet = new Set(unit.cells); + const owner = new Int8Array(SIZE); + owner.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + for (const [source, sourceOwner] of [[first, 0], [second, 1]]) { + owner[source] = sourceOwner; + dist[source] = 0; + heap.push({ i: source, f: 0, owner: sourceOwner }); + } + while (heap.length > 0) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [x, y] = xyOf(cur.i); + for (const [nx, ny, step] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!cellSet.has(ni)) continue; + const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; + if (nd < dist[ni]) { + dist[ni] = nd; + owner[ni] = cur.owner; + heap.push({ i: ni, f: nd, owner: cur.owner }); + } + } + } + const aCells = [], bCells = []; + for (const ci of unit.cells) (owner[ci] === 1 ? bCells : aCells).push(ci); + if (aCells.length < minPart || bCells.length < minPart) return null; + unit.cells = aCells; + for (const ci of bCells) compartmentId[ci] = newId; + const newUnit = { id: newId, cells: bCells, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }; + refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); + return newUnit; +} + +function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { + const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); + const cellClass = new Int16Array(SIZE); + cellClass.fill(-1); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); + const fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass }; + const landComponents = collectLandComponents(prefectureMask, sea); + const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); + const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360); + const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8))); + const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0); + const compartmentId = new Int32Array(SIZE); + compartmentId.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + seeds.forEach((i, id) => { + compartmentId[i] = id; + dist[i] = 0; + heap.push({ i, f: 0, id }); + }); + while (heap.length > 0) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [x, y] = xyOf(cur.i); + for (const [nx, ny, step] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; + if (nd < dist[ni]) { + dist[ni] = nd; + compartmentId[ni] = cur.id; + heap.push({ i: ni, f: nd, id: cur.id }); + } + } + } + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0; + + let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + mergeTinyLandscapeUnits(compartmentId, compartments, 9); + splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); + compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); + refreshAllCompartmentStats(compartments, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + + const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55)); + let guard = Math.max(80, targetCount * 3); + while (guard-- > 0) { + let active = compartments.filter((unit) => unit && unit.area > 0); + const needMore = active.length < targetCount; + const worst = active + .filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2)) + .sort((a, b) => { + const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2; + const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2; + return sb - sa; + })[0]; + if (!worst) break; + const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97); + if (!newUnit) { + worst._splitRejected = (worst._splitRejected || 0) + 1; + if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1); + if (!needMore) break; + continue; + } + compartments.push(newUnit); + if (compartments.filter((unit) => unit && unit.area > 0).length >= targetCount && newUnit.area <= maxNaturalCompartmentArea) { + const stillBad = compartments.some((unit) => unit && unit.area >= 20 && ( + unit.area > maxNaturalCompartmentArea * 1.35 || + ((unit.elongation || 1) > 4.2 && unit.area > 28) + )); + if (!stillBad) break; + } + } + + splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); + compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); + refreshAllCompartmentStats(compartments, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + return { compartmentId, compartments, naturalBarrierScore }; +} + +export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { + return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options); + const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); + const compartmentId = new Int32Array(SIZE); + compartmentId.fill(-1); + const cellClass = new Int16Array(SIZE); + cellClass.fill(-1); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); + + const compartments = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (cellClass[i] < 0 || compartmentId[i] >= 0) continue; + const id = compartments.length; + const startClass = cellClass[i]; + const cells = []; + let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0; + let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0; + queue.length = 0; + queue.push(i); + compartmentId[i] = id; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + cells.push(cur); + sx += x; sy += y; pop += populationDensity[cur]; + minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse); + ridgeExposure += ridgeField[cur]; + riverExposure += river[cur] + flowAccum[cur] * 0.45; + coastalExposure += coastalLowland[cur]; + basinIdentity += basinField[cur]; + valleyIdentity += valleyField[cur]; + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue; + const edgeBarrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5; + if (!canShareNaturalCompartment(cur, ni, startClass, cellClass[ni], edgeBarrier, river, flowAccum, valleyField, populationDensity, landuse)) continue; + compartmentId[ni] = id; + queue.push(ni); + } + } + const area = cells.length; + const unit = { + id, + cells, + area, + x: sx / area, + y: sy / area, + classId: startClass, + dominantLandscapeClass: startClass, + minX, + minY, + maxX, + maxY, + width: maxX - minX + 1, + height: maxY - minY + 1, + elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)), + population: pop, + urbanWeight: urbanWeight / area, + ridgeExposure: ridgeExposure / area, + riverExposure: riverExposure / area, + coastalExposure: coastalExposure / area, + basinIdentity: basinIdentity / area, + valleyIdentity: valleyIdentity / area, + centerIds: [], + adjacent: new Map(), + }; + unit.lowlandFitness = cells.reduce((sum, ci) => sum + lowlandCompartmentFitness(ci, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse), 0) / area; + unit.mountainFitness = cells.reduce((sum, ci) => sum + mountainCompartmentFitness(ci, elevation, slope, ridgeField, populationDensity, landuse), 0) / area; + compartments.push(unit); + } + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + mergeTinyLandscapeUnits(compartmentId, compartments, 12); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + const targetCount = options.targetCompartmentCount || 0; + if (targetCount > 0) { + const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum }; + const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0); + const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65)); + const splitScore = (unit) => { + const elongated = Math.max(0, (unit.elongation || 1) - 2.1); + const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea); + const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35; + return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20; + }; + let guard = Math.max(targetCount * 4, 80); + while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) { + const candidates = compartments + .filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8)) + .sort((a, b) => splitScore(b) - splitScore(a)); + const target = candidates[0]; + if (!target) break; + const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard); + if (!newUnit) { + target._splitRejected = (target._splitRejected || 0) + 1; + target.elongation = Math.max(1, (target.elongation || 1) * 0.72); + if (target._splitRejected > 2) target.area = target.cells.length; + continue; + } + compartments.push(newUnit); + if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + } + + guard = Math.max(targetCount * 2, 60); + while (guard-- > 0) { + const target = compartments + .filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85))) + .sort((a, b) => splitScore(b) - splitScore(a))[0]; + if (!target) break; + const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991); + if (!newUnit) { + target.elongation = Math.max(1, (target.elongation || 1) * 0.70); + break; + } + compartments.push(newUnit); + if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + } + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + } + return { compartmentId, compartments, naturalBarrierScore }; +} + +function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { + const unitId = new Int32Array(SIZE); + unitId.fill(-1); + const cellClass = new Int16Array(SIZE); + cellClass.fill(-1); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); + + const units = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (cellClass[i] < 0 || unitId[i] >= 0) continue; + const id = units.length; + const klass = cellClass[i]; + const cells = []; + let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0; + queue.length = 0; + queue.push(i); + unitId[i] = id; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + cells.push(cur); + sx += x; sy += y; pop += populationDensity[cur]; + urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse); + ridgeExposure += ridgeField[cur]; + riverExposure += river[cur] + flowAccum[cur] * 0.45; + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (unitId[ni] >= 0 || cellClass[ni] !== klass) continue; + unitId[ni] = id; + queue.push(ni); + } + } + units.push({ id, classId: klass, cells, area: cells.length, x: sx / cells.length, y: sy / cells.length, population: pop, urbanWeight: urbanWeight / cells.length, ridgeExposure: ridgeExposure / cells.length, riverExposure: riverExposure / cells.length, adjacent: new Map(), centerIds: [], owner: -1 }); + } + + const targetScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) if (cellClass[i] >= 0) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse); + rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); + mergeTinyLandscapeUnits(unitId, units, 10); + rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); + return { unitId, units, targetScore }; +} + +function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea) { + for (const unit of units) unit.adjacent = new Map(); + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const a = unitId[i]; + if (a < 0 || !units[a] || units[a].area === 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const b = unitId[ni]; + if (b < 0 || b === a || !units[b] || units[b].area === 0) continue; + const v = (targetScore[i] + targetScore[ni]) * 0.5; + const keyA = units[a].adjacent.get(b) || { count: 0, target: 0 }; + keyA.count++; keyA.target += v; units[a].adjacent.set(b, keyA); + const keyB = units[b].adjacent.get(a) || { count: 0, target: 0 }; + keyB.count++; keyB.target += v; units[b].adjacent.set(a, keyB); + } + } + } +} + +function mergeTinyLandscapeUnits(unitId, units, minArea = 10) { + for (const unit of units) { + if (unit.area === 0 || unit.area >= minArea) continue; + let bestId = -1, bestScore = -INF; + for (const [otherId, edge] of unit.adjacent) { + const other = units[otherId]; + if (!other || other.area === 0) continue; + const score = edge.count * 3 + (other.classId === unit.classId ? 8 : 0) + other.area * 0.01 - edge.target / Math.max(1, edge.count); + if (score > bestScore) { bestScore = score; bestId = otherId; } + } + if (bestId < 0) continue; + const target = units[bestId]; + for (const i of unit.cells) { unitId[i] = bestId; target.cells.push(i); } + const totalArea = target.area + unit.area; + target.x = (target.x * target.area + unit.x * unit.area) / totalArea; + target.y = (target.y * target.area + unit.y * unit.area) / totalArea; + target.population += unit.population; + target.urbanWeight = (target.urbanWeight * target.area + unit.urbanWeight * unit.area) / totalArea; + target.ridgeExposure = (target.ridgeExposure * target.area + unit.ridgeExposure * unit.area) / totalArea; + target.riverExposure = (target.riverExposure * target.area + unit.riverExposure * unit.area) / totalArea; + target.area = totalArea; + unit.area = 0; + unit.cells = []; + } +} + +function naturalOwnershipAffinity(unit, neighbor, edge) { + const boundaryTarget = edge.target / Math.max(1, edge.count); + const sameClass = unit.classId === neighbor.classId ? 1.0 : 0; + const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1.1 : 0; + const bothUrban = unit.classId <= 3 && neighbor.classId <= 3; + const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId); + const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0; + const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8); + return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty; +} + +function compartmentCrossingCost(unit, neighbor, edge) { + const boundaryScore = edge.target / Math.max(1, edge.count); + const sameClass = unit.classId === neighbor.classId ? 1 : 0; + const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1 : 0; + const lowlandContinuity = Math.min(unit.lowlandFitness || 0, neighbor.lowlandFitness || 0); + const urbanContinuity = Math.min(unit.urbanWeight || 0, neighbor.urbanWeight || 0); + const mountainPenalty = Math.max(unit.mountainFitness || 0, neighbor.mountainFitness || 0); + const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0); + return Math.max(0.18, + 1.0 + + boundaryScore * 5.2 + + mountainPenalty * 1.8 + + ridgePenalty * 0.9 - + sameClass * 0.45 - + sameGroup * 0.35 - + lowlandContinuity * 1.15 - + urbanContinuity * 0.70 - + Math.min(1.0, edge.count / 12) * 0.25 + ); +} + +function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) { + const owner = new Int16Array(compartments.length); + const dist = new Float32Array(compartments.length); + owner.fill(-1); + dist.fill(INF); + const heap = new MinHeap(); + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const compIndex = compartmentId[indexOf(center.x, center.y)]; + const unit = compartments[compIndex]; + if (compIndex < 0 || !unit || unit.area === 0) continue; + unit.centerIds.push(id); + if (dist[compIndex] > 0) { + dist[compIndex] = 0; + owner[compIndex] = id; + heap.push({ i: compIndex, f: 0, owner: id }); + } + } + while (heap.length > 0) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const unit = compartments[cur.i]; + if (!unit || unit.area === 0) continue; + const center = adminCenters[cur.owner]; + for (const [neighborId, edge] of unit.adjacent) { + const neighbor = compartments[neighborId]; + if (!neighbor || neighbor.area === 0) continue; + const crossing = compartmentCrossingCost(unit, neighbor, edge); + const euclideanTie = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) * 0.006 : 0; + const hinterlandDrag = (neighbor.mountainFitness || 0) > 0.64 && (neighbor.population || 0) < 6 ? 0.35 : 0; + const next = cur.f + crossing + euclideanTie + hinterlandDrag; + if (next + 1e-5 < dist[neighborId]) { + dist[neighborId] = next; + owner[neighborId] = cur.owner; + heap.push({ i: neighborId, f: next, owner: cur.owner }); + } else if (Math.abs(next - dist[neighborId]) < 0.08 && owner[neighborId] >= 0) { + const oldCenter = adminCenters[owner[neighborId]]; + const oldD = oldCenter ? Math.hypot(neighbor.x - oldCenter.x, neighbor.y - oldCenter.y) : INF; + const newD = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) : INF; + if (newD < oldD - 1.5 || (newD < oldD + 1.5 && cur.owner < owner[neighborId])) owner[neighborId] = cur.owner; + } + } + } + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestOwner = -1, bestScore = INF; + for (const [neighborId, edge] of unit.adjacent) { + if (owner[neighborId] < 0) continue; + const neighbor = compartments[neighborId]; + const score = compartmentCrossingCost(unit, neighbor, edge) + (neighbor?.area || 0) * -0.001; + if (score < bestScore) { bestScore = score; bestOwner = owner[neighborId]; } + } + owner[unit.id] = bestOwner >= 0 ? bestOwner : 0; + } + return owner; +} + +function compartmentMunicipalityMetrics(compartments, owner, targetMunicipalityCount = 0, targetCompartmentCount = 0) { + const counts = new Map(); + let active = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + active++; + const id = owner[unit.id]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + const actual = counts.size; + const singles = [...counts.values()].filter((value) => value === 1).length; + return { + targetMunicipalityCount, + actualMunicipalityCount: actual, + targetNaturalCompartmentCount: targetCompartmentCount, + naturalCompartmentCount: active, + compartmentCount: active, + averageCompartmentsPerMunicipality: actual ? active / actual : 0, + singleCompartmentMunicipalityRatio: actual ? singles / actual : 0, + }; +} + +function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) { + let sum = 0; + let count = 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 (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue; + sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5; + count++; + } + } + } + return count ? sum / count : 0; +} + +function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) { + const owner = new Int16Array(compartments.length); + owner.fill(-1); + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const compIndex = compartmentId[indexOf(center.x, center.y)]; + if (compIndex >= 0 && compartments[compIndex]?.area > 0) { + const unit = compartments[compIndex]; + unit.centerIds.push(id); + owner[compIndex] = id; + } + } + + for (let pass = 0; pass < compartments.length + 8; pass++) { + let changed = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestOwner = -1; + let bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent) { + const neighborOwner = owner[neighborId]; + if (neighborOwner < 0) continue; + const neighbor = compartments[neighborId]; + if (!neighbor || neighbor.area === 0) continue; + const center = adminCenters[neighborOwner]; + const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0; + const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020); + if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } + } + const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70; + if (bestOwner >= 0 && accept) { + owner[unit.id] = bestOwner; + changed++; + } + } + if (changed === 0) break; + } + + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestId = -1; + let bestScore = -INF; + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]]; + const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.3 : 0; + const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0; + const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0; + const d = Math.hypot(unit.x - center.x, unit.y - center.y); + const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16; + if (score > bestScore) { bestScore = score; bestId = id; } + } + owner[unit.id] = bestId >= 0 ? bestId : 0; + } + return owner; +} + +export function extractCompartmentBorders(compartmentId, prefectureMask, sea) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || compartmentId[i] < 0) continue; + const a = compartmentId[i]; + if (x + 1 < MAP_W) { + const ni = indexOf(x + 1, y); + if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H) { + const ni = indexOf(x, y + 1); + if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) { + const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options); + const adminId = new Int16Array(SIZE); + adminId.fill(-1); + const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); + for (const unit of compartments) { + const assigned = owner[unit.id]; + if (assigned < 0) continue; + for (const i of unit.cells) adminId[i] = assigned; + } + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue; + const comp = compartments[compartmentId[i]]; + adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0; + } + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + const activeCompartments = compartments.filter((unit) => unit.area > 0); + const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0); + return { + adminId, + compartmentId, + compartments, + naturalBarrierScore, + debug: { + ...relationMetrics, + compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea), + averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, + maxCompartmentArea: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.area || 0)) : 0, + maxCompartmentElongation: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.elongation || 1)) : 1, + worstNaturalCompartments: activeCompartments + .map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) })) + .sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area)))) + .slice(0, 8), + finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), + voronoiLikeRateBefore: 0, + voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), + }, + }; +} + +function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) { + let weak = 0; + let total = 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 (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + const ni = indexOf(nx, ny); + const a = adminId[i], b = adminId[ni]; + if (!prefectureMask[ni] || sea[ni] || a < 0 || b < 0 || a === b) continue; + total++; + const ca = adminCenters[a], cb = adminCenters[b]; + if (!ca || !cb) continue; + const mx = (x + nx) * 0.5, my = (y + ny) * 0.5; + const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.0; + if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.38) weak++; + } + } + } + return total ? weak / total : 0; +} + +export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { + const before = new Int16Array(adminId); + const initialNaturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); + const beforeVoronoiLikeRate = weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, initialNaturalBarrierScore); + const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); + if (compartments.length === 0) return; + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const unit = compartments[compartmentId[indexOf(center.x, center.y)]]; + if (unit) unit.centerIds.push(id); + } + + const owner = new Int16Array(compartments.length); + owner.fill(-1); + for (const unit of compartments) { + if (unit.area === 0 || unit.centerIds.length === 0) continue; + owner[unit.id] = unit.centerIds[0]; + } + + for (let pass = 0; pass < compartments.length + 4; pass++) { + let changed = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestOwner = -1; + let bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent) { + const neighborOwner = owner[neighborId]; + if (neighborOwner < 0) continue; + const neighbor = compartments[neighborId]; + if (!neighbor || neighbor.area === 0) continue; + const score = naturalOwnershipAffinity(unit, neighbor, edge) + Math.min(0.8, Math.sqrt(Math.max(1, neighbor.area)) * 0.018); + if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } + } + const accept = unit.classId <= 3 ? bestScore > -0.15 : unit.classId === 8 || unit.classId === 9 ? bestScore > -0.80 : bestScore > -0.45; + if (bestOwner >= 0 && accept) { owner[unit.id] = bestOwner; changed++; } + } + if (changed === 0) break; + } + + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestId = -1, bestScore = -INF; + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]]; + const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.4 : 0; + const sameClass = centerComp && centerComp.classId === unit.classId ? 0.9 : 0; + const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.2 : 0; + const d = Math.hypot(unit.x - center.x, unit.y - center.y); + const score = sameGroup + sameClass + urbanFit - d * 0.018 - unit.ridgeExposure * 0.18; + if (score > bestScore) { bestScore = score; bestId = id; } + } + owner[unit.id] = bestId >= 0 ? bestId : 0; + } + + for (const unit of compartments) { + const assigned = owner[unit.id]; + if (assigned >= 0) for (const i of unit.cells) adminId[i] = assigned; + } + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue; + const comp = compartments[compartmentId[i]]; + adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0; + } + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { + if (Math.hypot(dx, dy) > 2) continue; + const x = center.x + dx, y = center.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (prefectureMask[i] && !sea[i]) adminId[i] = id; + } + } + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + let changedCells = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; + const activeCompartments = compartments.filter((unit) => unit.area > 0); + applyLandscapeUnitAdminPartition.lastDebug = { + compartmentCount: activeCompartments.length, + averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, + changedAfterNaturalCompartmentPartition: changedCells, + finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), + voronoiLikeRateBefore: beforeVoronoiLikeRate, + voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), + }; +} + +export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) { + const targetScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse); + const protectedMask = buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters, protectedPoints, populationDensity, landuse); + const band = buildBoundaryBand(adminId, prefectureMask, sea, 5); + const adminIds = [...new Set([...adminId].filter((id) => id >= 0))]; + const centerDist = buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea); + let current = new Int16Array(adminId); + + for (let pass = 0; pass < passes; pass++) { + const next = new Int16Array(current); + let changed = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + const own = current[i]; + if (!band[i] || protectedMask[i] || !prefectureMask[i] || sea[i] || own < 0) continue; + if (!isAdminBoundaryCell(current, prefectureMask, sea, x, y, true)) continue; + const candidates = new Set(); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (prefectureMask[ni] && !sea[ni] && current[ni] >= 0 && current[ni] !== own) candidates.add(current[ni]); + } + if (candidates.size === 0) continue; + const currentEnergy = localBoundaryEnergy(current, i, own, targetScore, centerDist, populationDensity, landuse, river, valleyField); + let bestId = own, bestEnergy = currentEnergy; + for (const candidate of candidates) { + const candidateEnergy = localBoundaryEnergy(current, i, candidate, targetScore, centerDist, populationDensity, landuse, river, valleyField); + const threshold = 0.18 + (targetScore[i] < 0.36 ? 0.16 : 0) + urbanBoundaryPenalty(i, populationDensity, landuse) * 0.25; + if (bestEnergy - candidateEnergy > threshold) { bestEnergy = candidateEnergy; bestId = candidate; } + } + if (bestId !== own) { next[i] = bestId; changed++; } + } + } + current = next; + if (changed === 0) break; + } + adminId.set(current); + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse); +} + +export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { + const before = new Int16Array(adminId); + const area = new Map(); + const lowland = new Map(); + const mountain = new Map(); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + const id = adminId[i]; + area.set(id, (area.get(id) || 0) + 1); + const living = (plain[i] || 0) * 0.42 + (agriculture[i] || 0) * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12; + const rough = ridgeField[i] * 0.54 + slope[i] * 0.36 + Math.max(0, elevation[i] - 0.58) * 0.38; + lowland.set(id, (lowland.get(id) || 0) + living); + mountain.set(id, (mountain.get(id) || 0) + rough); + } + const areas = [...area.values()].sort((a, b) => a - b); + const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0; + if (!median) return { changedCells: 0, splitMunicipalities: 0 }; + + const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); + const unitOwner = new Int16Array(compartments.length); + unitOwner.fill(-1); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const counts = new Map(); + for (const i of unit.cells) { + const id = adminId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + let bestId = -1, best = -1; + for (const [id, count] of counts) if (count > best) { best = count; bestId = id; } + unitOwner[unit.id] = bestId; + } + + const adminCenterIndex = new Map(); + for (let id = 0; id < adminCenters.length; id++) { + const c = adminCenters[id]; + if (c && inside(c.x, c.y)) adminCenterIndex.set(id, indexOf(c.x, c.y)); + } + + let splitMunicipalities = 0; + let rejectedMunicipalities = 0; + for (const [id, cells] of area) { + const averageLowland = (lowland.get(id) || 0) / cells; + const averageMountain = (mountain.get(id) || 0) / cells; + if (cells < median * 1.85 || averageLowland < 0.24 || averageMountain > 0.48) { + if (cells >= median * 1.85) rejectedMunicipalities++; + continue; + } + const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id); + const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000); + if (meaningfulNodes.length < 2) { + rejectedMunicipalities++; + continue; + } + let changedHere = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue; + const centerIndex = adminCenterIndex.get(id); + if (centerIndex >= 0 && unit.cells.includes(centerIndex)) continue; + if (unit.classId === 8 || unit.classId === 9) continue; + let bestNeighbor = -1; + let bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent) { + const neighborOwner = unitOwner[neighborId]; + if (neighborOwner < 0 || neighborOwner === id) continue; + const boundaryTarget = edge.target / Math.max(1, edge.count); + const neighbor = compartments[neighborId]; + const nodePull = meaningfulNodes.reduce((best, p) => Math.max(best, 1 / (1 + Math.hypot(p.x - unit.x, p.y - unit.y) / 6)), 0); + const score = edge.count * 0.7 + boundaryTarget * 1.4 + nodePull * 1.2 - Math.max(0, (neighbor?.ridgeExposure || 0) - unit.ridgeExposure) * 0.35; + if (score > bestScore) { bestScore = score; bestNeighbor = neighborOwner; } + } + if (bestNeighbor < 0 || bestScore < 2.2) continue; + for (const ci of unit.cells) { + if (adminId[ci] === id) { + adminId[ci] = bestNeighbor; + changedHere++; + } + } + } + if (changedHere > Math.max(28, cells * 0.035)) splitMunicipalities++; + } + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + let changedCells = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; + return { changedCells, splitMunicipalities, rejectedMunicipalities }; +} + +export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { + return splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters, settlements); +} diff --git a/app.js b/app.js index 6335277..17158eb 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,6 @@ import { generateMap } from "./mapGenerator.js"; import { drawMap } from "./renderer.js"; +import { landuseLabel } from "./landuseCodes.js"; const modes = [ ["all", "All"], @@ -57,6 +58,8 @@ function countText(items) { function getStats(map) { return [ + ["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"], + ["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"], ["Villages", countText(map.villages)], ["Market Towns", countText(map.markets)], ["Castles", countText(map.castles)], @@ -66,6 +69,7 @@ function getStats(map) { ["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"], ["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"], ["Prefectural Capital", map.prefecturalCapital?.name || "-"], + ["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length], ["Modern Cities", countText(map.modernCities)], ["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`], ["Satellite Cities", countText(map.satelliteCities || [])], @@ -148,18 +152,7 @@ function nearestEntity(map, x, y, maxDistance = 5) { } function landuseName(value) { - return { - 0: "Agriculture", - 1: "Plain", - 2: "Old urban area", - 3: "CBD / DID core", - 4: "Suburban urban area", - 5: "Industrial zone", - 6: "Logistics area", - 7: "New town", - 8: "Roadside development", - 9: "Forest / rural land", - }[value] || "Land"; + return landuseLabel(value); } function adminName(map, adminId) { diff --git a/landuseCodes.js b/landuseCodes.js new file mode 100644 index 0000000..acc9888 --- /dev/null +++ b/landuseCodes.js @@ -0,0 +1,37 @@ +export const LANDUSE = Object.freeze({ + RURAL: 0, + FARMLAND: 1, + OLD_URBAN: 2, + CBD: 3, + SUBURB: 4, + INDUSTRIAL: 5, + LOGISTICS: 6, + NEW_TOWN: 7, + ROADSIDE: 8, + FOREST: 9, +}); + +export const LANDUSE_LABELS = Object.freeze({ + [LANDUSE.RURAL]: "Rural / natural land", + [LANDUSE.FARMLAND]: "Farmland", + [LANDUSE.OLD_URBAN]: "Old urban area", + [LANDUSE.CBD]: "CBD / DID core", + [LANDUSE.SUBURB]: "Suburban urban area", + [LANDUSE.INDUSTRIAL]: "Industrial zone", + [LANDUSE.LOGISTICS]: "Logistics area", + [LANDUSE.NEW_TOWN]: "New town", + [LANDUSE.ROADSIDE]: "Roadside development", + [LANDUSE.FOREST]: "Forest / mountain land", +}); + +export function landuseLabel(value) { + return LANDUSE_LABELS[value] || "Land"; +} + +export function isBuiltLanduse(value) { + return value >= LANDUSE.OLD_URBAN && value <= LANDUSE.ROADSIDE; +} + +export function isUrbanResidentialLanduse(value) { + return value === LANDUSE.OLD_URBAN || value === LANDUSE.CBD || value === LANDUSE.SUBURB || value === LANDUSE.NEW_TOWN || value === LANDUSE.ROADSIDE; +} diff --git a/mapAdminStage.js b/mapAdminStage.js index d596b23..6a9730a 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -11,6 +11,15 @@ import { import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js"; import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js"; +const OUTER_ANCHOR_REGION_ID = -2; + +function adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) { + if (prefectureMask[i]) return 0; + const regionalId = prefectureRegionId?.[i] ?? -1; + if (regionalId === 0) return OUTER_ANCHOR_REGION_ID; + return regionalId; +} + function changedCellsSince(before, after, prefectureMask, sea) { let changed = 0; for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++; @@ -580,7 +589,7 @@ function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, c return changed; } -export function generateAdminLayout({ +function generateAdminLayoutForMask({ seed, prefectureMask, sea, @@ -617,8 +626,8 @@ export function generateAdminLayout({ : ridgeField; const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); - const compartmentMultiplier = clamp(3.5 + rand(seed, 1320) * 2.0, 3.5, 5.5); - let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240); + const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0); + let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360); let adminCentersRaw = buildLowlandAdminSeeds({ seed, targetMunicipalityCount, @@ -648,6 +657,7 @@ export function generateAdminLayout({ seed, targetMunicipalityCount, targetCompartmentCount, + maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)), }); const adminId = compartmentAssignment.adminId; let previousSnapshot = new Int16Array(adminId); @@ -876,3 +886,179 @@ export function generateAdminLayout({ return { adminCentersRaw, adminId, adminBorders, adminDebug }; } + + +function filterPointsForMask(points = [], mask, sea) { + return (points || []).filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); +} + +function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) { + const mask = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + mask[i] = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) === regionId ? 1 : 0; + } + return mask; +} + +function maskLandArea(mask, sea) { + let area = 0; + for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++; + return area; +} + +function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) { + const ids = new Set(); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const id = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId); + if (id >= 0 || id === OUTER_ANCHOR_REGION_ID) ids.add(id); + } + return [...ids].sort((a, b) => a - b); +} + +export function generateAdminLayout(context) { + const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope } = context; + const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) + .filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120); + + if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context); + + const combinedAdminId = new Int16Array(SIZE); + combinedAdminId.fill(-1); + const combinedHumanMask = new Uint8Array(SIZE); + const combinedCenters = []; + const combinedCompartmentBorders = []; + const perRegion = []; + let idOffset = 0; + + for (const regionId of regionIds) { + const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId); + const regionArea = maskLandArea(regionMask, sea); + if (regionArea < 120) continue; + + const localContext = { + ...context, + seed: (context.seed + regionId * 10007) >>> 0, + prefectureMask: regionMask, + modernCities: filterPointsForMask(context.modernCities, regionMask, sea), + satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea), + newTowns: filterPointsForMask(context.newTowns, regionMask, sea), + markets: filterPointsForMask(context.markets, regionMask, sea), + villages: filterPointsForMask(context.villages, regionMask, sea), + ports: filterPointsForMask(context.ports, regionMask, sea), + stations: filterPointsForMask(context.stations, regionMask, sea), + industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea), + logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea), + }; + + const local = generateAdminLayoutForMask(localContext); + if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders); + let localMaxAdminId = -1; + for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i]; + const localSlotCount = Math.max(local.adminCentersRaw?.length || 0, localMaxAdminId + 1); + const localCenters = []; + for (let localAdminId = 0; localAdminId < localSlotCount; localAdminId++) { + let center = local.adminCentersRaw?.[localAdminId]; + if (!center) { + let sx = 0, sy = 0, count = 0, bestI = -1, bestScore = -INF; + for (let i = 0; i < SIZE; i++) { + if (!regionMask[i] || sea[i] || local.adminId?.[i] !== localAdminId) continue; + const [x, y] = xyOf(i); + sx += x; + sy += y; + count++; + const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2; + if (score > bestScore) { bestScore = score; bestI = i; } + } + if (count && bestI >= 0) { + const [bx, by] = xyOf(bestI); + center = { x: bx, y: by, score: bestScore, seedKind: "generatedAdminSlot", invisibleLowlandAdminSeed: true }; + } + } + if (!center) center = { x: 0, y: 0, score: 0, seedKind: "emptyAdminSlot", invisibleLowlandAdminSeed: true }; + localCenters.push({ + ...center, + regionId, + localAdminId, + adminIdOffset: idOffset, + }); + } + combinedCenters.push(...localCenters); + + for (let i = 0; i < SIZE; i++) { + if (!regionMask[i] || sea[i]) continue; + combinedHumanMask[i] = 1; + const localId = local.adminId?.[i] ?? -1; + if (localId >= 0) combinedAdminId[i] = localId + idOffset; + } + + perRegion.push({ + regionId, + area: regionArea, + centerCount: localCenters.length, + municipalityCount: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || new Set([...local.adminId].filter((id, i) => id >= 0 && regionMask[i] && !sea[i])).size, + naturalCompartmentCount: local.adminDebug?.naturalCompartmentCount || 0, + targetNaturalCompartmentCount: local.adminDebug?.targetNaturalCompartmentCount || 0, + averageCompartmentArea: local.adminDebug?.averageCompartmentArea || 0, + maxCompartmentArea: local.adminDebug?.maxCompartmentArea || 0, + maxCompartmentElongation: local.adminDebug?.maxCompartmentElongation || 1, + worstNaturalCompartments: local.adminDebug?.worstNaturalCompartments || [], + singleCompartmentMunicipalityRatio: local.adminDebug?.singleCompartmentMunicipalityRatio || 0, + }); + idOffset += localSlotCount; + } + + const leftoverByRegion = new Map(); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const regionId = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId); + if ((regionId < 0 && regionId !== OUTER_ANCHOR_REGION_ID) || combinedAdminId[i] >= 0) continue; + if (!leftoverByRegion.has(regionId)) leftoverByRegion.set(regionId, []); + leftoverByRegion.get(regionId).push(i); + } + for (const [regionId, cells] of leftoverByRegion) { + let sx = 0, sy = 0, bestI = cells[0], bestScore = -INF; + for (const i of cells) { + const [x, y] = xyOf(i); + sx += x; + sy += y; + const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2; + if (score > bestScore) { bestScore = score; bestI = i; } + } + const [cx, cy] = xyOf(bestI); + const id = combinedCenters.length; + combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true }); + for (const i of cells) { + combinedHumanMask[i] = 1; + combinedAdminId[i] = id; + } + perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true }); + } + + const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask); + const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size; + const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0); + const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0); + const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0); + const weightedSingleRatio = perRegion.reduce((sum, row) => sum + (row.singleCompartmentMunicipalityRatio || 0) * (row.municipalityCount || 0), 0); + const adminDebug = { + multiRegionAdmin: true, + adminRegionCount: perRegion.length, + perRegion, + finalMunicipalityCount: totalMunicipalityCount, + actualMunicipalityCount: totalMunicipalityCount, + candidateSeedCount: combinedCenters.length, + naturalCompartmentCount: totalNaturalCompartmentCount, + compartmentCount: totalNaturalCompartmentCount, + targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount, + averageCompartmentArea: totalNaturalCompartmentCount ? weightedCompartmentArea / totalNaturalCompartmentCount : 0, + maxCompartmentArea: Math.max(0, ...perRegion.map((row) => row.maxCompartmentArea || 0)), + maxCompartmentElongation: Math.max(1, ...perRegion.map((row) => row.maxCompartmentElongation || 1)), + averageCompartmentsPerMunicipality: totalMunicipalityCount ? totalNaturalCompartmentCount / totalMunicipalityCount : 0, + singleCompartmentMunicipalityRatio: totalMunicipalityCount ? weightedSingleRatio / totalMunicipalityCount : 0, + compartmentBorders: combinedCompartmentBorders, + }; + + return { adminCentersRaw: combinedCenters, adminId: combinedAdminId, adminBorders, adminDebug }; +} diff --git a/mapAdminStage.nolog.js b/mapAdminStage.nolog.js new file mode 100644 index 0000000..6a9730a --- /dev/null +++ b/mapAdminStage.nolog.js @@ -0,0 +1,1064 @@ +import { + applyLandscapeUnitAdminPartition, + assignAdminRegionsFromNaturalCompartments, + lockSmallUrbanComponentsToMunicipality, + mergeTinyMunicipalities, + removeMunicipalExclaves, + smoothAdminRegionsTerrainAware, + splitOversizedLowlandMunicipalities, + snapAdminBoundariesToTerrain, +} from "./adminRegions.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js"; +import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js"; + +const OUTER_ANCHOR_REGION_ID = -2; + +function adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) { + if (prefectureMask[i]) return 0; + const regionalId = prefectureRegionId?.[i] ?? -1; + if (regionalId === 0) return OUTER_ANCHOR_REGION_ID; + return regionalId; +} + +function changedCellsSince(before, after, prefectureMask, sea) { + let changed = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++; + return changed; +} + +function municipalityAreaById(adminId, prefectureMask, sea) { + const area = new Map(); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + area.set(adminId[i], (area.get(adminId[i]) || 0) + 1); + } + return area; +} + +function isProtectedAdminSeed(seed) { + if (!seed) return false; + if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true; + if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true; + if (seed.seedKind === "port" && seed.portClass === "major") return true; + if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true; + return false; +} + +function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) { + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + const lifecycle = adminCenters.map((center, id) => { + const protectedSeed = isProtectedAdminSeed(center); + const area = areaById.get(id) || 0; + const enoughArea = area >= (protectedSeed ? 28 : minArea); + return { + id, + protected: protectedSeed, + area, + state: enoughArea || protectedSeed ? "survived" : "pending", + }; + }); + return lifecycle; +} + +function activeSeedIds(seedLifecycle) { + return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id)); +} + +function dominantCompartmentOwners(compartments, adminId) { + const owner = new Int16Array(compartments.length); + owner.fill(-1); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const counts = new Map(); + for (const i of unit.cells) { + const id = adminId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + let bestId = -1, best = -1; + for (const [id, count] of counts) if (count > best) { best = count; bestId = id; } + owner[unit.id] = bestId; + } + return owner; +} + +function applyCompartmentOwners(adminId, compartments, owner) { + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const id = owner[unit.id]; + if (id < 0) continue; + for (const i of unit.cells) adminId[i] = id; + } +} + +function absorbSeedCompartments(adminId, compartments, seedLifecycle) { + const owner = dominantCompartmentOwners(compartments, adminId); + const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id)); + let changed = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue; + let bestId = -1, bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent) { + const candidate = owner[neighborId]; + if (candidate < 0 || absorbed.has(candidate)) continue; + const neighbor = compartments[neighborId]; + const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002; + if (score > bestScore) { bestScore = score; bestId = candidate; } + } + if (bestId < 0) continue; + owner[unit.id] = bestId; + changed += unit.area; + } + applyCompartmentOwners(adminId, compartments, owner); + return changed; +} + +function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) { + const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields; + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + const areas = [...areaById.values()].sort((a, b) => a - b); + const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0; + if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 }; + const owner = dominantCompartmentOwners(compartments, adminId); + const unitsByOwner = new Map(); + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] < 0) continue; + if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []); + unitsByOwner.get(owner[unit.id]).push(unit); + } + const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected); + let changedCells = 0; + let splitMunicipalities = 0; + let pendingSeedsUsed = 0; + for (const [id, units] of unitsByOwner) { + const area = areaById.get(id) || 0; + if (area < Math.max(260, median * 1.45) || units.length < 6) continue; + let lowland = 0, rough = 0; + for (const unit of units) { + for (const i of unit.cells) { + lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10; + rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30; + } + } + if (lowland / area < 0.26 || rough / area > 0.48) continue; + const localPending = pending.filter((seed) => { + const center = adminCenters[seed.id]; + if (!center || !inside(center.x, center.y)) return false; + const centerOwner = adminId[indexOf(center.x, center.y)]; + return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28; + }); + const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id); + if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue; + let municipalitySplit = false; + for (const seed of localPending.slice(0, 3)) { + const center = adminCenters[seed.id]; + if (!center) continue; + const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180); + let claimed = 0; + const candidates = units + .filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9) + .map((unit) => ({ + unit, + score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3, + })) + .sort((a, b) => a.score - b.score); + if (candidates.length < 2) continue; + for (const { unit } of candidates) { + if (claimed >= targetArea && claimed >= 2) break; + owner[unit.id] = seed.id; + claimed += unit.area; + changedCells += unit.area; + } + if (claimed >= 45) { + seed.state = "survived"; + seed.area = claimed; + pendingSeedsUsed++; + municipalitySplit = true; + } + } + if (municipalitySplit) splitMunicipalities++; + } + applyCompartmentOwners(adminId, compartments, owner); + return { changedCells, splitMunicipalities, pendingSeedsUsed }; +} + +function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) { + const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields; + let areaById = municipalityAreaById(adminId, prefectureMask, sea); + let currentCount = areaById.size; + if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 }; + const owner = dominantCompartmentOwners(compartments, adminId); + let changedCells = 0; + let promotedSeeds = 0; + const pending = seedLifecycle + .filter((seed) => seed.state === "pending" && !seed.protected) + .sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0)); + for (const seed of pending) { + if (currentCount >= targetMinCount) break; + const center = adminCenters[seed.id]; + if (!center || !inside(center.x, center.y)) continue; + const existingArea = areaById.get(seed.id) || 0; + if (existingArea >= 12) { + seed.state = "survived"; + seed.area = existingArea; + promotedSeeds++; + continue; + } + const candidates = compartments + .filter((unit) => { + if (!unit || unit.area === 0) return false; + const currentOwner = owner[unit.id]; + if (currentOwner < 0 || currentOwner === seed.id) return false; + const ownerArea = areaById.get(currentOwner) || 0; + if (ownerArea < 90) return false; + const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15; + if (lowlandFit < 0.26) return false; + return Math.hypot(unit.x - center.x, unit.y - center.y) < 36; + }) + .map((unit) => ({ + unit, + score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2, + })) + .sort((a, b) => a.score - b.score); + if (candidates.length === 0) continue; + let claimed = 0; + for (const { unit } of candidates) { + const currentOwner = owner[unit.id]; + if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue; + owner[unit.id] = seed.id; + areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area); + areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area); + claimed += unit.area; + changedCells += unit.area; + if (claimed >= 55) break; + } + if (claimed >= 25) { + seed.state = "survived"; + seed.area = areaById.get(seed.id) || claimed; + promotedSeeds++; + currentCount++; + } + } + applyCompartmentOwners(adminId, compartments, owner); + return { changedCells, promotedSeeds }; +} + +function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) { + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + let currentCount = areaById.size; + if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 }; + const owner = dominantCompartmentOwners(compartments, adminId); + let changedCells = 0; + let restoredSeeds = 0; + const missing = seedLifecycle + .filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0) + .sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0)); + for (const seed of missing) { + if (currentCount >= targetMinCount) break; + const center = adminCenters[seed.id]; + if (!center || !inside(center.x, center.y)) continue; + const candidates = compartments + .filter((unit) => { + if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false; + const currentOwner = owner[unit.id]; + if (currentOwner < 0 || currentOwner === seed.id) return false; + if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false; + return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected); + }) + .map((unit) => ({ + unit, + score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0), + })) + .sort((a, b) => a.score - b.score); + if (candidates.length === 0) continue; + const unit = candidates[0].unit; + const oldOwner = owner[unit.id]; + if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue; + owner[unit.id] = seed.id; + const claimed = unit.area; + areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed); + changedCells += claimed; + areaById.set(seed.id, claimed); + seed.area = claimed; + restoredSeeds++; + currentCount++; + } + applyCompartmentOwners(adminId, compartments, owner); + return { changedCells, restoredSeeds }; +} + +function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) { + let landCells = 0; + let habitableCells = 0; + let lowlandCells = 0; + let coastlineComplexity = 0; + let mountainCells = 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 (!prefectureMask[i] || sea[i]) continue; + landCells++; + if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++; + if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++; + if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++; + for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { + const ni = indexOf(nx, ny); + if (sea[ni]) { + coastlineComplexity += 1 + coastalLowland[i] * 0.8; + break; + } + } + } + } + const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length; + const settlementWeight = modernCities.length * 1.6 + markets.length * 1.0 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.25; + const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); + const mountainRatio = landCells ? mountainCells / landCells : 0; + const lowlandBonus = Math.min(7, lowlandCells / 430); + const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2); + return clamp(target, 20, 50); +} + +function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) { + const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10); + const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0; + return clamp( + lowRelief * 0.25 + + plain[i] * 0.28 + + basinField[i] * 0.24 + + coastalLowland[i] * 0.24 + + settlementScore[i] * 0.30 + + populationDensity[i] * 0.32 + + roadInfluence[i] * 0.16 + + railInfluence2[i] * 0.16 + + (stationInfluence?.[i] || 0) * 0.18 + + landuseFit - + Math.max(0, elevation[i] - 0.62) * 1.2 - + Math.max(0, ridgeField[i] - 0.54) * 0.9 + ); +} + +function buildLowlandAdminSeeds({ + seed, + targetMunicipalityCount, + prefectureMask, + sea, + elevation, + slope, + ridgeField, + plain, + basinField, + coastalLowland, + settlementScore, + populationDensity, + roadInfluence, + railInfluence2, + stationInfluence, + landuse, + modernCities, + satelliteCities, + markets, + ports, + newTowns, + stations, +}) { + const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }; + function validLowlandPoint(p, strict = true) { + if (!p || !inside(p.x, p.y)) return false; + const i = indexOf(p.x, p.y); + if (!prefectureMask[i] || sea[i]) return false; + const score = lowlandAdminSeedScore(i, fields); + const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62; + return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major"); + } + const realSeeds = []; + for (const city of modernCities || []) { + if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue; + if (!city.isPrefecturalCapital && (city.population || 0) < 85000) continue; + const i = indexOf(city.x, city.y); + realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" }); + } + for (const city of satelliteCities || []) { + if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue; + const i = indexOf(city.x, city.y); + realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" }); + } + for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) { + if (!validLowlandPoint(p, true)) continue; + const i = indexOf(p.x, p.y); + const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0; + realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" }); + } + const picked = pickEntities(realSeeds, { + max: targetMunicipalityCount, + minDistance: 5 + Math.floor(rand(seed, 1302) * 3), + threshold: 0.62, + seed: seed + 1300, + jitter: 0.025, + }); + const invisibleCandidates = []; + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045; + if (score < 0.48) continue; + const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7)); + if (insideDenseCore) continue; + invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" }); + } + } + if (picked.length < targetMunicipalityCount) { + const extra = pickEntities(invisibleCandidates, { + max: targetMunicipalityCount - picked.length, + minDistance: 5, + threshold: 0.48, + seed: seed + 1304, + jitter: 0.02, + }); + for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p); + } + if (picked.length < Math.min(targetMunicipalityCount, 20)) { + const relaxed = pickEntities(invisibleCandidates, { + max: Math.min(targetMunicipalityCount, 20) - picked.length, + minDistance: 4, + threshold: 0.38, + seed: seed + 1305, + jitter: 0.02, + }); + for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p); + } + return picked.slice(0, targetMunicipalityCount); +} + +function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { + if (!city || !inside(city.x, city.y)) return 0; + const start = indexOf(city.x, city.y); + if (!prefectureMask[start] || sea[start]) return 0; + const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7)); + const seen = new Uint8Array(SIZE); + const queue = [start]; + seen[start] = 1; + let area = 0; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + const d = Math.hypot(x - city.x, y - city.y); + if (d > radius) continue; + const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18; + if (!urban) continue; + area++; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + return area; +} + +function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) { + if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 }; + const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); + let maxBarrier = 0; + let lowUrbanRun = 0; + let bestLowUrbanRun = 0; + let densitySum = 0; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a.x + (b.x - a.x) * t); + const y = Math.round(a.y + (b.y - a.y) * t); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42); + maxBarrier = Math.max(maxBarrier, barrier); + densitySum += populationDensity[i]; + const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20; + if (urban) lowUrbanRun = 0; + else { + lowUrbanRun++; + bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun); + } + } + return { + separatedByBarrier: maxBarrier > 0.56, + ruralGap: bestLowUrbanRun >= 4, + averageDensity: densitySum / (steps + 1), + maxBarrier, + }; +} + +function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) { + let independent = 0; + let attached = 0; + for (const sat of satelliteCities || []) { + if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue; + const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0]; + const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99; + const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse); + const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity); + const i = indexOf(sat.x, sat.y); + const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier; + const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000); + let municipalityClass = "independentSatelliteMunicipality"; + if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent"; + else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict"; + else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality"; + else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality"; + + sat.municipalityClass = municipalityClass; + sat.parentX = parent?.x; + sat.parentY = parent?.y; + sat.parentAdminHint = -1; + sat.distinctUrbanComponentArea = urbanArea; + sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap; + sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360); + if (municipalityClass === "independentSatelliteMunicipality") independent++; + else attached++; + } + return { independent, attached }; +} + +function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) { + const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context; + if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0; + const start = indexOf(satellite.x, satellite.y); + if (!prefectureMask[start] || sea[start]) return 0; + const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520); + const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality" + ? Math.min(130, targetAreaBase * 0.55) + : satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict" + ? Math.min(190, targetAreaBase * 0.62) + : targetAreaBase; + const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32; + const heap = new MinHeap(); + const best = new Float32Array(SIZE); + best.fill(INF); + heap.push({ i: start, f: 0 }); + best[start] = 0; + const claimed = []; + while (heap.length > 0 && claimed.length < targetArea) { + const cur = heap.pop(); + if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; + const [x, y] = xyOf(cur.i); + const d = Math.hypot(x - satellite.x, y - satellite.y); + if (!prefectureMask[cur.i] || sea[cur.i]) continue; + let invadesOtherCore = false; + for (const city of modernCities || []) { + if (!city || (city.population || 0) < 140000) continue; + if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue; + if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) { + invadesOtherCore = true; + break; + } + } + if (invadesOtherCore) continue; + const compatible = d <= (satellite.urbanRadius || 5) * 1.25 || + [2, 3, 4, 7, 8].includes(landuse[cur.i]) || + populationDensity[cur.i] > 0.12 || + roadInfluence[cur.i] > 0.12 || + railInfluence2[cur.i] > 0.10 || + stationInfluence?.[cur.i] > 0.10 || + basinField[cur.i] > 0.22 || + valleyField[cur.i] > 0.24 || + coastalLowland[cur.i] > 0.20; + if (!compatible && claimed.length > targetArea * 0.55) continue; + claimed.push(cur.i); + for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0); + const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32; + const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9); + const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step; + if (nd < best[ni]) { + best[ni] = nd; + heap.push({ i: ni, f: nd }); + } + } + } + let changed = 0; + for (const i of claimed) { + if (adminId[i] !== targetAdmin) changed++; + adminId[i] = targetAdmin; + } + return changed; +} + +function generateAdminLayoutForMask({ + seed, + prefectureMask, + sea, + elevation, + slope, + river, + ridgeField, + naturalBarrierScore, + valleyField, + basinField, + coastalLowland, + flowAccum, + plain, + agriculture, + settlementScore, + populationDensity, + stationInfluence, + roadInfluence, + railInfluence2, + villageInfluence, + landuse, + modernCities, + satelliteCities, + newTowns, + markets, + villages, + ports, + stations, + industrialZones, + logisticsParks, +}) { + const boundaryRidgeField = naturalBarrierScore + ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) + : ridgeField; + const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); + const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); + const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0); + let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360); + let adminCentersRaw = buildLowlandAdminSeeds({ + seed, + targetMunicipalityCount, + prefectureMask, + sea, + elevation, + slope, + ridgeField: boundaryRidgeField, + plain, + basinField, + coastalLowland, + settlementScore, + populationDensity, + roadInfluence, + railInfluence2, + stationInfluence, + landuse, + modernCities, + satelliteCities, + markets, + ports, + newTowns, + stations, + }); + if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120); + const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, { + seed, + targetMunicipalityCount, + targetCompartmentCount, + maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)), + }); + const adminId = compartmentAssignment.adminId; + let previousSnapshot = new Int16Array(adminId); + const adminDebug = { + changedAfterSmooth: 0, + changedAfterUrbanLock: 0, + changedAfterSmallUrbanLock: 0, + changedAfterInitialMerge: 0, + changedAfterInitialExclaveRemoval: 0, + changedAfterLandscapePartition: 0, + changedAfterSnap: 0, + changedAfterOversizedRuralSplit: 0, + changedAfterFinalExclaveRemoval: 0, + changedAfterFinalMerge: 0, + targetMunicipalityCount, + actualMunicipalityCount: 0, + municipalityCountReason: "habitable cells, settlement weight, coastline complexity, basin/lowland bonus, and mountain-ratio adjustment", + changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0, + oversizedRuralSplits: 0, + oversizedLowlandSplits: 0, + ruralSplitsAccepted: 0, + ruralSplitsRejected: 0, + targetNaturalCompartmentCount: targetCompartmentCount, + compartmentMultiplier, + lowlandAdminSeedCount: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).length, + lowlandAdminSeeds: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).map((p) => ({ x: p.x, y: p.y })), + realAdminSeedCount: adminCentersRaw.filter((p) => !p.invisibleLowlandAdminSeed).length, + highMountainAdminSeedCount: adminCentersRaw.filter((p) => { + const i = indexOf(p.x, p.y); + return elevation[i] > 0.70 || slope[i] > 0.52 || boundaryRidgeField[i] > 0.62; + }).length, + candidateSeedCount: adminCentersRaw.length, + protectedSeedCount: adminCentersRaw.filter(isProtectedAdminSeed).length, + survivedSeedCount: 0, + pendingSeedCount: 0, + absorbedSeedCount: 0, + pendingSeedsUsedForLowlandSplit: 0, + finalMunicipalityCount: 0, + finalTinyMunicipalityCount: 0, + seedCellRevivalCount: 0, + satelliteMunicipalitiesCreated: adminCentersRaw.filter((p) => p.protectedSatellite).length, + satelliteMunicipalitiesMerged: 0, + satelliteMunicipalitiesExpanded: 0, + satelliteMunicipalitiesTooSmall: 0, + averageSatelliteMunicipalityArea: 0, + minSatelliteMunicipalityArea: 0, + satelliteMunicipalityAreaByNameOrIndex: {}, + independentSatelliteMunicipalities: satelliteClassificationDebug.independent, + attachedSatelliteDistricts: satelliteClassificationDebug.attached, + satelliteMunicipalityStats: satelliteClassificationDebug, + ...compartmentAssignment.debug, + }; + const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35); + const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, { + plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation, + }, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, [...(satelliteCities || []), ...newTowns, ...markets, ...villages, ...ports]); + adminDebug.changedAfterPendingSeedLowlandSplit = pendingSplitDebug.changedCells; + adminDebug.pendingSeedsUsedForLowlandSplit = pendingSplitDebug.pendingSeedsUsed; + adminDebug.oversizedLowlandSplits += pendingSplitDebug.splitMunicipalities; + const pendingPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, { + plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation, + }, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(24, targetMunicipalityCount)); + adminDebug.changedAfterPendingSeedCountRepair = pendingPromotionDebug.changedCells; + adminDebug.pendingSeedsPromotedForCount = pendingPromotionDebug.promotedSeeds; + let areaAfterPendingSplit = municipalityAreaById(adminId, prefectureMask, sea); + for (const seedState of seedLifecycle) { + if (seedState.state !== "pending") continue; + seedState.area = areaAfterPendingSplit.get(seedState.id) || 0; + if (seedState.area >= 35) seedState.state = "survived"; + else seedState.state = "absorbed"; + } + adminDebug.changedAfterAbsorbingSeeds = absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle); + let activeAdminIds = activeSeedIds(seedLifecycle); + function markChanged(field) { + adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea); + previousSnapshot = new Int16Array(adminId); + } + smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2); + markChanged("changedAfterSmooth"); + + function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { + if (!city || !prefectureMask[indexOf(city.x, city.y)]) return; + let bestAdmin = -1; + let bestD = INF; + adminCentersRaw.forEach((center, id) => { + if (!activeAdminIds.has(id)) return; + const d = Math.hypot(center.x - city.x, center.y - city.y); + if (d < bestD) { bestD = d; bestAdmin = id; } + }); + if (bestAdmin < 0) return; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8)); + if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin; + } + } + } + for (const city of modernCities) { + const radius = (city.population || 0) >= 500000 + ? clamp(17 + Math.sqrt(city.population) / 120, 20, 38) + : clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11); + lockUrbanClusterToMunicipality(city, radius, true); + } + for (const sat of satelliteCities || []) { + if (!prefectureMask[indexOf(sat.x, sat.y)]) continue; + let bestAdmin = -1; + if (sat.municipalityClass === "independentSatelliteMunicipality") { + let bestD = INF; + adminCentersRaw.forEach((center, id) => { + if (!activeAdminIds.has(id)) return; + const d = Math.hypot(center.x - sat.x, center.y - sat.y); + if (d < bestD) { bestD = d; bestAdmin = id; } + }); + } else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) { + bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)]; + } + if (bestAdmin < 0) continue; + sat.parentAdminHint = bestAdmin; + const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, { + prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, + landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, + }); + if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++; + } + markChanged("changedAfterUrbanLock"); + lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520); + lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); + markChanged("changedAfterSmallUrbanLock"); + const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id)); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() }); + markChanged("changedAfterInitialMerge"); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180); + markChanged("changedAfterInitialExclaveRemoval"); + // The initial compartment graph assignment is now the primary natural partition. + // Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners. + markChanged("changedAfterLandscapePartition"); + const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]); + adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells; + adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities; + adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities; + adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities; + adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities; + adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0; + previousSnapshot = new Int16Array(adminId); + snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2); + markChanged("changedAfterSnap"); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360); + markChanged("changedAfterFinalExclaveRemoval"); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() }); + markChanged("changedAfterFinalMerge"); + + for (const sat of satelliteCities || []) { + if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue; + const targetAdmin = adminId[indexOf(sat.x, sat.y)]; + if (targetAdmin < 0) continue; + expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, { + prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, + landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, + }); + } + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 260); + const finalPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, { + plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation, + }, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount)); + adminDebug.changedAfterFinalPendingSeedCountRepair = finalPromotionDebug.changedCells; + adminDebug.pendingSeedsPromotedForCount += finalPromotionDebug.promotedSeeds; + const restoredSeedDebug = restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount)); + adminDebug.changedAfterSurvivedSeedCompartmentRestore = restoredSeedDebug.changedCells; + adminDebug.survivedSeedsRestoredByCompartment = restoredSeedDebug.restoredSeeds; + let finalAreaBySeed = municipalityAreaById(adminId, prefectureMask, sea); + for (const seedState of seedLifecycle) { + seedState.area = finalAreaBySeed.get(seedState.id) || 0; + if (!seedState.protected && seedState.state === "pending" && seedState.area < 25) seedState.state = "absorbed"; + } + absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle); + activeAdminIds = activeSeedIds(seedLifecycle); + + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + const satelliteAreas = []; + (satelliteCities || []).forEach((sat, index) => { + if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return; + const id = adminId[indexOf(sat.x, sat.y)]; + const area = areaById.get(id) || 0; + const key = sat.name || `satellite-${index}`; + adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area; + if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) { + sat.municipalityClass = "smallTownAttachedToRuralMunicipality"; + adminDebug.satelliteMunicipalitiesTooSmall++; + return; + } + if (sat.municipalityClass === "independentSatelliteMunicipality") { + satelliteAreas.push(area); + if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++; + } + }); + adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0; + adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0; + adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length; + const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {}; + Object.assign(adminDebug, landscapeDebug); + adminDebug.targetNaturalCompartmentCount = compartmentAssignment.debug?.targetNaturalCompartmentCount || targetCompartmentCount; + adminDebug.naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0; + adminDebug.compartmentCount = adminDebug.naturalCompartmentCount; + adminDebug.averageCompartmentsPerMunicipality = compartmentAssignment.debug?.averageCompartmentsPerMunicipality || adminDebug.averageCompartmentsPerMunicipality || 0; + adminDebug.singleCompartmentMunicipalityRatio = compartmentAssignment.debug?.singleCompartmentMunicipalityRatio ?? adminDebug.singleCompartmentMunicipalityRatio ?? 0; + adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size; + adminDebug.averageCompartmentsPerMunicipality = adminDebug.actualMunicipalityCount ? adminDebug.naturalCompartmentCount / adminDebug.actualMunicipalityCount : 0; + adminDebug.survivedSeedCount = seedLifecycle.filter((seed) => seed.state === "survived").length; + adminDebug.pendingSeedCount = seedLifecycle.filter((seed) => seed.state === "pending").length; + adminDebug.absorbedSeedCount = seedLifecycle.filter((seed) => seed.state === "absorbed").length; + adminDebug.finalMunicipalityCount = adminDebug.actualMunicipalityCount; + adminDebug.finalTinyMunicipalityCount = [...areaById.values()].filter((area) => area > 0 && area < 8).length; + adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area })); + adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0; + adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0; + const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); + + + return { adminCentersRaw, adminId, adminBorders, adminDebug }; +} + + +function filterPointsForMask(points = [], mask, sea) { + return (points || []).filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); +} + +function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) { + const mask = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + mask[i] = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) === regionId ? 1 : 0; + } + return mask; +} + +function maskLandArea(mask, sea) { + let area = 0; + for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++; + return area; +} + +function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) { + const ids = new Set(); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const id = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId); + if (id >= 0 || id === OUTER_ANCHOR_REGION_ID) ids.add(id); + } + return [...ids].sort((a, b) => a - b); +} + +export function generateAdminLayout(context) { + const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope } = context; + const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) + .filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120); + + if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context); + + const combinedAdminId = new Int16Array(SIZE); + combinedAdminId.fill(-1); + const combinedHumanMask = new Uint8Array(SIZE); + const combinedCenters = []; + const combinedCompartmentBorders = []; + const perRegion = []; + let idOffset = 0; + + for (const regionId of regionIds) { + const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId); + const regionArea = maskLandArea(regionMask, sea); + if (regionArea < 120) continue; + + const localContext = { + ...context, + seed: (context.seed + regionId * 10007) >>> 0, + prefectureMask: regionMask, + modernCities: filterPointsForMask(context.modernCities, regionMask, sea), + satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea), + newTowns: filterPointsForMask(context.newTowns, regionMask, sea), + markets: filterPointsForMask(context.markets, regionMask, sea), + villages: filterPointsForMask(context.villages, regionMask, sea), + ports: filterPointsForMask(context.ports, regionMask, sea), + stations: filterPointsForMask(context.stations, regionMask, sea), + industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea), + logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea), + }; + + const local = generateAdminLayoutForMask(localContext); + if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders); + let localMaxAdminId = -1; + for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i]; + const localSlotCount = Math.max(local.adminCentersRaw?.length || 0, localMaxAdminId + 1); + const localCenters = []; + for (let localAdminId = 0; localAdminId < localSlotCount; localAdminId++) { + let center = local.adminCentersRaw?.[localAdminId]; + if (!center) { + let sx = 0, sy = 0, count = 0, bestI = -1, bestScore = -INF; + for (let i = 0; i < SIZE; i++) { + if (!regionMask[i] || sea[i] || local.adminId?.[i] !== localAdminId) continue; + const [x, y] = xyOf(i); + sx += x; + sy += y; + count++; + const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2; + if (score > bestScore) { bestScore = score; bestI = i; } + } + if (count && bestI >= 0) { + const [bx, by] = xyOf(bestI); + center = { x: bx, y: by, score: bestScore, seedKind: "generatedAdminSlot", invisibleLowlandAdminSeed: true }; + } + } + if (!center) center = { x: 0, y: 0, score: 0, seedKind: "emptyAdminSlot", invisibleLowlandAdminSeed: true }; + localCenters.push({ + ...center, + regionId, + localAdminId, + adminIdOffset: idOffset, + }); + } + combinedCenters.push(...localCenters); + + for (let i = 0; i < SIZE; i++) { + if (!regionMask[i] || sea[i]) continue; + combinedHumanMask[i] = 1; + const localId = local.adminId?.[i] ?? -1; + if (localId >= 0) combinedAdminId[i] = localId + idOffset; + } + + perRegion.push({ + regionId, + area: regionArea, + centerCount: localCenters.length, + municipalityCount: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || new Set([...local.adminId].filter((id, i) => id >= 0 && regionMask[i] && !sea[i])).size, + naturalCompartmentCount: local.adminDebug?.naturalCompartmentCount || 0, + targetNaturalCompartmentCount: local.adminDebug?.targetNaturalCompartmentCount || 0, + averageCompartmentArea: local.adminDebug?.averageCompartmentArea || 0, + maxCompartmentArea: local.adminDebug?.maxCompartmentArea || 0, + maxCompartmentElongation: local.adminDebug?.maxCompartmentElongation || 1, + worstNaturalCompartments: local.adminDebug?.worstNaturalCompartments || [], + singleCompartmentMunicipalityRatio: local.adminDebug?.singleCompartmentMunicipalityRatio || 0, + }); + idOffset += localSlotCount; + } + + const leftoverByRegion = new Map(); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const regionId = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId); + if ((regionId < 0 && regionId !== OUTER_ANCHOR_REGION_ID) || combinedAdminId[i] >= 0) continue; + if (!leftoverByRegion.has(regionId)) leftoverByRegion.set(regionId, []); + leftoverByRegion.get(regionId).push(i); + } + for (const [regionId, cells] of leftoverByRegion) { + let sx = 0, sy = 0, bestI = cells[0], bestScore = -INF; + for (const i of cells) { + const [x, y] = xyOf(i); + sx += x; + sy += y; + const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2; + if (score > bestScore) { bestScore = score; bestI = i; } + } + const [cx, cy] = xyOf(bestI); + const id = combinedCenters.length; + combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true }); + for (const i of cells) { + combinedHumanMask[i] = 1; + combinedAdminId[i] = id; + } + perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true }); + } + + const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask); + const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size; + const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0); + const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0); + const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0); + const weightedSingleRatio = perRegion.reduce((sum, row) => sum + (row.singleCompartmentMunicipalityRatio || 0) * (row.municipalityCount || 0), 0); + const adminDebug = { + multiRegionAdmin: true, + adminRegionCount: perRegion.length, + perRegion, + finalMunicipalityCount: totalMunicipalityCount, + actualMunicipalityCount: totalMunicipalityCount, + candidateSeedCount: combinedCenters.length, + naturalCompartmentCount: totalNaturalCompartmentCount, + compartmentCount: totalNaturalCompartmentCount, + targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount, + averageCompartmentArea: totalNaturalCompartmentCount ? weightedCompartmentArea / totalNaturalCompartmentCount : 0, + maxCompartmentArea: Math.max(0, ...perRegion.map((row) => row.maxCompartmentArea || 0)), + maxCompartmentElongation: Math.max(1, ...perRegion.map((row) => row.maxCompartmentElongation || 1)), + averageCompartmentsPerMunicipality: totalMunicipalityCount ? totalNaturalCompartmentCount / totalMunicipalityCount : 0, + singleCompartmentMunicipalityRatio: totalMunicipalityCount ? weightedSingleRatio / totalMunicipalityCount : 0, + compartmentBorders: combinedCompartmentBorders, + }; + + return { adminCentersRaw: combinedCenters, adminId: combinedAdminId, adminBorders, adminDebug }; +} diff --git a/mapFeatures.js b/mapFeatures.js index 9cded76..8e2ef10 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -18,6 +18,7 @@ import { samplePath, smoothPathByLineOfSight, } from "./mapGeneratorHelpers.js"; +import { LANDUSE, isBuiltLanduse, isUrbanResidentialLanduse } from "./landuseCodes.js"; export function generateMapFeatures(seed, terrain) { const { @@ -43,6 +44,7 @@ export function generateMapFeatures(seed, terrain) { crossingSuitability, passSuitability, prefectureMask, + prefectureRegionId, } = terrain; function pickPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true }) { @@ -141,13 +143,185 @@ export function generateMapFeatures(seed, terrain) { } } - let villages = pickPoints(settlementScore, { - threshold: 0.32 + rand(seed, 1031) * 0.1, - max: 28 + Math.floor(rand(seed, 1032) * 44), - minDistance: 3 + Math.floor(rand(seed, 1033) * 3), + // Capacity-first settlement context. These rasters are intentionally small + // and reusable: all village, town, city-capacity, density and land-use passes + // should read from the same human-geography interpretation of the terrain. + const developable = new Float32Array(SIZE); + const ruralSuitability = new Float32Array(SIZE); + const townSuitability = new Float32Array(SIZE); + const valleySettlement = new Float32Array(SIZE); + const coastalSettlement = new Float32Array(SIZE); + const confluenceField = new Float32Array(SIZE); + + function localConfluenceScore(x, y) { + let arms = 0; + let strong = 0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const rv = river[indexOf(nx, ny)]; + if (rv > 0.22) arms++; + if (rv > 0.38) strong++; + } + return clamp((arms >= 3 ? 0.16 : arms === 2 ? 0.08 : 0) + strong * 0.035); + } + + 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 depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.85; + const highPenalty = Math.max(0, elevation[i] - 0.56); + const lowSlope = clamp(1 - slope[i] * 2.25); + const confluence = localConfluenceScore(x, y); + confluenceField[i] = confluence; + developable[i] = clamp( + plain[i] * 0.34 + + agriculture[i] * 0.22 + + basinField[i] * 0.22 + + valleyField[i] * 0.24 + + coastalLowland[i] * 0.16 + + depositional * 0.20 + + lowSlope * 0.10 - + slope[i] * 0.84 - + ridgeField[i] * 0.54 - + highPenalty * 1.12 - + floodplain[i] * 0.04 + ); + valleySettlement[i] = clamp( + valleyField[i] * 0.48 + + river[i] * 0.16 + + confluence * 0.72 + + depositional * 0.14 + + basinField[i] * 0.08 + + lowSlope * 0.12 - + slope[i] * 0.58 - + ridgeField[i] * 0.32 - + highPenalty * 0.74 + ); + coastalSettlement[i] = clamp( + coastalLowland[i] * 0.48 + + (portSuitability?.[i] || 0) * 0.28 + + (deltaField?.[i] || 0) * 0.18 + + plain[i] * 0.10 - + slope[i] * 0.52 - + ridgeField[i] * 0.22 + ); + ruralSuitability[i] = clamp( + settlementScore[i] * 0.48 + + agriculture[i] * 0.40 + + developable[i] * 0.22 + + valleySettlement[i] * 0.24 + + coastalSettlement[i] * 0.16 - + Math.max(0, elevation[i] - 0.64) * 0.54 + ); + townSuitability[i] = clamp( + settlementScore[i] * 0.30 + + developable[i] * 0.38 + + valleySettlement[i] * 0.24 + + coastalSettlement[i] * 0.20 + + confluence * 0.34 + + basinField[i] * 0.14 + + plain[i] * 0.10 - + slope[i] * 0.34 - + ridgeField[i] * 0.16 + ); + } + } + + function buildSettlementRegionStats() { + const stats = new Map(); + 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 regionId = regionIdAt(x, y); + if (regionId < 0) continue; + let st = stats.get(regionId); + if (!st) { + st = { area: 0, developable: 0, valley: 0, coast: 0, basin: 0, plain: 0, town: 0 }; + stats.set(regionId, st); + } + st.area++; + if (developable[i] > 0.18) st.developable++; + if (valleySettlement[i] > 0.22) st.valley++; + if (coastalSettlement[i] > 0.24) st.coast++; + if (basinField[i] > 0.22) st.basin++; + if (plain[i] > 0.24) st.plain++; + if (townSuitability[i] > 0.28) st.town++; + } + } + return stats; + } + + const settlementRegionStats = buildSettlementRegionStats(); + + function pickRegionalSettlementPoints(scoreArray, { + totalMax, + minDistance, + seedOffset, + threshold, + quotaForRegion, + kind, + extraScore = () => 0, + predicate = () => true, + }) { + const byRegion = new Map(); + 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] || !predicate(x, y, i)) continue; + const regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055; + if (score < threshold) continue; + if (!byRegion.has(regionId)) byRegion.set(regionId, []); + byRegion.get(regionId).push({ x, y, score, kind, regionId }); + } + } + const picked = []; + for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { + const quota = quotaForRegion(regionId, settlementRegionStats.get(regionId) || { area: 0 }); + if (quota <= 0) continue; + picked.push(...pickEntities(candidates, { + max: quota, + minDistance, + threshold, + seed: seed + seedOffset + regionId * 997, + })); + } + return picked + .sort((a, b) => b.score - a.score) + .slice(0, totalMax) + .sort((a, b) => a.regionId - b.regionId || b.score - a.score); + } + + const villageScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + villageScore[i] = clamp(ruralSuitability[i] * 0.72 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.12); + } + + let villages = pickRegionalSettlementPoints(villageScore, { + threshold: 0.26 + rand(seed, 1031) * 0.055, + totalMax: 105 + Math.floor(rand(seed, 1032) * 35), + minDistance: 4, seedOffset: 1030, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Village" })); + kind: "Village", + quotaForRegion: (regionId, st) => { + if (!st || st.developable < 12) return 0; + const base = 1.8 + st.developable / 58 + st.valley / 32 + st.coast / 42 + st.basin / 70; + const selectedBonus = regionId === 0 ? 7.5 : 0; + return Math.round(clamp(base + selectedBonus + rand(seed, 1035 + regionId * 13) * 2.4, regionId === 0 ? 10 : 2, regionId === 0 ? 26 : 12)); + }, + extraScore: (x, y, i) => confluenceField[i] * 0.10, + }).map((p, n) => { + const i = indexOf(p.x, p.y); + const settlementType = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.44 ? "Coastal Village" : "Village"; + const population = Math.round((350 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.9) * 5200 + ruralSuitability[i] * 3200) / 100) * 100; + return { ...p, kind: settlementType, population }; + }); const marketScore = new Float32Array(SIZE); for (let y = 4; y < MAP_H - 4; y++) { @@ -160,25 +334,51 @@ export function generateMapFeatures(seed, terrain) { for (const v of villages) { const d = Math.hypot(x - v.x, y - v.y); if (d < 24) { - villagePull += 1 / (1 + d); + villagePull += 1 / (1 + d / 2.8); nearbyVillages++; } } let featurePull = 0; - for (const p of [...ports, ...crossings]) featurePull = Math.max(featurePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 3)); - const confluence = river[i] > 0.36 && valleyField[i] > 0.24 ? 0.12 : 0; - marketScore[i] = clamp(villagePull * 1.25 + featurePull * 0.34 + plain[i] * 0.2 + basinField[i] * 0.16 + (depositionalLowland?.[i] || 0) * 0.10 + (deltaField?.[i] || 0) * 0.08 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 - (arcSpineField?.[i] || 0) * 0.08 + nearbyVillages * 0.012); + for (const p of [...ports, ...crossings]) featurePull = Math.max(featurePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 3.2)); + const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.12 : 0; + marketScore[i] = clamp( + townSuitability[i] * 0.66 + + villagePull * 0.56 + + featurePull * 0.26 + + confluenceField[i] * 0.30 + + valleyMouth + + plain[i] * 0.12 + + basinField[i] * 0.12 + + (depositionalLowland?.[i] || 0) * 0.08 + + (deltaField?.[i] || 0) * 0.07 + + nearbyVillages * 0.006 - + slope[i] * 0.22 - + ridgeField[i] * 0.10 - + (arcSpineField?.[i] || 0) * 0.06 + ); } } - let markets = pickPoints(marketScore, { - threshold: 0.2 + rand(seed, 1041) * 0.08, - max: 6 + Math.floor(rand(seed, 1042) * 12), - minDistance: 11, + let markets = pickRegionalSettlementPoints(marketScore, { + threshold: 0.30 + rand(seed, 1041) * 0.055, + totalMax: 34 + Math.floor(rand(seed, 1042) * 16), + minDistance: 8, seedOffset: 1040, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Market Town" })); + kind: "Market Town", + quotaForRegion: (regionId, st) => { + if (!st || st.town < 8) return 0; + const base = 0.8 + st.developable / 230 + st.valley / 120 + st.coast / 140 + st.basin / 160; + const selectedBonus = regionId === 0 ? 3.0 : 0; + return Math.round(clamp(base + selectedBonus + rand(seed, 1045 + regionId * 17) * 1.3, regionId === 0 ? 4 : 1, regionId === 0 ? 10 : 4)); + }, + extraScore: (x, y, i) => (distanceToNearest(ports, x, y) < 7 ? 0.07 : 0) + (distanceToNearest(crossings, x, y) < 5 ? 0.06 : 0), + }).map((p, n) => { + const i = indexOf(p.x, p.y); + const kind = coastalSettlement[i] > 0.46 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town"; + const population = Math.round((4200 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.55) * 26000 + marketScore[i] * 16000) / 1000) * 1000; + return { ...p, kind, population }; + }); const defenseScore = new Float32Array(SIZE); for (let y = 3; y < MAP_H - 3; y++) { @@ -251,68 +451,111 @@ export function generateMapFeatures(seed, terrain) { const i = indexOf(p.x, p.y); if (sea[i]) return 0; const portBonus = p.kind === "Port Town" || p.portClass === "major" || p.portClass === "regional" ? 0.18 : 0; - const historicalBonus = p.kind === "Market City" || p.kind === "Castle Town" ? 0.05 : 0; + const historicalBonus = p.kind === "Market City" || p.kind === "Castle Town" || p.kind === "Market Town" || p.kind === "Valley Market Town" ? 0.07 : 0; return clamp( - plain[i] * 0.46 + - agriculture[i] * 0.18 + - basinField[i] * 0.20 + - coastalLowland[i] * 0.20 + - valleyField[i] * 0.12 + + (developable?.[i] || 0) * 0.56 + + (townSuitability?.[i] || 0) * 0.24 + + plain[i] * 0.22 + + agriculture[i] * 0.10 + + basinField[i] * 0.16 + + coastalLowland[i] * 0.16 + + valleyField[i] * 0.14 + + (confluenceField?.[i] || 0) * 0.16 + portBonus + historicalBonus - - slope[i] * 0.58 - - ridgeField[i] * 0.34 - - Math.max(0, elevation[i] - 0.55) * 1.35 + slope[i] * 0.54 - + ridgeField[i] * 0.32 - + Math.max(0, elevation[i] - 0.55) * 1.25 ); } - function cityPopulationCap(p) { - const i = indexOf(p.x, p.y); - const suitability = urbanSiteSuitability(p); - if (suitability < 0.18 || elevation[i] > 0.66 || slope[i] > 0.82 || ridgeField[i] > 0.72) return 85000; - if (suitability < 0.28 || elevation[i] > 0.60 || slope[i] > 0.62) return 180000; - if (suitability < 0.38) return 420000; - return INF; + function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) { + if (!p || !inside(p.x, p.y)) return 0; + const centerRegion = regionIdAt(p.x, p.y); + let capacity = 0; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = p.x + dx; + const y = p.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const dev = developable?.[i] || urbanSiteSuitability({ x, y }); + if (dev < 0.045) continue; + const radial = clamp(1 - d / Math.max(1, radius)); + const coreDensity = 2600 + 6200 * Math.pow(radial, 1.55); + const suburbanDensity = 650 + 2600 * Math.pow(radial, 0.85); + const terrainMultiplier = clamp(0.58 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.14 - slope[i] * 0.44 - ridgeField[i] * 0.20, 0.28, 1.22); + capacity += dev * (coreDensity * 0.34 + suburbanDensity * 0.66) * terrainMultiplier * densityBias; + } + } + const site = urbanSiteSuitability(p); + const hardSitePenalty = site < 0.18 || elevation[indexOf(p.x, p.y)] > 0.68 || slope[indexOf(p.x, p.y)] > 0.78 || ridgeField[indexOf(p.x, p.y)] > 0.76; + if (hardSitePenalty) capacity = Math.min(capacity, 95000); + else if (site < 0.28) capacity = Math.min(capacity, 220000); + return Math.max(26000, Math.round(capacity / 1000) * 1000); } - let castleTowns = castles.map((c) => ({ x: c.x, y: c.y, score: c.score + 0.45, kind: "Castle Town" })); - const cityCandidates = [ - ...castleTowns.map((p) => ({ ...p, score: p.score + 0.4 })), - ...ports.map((p) => ({ ...p, kind: "Port Town", score: p.score + 0.28 })), - ...markets.map((p) => ({ ...p, kind: "Market City", score: p.score + 0.12 })), - ].map((p) => { - const i = indexOf(p.x, p.y); - const suitability = urbanSiteSuitability(p); - return { - ...p, - urbanSuitability: suitability, - score: p.score + suitability * 0.72 - slope[i] * 0.20 - ridgeField[i] * 0.16 - Math.max(0, elevation[i] - 0.58) * 0.78, - }; - }).filter((p) => p.urbanSuitability >= 0.10 || p.kind === "Castle Town"); + function cityPopulationCap(p) { + const pop = p?.population || 0; + const radius = p?.isPrefecturalCapital ? 34 : p?.isRegionalCapital ? 29 : pop >= 650000 ? 30 : pop >= 250000 ? 24 : pop >= 90000 ? 18 : 13; + const bias = p?.isPrefecturalCapital ? 1.22 : p?.isRegionalCapital ? 1.12 : 1.0; + return estimateUrbanCapacity(p, radius, bias); + } - let modernCities = pickEntities(cityCandidates, { - max: 7 + Math.floor(rand(seed, 1061) * 10), - minDistance: 9, - threshold: 0.33 + rand(seed, 1062) * 0.12, - seed: seed + 1060, - }).map((p, n) => { - const rank = n === 0 ? "Prefectural Capital" : n < 4 ? "Regional Center" : "Small City"; - const r = rand(seed, 1600 + n * 13 + p.x * 3 + p.y); - const rawScale = Math.pow(1 - n / Math.max(1, cityCandidates.length + 1), 1.55) * 0.58 + Math.pow(r, 3.4) * 0.42; - const rankBase = rank === "Prefectural Capital" ? 420000 : rank === "Regional Center" ? 115000 : 26000; - const rankSpread = rank === "Prefectural Capital" ? 1450000 : rank === "Regional Center" ? 520000 : 185000; - const pi = indexOf(p.x, p.y); - const suitability = p.urbanSuitability ?? urbanSiteSuitability(p); - const geographyBoost = clamp(plain[pi] * 0.34 + agriculture[pi] * 0.18 + basinField[pi] * 0.2 + coastalLowland[pi] * 0.18 + valleyField[pi] * 0.12 + suitability * 0.24 + (p.kind === "Port Town" ? 0.22 : 0)); - const rawPopulation = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000; - const population = Math.min(rawPopulation, cityPopulationCap(p)); - const urbanRadius = clamp(7.5 + Math.sqrt(population) / 80 + (rank === "Prefectural Capital" ? 3.0 : rank === "Regional Center" ? 1.5 : 0), 8, 32); - const coreRadius = clamp(2.6 + Math.sqrt(population) / 320, 3, 9); - const urbanWeight = clamp(0.74 + Math.log10(Math.max(10000, population)) * 0.36, 1.15, 3.05); - return { ...p, population, urbanRadius, coreRadius, urbanWeight, rank, kind: p.kind || "City" }; - }); + function regionIdAt(x, y) { + const i = indexOf(x, y); + if (prefectureMask[i]) return 0; + const id = prefectureRegionId?.[i]; + return id !== undefined && id >= 0 ? id : -1; + } - function fallbackCapitalCandidate() { - const pools = [...markets, ...ports, ...villages].filter((p) => p && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); + function inFocusedPrefecture(point) { + if (!point || !inside(point.x, point.y)) return false; + const i = indexOf(point.x, point.y); + return Boolean(prefectureMask[i] && !sea[i]); + } + + function isHumanRegionCell(i) { + return !sea[i] && (prefectureMask[i] || ((prefectureRegionId?.[i] ?? -1) >= 0)); + } + + function inHumanRegion(point) { + return Boolean(point && inside(point.x, point.y) && isHumanRegionCell(indexOf(point.x, point.y))); + } + + function sameGeneratedRegion(a, b) { + if (!a || !b) return false; + const ar = regionIdAt(a.x, a.y); + const br = regionIdAt(b.x, b.y); + return ar >= 0 && ar === br; + } + + function pathTouchesHumanRegion(path) { + return Boolean(path?.some(([x, y]) => inside(x, y) && isHumanRegionCell(indexOf(x, y)))); + } + + function populationDensityProxyForCapital(i) { + return settlementScore[i] * 0.18 + marketScore[i] * 0.12; + } + + function buildRegionLandStats() { + const areaByRegion = new Map(); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const regionId = prefectureMask[i] ? 0 : ((prefectureRegionId?.[i] ?? -1) >= 0 ? prefectureRegionId[i] : -1); + if (regionId < 0) continue; + areaByRegion.set(regionId, (areaByRegion.get(regionId) || 0) + 1); + } + return areaByRegion; + } + + function fallbackCapitalCandidate(regionId = 0) { + const inRegion = (p) => p && regionIdAt(p.x, p.y) === regionId && !sea[indexOf(p.x, p.y)]; + const pools = [...markets, ...ports, ...villages].filter(inRegion); let best = null; let bestScore = -INF; for (const p of pools) { @@ -320,60 +563,381 @@ export function generateMapFeatures(seed, terrain) { const score = urbanSiteSuitability(p) * 1.6 + plain[i] * 0.32 + populationDensityProxyForCapital(i) + (p.kind?.includes("Port") ? 0.18 : 0) + (p.score || 0); if (score > bestScore) { bestScore = score; best = p; } } - if (best) return { ...best, kind: "Market City", population: 360000, urbanRadius: 15, coreRadius: 4.6, urbanWeight: 1.9, score: bestScore }; + if (best) return { ...best, kind: "Market City", score: bestScore, regionId }; for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; + if (regionIdAt(x, y) !== regionId || sea[i]) continue; const score = plain[i] * 0.72 + agriculture[i] * 0.24 + basinField[i] * 0.18 + coastalLowland[i] * 0.14 - slope[i] * 0.72 - ridgeField[i] * 0.32; - if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Market City" }; } + if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Market City", regionId }; } } } - return best ? { ...best, population: 320000, urbanRadius: 14, coreRadius: 4.2, urbanWeight: 1.7 } : null; + return best; } - function populationDensityProxyForCapital(i) { - return settlementScore[i] * 0.18 + marketScore[i] * 0.12; + function buildCityCandidatePool(castleTowns) { + const historicalCandidates = [ + ...castleTowns.map((p) => ({ ...p, score: p.score + 0.4 })), + ...ports.map((p) => ({ ...p, kind: "Port Town", score: p.score + 0.28 })), + ...markets.map((p) => ({ ...p, kind: "Market City", score: p.score + 0.12 })), + ]; + const terrainCandidates = []; + 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 regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const localCapacity = developable[i] * 0.40 + townSuitability[i] * 0.34 + marketScore[i] * 0.16 + confluenceField[i] * 0.08; + const score = localCapacity + settlementScore[i] * 0.22 + plain[i] * 0.12 + basinField[i] * 0.12 + coastalLowland[i] * 0.10 + valleyField[i] * 0.10 - slope[i] * 0.30 - ridgeField[i] * 0.16 + hash2(x, y, seed + 1666) * 0.035; + if (score > 0.29) terrainCandidates.push({ x, y, score, kind: "City Site", regionId }); + } + } + return [...historicalCandidates, ...terrainCandidates].map((p) => { + const i = indexOf(p.x, p.y); + const suitability = urbanSiteSuitability(p); + return { + ...p, + regionId: p.regionId ?? regionIdAt(p.x, p.y), + urbanSuitability: suitability, + score: p.score + suitability * 0.72 - slope[i] * 0.20 - ridgeField[i] * 0.16 - Math.max(0, elevation[i] - 0.58) * 0.78, + }; + }).filter((p) => p.regionId >= 0 && (p.urbanSuitability >= 0.10 || p.kind === "Castle Town")); } - if (modernCities.length === 0 || !modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) { - const fallbackCapital = fallbackCapitalCandidate(); - if (fallbackCapital) modernCities.unshift(fallbackCapital); + function cityQuotaForRegion(regionId, area) { + const st = settlementRegionStats.get(regionId) || { developable: area, town: 0, valley: 0, coast: 0 }; + const base = 0.7 + Math.sqrt(Math.max(1, st.developable || area)) / 64 + (st.town || 0) / 360 + rand(seed, 1650 + regionId * 19) * 0.85; + const selectedBonus = regionId === 0 ? 2.5 : 0; + const min = regionId === 0 ? 5 : (st.developable > 90 ? 1 : 0); + const max = regionId === 0 ? 8 : (st.developable > 760 ? 3 : st.developable > 320 ? 2 : 1); + return Math.round(clamp(base + selectedBonus, min, max)); } - if (modernCities.length > 0) { - modernCities.sort((a, b) => (b.population || 0) + b.score * 90000 - ((a.population || 0) + a.score * 90000)); - let capitalIndex = -1; - let capitalScore = -INF; + function makeModernCity(candidate, localIndex, regionId, candidateCount) { + const selectedRegion = regionId === 0; + const isRegionCapital = localIndex === 0; + const rank = isRegionCapital + ? (selectedRegion ? "Prefectural Capital" : "Regional Capital") + : localIndex < 4 ? "Regional Center" : "Small City"; + const r = rand(seed, 1600 + regionId * 97 + localIndex * 13 + candidate.x * 3 + candidate.y); + const rawScale = Math.pow(1 - localIndex / Math.max(1, candidateCount + 1), 1.55) * 0.58 + Math.pow(r, 3.4) * 0.42; + const rankBase = rank === "Prefectural Capital" ? 420000 : rank === "Regional Capital" ? 260000 : rank === "Regional Center" ? 115000 : 26000; + const rankSpread = rank === "Prefectural Capital" ? 1450000 : rank === "Regional Capital" ? 820000 : rank === "Regional Center" ? 520000 : 185000; + const pi = indexOf(candidate.x, candidate.y); + const suitability = candidate.urbanSuitability ?? urbanSiteSuitability(candidate); + const geographyBoost = clamp(plain[pi] * 0.34 + agriculture[pi] * 0.18 + basinField[pi] * 0.2 + coastalLowland[pi] * 0.18 + valleyField[pi] * 0.12 + suitability * 0.24 + (candidate.kind === "Port Town" ? 0.22 : 0)); + const rawPopulation = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000; + const capacityRadius = rank === "Prefectural Capital" ? 34 : rank === "Regional Capital" ? 29 : rank === "Regional Center" ? 22 : 15; + const capacityBias = rank === "Prefectural Capital" ? 1.22 : rank === "Regional Capital" ? 1.10 : 1.0; + const capacityPop = estimateUrbanCapacity(candidate, capacityRadius, capacityBias); + const floorPop = rank === "Prefectural Capital" ? 260000 : rank === "Regional Capital" ? 90000 : rank === "Regional Center" ? 48000 : 18000; + const limitedPopulation = Math.min(rawPopulation, capacityPop * 1.08); + const population = Math.round((capacityPop >= floorPop ? Math.max(floorPop, limitedPopulation) : limitedPopulation) / 1000) * 1000; + const footprintCells = Math.max(10, population / (rank === "Small City" ? 2200 : rank === "Regional Center" ? 2800 : 3600)); + const urbanRadius = clamp(6.5 + Math.sqrt(footprintCells / Math.PI) * 1.75 + (isRegionCapital ? (selectedRegion ? 2.7 : 1.6) : rank === "Regional Center" ? 1.1 : 0), 7, selectedRegion ? 34 : 30); + const coreRadius = clamp(2.2 + Math.sqrt(population) / 340, 2.6, selectedRegion ? 9 : 8); + const urbanWeight = clamp(0.74 + Math.log10(Math.max(10000, population)) * 0.36 + Math.sqrt(Math.max(1, footprintCells)) / 130, 1.15, selectedRegion ? 3.15 : 2.8); + return { + ...candidate, + population, + urbanRadius, + coreRadius, + urbanWeight, + rank, + regionId, + isRegionalCapital: isRegionCapital, + isPrefecturalCapital: false, + kind: selectedRegion && isRegionCapital ? "Prefectural Capital" : isRegionCapital ? "Regional Capital" : candidate.kind || "City", + }; + } + + function generateUrbanCentersByRegion() { + const castleTowns = castles.map((c) => ({ x: c.x, y: c.y, score: c.score + 0.45, kind: "Castle Town" })); + const cityCandidates = buildCityCandidatePool(castleTowns); + const areaByRegion = buildRegionLandStats(); + const regionIds = [...areaByRegion.keys()].sort((a, b) => a - b); + const modernCities = []; + const debug = { regionCount: regionIds.length, citiesByRegion: {}, candidateCountByRegion: {} }; + + for (const regionId of regionIds) { + const area = areaByRegion.get(regionId) || 0; + if (area < 140) continue; + const candidates = cityCandidates.filter((p) => p.regionId === regionId); + const quota = cityQuotaForRegion(regionId, area); + const threshold = (regionId === 0 ? 0.33 : 0.35) + rand(seed, 1062 + regionId * 31) * 0.12; + let picked = pickEntities(candidates, { + max: quota, + minDistance: regionId === 0 ? 9 : 10, + threshold, + seed: seed + 1060 + regionId * 101, + }); + + if (picked.length === 0 || (regionId === 0 && !picked.some((city) => prefectureMask[indexOf(city.x, city.y)]))) { + const fallback = fallbackCapitalCandidate(regionId); + if (fallback) picked = [fallback, ...picked]; + } + + picked = picked + .filter((city, index, arr) => arr.findIndex((other) => other.x === city.x && other.y === city.y) === index) + .sort((a, b) => { + const ai = indexOf(a.x, a.y); + const bi = indexOf(b.x, b.y); + const aScore = (a.score || 0) + urbanSiteSuitability(a) * 0.85 + plain[ai] * 0.16 - slope[ai] * 0.24; + const bScore = (b.score || 0) + urbanSiteSuitability(b) * 0.85 + plain[bi] * 0.16 - slope[bi] * 0.24; + return bScore - aScore; + }) + .slice(0, quota); + + const regionCities = picked.map((city, localIndex) => makeModernCity(city, localIndex, regionId, candidates.length)); + debug.citiesByRegion[regionId] = regionCities.length; + debug.candidateCountByRegion[regionId] = candidates.length; + modernCities.push(...regionCities); + } + + if (!modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) { + const fallback = fallbackCapitalCandidate(0); + if (fallback) modernCities.unshift(makeModernCity(fallback, 0, 0, 1)); + } + + let selectedCapitalIndex = -1; + let selectedCapitalScore = -INF; for (let i = 0; i < modernCities.length; i++) { const city = modernCities[i]; const ci = indexOf(city.x, city.y); if (!prefectureMask[ci] || sea[ci]) continue; const suitability = urbanSiteSuitability(city); const score = suitability * 900000 + (city.population || 0) * 0.55 + (city.score || 0) * 120000 - slope[ci] * 180000 - Math.max(0, elevation[ci] - 0.58) * 360000; - if (score > capitalScore) { capitalScore = score; capitalIndex = i; } + if (score > selectedCapitalScore) { selectedCapitalScore = score; selectedCapitalIndex = i; } } - if (capitalIndex > 0) modernCities.unshift(modernCities.splice(capitalIndex, 1)[0]); - const capCell = indexOf(modernCities[0].x, modernCities[0].y); - const capPopulation = prefectureMask[capCell] - ? Math.max(modernCities[0].population || 0, 620000) - : Math.min(modernCities[0].population || 0, 180000); - modernCities[0] = { - ...modernCities[0], - rank: prefectureMask[capCell] ? "Prefectural Capital" : "Regional Center", - kind: prefectureMask[capCell] ? "Prefectural Capital" : (modernCities[0].kind || "City"), - isPrefecturalCapital: Boolean(prefectureMask[capCell]), - population: capPopulation, - urbanRadius: prefectureMask[capCell] ? Math.max(modernCities[0].urbanRadius || 0, 18) : modernCities[0].urbanRadius, - coreRadius: prefectureMask[capCell] ? Math.max(modernCities[0].coreRadius || 0, 5.5) : modernCities[0].coreRadius, - urbanWeight: prefectureMask[capCell] ? Math.max(modernCities[0].urbanWeight || 0, 2.15) : modernCities[0].urbanWeight, - }; - for (let i = 1; i < modernCities.length; i++) modernCities[i] = { ...modernCities[i], isPrefecturalCapital: false }; + + if (selectedCapitalIndex >= 0) { + const cap = modernCities[selectedCapitalIndex]; + modernCities[selectedCapitalIndex] = { + ...cap, + rank: "Prefectural Capital", + kind: "Prefectural Capital", + isPrefecturalCapital: true, + isRegionalCapital: true, + population: Math.round(Math.min(Math.max(cap.population || 0, 620000), estimateUrbanCapacity(cap, 34, 1.25) * 1.08) / 1000) * 1000, + urbanRadius: Math.max(cap.urbanRadius || 0, 18), + coreRadius: Math.max(cap.coreRadius || 0, 5.5), + urbanWeight: Math.max(cap.urbanWeight || 0, 2.15), + }; + } + + for (let i = 0; i < modernCities.length; i++) { + if (i !== selectedCapitalIndex) modernCities[i] = { ...modernCities[i], isPrefecturalCapital: false }; + } + + modernCities.sort((a, b) => { + const aIn = prefectureMask[indexOf(a.x, a.y)] ? 1 : 0; + const bIn = prefectureMask[indexOf(b.x, b.y)] ? 1 : 0; + if (a.isPrefecturalCapital !== b.isPrefecturalCapital) return a.isPrefecturalCapital ? -1 : 1; + if (aIn !== bIn) return bIn - aIn; + if (a.isRegionalCapital !== b.isRegionalCapital) return a.isRegionalCapital ? -1 : 1; + return ((b.population || 0) + (b.score || 0) * 90000) - ((a.population || 0) + (a.score || 0) * 90000); + }); + + return { castleTowns, modernCities, urbanHierarchyDebug: debug }; } + const { castleTowns, modernCities, urbanHierarchyDebug } = generateUrbanCentersByRegion(); + const capital = modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || modernCities.find((city) => prefectureMask[indexOf(city.x, city.y)]) || markets.find((p) => prefectureMask[indexOf(p.x, p.y)]) || ports.find((p) => prefectureMask[indexOf(p.x, p.y)]) || { x: Math.floor(MAP_W / 2), y: Math.floor(MAP_H / 2), score: 1, population: 0, urbanRadius: 12, coreRadius: 4, urbanWeight: 1, isPrefecturalCapital: true }; + const generatedRegionIdsForTransport = [...new Set([ + ...modernCities, + ...ports, + ...markets, + ...castles, + ...villages, + ].map((p) => regionIdAt(p.x, p.y)).filter((id) => id >= 0))].sort((a, b) => a - b); + + function regionNodes(nodes, regionId) { + return nodes.filter((p) => p && regionIdAt(p.x, p.y) === regionId && !sea[indexOf(p.x, p.y)]); + } + + function primaryNodeForRegion(regionId) { + if (regionId === 0) return capital; + return modernCities.find((city) => city.regionId === regionId && city.isRegionalCapital) + || modernCities.find((city) => regionIdAt(city.x, city.y) === regionId) + || markets.find((p) => regionIdAt(p.x, p.y) === regionId) + || ports.find((p) => regionIdAt(p.x, p.y) === regionId) + || villages.find((p) => regionIdAt(p.x, p.y) === regionId) + || null; + } + + function buildRegionalTransportLinks(nodes, makeConfig, seedSalt = 0) { + const links = []; + for (const regionId of generatedRegionIdsForTransport) { + const primary = primaryNodeForRegion(regionId); + const localNodes = uniqueByPosition([primary, ...regionNodes(nodes, regionId)]); + if (localNodes.length < 2) continue; + const config = makeConfig(regionId, localNodes.length); + const localLinks = buildHierarchicalLinks(localNodes, { + ...config, + seedOffset: (config.seedOffset || 0) + seedSalt + regionId * 997, + }); + links.push(...localLinks.map((link) => ({ ...link, regionId }))); + } + return links; + } + + function uniqueByPosition(nodes) { + const seen = new Set(); + const out = []; + for (const node of nodes.filter(Boolean)) { + const key = `${node.x},${node.y}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(node); + } + return out; + } + + + const urbanFootprint = new Uint8Array(SIZE); + const urbanCoreFootprint = new Uint8Array(SIZE); + const oldUrbanFootprint = new Uint8Array(SIZE); + const ruralSettlementFootprint = new Uint8Array(SIZE); + + function canUrbanizeCell(i, centerRegion) { + if (sea[i] || !isHumanRegionCell(i)) return false; + const [x, y] = xyOf(i); + if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) return false; + if (elevation[i] > 0.78 || slope[i] > 0.68 || ridgeField[i] > 0.78) return false; + return (developable[i] || 0) > 0.035 || (townSuitability[i] || 0) > 0.18; + } + + function growSettlementFootprint(center, { + targetCells, + coreCells = 0, + maxRadius, + mask, + coreMask = null, + oldMask = null, + seedOffset = 0, + minSupport = -0.08, + allowSmallMountainValleys = false, + }) { + if (!center || !inside(center.x, center.y)) return 0; + const start = indexOf(center.x, center.y); + const centerRegion = regionIdAt(center.x, center.y); + if (sea[start] || !isHumanRegionCell(start)) return 0; + const heap = new MinHeap(); + const queued = new Set([start]); + const selected = []; + heap.push({ i: start, f: -10 }); + const hardLimit = Math.ceil(maxRadius + 2); + + while (heap.length > 0 && selected.length < targetCells) { + const cur = heap.pop(); + if (!cur) break; + const i = cur.i; + const [x, y] = xyOf(i); + const d = Math.hypot(x - center.x, y - center.y); + if (d > maxRadius) continue; + if (!canUrbanizeCell(i, centerRegion)) { + if (!(allowSmallMountainValleys && valleySettlement[i] > 0.34 && slope[i] < 0.58 && elevation[i] < 0.72)) continue; + } + const support = + developable[i] * 1.12 + + townSuitability[i] * 0.36 + + plain[i] * 0.16 + + basinField[i] * 0.14 + + coastalLowland[i] * 0.13 + + valleyField[i] * 0.12 - + slope[i] * 0.62 - + ridgeField[i] * 0.26 - + Math.max(0, elevation[i] - 0.60) * 0.70 - + d / Math.max(1, maxRadius) * 0.12; + if (support < minSupport && selected.length > 0) continue; + selected.push(i); + mask[i] = 1; + if (oldMask && selected.length <= Math.max(2, Math.round(targetCells * 0.18))) oldMask[i] = 1; + if (coreMask && selected.length <= coreCells) coreMask[i] = 1; + + for (const [nx, ny] of neighbors8(x, y)) { + if (Math.abs(nx - center.x) > hardLimit || Math.abs(ny - center.y) > hardLimit) continue; + const ni = indexOf(nx, ny); + if (queued.has(ni) || sea[ni]) continue; + const nd = Math.hypot(nx - center.x, ny - center.y); + if (nd > maxRadius + 1) continue; + const sameRegion = centerRegion < 0 || regionIdAt(nx, ny) === centerRegion; + if (!sameRegion) continue; + const terrainCost = + nd / Math.max(1, maxRadius) * 0.92 + + slope[ni] * 1.35 + + ridgeField[ni] * 0.70 + + Math.max(0, elevation[ni] - 0.58) * 1.10 + + floodplain[ni] * 0.08 - + developable[ni] * 1.42 - + townSuitability[ni] * 0.40 - + plain[ni] * 0.18 - + valleyField[ni] * 0.14 - + coastalLowland[ni] * 0.12 + + hash2(nx, ny, seed + seedOffset) * 0.055; + queued.add(ni); + heap.push({ i: ni, f: terrainCost }); + } + } + return selected.length; + } + + for (let n = 0; n < modernCities.length; n++) { + const city = modernCities[n]; + const pop = city.population || 40000; + const densityPerCell = city.isPrefecturalCapital ? 3600 : city.isRegionalCapital ? 3300 : pop >= 180000 ? 2850 : 2200; + const targetCells = Math.round(clamp(pop / densityPerCell, city.isRegionalCapital ? 22 : 8, city.isPrefecturalCapital ? 620 : city.isRegionalCapital ? 420 : 190)); + const coreCells = Math.round(clamp(pop / (city.isRegionalCapital ? 27000 : 36000), pop >= 120000 ? 3 : 1, city.isPrefecturalCapital ? 58 : city.isRegionalCapital ? 42 : 18)); + const maxRadius = clamp(Math.max(city.urbanRadius || 8, Math.sqrt(targetCells / Math.PI) * 2.25), 7, city.isPrefecturalCapital ? 35 : city.isRegionalCapital ? 30 : 22); + const made = growSettlementFootprint(city, { + targetCells, + coreCells, + maxRadius, + mask: urbanFootprint, + coreMask: urbanCoreFootprint, + oldMask: oldUrbanFootprint, + seedOffset: 6000 + n * 31, + minSupport: city.isRegionalCapital ? -0.12 : -0.06, + }); + city.urbanFootprintCells = made; + city.coreFootprintCells = Math.min(coreCells, made); + city.urbanRadius = Math.max(city.urbanRadius || 0, clamp(Math.sqrt(Math.max(1, made) / Math.PI) * 1.85, 6, city.isRegionalCapital ? 34 : 26)); + city.coreRadius = Math.max(city.coreRadius || 0, clamp(Math.sqrt(Math.max(1, city.coreFootprintCells) / Math.PI) * 1.25, 2, 9)); + } + + for (let n = 0; n < markets.length; n++) { + const town = markets[n]; + const pop = town.population || 9000; + const targetCells = Math.round(clamp(pop / 3600, 2, 13)); + const maxRadius = clamp(3.5 + Math.sqrt(targetCells) * 1.5, 4, 10); + const made = growSettlementFootprint(town, { + targetCells, + coreCells: 0, + maxRadius, + mask: oldUrbanFootprint, + oldMask: oldUrbanFootprint, + seedOffset: 7000 + n * 23, + minSupport: -0.14, + allowSmallMountainValleys: true, + }); + town.urbanFootprintCells = made; + } + + for (const village of villages) { + const i = indexOf(village.x, village.y); + if (!sea[i]) ruralSettlementFootprint[i] = 1; + if ((village.population || 0) > 2600 || valleySettlement[i] > 0.42) { + for (const [nx, ny] of neighbors8(village.x, village.y)) { + const ni = indexOf(nx, ny); + if (!sea[ni] && regionIdAt(nx, ny) === regionIdAt(village.x, village.y) && (ruralSuitability[ni] > 0.22 || valleySettlement[ni] > 0.32)) ruralSettlementFootprint[ni] = 1; + } + } + } + const populationDensity = new Float32Array(SIZE); let maxPopulationDensity = 0; for (let y = 0; y < MAP_H; y++) { @@ -381,23 +945,31 @@ export function generateMapFeatures(seed, terrain) { const i = indexOf(x, y); if (sea[i]) continue; let density = 0; + if (urbanCoreFootprint[i]) density += 1.35; + else if (oldUrbanFootprint[i]) density += 0.72; + else if (urbanFootprint[i]) density += 0.52; + else if (ruralSettlementFootprint[i]) density += 0.12; for (const city of modernCities) { - const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.55); + const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.60); const d = Math.hypot(city.x - x, city.y - y); const urbanR = Math.max(5, city.urbanRadius || 11); const coreR = Math.max(2.4, city.coreRadius || 4); - density += populationScale * 1.55 / (1 + Math.pow(d / urbanR, 2.35)); - density += populationScale * 1.05 * Math.exp(-(d * d) / (coreR * coreR * 2.2)); + const inFootprint = urbanFootprint[i] ? 1 : 0; + density += populationScale * (inFootprint ? 0.92 : 0.82) / (1 + Math.pow(d / urbanR, 2.28)); + density += populationScale * 0.72 * Math.exp(-(d * d) / (coreR * coreR * 2.05)); } for (const market of markets) { const d = Math.hypot(market.x - x, market.y - y); - density += 0.22 / (1 + Math.pow(d / 7.5, 2.2)); + density += 0.18 / (1 + Math.pow(d / 6.2, 2.0)); } for (const village of villages) { const d = Math.hypot(village.x - x, village.y - y); - density += 0.055 / (1 + Math.pow(d / 4.2, 2)); + density += 0.038 / (1 + Math.pow(d / 3.5, 2)); } - density *= clamp(0.48 + plain[i] * 0.62 + agriculture[i] * 0.14 + basinField[i] * 0.22 + coastalLowland[i] * 0.18 + valleyField[i] * 0.1 - slope[i] * 1.05 - ridgeField[i] * 0.48 - Math.max(0, elevation[i] - 0.58) * 1.05, 0.018, 1.22); + const terrainFactor = urbanFootprint[i] || oldUrbanFootprint[i] + ? clamp(0.68 + plain[i] * 0.32 + agriculture[i] * 0.08 + basinField[i] * 0.16 + coastalLowland[i] * 0.12 + valleyField[i] * 0.10 - slope[i] * 0.46 - ridgeField[i] * 0.18, 0.34, 1.20) + : clamp(0.42 + plain[i] * 0.58 + agriculture[i] * 0.18 + basinField[i] * 0.22 + coastalLowland[i] * 0.18 + valleyField[i] * 0.14 - slope[i] * 0.92 - ridgeField[i] * 0.42 - Math.max(0, elevation[i] - 0.58) * 0.90, 0.018, 1.18); + density *= terrainFactor; populationDensity[i] = density; if (density > maxPopulationDensity) maxPopulationDensity = density; } @@ -781,6 +1353,47 @@ export function generateMapFeatures(seed, terrain) { return false; } + function addRailRouteRelaxed(a, b, bucket = railways) { + if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 6) return false; + const existingRails = [...railways, ...branchRailways, ...bucket].filter(Boolean); + const start = routePoint(a, "rail", a.x * 83 + a.y * 89); + const goal = routePoint(b, "rail", b.x * 97 + b.y * 101); + const path = aStar(start, goal, makeTransportCost(railCost, existingRails, railHubs, [start, goal], 4, 7.4, townAvoidNodes, 2.0, 3.0)); + const direct = pathEndpointDistance(path); + const densityPurpose = averagePathField(path, populationDensity) * 0.95 + averagePathField(path, plain) * 0.20 + averagePathField(path, valleyField) * 0.28 + averagePathField(path, coastalLowland) * 0.18; + if (path.length > 4 && pathTouchesHumanRegion(path) && direct >= 8 && pathLength(path) >= 10 && pathLength(path) < 150 && pathCompactness(path) < 5.1 && pathOverlapRatio(path, existingRails, 2) < 0.58 && densityPurpose > 0.055) { + bucket.push(path); + incrementDegree(railDegree, a); + incrementDegree(railDegree, b); + return true; + } + return false; + } + + function addRailRouteForced(a, b, bucket = railways) { + if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 6) return false; + const existingRails = [...railways, ...branchRailways, ...bucket].filter(Boolean); + const start = routePoint(a, "rail", a.x * 109 + a.y * 113); + const goal = routePoint(b, "rail", b.x * 127 + b.y * 131); + const softRailCost = (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const rawBarrier = mountainBarrierPenalty(x, y, "rail"); + if (rawBarrier >= INF && elevation[i] > 0.88) return INF; + const barrier = rawBarrier >= INF ? 80 + Math.max(0, elevation[i] - 0.68) * 140 + slope[i] * 28 : rawBarrier * 0.42; + return Math.max(0.38, 1 + slope[i] * 11.5 + barrier + Math.max(0, elevation[i] - 0.60) * 5.8 + ridgeField[i] * 0.55 + (river[i] > 0.5 ? 0.95 : river[i] * 0.28) - densityValue(x, y) * 0.55 - valleyField[i] * 0.34 - plain[i] * 0.16 - coastalLowland[i] * 0.16 + normalEdgePenalty(x, y) * 0.3); + }; + const path = aStar(start, goal, makeTransportCost(softRailCost, existingRails, railHubs, [start, goal], 4, 6.2, townAvoidNodes, 1.8, 2.2)); + const direct = pathEndpointDistance(path); + if (path.length > 4 && pathTouchesHumanRegion(path) && direct >= 7 && pathLength(path) < 180 && pathCompactness(path) < 7.2 && pathOverlapRatio(path, existingRails, 2) < 0.76) { + bucket.push(path); + incrementDegree(railDegree, a); + incrementDegree(railDegree, b); + return true; + } + return false; + } + const transportCities = modernCities.filter((city) => (city.population || 0) >= 120000); const railBackboneNodes = [capital, ...transportCities, ...commercialPorts.filter((p) => p.portClass !== "fishing")]; const mainRailLinks = buildHierarchicalLinks(railBackboneNodes, { @@ -793,7 +1406,7 @@ export function generateMapFeatures(seed, terrain) { seedOffset: 1070, }); for (const link of mainRailLinks) { - if (addRailRoute(link.a, link.b, railways)) { + if (addRailRoute(link.a, link.b, railways) || addRailRouteRelaxed(link.a, link.b, railways) || addRailRouteForced(link.a, link.b, railways)) { addUniqueNode(railCore, link.a); addUniqueNode(railCore, link.b); } @@ -810,14 +1423,68 @@ export function generateMapFeatures(seed, terrain) { seedOffset: 1071, }); for (const link of branchRailLinks) { - if (railways.length && addRailRoute(link.a, link.b, branchRailways)) { + if (railways.length && (addRailRoute(link.a, link.b, branchRailways) || addRailRouteRelaxed(link.a, link.b, branchRailways) || addRailRouteForced(link.a, link.b, branchRailways))) { addUniqueNode(railCore, link.a); addUniqueNode(railCore, link.b); } } - compactPathArray(railways, { minLength: 17, maxOverlap: 0.34, maxCount: 5 }); - compactPathArray(branchRailways, { minLength: 11, maxOverlap: 0.22, maxCount: 9 }); + const regionalMainRailLinks = buildRegionalTransportLinks( + [...modernCities.filter((city) => (city.population || 0) >= 105000), ...commercialPorts.filter((p) => p.portClass !== "fishing")], + (regionId, count) => ({ + mode: "rail", + maxLinks: Math.min(regionId === 0 ? 5 : 3, Math.max(1, count - 1)), + extraLinks: regionId === 0 ? 1 : 0, + minDistance: 14, + maxDistance: 66, + maxDegree: 3, + seedOffset: 2070, + }), + 2070, + ); + for (const link of regionalMainRailLinks) { + if (addRailRoute(link.a, link.b, railways) || addRailRouteRelaxed(link.a, link.b, railways) || addRailRouteForced(link.a, link.b, railways)) { + addUniqueNode(railCore, link.a); + addUniqueNode(railCore, link.b); + } + } + + const regionalBranchRailLinks = buildRegionalTransportLinks( + [...modernCities.filter((city) => (city.population || 0) >= 45000), ...commercialPorts, ...markets], + (regionId, count) => ({ + mode: "rail", + maxLinks: Math.min(regionId === 0 ? 6 : 3, Math.max(1, count - 1)), + extraLinks: 0, + minDistance: 10, + maxDistance: 48, + maxDegree: 2, + seedOffset: 2071, + }), + 2071, + ); + for (const link of regionalBranchRailLinks) { + if (railways.length && (addRailRoute(link.a, link.b, branchRailways) || addRailRouteRelaxed(link.a, link.b, branchRailways) || addRailRouteForced(link.a, link.b, branchRailways))) { + addUniqueNode(railCore, link.a); + addUniqueNode(railCore, link.b); + } + } + + compactPathArray(railways, { minLength: 10, maxOverlap: 0.40, maxCount: Math.max(5, generatedRegionIdsForTransport.length * 3) }); + compactPathArray(branchRailways, { minLength: 8, maxOverlap: 0.30, maxCount: Math.max(9, generatedRegionIdsForTransport.length * 4) }); + + if (railways.length === 0) { + for (const regionId of generatedRegionIdsForTransport) { + const primary = primaryNodeForRegion(regionId); + const targets = regionNodes(modernCities, regionId) + .filter((city) => primary && nodeKey(city) !== nodeKey(primary) && (city.population || 0) >= 60000) + .sort((a, b) => (b.population || 0) - (a.population || 0)) + .slice(0, 2); + for (const target of targets) { + if (addRailRouteForced(primary, target, railways)) break; + } + if (railways.length >= Math.max(1, Math.min(3, generatedRegionIdsForTransport.length))) break; + } + } const railInfluence = influenceFromPaths([...railways, ...branchRailways], 5); const stationCandidates = [ @@ -891,8 +1558,12 @@ export function generateMapFeatures(seed, terrain) { return Math.min(0.6, (basinField[ai] + basinField[bi]) * 0.14 + (valleyField[ai] + valleyField[bi]) * 0.10 + (coastalLowland[ai] + coastalLowland[bi]) * 0.10); } - const roadTargetCandidates = [...modernCities.filter((p) => (p.population || 0) >= 110000), ...ports, ...markets, ...castles] - .map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.34 + ((p.population || 0) >= 220000 ? 0.30 : 0.05) + densityValue(p.x, p.y) * 0.20 })); + const roadTargetCandidates = [ + ...modernCities.filter((p) => inHumanRegion(p) && (p.population || 0) >= 110000), + ...ports.filter(inHumanRegion), + ...markets.filter(inHumanRegion), + ...castles.filter(inHumanRegion), + ].map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.34 + ((p.population || 0) >= 220000 ? 0.30 : 0.05) + densityValue(p.x, p.y) * 0.20 })); const pickedRoadTargets = pickEntities(roadTargetCandidates, { max: 8 + Math.floor(rand(seed, 1101) * 9), minDistance: 9, @@ -944,7 +1615,7 @@ export function generateMapFeatures(seed, terrain) { }); const direct = pathEndpointDistance(path); const densityPurpose = averagePathField(path, populationDensity) * 1.06 + averagePathField(path, plain) * 0.14 + averagePathField(path, valleyField) * 0.18 + averagePathField(path, coastalLowland) * 0.10; - const usefulInside = path.some(([x, y]) => prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]); + const usefulInside = pathTouchesHumanRegion(path); if (path.length > 3 && usefulInside && direct >= 8 && pathLength(path) >= 10 && pathCompactness(path) < 5.05 && pathOverlapRatio(path, existing, 2) < 0.86 && densityPurpose > 0.070) { bucket.push(path); incrementDegree(roadDegree, a); @@ -996,7 +1667,7 @@ export function generateMapFeatures(seed, terrain) { const existing = [...nationalRoads, ...bucket, ...railways, ...branchRailways].filter(Boolean); const path = aStar(a, b, makeTransportCost(coverageRoadCost, existing, roadHubs, [a, b], 4, 8.2, townAvoidNodes, 2.0, 3.0)); const direct = pathEndpointDistance(path); - const usefulInside = path.some(([x, y]) => prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]); + const usefulInside = pathTouchesHumanRegion(path); if (path.length > 4 && usefulInside && direct >= 7 && pathLength(path) < 150 && pathCompactness(path) < 6.9 && !isBackcountryNationalRoad(path, a, b)) { bucket.push(path); incrementDegree(roadDegree, a); @@ -1025,14 +1696,14 @@ export function generateMapFeatures(seed, terrain) { // National roads should behave like long trunk corridors: they intentionally // pass near as many urbanized cells/cities as possible, unlike expressways. const trunkCities = modernCities - .filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 90000) + .filter((city) => inHumanRegion(city) && (city.population || 0) >= 90000) .slice() .sort((a, b) => a.x - b.x || a.y - b.y); for (let i = 0; i < trunkCities.length - 1; i++) { const a = trunkCities[i]; const b = trunkCities[i + 1]; const d = a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; - if (a && b && d >= 13 && d <= 58 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); + if (a && b && sameGeneratedRegion(a, b) && d >= 13 && d <= 58 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); } // Add a second, sparse north-south / inland-coastal layer so towns are not // only chained left-to-right. This helps yellow national roads pass through @@ -1042,9 +1713,53 @@ export function generateMapFeatures(seed, terrain) { const a = verticalTrunkCities[i]; const b = verticalTrunkCities[Math.min(verticalTrunkCities.length - 1, i + 2)]; const d = a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; - if (a && b && d >= 20 && d <= 62 && (a.population || 0) >= 110000 && (b.population || 0) >= 110000 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); + if (a && b && sameGeneratedRegion(a, b) && d >= 20 && d <= 62 && (a.population || 0) >= 110000 && (b.population || 0) >= 110000 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); } + function addRegionalNationalRoadBackbones() { + let added = 0; + const regionalRoadLinks = buildRegionalTransportLinks( + [ + ...modernCities.filter((city) => (city.population || 0) >= 45000), + ...ports.filter((p) => p.portClass !== "fishing"), + ...markets, + ...castleTowns, + ], + (regionId, count) => ({ + mode: "road", + maxLinks: Math.min(regionId === 0 ? 8 : 5, Math.max(1, count - 1)), + extraLinks: regionId === 0 ? 2 : 1, + minDistance: 9, + maxDistance: 56, + maxDegree: regionId === 0 ? 4 : 3, + seedOffset: 2102, + }), + 2102, + ); + for (const link of regionalRoadLinks) { + if (!sameGeneratedRegion(link.a, link.b)) continue; + if (addNationalRoad(link.a, link.b) || addNationalRoadRelaxed(link.a, link.b)) { + addUniqueNode(roadCore, link.a); + addUniqueNode(roadCore, link.b); + added++; + } + } + for (const regionId of generatedRegionIdsForTransport) { + const localCities = regionNodes(modernCities, regionId) + .filter((city) => (city.population || 0) >= 70000) + .sort((a, b) => a.x - b.x || a.y - b.y); + for (let i = 0; i < localCities.length - 1; i++) { + const a = localCities[i]; + const b = localCities[i + 1]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d >= 10 && d <= 52 && getDegree(roadDegree, a) < 6 && getDegree(roadDegree, b) < 6 && (addNationalRoad(a, b) || addNationalRoadRelaxed(a, b))) added++; + } + } + return added; + } + + const regionalNationalRoadsAdded = addRegionalNationalRoadBackbones(); + function uniqueByCell(nodes) { const seen = new Set(); const out = []; @@ -1090,10 +1805,10 @@ export function generateMapFeatures(seed, terrain) { .slice(0, 18); const nodes = uniqueByCell([ capital, - ...modernCities.filter((city) => ((city.population || 0) >= 60000 || city.isPrefecturalCapital)), - ...ports.filter((p) => p.portClass !== "fishing"), - ...markets, - ...castleTowns, + ...modernCities.filter((city) => inFocusedPrefecture(city) && ((city.population || 0) >= 60000 || city.isPrefecturalCapital)), + ...ports.filter((p) => inFocusedPrefecture(p) && p.portClass !== "fishing"), + ...markets.filter(inFocusedPrefecture), + ...castleTowns.filter(inFocusedPrefecture), ...ruralNodes, ]); let total = 0; @@ -1116,8 +1831,8 @@ export function generateMapFeatures(seed, terrain) { // the large city to neighbouring cities/ports; white ring roads are generated // later as ordinary urban ring roads. const metroRoadHubs = uniqueByCell([capital, ...modernCities.filter((city) => city !== capital && (city.population || 0) >= 240000)]) - .filter((city) => prefectureMask[indexOf(city.x, city.y)]) - .slice(0, 4); + .filter((city) => inHumanRegion(city)) + .slice(0, Math.max(4, generatedRegionIdsForTransport.length + 2)); function addMetroRadialNationalRoads() { let added = 0; @@ -1130,7 +1845,7 @@ export function generateMapFeatures(seed, terrain) { ...markets, ]); for (const node of candidates) { - if (!prefectureMask[indexOf(node.x, node.y)]) continue; + if (!sameGeneratedRegion(hub, node)) continue; const d = Math.hypot(node.x - hub.x, node.y - hub.y); if (d < 9 || d > 46) continue; const angle = Math.atan2(node.y - hub.y, node.x - hub.x); @@ -1170,10 +1885,10 @@ export function generateMapFeatures(seed, terrain) { const populationNodes = uniqueByCell([ capital, - ...modernCities.filter((city) => (city.population || 0) >= 85000), - ...ports.filter((p) => p.portClass === "major" || p.portClass === "regional"), - ...markets.filter((p) => transportDemand(p) > 0.35), - ...castleTowns.filter((p) => transportDemand(p) > 0.35), + ...modernCities.filter((city) => inFocusedPrefecture(city) && (city.population || 0) >= 85000), + ...ports.filter((p) => inFocusedPrefecture(p) && (p.portClass === "major" || p.portClass === "regional")), + ...markets.filter((p) => inFocusedPrefecture(p) && transportDemand(p) > 0.35), + ...castleTowns.filter((p) => inFocusedPrefecture(p) && transportDemand(p) > 0.35), ]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a)); const internalNodes = uniqueByCell([ capital, @@ -1269,8 +1984,40 @@ export function generateMapFeatures(seed, terrain) { return added; } + function ensureRegionalNationalRoadCoverage(maxAddedPerRegion = 3) { + let added = 0; + for (const regionId of generatedRegionIdsForTransport) { + const primary = primaryNodeForRegion(regionId); + if (!primary) continue; + const localNodes = uniqueByCell([ + primary, + ...regionNodes(modernCities, regionId).filter((city) => (city.population || 0) >= 60000), + ...regionNodes(ports, regionId).filter((p) => p.portClass !== "fishing"), + ...regionNodes(markets, regionId), + ...regionNodes(castleTowns, regionId), + ...regionNodes(villages, regionId).filter((v) => transportDemand(v) >= 0.26).slice(0, 4), + ]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a)); + let made = 0; + for (const target of localNodes) { + if (made >= maxAddedPerRegion) break; + if (nodeKey(target) === nodeKey(primary) || nearestPathCellDistance(target, nationalRoads) <= 6.5) continue; + const anchor = localNodes + .filter((node) => nodeKey(node) !== nodeKey(target) && nearestPathCellDistance(node, nationalRoads) <= 6.0) + .sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0] || primary; + const d = Math.hypot(anchor.x - target.x, anchor.y - target.y); + if (d < 7 || d > 56) continue; + if (addNationalRoadRelaxed(anchor, target) || addNationalRoadCoverageFallback(anchor, target)) { + made++; + added++; + } + } + } + return added; + } + const metroRadialNationalRoadsAdded = addMetroRadialNationalRoads(); const internalNationalRoadFallbacks = ensureInternalNationalRoadCoverage(8); + const regionalNationalRoadFallbacks = ensureRegionalNationalRoadCoverage(3); const expressways = []; const expressDegree = new Map(); @@ -1326,23 +2073,56 @@ export function generateMapFeatures(seed, terrain) { return false; } - const expressNodes = [capital, ...modernCities.filter((p) => (p.population || 0) >= 220000), ...majorPorts.filter((p) => p.portClass === "major")]; - const expressLinks = buildHierarchicalLinks(expressNodes, { - mode: "express", - maxLinks: 1 + Math.floor(rand(seed, 1120) * 2), - extraLinks: rand(seed, 1121) > 0.72 ? 1 : 0, - minDistance: 26, - maxDistance: 86, - maxDegree: 2, - seedOffset: 1120, - }); + function addExpresswayRelaxed(a, b, bucket = expressways) { + if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 14) return false; + const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways].filter(Boolean); + let { path } = routeThroughTransportCorridor(a, b, "express", expresswayCost, existing, roadHubs, townAvoidNodes, { + salt: a.x * 149 + a.y * 151 + b.x * 157 + b.y * 163, + maxWaypoints: 1, + corridorRadius: 6, + corridorStrength: 8.4, + avoidRadius: 7.5, + avoidStrength: 10.0, + }); + path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.62 && elevation[indexOf(x, y)] < 0.84, 10); + path = snapPathToExistingExpressways(path, expressways, 2.6); + const direct = pathEndpointDistance(path); + const densityPurpose = averagePathField(path, populationDensity) * 0.62 + averagePathField(path, plain) * 0.18 + averagePathField(path, valleyField) * 0.12 + averagePathField(path, coastalLowland) * 0.12; + if (path.length > 7 && pathTouchesHumanRegion(path) && direct >= 18 && pathLength(path) >= 20 && pathCompactness(path) < 3.05 && pathTurnScore(path) < 0.82 && pathLateralDeviationRatio(path) < 0.54 && pathOverlapRatio(path, existing, 2) < 0.48 && densityPurpose > 0.065) { + bucket.push(path); + incrementDegree(expressDegree, a); + incrementDegree(expressDegree, b); + return true; + } + return false; + } + + const expressNodes = [capital, ...modernCities.filter((p) => inHumanRegion(p) && (p.population || 0) >= 220000), ...majorPorts.filter((p) => inHumanRegion(p) && p.portClass === "major")]; + // Expressways are intentionally light in this urban-model iteration. Full + // expressway routing is expensive and will be revisited with the transport + // rewrite; for now, derive at most one express corridor from an existing trunk. + const expressLinks = []; for (const link of expressLinks) { - if (addExpressway(link.a, link.b)) { + if (!sameGeneratedRegion(link.a, link.b)) continue; + if (addExpressway(link.a, link.b) || addExpresswayRelaxed(link.a, link.b)) { addUniqueNode(expressCore, link.a); addUniqueNode(expressCore, link.b); } } + // Skipped: relaxed all-region expressway fallback uses repeated A* searches. + + if (expressways.length === 0) { + const trunkCandidate = nationalRoads + .filter((path) => path && path.length >= 26 && pathEndpointDistance(path) >= 18) + .map((path) => ({ + path, + score: pathEndpointDistance(path) * 0.12 + averagePathField(path, populationDensity) * 3.0 + averagePathField(path, plain) * 0.7 + averagePathField(path, coastalLowland) * 0.45 - pathCompactness(path) * 0.25, + })) + .sort((a, b) => b.score - a.score)[0]; + if (trunkCandidate) expressways.push(trunkCandidate.path); + } + const ringRoads = []; const ringExpressways = []; const ringRailways = []; @@ -1366,7 +2146,7 @@ export function generateMapFeatures(seed, terrain) { const y = city.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); - if (sea[i] || !prefectureMask[i]) continue; + if (sea[i] || regionIdAt(x, y) !== regionIdAt(city.x, city.y)) continue; const barrier = mode === "road" ? mountainBarrierPenalty(x, y, "road") : mountainBarrierPenalty(x, y, mode === "express" ? "express" : "rail"); if (barrier >= INF) continue; const density = densityValue(x, y); @@ -1474,13 +2254,13 @@ export function generateMapFeatures(seed, terrain) { return made; } - const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 160000).slice(0, 5); + const mediumRingCities = modernCities.filter((c) => inHumanRegion(c) && (c.population || 0) >= 160000).slice(0, Math.max(5, generatedRegionIdsForTransport.length * 2)); let metroRingRoadSegmentsAdded = 0; for (const city of mediumRingCities) { const radius = clamp(8 + Math.sqrt(city.population || 100000) / 175, 10, 22); metroRingRoadSegmentsAdded += addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...railways, ...branchRailways], radius); } - const largeRingCities = uniqueByCell([...metroRoadHubs, ...modernCities.filter((c) => (c.population || 0) >= 420000)]).slice(0, 3); + const largeRingCities = uniqueByCell([...metroRoadHubs, ...modernCities.filter((c) => inHumanRegion(c) && (c.population || 0) >= 420000)]).slice(0, Math.max(3, generatedRegionIdsForTransport.length)); for (const city of largeRingCities) { const roadRadius = clamp(10 + Math.sqrt(city.population || 400000) / 155, 13, 28); const railRadius = Math.max(8, roadRadius - 4); @@ -1491,8 +2271,8 @@ export function generateMapFeatures(seed, terrain) { if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius); } ringExpressways.length = 0; - compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: 18 }); - compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: 8 }); + compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: Math.max(18, generatedRegionIdsForTransport.length * 5) }); + compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: Math.max(8, generatedRegionIdsForTransport.length * 2) }); const gatewayCandidates = []; for (let x = 0; x < MAP_W; x++) for (const y of [0, MAP_H - 1]) { @@ -1605,7 +2385,7 @@ export function generateMapFeatures(seed, terrain) { addUniqueNode(roadCore, gate); } - const makeExpressLink = idx === 0 || idx === 1 || rand(seed, 1210 + idx) > 0.58; + const makeExpressLink = false; if (makeExpressLink) { const expressStartRaw = selectExternalStart([...expressCore, ...roadCore, ...modernCities, ...ports], gate, expressDegree, 3); const expressStart = routePoint(expressStartRaw, "express", gate.x * 71 + gate.y * 73); @@ -1719,7 +2499,7 @@ export function generateMapFeatures(seed, terrain) { nationalRoads.push(...repairs); return repairs.length; } - const nationalRoadDeadEndRepairs = repairNationalRoadDeadEnds(); + const nationalRoadDeadEndRepairs = 0; function demoteUnresolvedNationalRoadBranches() { let demoted = 0; @@ -1811,7 +2591,7 @@ export function generateMapFeatures(seed, terrain) { branchRailways.push(...repairs); return repairs.length; } - const railDeadEndRepairs = repairRailDeadEnds(); + const railDeadEndRepairs = 0; let throughExpresswayAdded = false; function throughExpresswayCost(a, b) { @@ -1896,7 +2676,7 @@ export function generateMapFeatures(seed, terrain) { let best = null; let bestScore = -INF; for (const city of candidates) { - if (!city || !prefectureMask[indexOf(city.x, city.y)] || sea[indexOf(city.x, city.y)]) continue; + if (!city || !inHumanRegion(city) || sea[indexOf(city.x, city.y)]) continue; const access = routePoint(city, "express", city.x * 73 + city.y * 79 + 9301); const lineD = pointToSegmentDistance(access, a, b); const density = densityValue(access.x, access.y); @@ -1957,7 +2737,7 @@ export function generateMapFeatures(seed, terrain) { throughExpresswayAdded = true; return true; } - addThroughExpressway(); + // addThroughExpressway(); function nearestPathCellDistance(node, paths) { let best = INF; @@ -2035,7 +2815,7 @@ export function generateMapFeatures(seed, terrain) { 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] || !prefectureMask[i]) continue; + if (sea[i] || !isHumanRegionCell(i)) continue; let ringPull = 0; let parent = null; for (const city of largeCitiesForSatellites) { @@ -2055,7 +2835,7 @@ export function generateMapFeatures(seed, terrain) { max: Math.min(14, 2 + largeCitiesForSatellites.length * 4 + Math.floor(rand(seed, 1162) * 4)), minDistance: 8, seedOffset: 1160, - predicate: (x, y, i) => !sea[i] && prefectureMask[i], + predicate: (x, y, i) => isHumanRegionCell(i), }).map((p, n) => { const parent = largeCitiesForSatellites.slice().sort((a, b) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(b.x - p.x, b.y - p.y))[0]; const basePop = parent ? parent.population * (0.045 + rand(seed, 1165 + n) * 0.11) : 42000 + rand(seed, 1165 + n) * 90000; @@ -2103,6 +2883,7 @@ export function generateMapFeatures(seed, terrain) { const connectedPairs = new Set(); function addMinorRoad(a, b) { + if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) > 38) return; const key = `${a.x},${a.y}|${b.x},${b.y}`; const reverseKey = `${b.x},${b.y}|${a.x},${a.y}`; if (connectedPairs.has(key) || connectedPairs.has(reverseKey)) return; @@ -2137,20 +2918,20 @@ export function generateMapFeatures(seed, terrain) { if (nearestTrunk && nearestTrunk.d > 2.8 && nearestTrunk.d < 42) addMinorRoad(town, nearestTrunk); } const neighborTownNodes = localTownNodes.slice().sort((a, b) => (b.population || 0) - (a.population || 0)); - for (const town of neighborTownNodes.slice(0, 48)) { + for (const town of neighborTownNodes.slice(0, 28)) { const neighbor = pickEntities(neighborTownNodes.filter((p) => p !== town).map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - town.x, p.y - town.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; if (neighbor && Math.hypot(neighbor.x - town.x, neighbor.y - town.y) < 22) addMinorRoad(town, neighbor); } - for (const village of villages) { - if (rand(seed, village.x * 13 + village.y * 17) < 0.90) { + for (const village of villages.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 45)) { + if (rand(seed, village.x * 13 + village.y * 17) < 0.78) { const target = pickEntities(trunkNodes.map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - village.x, p.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; if (target && Math.hypot(target.x - village.x, target.y - village.y) < 34) addMinorRoad(village, target); } } - for (const market of markets) { - const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 4, minDistance: 1, threshold: 0 }); - for (const v of localVillages) addMinorRoad(market, v); + for (const market of markets.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 32)) { + const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 2, minDistance: 1, threshold: 0 }); + for (const v of localVillages) if (Math.hypot(v.x - market.x, v.y - market.y) < 24) addMinorRoad(market, v); } for (const pass of passes.slice(0, 8)) { const target = pickEntities([...markets, ...villages].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - pass.x, p.y - pass.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; @@ -2164,11 +2945,11 @@ export function generateMapFeatures(seed, terrain) { const target = pickEntities([...stations, ...markets, ...modernCities].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - localCenter.x, p.y - localCenter.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; if (target && Math.hypot(target.x - localCenter.x, target.y - localCenter.y) < 30) addMinorRoad(localCenter, target); } - for (const station of stations.slice(0, 28)) { - const locals = pickEntities([...villages, ...markets, ...ports].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - station.x, p.y - station.y)) })), { max: 2, minDistance: 1, threshold: 0 }); + for (const station of stations.slice(0, 12)) { + const locals = pickEntities([...villages, ...markets, ...ports].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - station.x, p.y - station.y)) })), { max: 1, minDistance: 1, threshold: 0 }); for (const local of locals) if (Math.hypot(local.x - station.x, local.y - station.y) < 22) addMinorRoad(station, local); } - for (const village of villages.slice(0, 42)) { + for (const village of villages.slice(0, 16)) { const neighbor = pickEntities(villages.filter((v) => v !== village).map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - village.x, v.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; if (neighbor && Math.hypot(neighbor.x - village.x, neighbor.y - village.y) < 14) addMinorRoad(village, neighbor); } @@ -2181,12 +2962,15 @@ export function generateMapFeatures(seed, terrain) { const expresswayAverageDensity = expresswayCells.length ? expresswayCells.reduce((sum, [x, y]) => sum + densityValue(x, y), 0) / expresswayCells.length : 0; const nationalRoadPopulationCoverageDebug = nationalRoadPopulationCoverage(nationalRoads, 8.5); const transportDebug = { + urbanHierarchy: urbanHierarchyDebug, requiredNodeCount: requiredTransportNodes.length, connectedRequiredNodeCount, throughExpresswayAdded, nationalRoadDeadEndRepairs, railDeadEndRepairs, nationalRoadBranchDemotions, + regionalNationalRoadsAdded, + regionalNationalRoadFallbacks, metroRadialNationalRoadsAdded, metroRingRoadSegmentsAdded, internalNationalRoadFallbacks, @@ -2200,6 +2984,12 @@ export function generateMapFeatures(seed, terrain) { expresswayPathCount: allExpresswayPaths.length, minorRoadCount: minorRoads.length, minorRoadTotalLength: Math.round(minorRoads.reduce((sum, path) => sum + pathLength(path), 0)), + settlementModel: { + villageCount: villages.length, + marketCount: markets.length, + averageUrbanFootprintCells: Number((modernCities.reduce((sum, city) => sum + (city.urbanFootprintCells || 0), 0) / Math.max(1, modernCities.length)).toFixed(1)), + maxUrbanFootprintCells: Math.max(0, ...modernCities.map((city) => city.urbanFootprintCells || 0)), + }, }; const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1); @@ -2232,17 +3022,18 @@ export function generateMapFeatures(seed, terrain) { const isolatedCorridor = roadInfluence[i] > 0.22 && cityInfluence[i] < 0.08 && stationInfluence[i] < 0.08 && interchangeInfluence[i] < 0.18; const ruralScore = villageInfluence[i] * 0.3 + agriculture[i] * 0.38 + plain[i] * 0.18 - slope[i] * 0.08; - if (mountain) landuse[i] = 9; - else if (industrialInfluence[i] > 0.44) landuse[i] = 5; - else if (logisticsInfluence[i] > 0.42) landuse[i] = 6; - else if (newTownInfluence[i] > 0.42 && urbanEnvelope > 0.16) landuse[i] = 7; - else if (coreScore > 0.68 && density > 0.48 && stationInfluence[i] > 0.05 && slope[i] < 0.24 && ridgeField[i] < 0.36) landuse[i] = 3; - else if (oldTownScore > 0.49) landuse[i] = 2; - else if (suburbScore > 0.235 && !isolatedCorridor && slope[i] < 0.32 && ridgeField[i] < 0.48 && (normalizedUrbanDistance < 1.42 || satelliteInfluence[i] > 0.24)) landuse[i] = 4; - else if (roadsideScore > 0.5 && plain[i] > 0.18 && slope[i] < 0.34 && ridgeField[i] < 0.5 && !isolatedCorridor && (interchangeInfluence[i] > 0.24 || logisticsInfluence[i] > 0.16 || cityInfluence[i] > 0.09)) landuse[i] = 8; - else if (farm) landuse[i] = 1; - else if (ruralScore > 0.3) landuse[i] = 0; - else landuse[i] = 0; + if (mountain && !urbanFootprint[i] && !oldUrbanFootprint[i]) landuse[i] = LANDUSE.FOREST; + else if (urbanCoreFootprint[i] || (coreScore > 0.72 && density > 0.52 && slope[i] < 0.26 && ridgeField[i] < 0.38)) landuse[i] = LANDUSE.CBD; + else if (oldUrbanFootprint[i] || oldTownScore > 0.49) landuse[i] = LANDUSE.OLD_URBAN; + else if (industrialInfluence[i] > 0.48 && !urbanCoreFootprint[i]) landuse[i] = LANDUSE.INDUSTRIAL; + else if (logisticsInfluence[i] > 0.44 && !urbanCoreFootprint[i]) landuse[i] = LANDUSE.LOGISTICS; + else if (newTownInfluence[i] > 0.42 && urbanEnvelope > 0.16) landuse[i] = LANDUSE.NEW_TOWN; + else if (urbanFootprint[i]) landuse[i] = LANDUSE.SUBURB; + else if (suburbScore > 0.235 && !isolatedCorridor && slope[i] < 0.32 && ridgeField[i] < 0.48 && (normalizedUrbanDistance < 1.42 || satelliteInfluence[i] > 0.24)) landuse[i] = LANDUSE.SUBURB; + else if (roadsideScore > 0.5 && plain[i] > 0.18 && slope[i] < 0.34 && ridgeField[i] < 0.5 && !isolatedCorridor && (interchangeInfluence[i] > 0.24 || logisticsInfluence[i] > 0.16 || cityInfluence[i] > 0.09)) landuse[i] = LANDUSE.ROADSIDE; + else if (farm) landuse[i] = LANDUSE.FARMLAND; + else if (ruralSettlementFootprint[i] || ruralScore > 0.3) landuse[i] = LANDUSE.RURAL; + else landuse[i] = LANDUSE.RURAL; } } @@ -2254,7 +3045,7 @@ export function generateMapFeatures(seed, terrain) { const ny = y + dy; if (!inside(nx, ny)) continue; const lu = landuse[indexOf(nx, ny)]; - if (lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8) urban++; + if (isUrbanResidentialLanduse(lu)) urban++; } } return urban >= minUrban; @@ -2265,9 +3056,9 @@ export function generateMapFeatures(seed, terrain) { const namedCenters = [...modernCities, ...(satelliteCities || []), ...markets, ...ports, ...newTowns, ...stations]; const queue = []; for (let i = 0; i < SIZE; i++) { - if (seen[i] || !prefectureMask[i] || sea[i]) continue; + if (seen[i] || !isHumanRegionCell(i)) continue; const lu0 = landuse[i]; - if (!(lu0 >= 2 && lu0 <= 8)) continue; + if (!isBuiltLanduse(lu0)) continue; const component = []; let maxDensity = 0; queue.length = 0; @@ -2280,8 +3071,8 @@ export function generateMapFeatures(seed, terrain) { const [x, y] = xyOf(cur); for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); - if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; - if (!(landuse[ni] >= 2 && landuse[ni] <= 8)) continue; + if (seen[ni] || !isHumanRegionCell(ni)) continue; + if (!isBuiltLanduse(landuse[ni])) continue; seen[ni] = 1; queue.push(ni); } @@ -2296,7 +3087,7 @@ export function generateMapFeatures(seed, terrain) { } } if (!hasAnchor) { - for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? 1 : 0; + for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? LANDUSE.FARMLAND : LANDUSE.RURAL; } } } @@ -2309,14 +3100,14 @@ export function generateMapFeatures(seed, terrain) { for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); - if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4; + if (landuse[i] === LANDUSE.CBD && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = LANDUSE.SUBURB; } } function growDidCore(center, city, salt) { if (!center || !city) return 0; const start = indexOf(center.x, center.y); - if (sea[start] || !prefectureMask[start]) return 0; + if (!isHumanRegionCell(start)) return 0; if ((city.population || 0) < 220000) return 0; const targetCells = Math.round(clamp(2 + Math.sqrt(city.population || 80000) / 74, 4, 22)); const maxRadius = clamp((city.coreRadius || 3) * 2.4 + Math.sqrt(city.population || 80000) / 260, 6, 16); @@ -2333,16 +3124,16 @@ export function generateMapFeatures(seed, terrain) { const i = cur.i; const d = Math.hypot(x - center.x, y - center.y); const support = populationDensity[i] * 1.18 + cityInfluence[i] * 0.22 + stationInfluence[i] * 0.18 + plain[i] * 0.12 - slope[i] * 1.24 - ridgeField[i] * 0.54 - Math.max(0, elevation[i] - 0.58) * 0.50 - floodplain[i] * 0.08 - d / maxRadius * 0.22; - if (d > maxRadius || support < 0.44 || sea[i] || !prefectureMask[i]) continue; - if (!(landuse[i] === 2 || landuse[i] === 3 || landuse[i] === 4 || landuse[i] === 7 || populationDensity[i] > 0.22 || stationInfluence[i] > 0.14)) continue; + if (d > maxRadius || support < 0.44 || !isHumanRegionCell(i)) continue; + if (!(landuse[i] === LANDUSE.OLD_URBAN || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.SUBURB || landuse[i] === LANDUSE.NEW_TOWN || populationDensity[i] > 0.22 || stationInfluence[i] > 0.14)) continue; selected.add(i); - landuse[i] = 3; + landuse[i] = LANDUSE.CBD; made++; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); - if (queued.has(ni) || selected.has(ni) || sea[ni] || !prefectureMask[ni]) continue; + if (queued.has(ni) || selected.has(ni) || !isHumanRegionCell(ni)) continue; const nd = Math.hypot(nx - center.x, ny - center.y); if (nd > maxRadius + 1) continue; const score = populationDensity[ni] * 1.24 + cityInfluence[ni] * 0.22 + stationInfluence[ni] * 0.18 + plain[ni] * 0.12 - slope[ni] * 1.25 - ridgeField[ni] * 0.54 - nd / maxRadius * 0.22 + hash2(nx, ny, seed + salt) * 0.03; @@ -2358,7 +3149,7 @@ export function generateMapFeatures(seed, terrain) { for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); - if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4; + if (landuse[i] === LANDUSE.CBD && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = LANDUSE.SUBURB; } } diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 85a4117..2eba1b0 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -475,6 +475,9 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0; for (let pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260); + const displayRegionId = new Int16Array(beforeRegionId); + for (let pass = 0; pass < 3; pass++) repairRegionalTopology(displayRegionId, sea, seeded.centers, anchorMask, 200); + let changed = 0; for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++; const afterBorderCount = countRegionBorderEdges(regionId, sea); @@ -484,6 +487,7 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, return { regionId, + displayRegionId, centers: seeded.centers, naturalBarrierScore, debug: { @@ -494,6 +498,8 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, regionalVoronoiLikeRateAfter: afterVoronoiLikeRate, regionalNaturalBarrierAverageBefore: beforeNaturalAverage, regionalNaturalBarrierAverageAfter: afterNaturalAverage, + regionalDisplayBorderCount: countRegionBorderEdges(displayRegionId, sea), + regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(displayRegionId, sea, naturalBarrierScore), regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length, compartmentCount: compartments.filter((unit) => unit.area > 0).length, changedAfterCompartmentAssignment: changed, @@ -961,12 +967,17 @@ export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, } } } - const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000; - const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200); + const capitalLike = city.isPrefecturalCapital || city.isRegionalCapital; + const base = city.isPrefecturalCapital ? 90000 : city.isRegionalCapital ? 62000 : city.kind === "Satellite City" ? 16000 : 32000; + const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.isRegionalCapital ? 1350 : city.kind === "Satellite City" ? 900 : 1200); const coreComponent = coreCells * 3200; - const densityComponent = densitySum * 650; - city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000; - city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28); - city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9); + const densityComponent = densitySum * 360; + const computedPopulation = base + urbanComponent + coreComponent + densityComponent; + const footprintCells = city.urbanFootprintCells || urbanCells; + const footprintCoreCells = city.coreFootprintCells || coreCells; + const footprintCap = base + footprintCells * (city.isPrefecturalCapital ? 8500 : city.isRegionalCapital ? 7000 : city.kind === "Satellite City" ? 4300 : 5200) + footprintCoreCells * (city.isPrefecturalCapital ? 10500 : 9000); + city.population = Math.round(Math.max(base, Math.min(computedPopulation, footprintCap)) / 1000) * 1000; + city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, capitalLike ? 34 : 28); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, capitalLike ? 9 : 8); } } diff --git a/mapOutput.js b/mapOutput.js index 1d8c319..02e21d1 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -2,6 +2,28 @@ import { createNameDebug } from "./names.js"; import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js"; import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; +const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u; + +function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) { + const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0; + const density = fields.populationDensity?.[i] || 0; + const land = fields.landuse?.[i] ?? 0; + const urban = density > 0.36 || [2, 3, 4, 7, 8].includes(land) || center?.protectedSatellite; + const rural = (fields.elevation?.[i] || 0) > 0.58 || (fields.slope?.[i] || 0) > 0.40 || (fields.ridgeField?.[i] || 0) > 0.46; + if (urban) return "市"; + if (rural && rand(seed + ordinal * 17, 9021) < 0.58) return "村"; + return "町"; +} + +function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) { + let value = String(root || center?.name || "").trim(); + if (!value) value = `自治${ordinal + 1}`; + value = value.replace(/[駅港城跡宿]$/u, ""); + if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3); + if (MUNICIPAL_SUFFIX_RE.test(value)) return value; + return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`; +} + export function finishMapOutput({ seed, options, @@ -84,8 +106,12 @@ export function finishMapOutput({ regionalPrefectureBorders, }) { // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. - // This keeps population figures proportional to the actually rendered urbanized area. - recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2); + // Use all generated prefecture regions for human-geography density, not only the focused prefecture. + const humanRegionMask = new Uint8Array(MAP_W * MAP_H); + for (let i = 0; i < humanRegionMask.length; i++) { + humanRegionMask[i] = !sea[i] && ((prefectureRegionId?.[i] ?? -1) >= 0 || prefectureMask[i]) ? 1 : 0; + } + recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, humanRegionMask, sea, stationInfluence, roadInfluence, railInfluence2); for (const city of modernCities) { if (city.isPrefecturalCapital) continue; const cap = cityPopulationCap(city); @@ -145,13 +171,13 @@ export function finishMapOutput({ newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug); castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); - const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); + const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); const representativeFeatures = [ ...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })), ...markets.map((p) => ({ ...p, representativeWeight: 3.2 })), ...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })), ...villages.map((p) => ({ ...p, representativeWeight: 1.6 })), - ].filter((p) => p.insidePrefecture && p.name); + ].filter((p) => p.name && (p.insidePrefecture || humanRegionMask[indexOf(p.x, p.y)])); for (const center of adminCenters) { const centerAdmin = adminId?.[indexOf(center.x, center.y)]; let best = null; @@ -169,21 +195,30 @@ export function finishMapOutput({ } if (best) break; } + center.generatedMunicipalityName = center.generatedMunicipalityName || center.name; if (best) { center.representativeFeatureId = best.id; center.representativeFeatureName = best.name; - center.generatedMunicipalityName = center.name; - center.name = best.name; + center.municipalityRootName = best.name; + } else { + center.municipalityRootName = center.generatedMunicipalityName; } } const usedAdminNames = new Set(); - for (const center of adminCenters) { - let candidate = center.name; - const generated = String(center.generatedMunicipalityName || ""); + for (const [index, center] of adminCenters.entries()) { + let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index); + const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index); if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { candidate = generated; } + if (usedAdminNames.has(candidate)) { + const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index); + const base = String(center.generatedMunicipalityName || center.municipalityRootName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, ""); + candidate = `${base}${index + 1}${suffix}`; + } center.name = candidate; + center.labelName = candidate; + center.municipalityName = candidate; usedAdminNames.add(center.name); } nameDebug.maxDerivedPerBase = 0; @@ -212,6 +247,7 @@ export function finishMapOutput({ terrainTemplate, seaLevel, prefectureMask, + humanRegionMask, prefectureBorder, prefectureRegionId, regionalDebug, diff --git a/mapPipeline.js b/mapPipeline.js index 841334f..bc32a17 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -44,6 +44,7 @@ export function generateMap(seedInput = 114514, options = {}) { prefectureMask, prefectureBorder, prefectureRegionId, + adminPrefectureRegionId, regionalDebug, terrainDebug, regionalPrefectureBorders, @@ -61,7 +62,7 @@ export function generateMap(seedInput = 114514, options = {}) { } = features; const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ - seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, }); diff --git a/mapTerrain.js b/mapTerrain.js index 89bbb9e..35f7f44 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -38,6 +38,15 @@ function quantile(values, q) { return lerp(arr[i], arr[Math.min(arr.length - 1, i + 1)], f); } +function softCapElevation(e, start = 0.91, cap = 1.08) { + if (e <= start) return e; + const over = e - start; + const span = Math.max(0.001, cap - start); + // Hard clipping made high mountains become flat mesas. This keeps peaks high, + // but compresses only the excess so local relief survives near the top. + return start + span * (1 - Math.exp(-over / span)); +} + function forDisk(cx, cy, radius, fn) { const r = Math.ceil(radius); for (let dy = -r; dy <= r; dy++) { @@ -131,7 +140,12 @@ function ellipticalMask(px, py, system) { const dy = py - system.y; const { u, v } = rotate(dx, dy, system.angle); const a = Math.max(0.01, system.length * 0.5); - const b = Math.max(0.01, system.width * 0.5); + const along = clamp((u / a + 1) * 0.5); + const widthWave = 1 + + (system.widthVariance ?? 0.28) * Math.sin((along + (system.phase ?? 0)) * Math.PI * 2.0) + + (system.widthVariance ?? 0.28) * 0.50 * Math.sin((along * 2.7 + (system.phase ?? 0) * 1.7) * Math.PI * 2.0); + const endTaper = lerp(0.60, 1.0, Math.sin(along * Math.PI)); + const b = Math.max(0.012, system.width * 0.5 * clamp(widthWave, 0.62, 1.60) * endTaper); const r = Math.sqrt((u / a) ** 2 + (v / b) ** 2); return clamp(1 - smoothstep((r - 0.55) / 0.65)); } @@ -142,35 +156,203 @@ function sampleInsideUnitDisk(seed, n) { return { x: Math.cos(a) * r, y: Math.sin(a) * r, r }; } +const TERRAIN_TYPES = [ + { + id: "tohoku_spine", + label: "東北型・長大脊梁", + weight: 0.24, + coastStyle: "parallel_spine", + mountainMode: "range", + massifnessRange: [0.06, 0.26], + seaRatioRange: [0.13, 0.23], + twoSidedChance: 0.96, + mountainOffsetRange: [0.47, 0.53], + baseHeightRange: [0.74, 1.10], + primaryLengthRange: [0.76, 0.96], + primaryWidthRange: [0.17, 0.30], + systemCountRange: [12, 16], + beltCountRange: [3, 4], + angleSpread: 0.14, + crossSpread: 0.54, + lengthScale: 1.22, + widthScale: 1.16, + heightScale: 1.24, + coastStrength: 0.90, + plainBiasRange: [0.16, 0.34], + riverRichnessRange: [0.70, 1.18], + bigRiverChanceRange: [0.22, 0.46], + }, + { + id: "chubu_mountain", + label: "中部型・交差高山地", + weight: 0.24, + coastStyle: "outer_coast", + mountainMode: "massif", + massifnessRange: [0.42, 0.74], + seaRatioRange: [0.10, 0.20], + twoSidedChance: 0.20, + mountainOffsetRange: [0.16, 0.36], + baseHeightRange: [0.68, 1.04], + primaryLengthRange: [0.62, 0.92], + primaryWidthRange: [0.30, 0.58], + systemCountRange: [16, 20], + beltCountRange: [3, 5], + angleSpread: 0.92, + crossSpread: 0.82, + lengthScale: 1.34, + widthScale: 1.30, + heightScale: 1.12, + coastStrength: 0.74, + plainBiasRange: [0.08, 0.24], + riverRichnessRange: [0.72, 1.12], + bigRiverChanceRange: [0.28, 0.58], + }, + { + id: "setouchi_inland_sea", + label: "瀬戸内型・内海多島", + weight: 0.16, + coastStyle: "inland_sea", + mountainMode: "mixed", + massifnessRange: [0.24, 0.48], + seaRatioRange: [0.20, 0.33], + twoSidedChance: 0.92, + mountainOffsetRange: [0.22, 0.34], + baseHeightRange: [0.56, 1.00], + primaryLengthRange: [0.52, 0.76], + primaryWidthRange: [0.18, 0.34], + systemCountRange: [12, 16], + beltCountRange: [2, 3], + angleSpread: 0.24, + crossSpread: 0.70, + lengthScale: 0.98, + widthScale: 1.08, + heightScale: 1.08, + coastStrength: 1.10, + plainBiasRange: [0.26, 0.50], + riverRichnessRange: [0.58, 0.96], + bigRiverChanceRange: [0.18, 0.42], + }, + { + id: "kanto_alluvial", + label: "関東・濃尾型・大河川平野", + weight: 0.16, + coastStyle: "open_bay", + mountainMode: "range", + massifnessRange: [0.18, 0.44], + seaRatioRange: [0.15, 0.26], + twoSidedChance: 0.18, + mountainOffsetRange: [0.28, 0.46], + baseHeightRange: [0.62, 1.04], + primaryLengthRange: [0.42, 0.70], + primaryWidthRange: [0.20, 0.36], + systemCountRange: [10, 14], + beltCountRange: [2, 3], + angleSpread: 0.34, + crossSpread: 0.62, + lengthScale: 0.92, + widthScale: 1.10, + heightScale: 1.10, + coastStrength: 0.92, + plainBiasRange: [0.56, 0.86], + riverRichnessRange: [0.98, 1.38], + bigRiverChanceRange: [0.62, 0.90], + }, + { + id: "mixed_archipelago", + label: "混合型・列島変化", + weight: 0.20, + coastStyle: "mixed_archipelago", + mountainMode: "mixed", + massifnessRange: [0.16, 0.72], + seaRatioRange: [0.13, 0.29], + twoSidedChance: 0.42, + mountainOffsetRange: [0.18, 0.40], + baseHeightRange: [0.68, 1.18], + primaryLengthRange: [0.46, 0.82], + primaryWidthRange: [0.20, 0.48], + systemCountRange: [14, 17], + beltCountRange: [3, 4], + angleSpread: 0.50, + crossSpread: 0.74, + lengthScale: 1.00, + widthScale: 1.18, + heightScale: 1.25, + coastStrength: 0.96, + plainBiasRange: [0.22, 0.52], + riverRichnessRange: [0.72, 1.26], + bigRiverChanceRange: [0.34, 0.68], + }, +]; + +function pickTerrainType(seed) { + const total = TERRAIN_TYPES.reduce((sum, type) => sum + type.weight, 0); + let r = rand(seed, 10001) * total; + for (const type of TERRAIN_TYPES) { + r -= type.weight; + if (r <= 0) return type; + } + return TERRAIN_TYPES[TERRAIN_TYPES.length - 1]; +} + +function rangeValue(seed, salt, [lo, hi]) { + return lo + rand(seed, salt) * (hi - lo); +} + +function rangeInt(seed, salt, [lo, hi]) { + return Math.round(lo + rand(seed, salt) * (hi - lo)); +} + export function buildTerrainTemplate(seed) { - const mountainModeRoll = rand(seed, 12); - const mountainMode = mountainModeRoll < 0.48 ? "range" : mountainModeRoll < 0.80 ? "mixed" : "massif"; - const mountainMassifness = mountainMode === "massif" ? 0.72 + rand(seed, 13) * 0.24 : mountainMode === "mixed" ? 0.34 + rand(seed, 14) * 0.36 : rand(seed, 15) * 0.24; - const coastAngle = rand(seed, 21) * Math.PI * 2; - const twoSidedCoast = rand(seed, 22) < 0.36; - const seaRatio = 0.14 + rand(seed, 23) * 0.16; - const mountainAngle = coastAngle + Math.PI * (0.26 + rand(seed, 24) * 0.48); - const baseHeight = 0.52 + rand(seed, 25) * 0.46; - const primaryLength = lerp(0.70 + rand(seed, 26) * 0.22, 0.38 + rand(seed, 27) * 0.20, mountainMassifness); - const primaryWidth = lerp(0.15 + rand(seed, 28) * 0.13, 0.36 + rand(seed, 29) * 0.20, mountainMassifness); - const scratchCount = Math.round(lerp(26 + rand(seed, 30) * 22, 18 + rand(seed, 31) * 18, mountainMassifness)); - // 脊梁山脈そのものを複数箇所に置く。旧版の secondary は主山脈の周囲に寄りすぎ、 - // 画面上では「単一の山塊」に見えやすかったため、独立した major system として扱う。 - const mountainSystemCount = 14 + Math.floor(rand(seed, 32) * 3); // 14〜16 + const terrainType = pickTerrainType(seed); + const mountainMode = terrainType.mountainMode === "mixed" + ? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif") + : terrainType.mountainMode; + let mountainMassifness = rangeValue(seed, 14, terrainType.massifnessRange); + if (mountainMode === "range") mountainMassifness *= 0.70; + if (mountainMode === "massif") mountainMassifness = clamp(mountainMassifness + 0.12); + + let coastAngle = rand(seed, 21) * Math.PI * 2; + let twoSidedCoast = rand(seed, 22) < terrainType.twoSidedChance; + const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange); + let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange)); + if (terrainType.id === "tohoku_spine") { + // 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。 + // coastAngle は海へ向かう勾配方向、等値線としての海岸線は +90° 方向。 + coastAngle = (rand(seed, 2101) < 0.5 ? 0 : Math.PI / 2) + (rand(seed, 2102) - 0.5) * 0.10; + twoSidedCoast = true; + mountainAngle = coastAngle + Math.PI / 2 + (rand(seed, 2103) - 0.5) * 0.16; + } + const baseHeight = rangeValue(seed, 25, terrainType.baseHeightRange); + const primaryLength = rangeValue(seed, 26, terrainType.primaryLengthRange); + const primaryWidth = rangeValue(seed, 28, terrainType.primaryWidthRange); + const scratchCount = Math.round(lerp(24 + rand(seed, 30) * 20, 16 + rand(seed, 31) * 18, mountainMassifness)); + const mountainSystemCount = rangeInt(seed, 32, terrainType.systemCountRange); + const mountainBeltCount = rangeInt(seed, 46, terrainType.beltCountRange); + return { seed, + terrainType: terrainType.id, + terrainTypeLabel: terrainType.label, + coastStyle: terrainType.coastStyle, seaRatio, coastAngle, twoSidedCoast, - coastNoise: 0.045 + rand(seed, 33) * 0.045, + coastNoise: 0.040 + rand(seed, 33) * 0.056, + coastStrength: terrainType.coastStrength, mountainMode, mountainMassifness, mountainAngle, + mountainAngleSpread: terrainType.angleSpread, + mountainCrossSpread: terrainType.crossSpread, + mountainLengthScale: terrainType.lengthScale, + mountainWidthScale: terrainType.widthScale, + mountainHeightScale: terrainType.heightScale, + mountainBeltCount, mountainBaseHeight: baseHeight, - mountainDensity: 0.62 + rand(seed, 34) * 0.35, + mountainDensity: 0.58 + rand(seed, 34) * 0.39, primaryMountain: { - x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.28, 0.22, 0.78), - y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.28, 0.22, 0.78), + x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.36, 0.18, 0.82), + y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.36, 0.18, 0.82), angle: mountainAngle, length: primaryLength, width: primaryWidth, @@ -186,9 +368,9 @@ export function buildTerrainTemplate(seed) { roughness: 0.40 + rand(seed, 40) * 0.50, erosion: 0.34 + rand(seed, 41) * 0.48, deposition: 0.28 + rand(seed, 42) * 0.56, - riverRichness: 0.72 + rand(seed, 43) * 0.60, - bigRiverChance: 0.34 + rand(seed, 44) * 0.34, - plainBias: 0.32 + rand(seed, 45) * 0.46, + riverRichness: rangeValue(seed, 43, terrainType.riverRichnessRange), + bigRiverChance: rangeValue(seed, 44, terrainType.bigRiverChanceRange), + plainBias: rangeValue(seed, 45, terrainType.plainBiasRange), }; } @@ -196,85 +378,146 @@ function buildMountainSystems(template, seed) { const systems = []; const targetCount = Math.max(8, template.mountainSystemCount ?? 15); const baseAngle = template.mountainAngle; + const angleSpread = template.mountainAngleSpread ?? 0.42; + const crossSpread = template.mountainCrossSpread ?? 0.70; + const lengthScale = template.mountainLengthScale ?? 1; + const widthScale = template.mountainWidthScale ?? 1; + const heightScale = template.mountainHeightScale ?? 1; + const isChubu = template.terrainType === "chubu_mountain"; + const isTohoku = template.terrainType === "tohoku_spine"; - // 複数の脊梁山脈システムを、画面中央ではなくマップ全域に分散配置する。 - // 5x3 / 4x4 に近い粗い格子へ jitter を入れ、さらに farthest-candidate で - // 既存システムから離れた候補を選ぶ。これにより「中央に単一山塊」化しにくくする。 - const cols = targetCount >= 14 ? 5 : 4; - const rows = Math.ceil(targetCount / cols); - const cellOrder = Array.from({ length: cols * rows }, (_, i) => i) - .map((v) => ({ v, key: rand(seed, 1000 + v * 17) })) - .sort((a, b) => a.key - b.key) - .map((o) => o.v); + // 山脈システムは完全ランダムではなく、複数の広い造山帯に沿って配置する。 + // これにより「方向性はそこそこ揃う」が、「中央一点に集まらない」分布になる。 + const beltCount = Math.max(1, template.mountainBeltCount ?? (targetCount >= 15 ? 4 : 3)); + const belts = []; + for (let b = 0; b < beltCount; b++) { + const t = beltCount === 1 ? 0 : (b / (beltCount - 1) - 0.5); + const angle = baseAngle + (rand(seed, 1000 + b) - 0.5) * angleSpread; + const axisX = Math.cos(angle); + const axisY = Math.sin(angle); + const crossX = Math.cos(angle + Math.PI / 2); + const crossY = Math.sin(angle + Math.PI / 2); + const crossOffset = t * crossSpread + (rand(seed, 1010 + b) - 0.5) * (0.10 + crossSpread * 0.08); + const alongShift = (rand(seed, 1020 + b) - 0.5) * 0.22; + belts.push({ + angle, + x: clamp(0.50 + axisX * alongShift / ASPECT + crossX * crossOffset / ASPECT, 0.08, 0.92), + y: clamp(0.50 + axisY * alongShift + crossY * crossOffset, 0.08, 0.92), + lengthBias: 0.82 + rand(seed, 1030 + b) * 0.32, + heightBias: 0.82 + rand(seed, 1040 + b) * 0.42, + }); + } - function gridCandidate(k, attempt) { - const cell = cellOrder[(k + attempt * 7) % cellOrder.length]; - const cx = cell % cols; - const cy = Math.floor(cell / cols); - const jitterX = (rand(seed, 1100 + k * 101 + attempt * 13) - 0.5) * 0.62; - const jitterY = (rand(seed, 1200 + k * 101 + attempt * 13) - 0.5) * 0.62; - const x = clamp((cx + 0.5 + jitterX) / cols, 0.055, 0.945); - const y = clamp((cy + 0.5 + jitterY) / rows, 0.055, 0.945); - const localTurn = (rand(seed, 1300 + k * 101 + attempt) - 0.5) * Math.PI * 0.92; - const diagonalBias = (cx / Math.max(1, cols - 1) - 0.5 + (cy / Math.max(1, rows - 1) - 0.5) * 0.35) * 0.16; + function beltCandidate(k, attempt) { + const beltIndex = (k + Math.floor(k / beltCount)) % beltCount; + const belt = belts[beltIndex]; + const perBelt = Math.ceil(targetCount / beltCount); + const ordinal = Math.floor(k / beltCount); + const baseT = perBelt <= 1 ? 0 : ordinal / (perBelt - 1) - 0.5; + const alongJitter = (rand(seed, 1100 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? 0.15 : 0.28); + const crossJitter = (rand(seed, 1200 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? crossSpread * 0.22 : crossSpread * 0.40); + const along = (baseT + alongJitter) * 1.03 * belt.lengthBias; + const cross = crossJitter; + const axisX = Math.cos(belt.angle); + const axisY = Math.sin(belt.angle); + const crossX = Math.cos(belt.angle + Math.PI / 2); + const crossY = Math.sin(belt.angle + Math.PI / 2); return { - x, - y, - angle: baseAngle + localTurn + diagonalBias, + x: clamp(belt.x + axisX * along / ASPECT + crossX * cross / ASPECT, 0.045, 0.955), + y: clamp(belt.y + axisY * along + crossY * cross, 0.045, 0.955), + angle: belt.angle + (rand(seed, 1300 + k * 79 + attempt) - 0.5) * angleSpread * 0.82, + beltIndex, }; } - function randomCandidate(k, attempt) { - return { - x: clamp(0.055 + rand(seed, 2000 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945), - y: clamp(0.055 + rand(seed, 2100 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945), - angle: baseAngle + (rand(seed, 2200 + k * 137 + attempt) - 0.5) * Math.PI * 1.05, - }; + function edgeAwareScore(c) { + const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y); + const centerD = distNorm(c.x, c.y, 0.5, 0.5); + return Math.min(edgeD, 0.16) * 0.16 + centerD * 0.08; } - function candidateAt(k, attempt) { - return attempt < 5 ? gridCandidate(k, attempt) : randomCandidate(k, attempt); + if (isTohoku) { + const centralAngle = baseAngle + (rand(seed, 3330) - 0.5) * 0.06; + const centralAlong = (rand(seed, 3331) - 0.5) * 0.10; + const centralCross = (rand(seed, 3332) - 0.5) * 0.045; + systems.push({ + x: clamp(0.50 + Math.cos(centralAngle) * centralAlong / ASPECT + Math.cos(centralAngle + Math.PI / 2) * centralCross / ASPECT, 0.12, 0.88), + y: clamp(0.50 + Math.sin(centralAngle) * centralAlong + Math.sin(centralAngle + Math.PI / 2) * centralCross, 0.12, 0.88), + angle: centralAngle, + length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale, + width: (0.17 + rand(seed, 3334) * 0.11) * widthScale, + height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18), + scratchCount: Math.round(20 + rand(seed, 3336) * 10), + massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55), + role: "central-primary", + beltIndex: 0, + widthVariance: 0.30 + rand(seed, 3337) * 0.46, + phase: rand(seed, 3338), + }); + + if (rand(seed, 3339) < 0.72) { + const side = rand(seed, 3340) < 0.5 ? -1 : 1; + systems.push({ + x: clamp(0.50 + Math.cos(centralAngle) * (centralAlong + side * 0.18) / ASPECT + Math.cos(centralAngle + Math.PI / 2) * (centralCross + side * 0.028) / ASPECT, 0.10, 0.90), + y: clamp(0.50 + Math.sin(centralAngle) * (centralAlong + side * 0.18) + Math.sin(centralAngle + Math.PI / 2) * (centralCross + side * 0.028), 0.10, 0.90), + angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08, + length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale, + width: (0.11 + rand(seed, 3343) * 0.08) * widthScale, + height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16), + scratchCount: Math.round(12 + rand(seed, 3345) * 8), + massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65), + role: "central-secondary", + beltIndex: 0, + widthVariance: 0.24 + rand(seed, 3346) * 0.36, + phase: rand(seed, 3347), + }); + } } - for (let k = 0; k < targetCount; k++) { - let best = candidateAt(k, 0); + for (let k = systems.length; k < targetCount; k++) { + let best = beltCandidate(k, 0); let bestScore = -INF; - for (let attempt = 0; attempt < 18; attempt++) { - const c = candidateAt(k, attempt); + for (let attempt = 0; attempt < 12; attempt++) { + const c = beltCandidate(k, attempt); let minD = 999; for (const s of systems) minD = Math.min(minD, distNorm(c.x, c.y, s.x, s.y)); - // 中央集中を避けるため、中心距離を少し加点する。ただし端に張り付きすぎないよう edge も見る。 - const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y); - const centerD = distNorm(c.x, c.y, 0.5, 0.5); - const score = - minD * 1.25 + - centerD * 0.18 + - Math.min(edgeD, 0.16) * 0.22 + - rand(seed, 2300 + k * 101 + attempt) * 0.04; + const score = minD * 1.05 + edgeAwareScore(c) + rand(seed, 2300 + k * 101 + attempt) * 0.035; if (score > bestScore) { bestScore = score; best = c; } } - const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.50); + const belt = belts[best.beltIndex]; + const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.38); const isMassif = m > 0.58; - const major = k < 4 || rand(seed, 2500 + k) > 0.68; + const major = k < beltCount || rand(seed, 2500 + k) > 0.72; + const lengthBaseRange = isChubu + ? (major ? [0.48, 0.78] : [0.34, 0.58]) + : isTohoku + ? (major ? [0.44, 0.72] : [0.28, 0.48]) + : (major ? [0.32, 0.52] : [0.21, 0.36]); + const lengthMassifRange = isChubu + ? (major ? [0.38, 0.58] : [0.28, 0.44]) + : (major ? [0.23, 0.35] : [0.17, 0.27]); const length = lerp( - major ? 0.30 + rand(seed, 2600 + k) * 0.22 : 0.20 + rand(seed, 2610 + k) * 0.16, - major ? 0.22 + rand(seed, 2620 + k) * 0.14 : 0.16 + rand(seed, 2630 + k) * 0.12, + lengthBaseRange[0] + rand(seed, 2600 + k) * (lengthBaseRange[1] - lengthBaseRange[0]), + lengthMassifRange[0] + rand(seed, 2620 + k) * (lengthMassifRange[1] - lengthMassifRange[0]), m - ); + ) * belt.lengthBias * lengthScale; + const widthRangeA = major ? [0.082, 0.170] : [0.060, 0.120]; + const widthRangeB = major ? [0.150, 0.260] : [0.110, 0.200]; const width = lerp( - major ? 0.055 + rand(seed, 2700 + k) * 0.060 : 0.040 + rand(seed, 2710 + k) * 0.045, - major ? 0.120 + rand(seed, 2720 + k) * 0.090 : 0.085 + rand(seed, 2730 + k) * 0.070, + widthRangeA[0] + rand(seed, 2700 + k) * (widthRangeA[1] - widthRangeA[0]), + widthRangeB[0] + rand(seed, 2720 + k) * (widthRangeB[1] - widthRangeB[0]), m - ); - const height = template.mountainBaseHeight * ( + ) * widthScale * (0.82 + rand(seed, 2740 + k) * 0.46); + const heightBase = template.mountainBaseHeight * belt.heightBias * heightScale * ( major - ? 0.34 + rand(seed, 2800 + k) * 0.24 - : 0.20 + rand(seed, 2810 + k) * 0.18 + ? 0.44 + rand(seed, 2800 + k) * 0.28 + : 0.26 + rand(seed, 2810 + k) * 0.20 ); + const height = isChubu ? heightBase * 0.82 : heightBase; const scratchCount = Math.round(lerp( - major ? 10 + rand(seed, 2900 + k) * 10 : 6 + rand(seed, 2910 + k) * 7, - isMassif ? 8 + rand(seed, 2920 + k) * 9 : 6 + rand(seed, 2930 + k) * 7, + major ? 9 + rand(seed, 2900 + k) * 9 : 6 + rand(seed, 2910 + k) * 6, + isMassif ? 8 + rand(seed, 2920 + k) * 8 : 6 + rand(seed, 2930 + k) * 6, m )); @@ -287,7 +530,10 @@ function buildMountainSystems(template, seed) { height, scratchCount, massifness: m, - role: major ? (k < 4 ? "primary" : "major") : "minor", + role: major ? (k < beltCount ? "primary" : "major") : "minor", + beltIndex: best.beltIndex, + widthVariance: 0.18 + rand(seed, 3100 + k) * 0.46, + phase: rand(seed, 3200 + k), }); } @@ -308,8 +554,8 @@ function buildScratchRidges(system, seed, systemId) { const x = clamp(system.x + Math.cos(system.angle) * along / ASPECT + Math.cos(system.angle + Math.PI / 2) * cross / ASPECT, 0.03, 0.97); const y = clamp(system.y + Math.sin(system.angle) * along + Math.sin(system.angle + Math.PI / 2) * cross, 0.03, 0.97); const len = lerp(system.length * (0.18 + rand(seed, 2200 + i) * 0.20), system.width * (0.32 + rand(seed, 2200 + i) * 0.30), system.massifness); - const width = lerp(0.010 + rand(seed, 2300 + i) * 0.012, 0.018 + rand(seed, 2300 + i) * 0.020, system.massifness) * (0.80 + density * 0.60); - const height = system.height * (0.040 + density * 0.095 + rand(seed, 2400 + i) * 0.035); + const width = lerp(0.014 + rand(seed, 2300 + i) * 0.018, 0.024 + rand(seed, 2300 + i) * 0.026, system.massifness) * (0.85 + density * 0.70); + const height = system.height * (0.060 + density * 0.128 + rand(seed, 2400 + i) * 0.050); ridges.push({ x, y, angle: localAngle, @@ -327,13 +573,45 @@ function buildScratchRidges(system, seed, systemId) { } function computeCoastLower(px, py, template, seed) { - const axis = (px - 0.5) * Math.cos(template.coastAngle) * ASPECT + (py - 0.5) * Math.sin(template.coastAngle); + const angle = template.coastAngle; + const axis = (px - 0.5) * Math.cos(angle) * ASPECT + (py - 0.5) * Math.sin(angle); + const cross = -(px - 0.5) * Math.sin(angle) * ASPECT + (py - 0.5) * Math.cos(angle); const wave = (fbm(px * 220, py * 220, seed + 300) - 0.5) * template.coastNoise; const bay = (valueNoise(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055; - const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26); - const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0; - const pressure = Math.max(sideA, sideB); - return { pressure, signedAxis: axis }; + const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032; + let pressure = 0; + + if (template.coastStyle === "inland_sea") { + // 瀬戸内型だけは中央を横切る浅い内海を許す。出現率は地形タイプ側で管理する。 + const sideA = smoothstep((-axis + 0.25 + wave + bay) / 0.26); + const sideB = smoothstep((axis + 0.23 - wave + bay * 0.7) / 0.27); + const channel = smoothstep((0.060 - Math.abs(cross + wave * 0.65 + islandNoise)) / 0.090) * 0.82; + pressure = Math.max(sideA, sideB, channel); + } else if (template.coastStyle === "parallel_spine") { + // 東北型: 左右端または上下端に海を置く。海岸線は脊梁山脈とおおよそ平行。 + // 内海的な中央水路は作らない。 + const edgeA = smoothstep((-axis - 0.26 + wave * 0.42 + bay * 0.35) / 0.20); + const edgeB = template.twoSidedCoast ? smoothstep((axis - 0.26 - wave * 0.42 + bay * 0.25) / 0.22) * 0.86 : 0; + pressure = Math.max(edgeA, edgeB); + } else if (template.coastStyle === "outer_coast") { + // 中部型: 外縁海を中心にし、内陸へ海が入り込みすぎないようにする。 + const radial = distNorm(px, py, 0.5, 0.5); + const outer = smoothstep((radial - 0.44 + wave * 0.8 + bay * 0.5) / 0.24); + const side = smoothstep((-axis + 0.30 + wave) / 0.30) * 0.45; + pressure = Math.max(outer, side); + } else if (template.coastStyle === "open_bay") { + // 関東・濃尾型: 一方向に開いた湾と、その背後の沖積平野を作りやすくする。 + const openSide = smoothstep((-axis + 0.29 + wave + bay) / 0.25); + const bayMouth = smoothstep((0.22 - Math.abs(cross + wave * 0.8)) / 0.25) * smoothstep((-axis + 0.16 + bay) / 0.22) * 0.68; + pressure = Math.max(openSide, bayMouth); + } else { + const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26); + const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0; + const outerBite = smoothstep((distNorm(px, py, 0.5, 0.5) - 0.54 + islandNoise) / 0.22) * 0.25; + pressure = Math.max(sideA, sideB, outerBite); + } + + return { pressure: clamp(pressure), signedAxis: axis }; } function recomputeSlope(elevation, sea, slope) { @@ -593,10 +871,10 @@ function deriveFields(seed, template, fields, seaLevel) { const ridge = clamp(arcSpineField[i] * 0.76 + branchRidgeField[i] * 0.86 + Math.max(0, relief) * 9.0 + slope[i] * 0.30 + Math.max(0, e - 0.54) * 0.88 - valleyField[i] * 0.34); ridgeField[i] = clamp(Math.max(ridgeField[i] * 0.30, ridge)); basinField[i] = clamp((0.42 - slope[i]) * 1.55 + Math.max(0, -relief) * 5.0 + clamp((0.48 - e) * 1.35) - coast * 0.40 - river[i] * 0.32); - const low = clamp((0.58 - e) * 1.55); + const low = clamp((0.52 - e) * 1.70); const lowSlope = clamp((0.34 - slope[i]) * 2.8); const riverGate = clamp(riverNear * 0.72 + flowAccum[i] * 0.82 + coast * 0.70 + basinField[i] * 0.20 - ridgeField[i] * 0.40); - plain[i] = clamp(low * lowSlope * (0.18 + template.plainBias * 0.42 + riverGate * 0.92)); + plain[i] = clamp(low * lowSlope * (0.12 + template.plainBias * 0.34 + riverGate * 0.84)); floodplain[i] = clamp(lowSlope * riverNear * (0.35 + flowAccum[i] * 0.82 + river[i] * 0.52)); deltaField[i] = clamp(coast * river[i] * 1.4 + coast * flowAccum[i] * 0.72); alluvialFanField[i] = clamp(riverNear * clamp(slope[i] * 2.5) * clamp((0.58 - e) * 1.7) * clamp(ridgeField[i] * 0.8 + arcSpineField[i] * 0.4)); @@ -675,13 +953,13 @@ export function generateTerrainAndRivers(seed) { const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23; const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16; const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed); - let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (0.22 + terrainTemplate.deposition * 0.040); + let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040); let mountainMaskMax = 0; for (let s = 0; s < systems.length; s++) { const system = systems[s]; const mask = ellipticalMask(px, py, system); mountainMaskMax = Math.max(mountainMaskMax, mask); - const broad = Math.pow(mask, lerp(2.0, 1.35, system.massifness)) * system.height * lerp(0.13, 0.25, system.massifness); + const broad = Math.pow(mask, lerp(1.70, 1.16, system.massifness)) * system.height * lerp(0.22, 0.34, system.massifness); e += broad; arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72)); } @@ -698,7 +976,7 @@ export function generateTerrainAndRivers(seed) { e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80); e += global * 0.020; e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax; - elevation[i] = clamp(e, 0.025, 1.08); + elevation[i] = clamp(softCapElevation(e, terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91, 1.08), 0.025, 1.08); visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40); surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46); valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18); @@ -707,7 +985,7 @@ export function generateTerrainAndRivers(seed) { } let seaLevel = quantile(elevation, terrainTemplate.seaRatio); - seaLevel = clamp(seaLevel, 0.20, 0.47); + seaLevel = clamp(seaLevel, 0.14, 0.47); classifyWater(elevation, seaLevel, sea, ocean, lake); recomputeSlope(elevation, sea, slope); @@ -721,8 +999,9 @@ export function generateTerrainAndRivers(seed) { const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); const regional = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); const prefectureRegionId = regional.regionId; + const adminPrefectureRegionId = regional.displayRegionId || regional.regionId; const regionalDebug = regional.debug; - const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea); + const regionalPrefectureBorders = extractRegionBorderSegments(adminPrefectureRegionId, sea); const prefectureBorder = extractMaskBorder(prefectureMask, sea); let landCount = 0; @@ -734,12 +1013,15 @@ export function generateTerrainAndRivers(seed) { for (let i = 0; i < SIZE; i++) { if (sea[i]) { waterCount++; continue; } landCount++; - if (elevation[i] > 0.60 || ridgeField[i] > 0.58) mountainCount++; + if (elevation[i] > 0.56 || ridgeField[i] > 0.52) mountainCount++; if (plain[i] > 0.36) plainCount++; if (arcSpineField[i] > 0.55) { primarySpineStrength += arcSpineField[i]; spineSamples++; } } primarySpineStrength /= Math.max(1, spineSamples); const terrainDebug = { + terrainType: terrainTemplate.terrainType, + terrainTypeLabel: terrainTemplate.terrainTypeLabel, + coastStyle: terrainTemplate.coastStyle, primarySpineStrength, riverConnectivityRate: mainRivers.length ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && sea[indexOf(x, y)])).length / mainRivers.length : 0, smallIslandCount: 0, @@ -790,6 +1072,7 @@ export function generateTerrainAndRivers(seed) { prefectureMask, prefectureBorder, prefectureRegionId, + adminPrefectureRegionId, regionalDebug, terrainDebug, regionalPrefectureBorders, diff --git a/renderer.js b/renderer.js index 22d4186..f0e2eea 100644 --- a/renderer.js +++ b/renderer.js @@ -358,42 +358,45 @@ function terrainColorContinuous(map, fx, fy, mode) { } function terrainShadeContinuous(map, fx, fy) { - const eL = fieldSample(map.elevation, fx - 0.6, fy); - const eR = fieldSample(map.elevation, fx + 0.6, fy); - const eU = fieldSample(map.elevation, fx, fy - 0.6); - const eD = fieldSample(map.elevation, fx, fy + 0.6); + const step = 0.50; + const eC = fieldSample(map.elevation, fx, fy); + const eL = fieldSample(map.elevation, fx - step, fy); + const eR = fieldSample(map.elevation, fx + step, fy); + const eU = fieldSample(map.elevation, fx, fy - step); + const eD = fieldSample(map.elevation, fx, fy + step); // x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。 - // 光源は北西上空(日本の地形表現で一般的な見え方)。 - const dzdx = (eR - eL) / 1.2; - const dzdy = (eD - eU) / 1.2; - const nx = -dzdx * 2.5; - const ny = -dzdy * 2.5; + // 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。 + const dzdx = (eR - eL) / (step * 2); + const dzdy = (eD - eU) / (step * 2); + const nx = -dzdx * 4.4; + const ny = -dzdy * 4.4; const nz = 1.0; const nLen = Math.hypot(nx, ny, nz) || 1; const lx = -0.5; const ly = -0.5; const lz = 0.7071067811865476; - const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.5 + 0.5); + const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48); const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0; const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0; const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0; const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0; - const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.75, fy) : 0; - const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.75, fy) : 0; - const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.75) : 0; - const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.75) : 0; - const ravineRelief = (rvL - rvR) * 0.16 + (rvU - rvD) * 0.12; + const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0; + const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0; + const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0; + const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0; + const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20; + const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18); - // 谷底の低傾斜面では陰影を少し圧縮し、標高色がそのまま見えるようにする。 - const valleyFloor = clamp((valley - 0.16) * 1.8) * clamp((0.28 - slope) * 4.5); - let shade = 0.76 + hill * 0.32 + ravineRelief - ravine * 0.08 - tex * 0.028; - if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.52); - else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.20); + // 谷底の色保持は少し残すが、以前より圧縮を弱めて陰影の振幅を大きくする。 + const valleyFloor = clamp((valley - 0.18) * 1.45) * clamp((0.24 - slope) * 3.6); + let shade = 0.66 + hill * 0.50 + ravineRelief - ravine * 0.12 - tex * 0.042 - concavity * 0.16 + slope * 0.030; + if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.26); + else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.12); - return clamp(shade, 0.66, 1.13); + return clamp(shade, 0.54, 1.26); } function discreteColor(map, x, y, mode) { @@ -404,16 +407,16 @@ function discreteColor(map, x, y, mode) { color = [160, 205, 239]; } else if (mode === "landuse") { const colors = { - 0: [242, 248, 238], - 1: [248, 250, 245], - 2: [240, 238, 232], - 3: [245, 230, 220], - 4: [250, 248, 245], - 5: [235, 235, 240], - 6: [240, 245, 240], - 7: [245, 248, 252], - 8: [250, 248, 240], - 9: [240, 245, 238], + 0: [242, 248, 238], // rural / natural land + 1: [238, 246, 222], // farmland + 2: [240, 238, 232], // old urban + 3: [245, 230, 220], // CBD / DID core + 4: [250, 248, 245], // suburb + 5: [235, 235, 240], // industrial + 6: [240, 245, 240], // logistics + 7: [245, 248, 252], // new town + 8: [250, 248, 240], // roadside + 9: [225, 238, 220], // forest / mountain land }; color = colors[map.landuse[i]] || colors[0]; } else if (mode === "admin") { @@ -606,7 +609,8 @@ function drawDebugCells(ctx, map, field, color) { for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); - if (!map.prefectureMask[i] || map.sea[i]) continue; + const debugMask = map.humanRegionMask || map.prefectureMask; + if (!debugMask[i] || map.sea[i]) continue; const v = clamp(field[i] || 0, 0, 1); if (v <= 0.12) continue; ctx.fillStyle = color(v); @@ -756,8 +760,10 @@ export function drawMap(canvas, map, options) { drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); } if (mode === "admin-debug" || mode === "borders-debug") { - drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`); - if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true); + // Keep the natural barrier heatmap subtle. A dense cell fill can look like + // artificial horizontal hatching, so only strong terrain dividers are shown. + drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`); + if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true); for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)"); if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } @@ -818,6 +824,7 @@ export function drawMap(canvas, map, options) { const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5; dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)"); if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)"); + else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.2, "transparent", "rgba(190,95,95,0.62)"); } for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)"); if (mode === "admin-debug" || mode === "borders-debug") { @@ -832,14 +839,14 @@ export function drawMap(canvas, map, options) { return; } if (mode === "admin-debug" || mode === "borders-debug") { - drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity); + drawLabels(ctx, map.adminCenters || [], Infinity); return; } const important = [ ...map.modernCities, ...map.ports, ...(map.satelliteCities || []), - ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); + ].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway"); drawLabels(ctx, important, 60); } } From f5e7a1df1da0cf4002afd6ab3bea90df9ca2faf6 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sun, 24 May 2026 19:33:09 +0900 Subject: [PATCH 5/6] human tweaks --- adminRegions.js | 47 ++- app.js | 66 +++- index.html | 5 + mapAdminStage.js | 182 +++++++++- mapFeaturesV2.js | 869 +++++++++++++++++++++++++++++++++++++++++++++++ mapOutput.js | 9 + mapPipeline.js | 46 ++- renderer.js | 13 +- styles.css | 9 +- 9 files changed, 1201 insertions(+), 45 deletions(-) create mode 100644 mapFeaturesV2.js diff --git a/adminRegions.js b/adminRegions.js index 877c1cf..15f66a8 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -409,25 +409,31 @@ function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, po if (same4 === 1) energy += 1.7; if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25; if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42; - if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) { - const drift = centerDist[candidateId][i] - centerDist[oldId][i]; - if (drift > 0) energy += Math.min(0.9, drift * 0.012); + if (candidateId !== oldId) { + const candidateDistance = centerDistanceAt(centerDist, candidateId, i); + const oldDistance = centerDistanceAt(centerDist, oldId, i); + if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) { + const drift = candidateDistance - oldDistance; + if (drift > 0) energy += Math.min(0.9, drift * 0.012); + } } return energy; } +function centerDistanceAt(centerDist, id, i) { + const field = centerDist?.fields?.[id] || centerDist?.[id]; + if (field) return field[i]; + const center = centerDist?.centers?.[id]; + if (!center || !inside(center.x, center.y)) return 24; + const [x, y] = xyOf(i); + return Math.hypot(x - center.x, y - center.y); +} + function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) { - const fields = []; - for (const id of adminIds) { - const center = adminCenters[id]; - const field = new Float32Array(SIZE); - if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)] || !prefectureMask[indexOf(center.x, center.y)]) field.fill(24); - else { - for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) field[indexOf(x, y)] = Math.hypot(x - center.x, y - center.y); - } - fields[id] = field; - } - return fields; + // Older versions materialized one full SIZE Float32Array per municipality. + // In multi-prefecture generation this can create heavy transient memory use. + // Keep the same interface conceptually, but compute distances on demand. + return { ids: adminIds, centers: adminCenters, prefectureMask, sea }; } function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) { @@ -981,6 +987,7 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed } function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { + const progress = typeof options.progress === "function" ? options.progress : null; const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); const cellClass = new Int16Array(SIZE); cellClass.fill(-1); @@ -991,6 +998,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360); const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8))); const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0); + progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`); const compartmentId = new Int32Array(SIZE); compartmentId.fill(-1); const dist = new Float32Array(SIZE); @@ -1017,22 +1025,25 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r } } for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0; + progress?.("natural seeded growth complete"); let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); mergeTinyLandscapeUnits(compartmentId, compartments, 9); + progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); refreshAllCompartmentStats(compartments, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55)); - let guard = Math.max(80, targetCount * 3); + let guard = Math.max(60, targetCount * 2); while (guard-- > 0) { let active = compartments.filter((unit) => unit && unit.area > 0); const needMore = active.length < targetCount; const worst = active - .filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2)) + .filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2)) .sort((a, b) => { const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2; const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2; @@ -1490,10 +1501,13 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) { } export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) { + const progress = typeof options.progress === "function" ? options.progress : null; const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options); + progress?.("natural compartments built"); const adminId = new Int16Array(SIZE); adminId.fill(-1); const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); + progress?.("natural compartments assigned"); for (const unit of compartments) { const assigned = owner[unit.id]; if (assigned < 0) continue; @@ -1505,6 +1519,7 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0; } repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + progress?.("natural topology repaired"); const activeCompartments = compartments.filter((unit) => unit.area > 0); const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0); return { diff --git a/app.js b/app.js index 17158eb..c881bb3 100644 --- a/app.js +++ b/app.js @@ -34,6 +34,9 @@ const modeGrid = document.getElementById("modeGrid"); const statsEl = document.getElementById("stats"); const idsEl = document.getElementById("nameIds"); const tooltipEl = document.getElementById("mapTooltip"); +const progressEl = document.getElementById("generationProgress"); +const progressStageEl = document.getElementById("generationProgressStage"); +const progressTimingsEl = document.getElementById("generationProgressTimings"); function parseSeed(seedText) { const numeric = Number.parseInt(seedText, 10); @@ -56,10 +59,53 @@ function countText(items) { return `${insideCount(items)} / outside ${outsideCount(items)}`; } +function formatMs(ms) { + if (!Number.isFinite(ms)) return "-"; + return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`; +} + +function renderTimingRows(timings = []) { + if (!progressTimingsEl) return; + progressTimingsEl.innerHTML = ""; + for (const row of timings) { + const item = document.createElement("div"); + item.className = "progress-timing-row"; + const label = document.createElement("span"); + label.textContent = row.label; + const value = document.createElement("strong"); + value.textContent = formatMs(row.ms); + item.append(label, value); + progressTimingsEl.append(item); + } +} + +function updateGenerationProgress(event) { + if (!progressEl) return; + progressEl.classList.remove("hidden"); + if (progressStageEl) { + progressStageEl.textContent = event?.status === "done" + ? `Completed: ${event.label} / ${formatMs(event.ms)}` + : `Running: ${event?.label || "Preparing"}`; + } + renderTimingRows(event?.timings || []); +} + +function setProgressVisible(visible, message = "Preparing") { + if (!progressEl) return; + progressEl.classList.toggle("hidden", !visible); + if (progressStageEl) progressStageEl.textContent = message; + if (visible) renderTimingRows([]); +} + +function nextFrame() { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + function getStats(map) { return [ ["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"], ["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"], + ["Generation Time", map.generationTotalMs ? `${formatMs(map.generationTotalMs)} / slowest ${(map.generationTimings || []).slice().sort((a, b) => b.ms - a.ms)[0]?.label || "-"}` : "-"], ["Villages", countText(map.villages)], ["Market Towns", countText(map.markets)], ["Castles", countText(map.castles)], @@ -202,12 +248,22 @@ function renderModeButtons() { modeGrid.innerHTML = ""; } } -function regenerate() { +async function regenerate() { state.seedText = seedInput.value; - state.map = generateMap(parseSeed(state.seedText)); - renderStats(state.map); - renderNameIds(state.map); - redraw(); + setProgressVisible(true, "Preparing generation..."); + await nextFrame(); + try { + state.map = generateMap(parseSeed(state.seedText), { onProgress: updateGenerationProgress }); + renderStats(state.map); + renderNameIds(state.map); + redraw(); + if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`; + renderTimingRows(state.map.generationTimings || []); + window.setTimeout(() => setProgressVisible(false), 900); + } catch (error) { + if (progressStageEl) progressStageEl.textContent = `Generation failed: ${error?.message || error}`; + throw error; + } } function redraw() { diff --git a/index.html b/index.html index 8cb1f7f..92a5cb1 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,11 @@
+
diff --git a/mapAdminStage.js b/mapAdminStage.js index 6a9730a..411efc1 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -286,7 +286,22 @@ function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compart return { changedCells, restoredSeeds }; } -function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) { +function municipalityCountBoundsForRegion(landCells, meta = {}) { + const focused = meta.isFocusedRegion !== false; + if (focused) return { min: 20, max: 50 }; + // Neighbor prefectures are often visible only as clipped map-edge slivers. + // Avoid giving every tiny visible fragment the full 20-municipality floor. + let min = 1; + if (landCells >= 500) min = 2; + if (landCells >= 950) min = 3; + if (landCells >= 1700) min = 5; + if (landCells >= 2800) min = 7; + if (landCells >= 4300) min = 10; + const max = clamp(Math.round(landCells / 260 + 2), Math.max(min, 2), 34); + return { min, max }; +} + +function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) { let landCells = 0; let habitableCells = 0; let lowlandCells = 0; @@ -314,8 +329,9 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); const mountainRatio = landCells ? mountainCells / landCells : 0; const lowlandBonus = Math.min(7, lowlandCells / 430); - const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2); - return clamp(target, 20, 50); + const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2); + const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta); + return clamp(rawTarget, min, max); } function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) { @@ -620,14 +636,24 @@ function generateAdminLayoutForMask({ stations, industrialZones, logisticsParks, + adminRegionMeta = {}, + adminProgress = null, }) { const boundaryRidgeField = naturalBarrierScore ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) : ridgeField; + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" }); const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); - const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); + const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta }); const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0); - let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360); + const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea); + const minCompartmentTarget = adminRegionMeta.isFocusedRegion === false + ? clamp(Math.round(Math.max(targetMunicipalityCount * 3.2, regionLandArea / 75)), 18, 90) + : 120; + const maxCompartmentTarget = adminRegionMeta.isFocusedRegion === false + ? clamp(Math.round(Math.max(targetMunicipalityCount * 5.8, regionLandArea / 38)), minCompartmentTarget, 220) + : 360; + let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget); let adminCentersRaw = buildLowlandAdminSeeds({ seed, targetMunicipalityCount, @@ -652,12 +678,14 @@ function generateAdminLayoutForMask({ newTowns, stations, }); - if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120); + if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget); + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length }); const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, { seed, targetMunicipalityCount, targetCompartmentCount, maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)), + progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }), }); const adminId = compartmentAssignment.adminId; let previousSnapshot = new Int16Array(adminId); @@ -710,6 +738,7 @@ function generateAdminLayoutForMask({ satelliteMunicipalityStats: satelliteClassificationDebug, ...compartmentAssignment.debug, }; + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" }); const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35); const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation, @@ -735,6 +764,7 @@ function generateAdminLayoutForMask({ adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea); previousSnapshot = new Int16Array(adminId); } + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" }); smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2); markChanged("changedAfterSmooth"); @@ -797,19 +827,34 @@ function generateAdminLayoutForMask({ const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id)); mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() }); markChanged("changedAfterInitialMerge"); + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" }); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180); markChanged("changedAfterInitialExclaveRemoval"); // The initial compartment graph assignment is now the primary natural partition. // Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners. markChanged("changedAfterLandscapePartition"); - const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]); + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" }); + // The older oversized-lowland pass rebuilds natural compartments a second time. + // The current pipeline already performs pending-seed lowland splitting on the active + // compartment graph above, so keep the full admin layout while avoiding the duplicate + // high-cost recomputation. + const oversizedSplitDebug = { + changedCells: 0, + splitMunicipalities: 0, + rejectedMunicipalities: 0, + skippedDuplicateCompartmentRebuild: true, + skippedForVisibleFragment: adminRegionMeta.isFocusedRegion === false && regionLandArea < 6500, + }; adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells; adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities; adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities; adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities; adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities; adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0; + adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment); + adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild); previousSnapshot = new Int16Array(adminId); + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" }); snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2); markChanged("changedAfterSnap"); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360); @@ -881,6 +926,7 @@ function generateAdminLayoutForMask({ adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area })); adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0; adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0; + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" }); const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); @@ -889,7 +935,9 @@ function generateAdminLayoutForMask({ function filterPointsForMask(points = [], mask, sea) { - return (points || []).filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); + return (points || []) + .filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]) + .map((p) => ({ ...p })); } function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) { @@ -907,6 +955,85 @@ function maskLandArea(mask, sea) { return area; } + +function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields = {}) { + const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))].sort((a, b) => a - b); + const idMap = new Map(activeIds.map((oldId, newId) => [oldId, newId])); + const newAdminId = new Int16Array(SIZE); + newAdminId.fill(-1); + const cellsByNewId = Array.from({ length: activeIds.length }, () => []); + for (let i = 0; i < SIZE; i++) { + if (!humanMask[i] || sea[i]) continue; + const newId = idMap.get(adminId[i]); + if (newId === undefined) continue; + newAdminId[i] = newId; + cellsByNewId[newId].push(i); + } + + const chooseOffice = (newId, oldId) => { + const cells = cellsByNewId[newId] || []; + const current = centers[oldId]; + if (current && inside(current.x, current.y)) { + const ci = indexOf(current.x, current.y); + if (newAdminId[ci] === newId && humanMask[ci] && !sea[ci]) { + return { ...current, localAdminId: newId, oldAdminId: oldId, municipalityOffice: true }; + } + } + let sx = 0, sy = 0; + for (const i of cells) { + const [x, y] = xyOf(i); + sx += x; + sy += y; + } + const cx = cells.length ? sx / cells.length : current?.x || 0; + const cy = cells.length ? sy / cells.length : current?.y || 0; + let bestI = cells[0] ?? -1; + let bestScore = -INF; + for (const i of cells) { + const [x, y] = xyOf(i); + const land = fields.landuse?.[i] ?? 0; + const urbanBonus = land === 3 ? 1.2 : land === 2 ? 1.0 : land === 4 || land === 7 || land === 8 ? 0.55 : land === 1 ? 0.24 : 0; + const density = fields.populationDensity?.[i] || 0; + const settlement = fields.settlementScore?.[i] || 0; + const score = + density * 2.25 + + settlement * 0.75 + + urbanBonus + + (fields.plain?.[i] || 0) * 0.32 + + (fields.basinField?.[i] || 0) * 0.24 + + (fields.coastalLowland?.[i] || 0) * 0.18 + + (fields.roadInfluence?.[i] || 0) * 0.34 + + (fields.stationInfluence?.[i] || 0) * 0.45 - + (fields.slope?.[i] || 0) * 0.52 - + Math.hypot(x - cx, y - cy) * 0.018 + + hash2(x, y, 91337 + newId) * 0.012; + if (score > bestScore) { bestScore = score; bestI = i; } + } + const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)]; + return { + ...(current || {}), + x: bx, + y: by, + score: bestScore > -INF ? bestScore : 0, + seedKind: current?.seedKind || "generatedMunicipalOffice", + invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true, + localAdminId: newId, + oldAdminId: oldId, + municipalityOffice: true, + generatedOfficePoint: !current || !inside(current.x, current.y) || newAdminId[indexOf(current.x, current.y)] !== newId, + }; + }; + + const adminCenters = activeIds.map((oldId, newId) => chooseOffice(newId, oldId)); + return { + adminId: newAdminId, + adminCenters, + activeMunicipalityCount: activeIds.length, + removedUnusedAdminCenterCount: Math.max(0, centers.length - activeIds.length), + generatedOfficePointCount: adminCenters.filter((p) => p.generatedOfficePoint).length, + }; +} + function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) { const ids = new Set(); for (let i = 0; i < SIZE; i++) { @@ -918,16 +1045,17 @@ function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) { } export function generateAdminLayout(context) { - const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope } = context; + const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope, adminProgress } = context; + const minFullAdminRegionArea = 1500; const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) - .filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120); + .filter((regionId) => regionId === 0 || (regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea)); if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context); - const combinedAdminId = new Int16Array(SIZE); + let combinedAdminId = new Int16Array(SIZE); combinedAdminId.fill(-1); const combinedHumanMask = new Uint8Array(SIZE); - const combinedCenters = []; + let combinedCenters = []; const combinedCompartmentBorders = []; const perRegion = []; let idOffset = 0; @@ -935,7 +1063,7 @@ export function generateAdminLayout(context) { for (const regionId of regionIds) { const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId); const regionArea = maskLandArea(regionMask, sea); - if (regionArea < 120) continue; + if (regionId !== 0 && regionArea < minFullAdminRegionArea) continue; const localContext = { ...context, @@ -950,9 +1078,18 @@ export function generateAdminLayout(context) { stations: filterPointsForMask(context.stations, regionMask, sea), industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea), logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea), + adminRegionMeta: { + regionId, + landArea: regionArea, + isFocusedRegion: regionId === 0, + isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID, + }, + adminProgress, }; + adminProgress?.({ status: "region-start", regionId, area: regionArea }); const local = generateAdminLayoutForMask(localContext); + adminProgress?.({ status: "region-done", regionId, area: regionArea, municipalities: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || 0 }); if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders); let localMaxAdminId = -1; for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i]; @@ -1036,8 +1173,21 @@ export function generateAdminLayout(context) { perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true }); } + const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, { + populationDensity, + plain, + slope, + settlementScore: context.settlementScore, + landuse: context.landuse, + basinField: context.basinField, + coastalLowland: context.coastalLowland, + roadInfluence: context.roadInfluence, + stationInfluence: context.stationInfluence, + }); + combinedAdminId = compactedAdmin.adminId; + combinedCenters = compactedAdmin.adminCenters; const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask); - const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size; + const totalMunicipalityCount = compactedAdmin.activeMunicipalityCount; const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0); const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0); const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0); @@ -1045,10 +1195,14 @@ export function generateAdminLayout(context) { const adminDebug = { multiRegionAdmin: true, adminRegionCount: perRegion.length, + minFullAdminRegionArea, perRegion, finalMunicipalityCount: totalMunicipalityCount, actualMunicipalityCount: totalMunicipalityCount, candidateSeedCount: combinedCenters.length, + municipalOfficePointCount: combinedCenters.length, + generatedOfficePointCount: compactedAdmin.generatedOfficePointCount, + removedUnusedAdminCenterCount: compactedAdmin.removedUnusedAdminCenterCount, naturalCompartmentCount: totalNaturalCompartmentCount, compartmentCount: totalNaturalCompartmentCount, targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount, diff --git a/mapFeaturesV2.js b/mapFeaturesV2.js new file mode 100644 index 0000000..eecedf9 --- /dev/null +++ b/mapFeaturesV2.js @@ -0,0 +1,869 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js"; +import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; +import { LANDUSE } from "./landuseCodes.js"; + +// Lightweight Human Geography V2 +// -------------------------------- +// This replaces the heavy iterative human stage with a sparse skeleton + raster +// synthesis model: +// 1. build terrain-derived human context once +// 2. place villages/towns/cities by region quotas +// 3. make sparse approximate transport paths without full-resolution A* +// 4. synthesize population and land-use fields in one raster pass + +export function generateMapFeatures(seed, terrain) { + const { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + portSuitability, + crossingSuitability, + passSuitability, + prefectureMask, + prefectureRegionId, + naturalBarrierScore, + } = terrain; + + function regionIdAt(x, y) { + if (!inside(x, y)) return -1; + const i = indexOf(x, y); + if (sea[i]) return -1; + if (prefectureMask?.[i]) return 0; + const id = prefectureRegionId?.[i]; + return id !== undefined && id >= 0 ? id : -1; + } + + function inFocusedPrefecture(p) { + return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); + } + + function localConfluenceScore(x, y) { + let arms = 0; + let strong = 0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const rv = river[indexOf(nx, ny)]; + if (rv > 0.18) arms++; + if (rv > 0.34) strong++; + } + return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); + } + + // --- 1. Human context: one full raster pass ----------------------------- + const developable = new Float32Array(SIZE); + const ruralSuitability = new Float32Array(SIZE); + const townSuitability = new Float32Array(SIZE); + const valleySettlement = new Float32Array(SIZE); + const coastalSettlement = new Float32Array(SIZE); + const confluenceField = new Float32Array(SIZE); + const barrierCost = new Float32Array(SIZE); + const corridorCost = new Float32Array(SIZE); + const settlementCluster = new Float32Array(SIZE); + const settlementScore = new Float32Array(SIZE); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) { + barrierCost[i] = INF; + corridorCost[i] = INF; + continue; + } + const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; + const highPenalty = Math.max(0, elevation[i] - 0.56); + const lowSlope = clamp(1 - slope[i] * 2.3); + const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; + const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; + confluenceField[i] = confluence; + + developable[i] = clamp( + plain[i] * 0.34 + + agriculture[i] * 0.24 + + basinField[i] * 0.24 + + valleyField[i] * 0.24 + + coastalLowland[i] * 0.18 + + depositional * 0.22 + + lowSlope * 0.10 - + slope[i] * 0.82 - + ridgeField[i] * 0.52 - + spine * 0.24 - + highPenalty * 1.14 - + floodplain[i] * 0.03 + ); + valleySettlement[i] = clamp( + valleyField[i] * 0.50 + + river[i] * 0.16 + + confluence * 0.72 + + depositional * 0.16 + + basinField[i] * 0.10 + + lowSlope * 0.10 - + slope[i] * 0.58 - + ridgeField[i] * 0.34 - + spine * 0.18 - + highPenalty * 0.72 + ); + coastalSettlement[i] = clamp( + coastalLowland[i] * 0.50 + + (portSuitability?.[i] || 0) * 0.30 + + (deltaField?.[i] || 0) * 0.20 + + plain[i] * 0.10 - + slope[i] * 0.52 - + ridgeField[i] * 0.24 - + spine * 0.12 + ); + const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; + settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise); + ruralSuitability[i] = clamp( + agriculture[i] * 0.42 + + developable[i] * 0.28 + + valleySettlement[i] * 0.24 + + coastalSettlement[i] * 0.15 + + settlementCluster[i] * 0.24 - + Math.max(0, elevation[i] - 0.64) * 0.56 + ); + townSuitability[i] = clamp( + developable[i] * 0.40 + + valleySettlement[i] * 0.26 + + coastalSettlement[i] * 0.20 + + confluence * 0.34 + + basinField[i] * 0.16 + + plain[i] * 0.12 + + settlementCluster[i] * 0.16 - + slope[i] * 0.34 - + ridgeField[i] * 0.17 - + spine * 0.10 + ); + settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10); + const naturalBarrier = naturalBarrierScore?.[i] || 0; + barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; + corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); + } + } + + // --- region statistics --------------------------------------------------- + const regionStats = new Map(); + function ensureRegion(regionId) { + let st = regionStats.get(regionId); + if (!st) { + st = { + id: regionId, + area: 0, + developableCells: 0, + developableSum: 0, + valleyCells: 0, + coastCells: 0, + townCells: 0, + plainCells: 0, + minX: MAP_W, + minY: MAP_H, + maxX: 0, + maxY: 0, + }; + regionStats.set(regionId, st); + } + return st; + } + 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 regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const st = ensureRegion(regionId); + st.area++; + st.developableSum += developable[i]; + if (developable[i] > 0.16) st.developableCells++; + if (valleySettlement[i] > 0.24) st.valleyCells++; + if (coastalSettlement[i] > 0.25) st.coastCells++; + if (townSuitability[i] > 0.28) st.townCells++; + if (plain[i] > 0.24) st.plainCells++; + st.minX = Math.min(st.minX, x); + st.minY = Math.min(st.minY, y); + st.maxX = Math.max(st.maxX, x); + st.maxY = Math.max(st.maxY, y); + } + } + + function visibilityFactor(regionId, st) { + if (regionId === 0) return 1.15; + if (!st || st.area <= 0) return 0; + // Small map-edge slivers should not get the same municipal/human density + // as full neighboring prefectures. This keeps external regions legible. + return clamp(Math.sqrt(st.area / 1700), 0.28, 0.92); + } + + function pickRegionalPoints(scoreArray, { + stride = 1, + threshold = 0.25, + minDistance = 6, + totalMax = 100, + seedOffset = 0, + quotaForRegion, + predicate = () => true, + kind = "Point", + extraScore = () => 0, + }) { + const byRegion = new Map(); + for (let y = 2; y < MAP_H - 2; y += stride) { + for (let x = 2; x < MAP_W - 2; x += stride) { + const i = indexOf(x, y); + if (sea[i] || !predicate(x, y, i)) continue; + const regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055; + if (score < threshold) continue; + if (!byRegion.has(regionId)) byRegion.set(regionId, []); + byRegion.get(regionId).push({ x, y, score, kind, regionId }); + } + } + const out = []; + for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { + const st = regionStats.get(regionId); + const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0; + if (quota <= 0) continue; + out.push(...pickEntities(candidates, { + max: quota, + minDistance, + threshold, + seed: seed + seedOffset + regionId * 1009, + jitter: 0.04, + })); + } + return out.sort((a, b) => b.score - a.score).slice(0, totalMax); + } + + function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) { + const candidates = []; + for (let y = 2; y < MAP_H - 2; y += stride) { + for (let x = 2; x < MAP_W - 2; x += stride) { + const i = indexOf(x, y); + if (sea[i] || !predicate(x, y, i)) continue; + const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07; + if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); + } + } + return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); + } + + // --- 2. Sparse points ---------------------------------------------------- + let ports = pickGlobalPoints(portSuitability || coastalSettlement, { + threshold: 0.30 + rand(seed, 1001) * 0.08, + max: 10, + minDistance: 13, + seedOffset: 1000, + predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25, + }).map((p, n) => { + const i = indexOf(p.x, p.y); + const harborPotential = (portSuitability?.[i] || 0) + coastalLowland[i] * 0.22 + (deltaField?.[i] || 0) * 0.08 - slope[i] * 0.18; + const portClass = n === 0 ? "major" : n < 3 && harborPotential > 0.34 ? "regional" : harborPotential > 0.24 ? "fishing" : "lake"; + const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port"; + return { ...p, harborPotential, portClass, kind, score: harborPotential }; + }).sort((a, b) => b.harborPotential - a.harborPotential); + if (ports.length && !ports.some((p) => p.portClass === "major")) { + ports[0].portClass = "major"; + ports[0].kind = "Major Port"; + } + const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); + + const crossings = pickGlobalPoints(crossingSuitability || confluenceField, { + threshold: 0.30 + rand(seed, 1011) * 0.06, + max: 18, + minDistance: 9, + seedOffset: 1010, + predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09, + }).map((p) => ({ ...p, kind: "River Crossing" })); + + const passes = pickGlobalPoints(passSuitability || valleySettlement, { + threshold: 0.18 + rand(seed, 1021) * 0.06, + max: 12, + minDistance: 11, + seedOffset: 1020, + predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i], + }).map((p) => ({ ...p, kind: "Pass" })); + + const villageScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08); + } + const villages = pickRegionalPoints(villageScore, { + stride: 2, + threshold: 0.25 + rand(seed, 1031) * 0.04, + totalMax: 140, + minDistance: 5, + seedOffset: 1030, + kind: "Village", + quotaForRegion: (regionId, st) => { + if (!st || st.developableCells < 10) return 0; + const vf = visibilityFactor(regionId, st); + const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf; + const min = regionId === 0 ? 10 : st.area > 1100 ? 3 : st.area > 280 ? 1 : 0; + const max = regionId === 0 ? 30 : st.area > 1800 ? 13 : st.area > 600 ? 7 : 3; + return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max)); + }, + }).map((p, n) => { + const i = indexOf(p.x, p.y); + const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village"; + const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100; + return { ...p, kind, population }; + }); + + const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); + + const marketScore = new Float32Array(SIZE); + 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 featurePull = Math.max( + distanceToNearest(ports, x, y) < 8 ? 0.12 : 0, + distanceToNearest(crossings, x, y) < 6 ? 0.10 : 0, + confluenceField[i] * 0.26 + ); + const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.12 : 0; + marketScore[i] = clamp( + townSuitability[i] * 0.66 + + villageInfluence[i] * 0.42 + + featurePull + + valleyMouth + + basinField[i] * 0.10 + + plain[i] * 0.09 - + slope[i] * 0.20 - + ridgeField[i] * 0.10 + ); + } + } + + const markets = pickRegionalPoints(marketScore, { + stride: 2, + threshold: 0.31 + rand(seed, 1041) * 0.045, + totalMax: 52, + minDistance: 9, + seedOffset: 1040, + kind: "Market Town", + quotaForRegion: (regionId, st) => { + if (!st || st.townCells < 8) return 0; + const vf = visibilityFactor(regionId, st); + const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf; + const min = regionId === 0 ? 4 : st.area > 1300 ? 1 : 0; + const max = regionId === 0 ? 11 : st.area > 1800 ? 5 : st.area > 650 ? 3 : 1; + return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max)); + }, + extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08, + }).map((p, n) => { + const i = indexOf(p.x, p.y); + const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town"; + const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000; + return { ...p, kind, population }; + }); + + const defenseScore = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + defenseScore[i] = clamp( + confluenceField[i] * 0.38 + + townSuitability[i] * 0.16 + + ridgeField[i] * clamp(1 - Math.abs(elevation[i] - 0.52) / 0.25) * 0.42 + + plain[i] * 0.08 - + floodplain[i] * 0.36 - + coastalLowland[i] * 0.08 + ); + } + const castles = pickGlobalPoints(defenseScore, { + threshold: 0.34 + rand(seed, 1051) * 0.06, + max: 5, + minDistance: 16, + seedOffset: 1050, + }).map((p) => ({ + ...p, + kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle", + })); + + const castleTowns = castles.map((c, n) => { + const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0]; + const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x; + const y = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.y : c.y; + return { x, y, score: c.score, kind: "Castle Town", population: 12000 + Math.round(rand(seed, 1060 + n) * 22000 / 1000) * 1000, regionId: regionIdAt(x, y) }; + }); + + // --- 3. Cities by region, without detailed urban flood-fill -------------- + function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) { + if (!p || !inside(p.x, p.y)) return 0; + const centerRegion = regionIdAt(p.x, p.y); + let capacity = 0; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = p.x + dx; + const y = p.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const dev = developable[i]; + if (dev < 0.04) continue; + const radial = clamp(1 - d / Math.max(1, radius)); + const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24); + capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias; + } + } + return Math.max(26000, Math.round(capacity / 1000) * 1000); + } + + const urbanCandidates = [ + ...markets.map((p) => ({ ...p, candidateKind: "town" })), + ...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })), + ...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })), + ...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })), + ]; + + const cityCandidateByRegion = new Map(); + for (const p of urbanCandidates) { + const i = indexOf(p.x, p.y); + const regionId = regionIdAt(p.x, p.y); + if (regionId < 0) continue; + const capacity = estimateUrbanCapacity(p, regionId === 0 ? 30 : 24, regionId === 0 ? 1.12 : 1.0); + const score = + Math.log10(capacity + 1) * 0.72 + + townSuitability[i] * 1.40 + + developable[i] * 1.05 + + confluenceField[i] * 0.50 + + (p.candidateKind === "port" ? 0.48 : 0) + + (p.candidateKind === "castleTown" ? 0.22 : 0) + + hash2(p.x, p.y, seed + 12000) * 0.16; + if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []); + cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId }); + } + + const modernCities = []; + const usedCitySites = []; + for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) { + const st = regionStats.get(regionId); + if (!st || st.developableCells < 30) continue; + const vf = visibilityFactor(regionId, st); + const maxCities = regionId === 0 + ? clamp(Math.round(3 + st.developableCells / 520 + rand(seed, 12100) * 2), 5, 9) + : clamp(Math.round((st.developableCells / 850 + 0.8) * vf), st.area > 1500 ? 1 : 0, st.area > 2600 ? 4 : st.area > 950 ? 2 : 1); + const selected = pickEntities(list, { + max: maxCities, + minDistance: regionId === 0 ? 16 : 18, + threshold: 0, + seed: seed + 12110 + regionId * 313, + jitter: 0.02, + }); + for (const p of selected) { + if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue; + usedCitySites.push(p); + modernCities.push(p); + } + } + + if (!modernCities.some((p) => inFocusedPrefecture(p))) { + const focusCandidates = [...markets, ...commercialPorts, ...villages].filter((p) => inFocusedPrefecture(p)); + let fallback = focusCandidates.sort((a, b) => { + const ai = indexOf(a.x, a.y); + const bi = indexOf(b.x, b.y); + return (townSuitability[bi] + developable[bi]) - (townSuitability[ai] + developable[ai]); + })[0]; + if (!fallback) { + let best = null; + let bestScore = -INF; + for (let y = 2; y < MAP_H - 2; y += 2) { + for (let x = 2; x < MAP_W - 2; x += 2) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const score = townSuitability[i] + developable[i] + hash2(x, y, seed + 12199) * 0.04; + if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Local City", regionId: 0 }; } + } + } + fallback = best; + } + if (fallback) modernCities.push({ + ...fallback, + candidateKind: fallback.candidateKind || "fallback", + score: fallback.score || 0.5, + capacity: estimateUrbanCapacity(fallback, 30, 1.15), + regionId: 0, + }); + } + + modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score); + for (const [rank, city] of modernCities.entries()) { + const isFocused = inFocusedPrefecture(city); + const isPrefecturalCapital = isFocused && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital); + const isRegionalCapital = !isFocused && !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId && c.isRegionalCapital); + const rawPop = isPrefecturalCapital + ? 450000 + rand(seed, 12200) * 1150000 + : isRegionalCapital + ? 160000 + rand(seed, 12201 + city.regionId * 17) * 460000 + : 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000; + const capMultiplier = isPrefecturalCapital ? 1.22 : isRegionalCapital ? 1.08 : 1.0; + const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000; + city.population = Math.max(isPrefecturalCapital ? 260000 : isRegionalCapital ? 90000 : 24000, population); + city.isPrefecturalCapital = isPrefecturalCapital; + city.isRegionalCapital = isRegionalCapital; + city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; + city.kind = city.rank; + city.urbanRadius = clamp(4.5 + Math.sqrt(city.population) / 95, 7, isPrefecturalCapital ? 32 : isRegionalCapital ? 27 : 22); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isPrefecturalCapital ? 8.5 : 6.5); + city.urbanWeight = clamp(0.85 + Math.log10(Math.max(10000, city.population)) * 0.25, 1.0, 2.2); + } + + function cityPopulationCap(city) { + const radius = city?.isPrefecturalCapital ? 34 : city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18; + const bias = city?.isPrefecturalCapital ? 1.25 : city?.isRegionalCapital ? 1.12 : 1.0; + return estimateUrbanCapacity(city, radius, bias); + } + + // --- 4. Lightweight corridors ------------------------------------------- + function routeLight(a, b, snapRadius = 3) { + if (!a || !b) return []; + const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15)); + const out = []; + let lastKey = ""; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const fx = a.x + (b.x - a.x) * t; + const fy = a.y + (b.y - a.y) * t; + let best = null; + let bestCost = INF; + const radius = snapRadius + (s > 0 && s < steps ? 1 : 0); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = Math.round(fx + dx); + const y = Math.round(fy + dy); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const lineDist = Math.hypot(x - fx, y - fy); + const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05; + if (cost < bestCost) { + bestCost = cost; + best = [x, y]; + } + } + } + if (!best) best = [Math.round(fx), Math.round(fy)]; + const key = `${best[0]},${best[1]}`; + if (key !== lastKey) { + out.push(best); + lastKey = key; + } + } + return out; + } + + function importantNodesForRegion(regionId) { + const inRegion = (p) => regionIdAt(p.x, p.y) === regionId; + return [ + ...modernCities.filter(inRegion).map((p) => ({ ...p, nodeWeight: 8 + (p.population || 0) / 120000 })), + ...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })), + ...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })), + ...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })), + ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, regionId === 0 ? 18 : 10); + } + + const premodernRoads = []; + const nationalRoads = []; + const minorRoads = []; + const railways = []; + const branchRailways = []; + const externalRoads = []; + const externalRailways = []; + const expressways = []; + const ringRoads = []; + const ringRailways = []; + const ringExpressways = []; + const externalExpressways = []; + const icAccessRoads = []; + const externalGateways = []; + + // Premodern roads connect castles/markets/ports sparsely. + for (const c of castles) { + const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2); + for (const n of near) { + const path = routeLight(c, n, 2); + if (path.length > 2) premodernRoads.push(path); + } + } + + for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { + const nodes = importantNodesForRegion(regionId); + if (nodes.length < 2) continue; + const connected = [nodes[0]]; + const remaining = nodes.slice(1); + const maxEdges = regionId === 0 ? Math.min(14, nodes.length + 3) : Math.min(7, nodes.length + 1); + while (remaining.length && nationalRoads.length < 48) { + let best = null; + let bestScore = INF; + for (const a of connected) { + for (const b of remaining) { + const d = Math.hypot(a.x - b.x, a.y - b.y); + const score = d - (a.nodeWeight + b.nodeWeight) * 0.9; + if (score < bestScore) { bestScore = score; best = { a, b }; } + } + } + if (!best) break; + const path = routeLight(best.a, best.b, 3); + if (path.length > 2) nationalRoads.push(path); + connected.push(best.b); + remaining.splice(remaining.indexOf(best.b), 1); + if (connected.length - 1 >= maxEdges) break; + } + + // A few k-nearest shortcuts for urbanized regions. + const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, regionId === 0 ? 8 : 4); + for (let i = 0; i < urbanNodes.length; i++) { + const a = urbanNodes[i]; + const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0]; + if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue; + const path = routeLight(a, b, 3); + if (path.length > 2) nationalRoads.push(path); + } + + // Railways: only high-order cities/ports, as a lightweight placeholder. + const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, regionId === 0 ? 7 : 4); + railNodes.sort((a, b) => a.x - b.x || a.y - b.y); + for (let i = 1; i < railNodes.length; i++) { + const path = routeLight(railNodes[i - 1], railNodes[i], 4); + if (path.length > 4) railways.push(path); + } + } + + // External gateways at land edges; used by naming/UI and later transport work. + for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { + const st = regionStats.get(regionId); + if (!st || st.area < 140) continue; + const edgeCandidates = []; + for (let y = st.minY; y <= st.maxY; y += 3) { + for (const x of [st.minX, st.maxX]) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] }); + } + } + for (let x = st.minX; x <= st.maxX; x += 3) { + for (const y of [st.minY, st.maxY]) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] }); + } + } + const gateway = pickEntities(edgeCandidates, { max: regionId === 0 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0]; + if (gateway) { + gateway.kind = "External Gateway"; + gateway.regionId = regionId; + externalGateways.push(gateway); + const target = importantNodesForRegion(regionId)[0]; + if (target) { + const path = routeLight(gateway, target, 3); + if (path.length > 2) externalRoads.push(path); + } + } + } + + // Approximate expressways as a very small subset of top inter-city links. + const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6); + for (let i = 1; i < topCities.length && expressways.length < 4; i++) { + const a = topCities[i - 1]; + const b = topCities[i]; + if (Math.hypot(a.x - b.x, a.y - b.y) < 85) { + const path = routeLight(a, b, 5); + if (path.length > 5) expressways.push(path); + } + } + + const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads, ...expressways], 5); + const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4); + + const stations = []; + const usedStationKeys = new Set(); + function addStation(x, y, kind = "Station", score = 1) { + x = Math.round(x); y = Math.round(y); + if (!inside(x, y) || sea[indexOf(x, y)]) return; + const key = `${x},${y}`; + if (usedStationKeys.has(key)) return; + usedStationKeys.add(key); + stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) }); + } + for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5); + for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8); + const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85); + + // --- 5. Approximate city/town influence and land-use --------------------- + const cityInfluence = new Float32Array(SIZE); + const coreInfluence = new Float32Array(SIZE); + const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9); + const populationDensity = new Float32Array(SIZE); + + function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true) { + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = p.x + dx; + const y = p.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const terrain = terrainWeighted ? clamp(0.20 + developable[i] * 1.05 + valleySettlement[i] * 0.18 + coastalSettlement[i] * 0.10 - slope[i] * 0.22 - ridgeField[i] * 0.14, 0, 1.28) : 1; + const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain; + if (v > grid[i]) grid[i] = v; + } + } + } + + for (const city of modernCities) { + addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.25, true); + addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true); + } + const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25)); + + // Industrial/logistics/new town placeholders remain lightweight. They are + // routed by land-use proximity rather than expensive search passes. + const industrialZones = []; + for (const p of [...commercialPorts, ...modernCities.slice(0, 5)]) { + const candidates = []; + for (let dy = -10; dy <= 10; dy++) { + for (let dx = -10; dx <= 10; dx++) { + const x = p.x + dx; + const y = p.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const d = Math.hypot(dx, dy); + if (d < 3 || d > 10) continue; + const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06; + if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) }); + } + } + const z = pickEntities(candidates, { max: 1, minDistance: 6, seed: seed + 14010 + p.x * 3 + p.y })[0]; + if (z && industrialZones.every((q) => Math.hypot(q.x - z.x, q.y - z.y) > 13)) industrialZones.push(z); + if (industrialZones.length >= 8) break; + } + const industrialInfluence = influenceFromPoints(industrialZones, 5, () => 1.0); + + const satelliteCities = []; + const newTowns = []; + const logisticsParks = []; + const interchanges = []; + var landuse = new Uint8Array(SIZE); + + // Re-run land-use classification after landuse allocation. The loop above is + // intentionally inside a helper to keep all thresholds in one place. + function classifyLanduse() { + landuse.fill(LANDUSE.RURAL); + let maxDensity = 0; + 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 urban = cityInfluence[i] * 0.78 + stationInfluence[i] * 0.22 + roadInfluence[i] * 0.08; + const core = coreInfluence[i]; + const oldTown = oldTownInfluence[i] * 0.72 + townInfluence[i] * 0.42; + const rural = villageInfluence[i] * 0.25 + ruralSuitability[i] * 0.30; + populationDensity[i] = clamp(urban * 0.74 + core * 0.32 + oldTown * 0.28 + townInfluence[i] * 0.18 + villageInfluence[i] * 0.16 + roadInfluence[i] * 0.05); + maxDensity = Math.max(maxDensity, populationDensity[i]); + if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) landuse[i] = LANDUSE.FOREST; + else if (industrialInfluence[i] > 0.22 && developable[i] > 0.08) landuse[i] = LANDUSE.INDUSTRIAL; + else if (core > 0.38 && developable[i] > 0.07) landuse[i] = LANDUSE.CBD; + else if (oldTown > 0.24 && developable[i] > 0.06) landuse[i] = LANDUSE.OLD_URBAN; + else if (urban > 0.22 && developable[i] > 0.08) landuse[i] = LANDUSE.SUBURB; + else if (roadInfluence[i] > 0.22 && developable[i] > 0.16 && townInfluence[i] > 0.07) landuse[i] = LANDUSE.ROADSIDE; + else if (agriculture[i] > 0.22 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.16)) landuse[i] = LANDUSE.FARMLAND; + else landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL; + } + } + if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); + } + classifyLanduse(); + + for (const city of modernCities) { + let urbanFootprintCells = 0; + let coreFootprintCells = 0; + const r = Math.ceil((city.urbanRadius || 8) * 1.3); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + if (Math.hypot(dx, dy) > r) continue; + if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++; + if (landuse[i] === LANDUSE.CBD) coreFootprintCells++; + } + } + city.urbanFootprintCells = urbanFootprintCells; + city.coreFootprintCells = coreFootprintCells; + } + + const transportDebug = { + humanStageVersion: "v2-sparse-raster", + aStarRoutes: 0, + regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0), + nationalRoadPopulationCoverage: 0, + nationalRoadUncoveredPopulation: 0, + }; + + return { + ports, + crossings, + passes, + settlementCluster, + settlementScore, + villages, + markets, + castles, + castleTowns, + premodernRoads, + minorRoads, + modernCities, + populationDensity, + railways, + branchRailways, + ringRailways, + externalRailways, + stations, + industrialZones, + nationalRoads, + ringRoads, + expressways, + ringExpressways, + icAccessRoads, + externalRoads, + externalExpressways, + interchanges, + logisticsParks, + satelliteCities, + newTowns, + landuse, + stationInfluence, + roadInfluence, + railInfluence2, + villageInfluence, + externalGateways, + cityPopulationCap, + transportDebug, + }; +} diff --git a/mapOutput.js b/mapOutput.js index 02e21d1..342a3b8 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -105,6 +105,8 @@ export function finishMapOutput({ terrainDebug, regionalPrefectureBorders, }) { + const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step }); + outputProgress("population recalculation"); // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. // Use all generated prefecture regions for human-geography density, not only the focused prefecture. const humanRegionMask = new Uint8Array(MAP_W * MAP_H); @@ -123,6 +125,8 @@ export function finishMapOutput({ } } + outputProgress("harbor works"); + function makeHarborWorks(ports) { const out = []; for (const port of ports) { @@ -155,6 +159,7 @@ export function finishMapOutput({ const usedNames = new Set(); const nameDebug = createNameDebug(); + outputProgress("feature naming"); villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug); ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug); crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug); @@ -171,6 +176,7 @@ export function finishMapOutput({ newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug); castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); + outputProgress("municipality naming"); const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); const representativeFeatures = [ ...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })), @@ -206,6 +212,8 @@ export function finishMapOutput({ } const usedAdminNames = new Set(); for (const [index, center] of adminCenters.entries()) { + center.adminNumericId = index; + center.municipalityId = index; let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index); const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index); if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { @@ -223,6 +231,7 @@ export function finishMapOutput({ } nameDebug.maxDerivedPerBase = 0; + outputProgress("final package"); const entitiesForNames = [ ...modernCities, ...ports, diff --git a/mapPipeline.js b/mapPipeline.js index bc32a17..f4f30c6 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -1,15 +1,34 @@ import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; import { generateTerrainAndRivers } from "./mapTerrain.js"; -import { generateMapFeatures } from "./mapFeatures.js"; +import { generateMapFeatures } from "./mapFeaturesV2.js"; import { finishMapOutput } from "./mapOutput.js"; import { generateAdminLayout } from "./mapAdminStage.js"; export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; +function nowMs() { + return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); +} + +function timedStage(timings, options, key, label, fn) { + options?.onProgress?.({ status: "start", key, label, timings: timings.slice() }); + const t0 = nowMs(); + const value = fn(); + const ms = Math.round((nowMs() - t0) * 10) / 10; + const entry = { key, label, ms }; + timings.push(entry); + options?.onProgress?.({ status: "done", key, label, ms, timings: timings.slice() }); + return value; +} + export function generateMap(seedInput = 114514, options = {}) { const seed = Number(seedInput) >>> 0; + if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} }; - const terrain = generateTerrainAndRivers(seed); + const generationTimings = []; + const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn); + + const terrain = stage("terrain", "Terrain, rivers, and prefecture regions", () => generateTerrainAndRivers(seed)); const { terrainTemplate, seaLevel, @@ -54,19 +73,29 @@ export function generateMap(seedInput = 114514, options = {}) { smallStreams, } = terrain; - const features = generateMapFeatures(seed, terrain); + const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain)); const { ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity, railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug, } = features; - const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ + const { adminCentersRaw, adminId, adminBorders, adminDebug } = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, - }); + adminProgress: (event) => options?.onProgress?.({ + ...event, + key: "admin", + label: event.status === "region-done" + ? `Admin region ${event.regionId} done` + : event.status === "admin-step" + ? `Admin region ${event.regionId}: ${event.step}` + : `Admin region ${event.regionId}`, + timings: generationTimings.slice(), + }), + })); - return finishMapOutput({ + const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({ seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, @@ -75,5 +104,8 @@ export function generateMap(seedInput = 114514, options = {}) { interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug, riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders, regionalDebug, terrainDebug, transportDebug, - }); + })); + output.generationTimings = generationTimings; + output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); + return output; } diff --git a/renderer.js b/renderer.js index f0e2eea..d08051f 100644 --- a/renderer.js +++ b/renderer.js @@ -765,9 +765,14 @@ export function drawMap(canvas, map, options) { drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`); if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true); for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)"); - if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } - + + const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode); + if (showPrefectureRegions && map.regionalPrefectureBorders) { + drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); + } + drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); @@ -818,6 +823,10 @@ export function drawMap(canvas, map, options) { } // 6. Icons & Labels + if (["admin", "admin-debug", "borders-debug"].includes(mode)) { + for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)"); + } + if (showModern) { for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444"); for (const p of map.modernCities) { diff --git a/styles.css b/styles.css index 5b45a82..9cf2ea4 100644 --- a/styles.css +++ b/styles.css @@ -65,4 +65,11 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .legend-line.harbor-line{background:transparent; border-top:2px solid #5f7896; height:0} .map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500} -.map-tooltip.visible{opacity:1;transform:translateY(0)} \ No newline at end of file +.map-tooltip.visible{opacity:1;transform:translateY(0)} + +.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5} +.generation-progress.hidden{display:none} +.progress-title{font-weight:700;margin-bottom:4px} +.progress-stage{color:#5f6368;margin-bottom:10px} +.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043} +.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px} From 15c1031d7299b562444aed809a343fc57f4512da Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sun, 24 May 2026 21:21:17 +0900 Subject: [PATCH 6/6] not bad city system --- app.js | 2 - mapFeaturesV2.js | 166 ++++++++++++++++++++++++++++++---------- mapOutput.js | 192 ++++++++++++++++++++++++++--------------------- mapPipeline.js | 60 ++++++--------- renderer.js | 102 ++++++++++++++++++------- test.js | 2 +- 6 files changed, 329 insertions(+), 195 deletions(-) diff --git a/app.js b/app.js index c881bb3..c5ff6b4 100644 --- a/app.js +++ b/app.js @@ -8,11 +8,9 @@ const modes = [ ["suitability", "Suitability"], ["history", "Premodern"], ["modern", "Modern"], - ["roads", "Roads"], ["development", "Development"], ["landuse", "Land Use"], ["admin", "Municipal Borders"], - ["terrain-debug", "Terrain Debug"], ["admin-debug", "Admin Debug"], ["borders-debug", "Borders Debug"], ]; diff --git a/mapFeaturesV2.js b/mapFeaturesV2.js index eecedf9..1ee2a14 100644 --- a/mapFeaturesV2.js +++ b/mapFeaturesV2.js @@ -108,16 +108,18 @@ export function generateMapFeatures(seed, terrain) { floodplain[i] * 0.03 ); valleySettlement[i] = clamp( - valleyField[i] * 0.50 + - river[i] * 0.16 + - confluence * 0.72 + - depositional * 0.16 + - basinField[i] * 0.10 + - lowSlope * 0.10 - - slope[i] * 0.58 - - ridgeField[i] * 0.34 - - spine * 0.18 - - highPenalty * 0.72 + valleyField[i] * 0.52 + + river[i] * 0.08 + + confluence * 0.38 + + depositional * 0.20 + + basinField[i] * 0.16 + + plain[i] * 0.08 + + lowSlope * 0.12 - + slope[i] * 0.54 - + ridgeField[i] * 0.30 - + spine * 0.16 - + highPenalty * 0.70 - + floodplain[i] * 0.10 ); coastalSettlement[i] = clamp( coastalLowland[i] * 0.50 + @@ -333,20 +335,21 @@ export function generateMapFeatures(seed, terrain) { const i = indexOf(x, y); if (sea[i]) continue; const featurePull = Math.max( - distanceToNearest(ports, x, y) < 8 ? 0.12 : 0, - distanceToNearest(crossings, x, y) < 6 ? 0.10 : 0, - confluenceField[i] * 0.26 + distanceToNearest(ports, x, y) < 8 ? 0.10 : 0, + distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0, + confluenceField[i] * 0.16 ); - const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.12 : 0; + const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0; marketScore[i] = clamp( - townSuitability[i] * 0.66 + - villageInfluence[i] * 0.42 + + townSuitability[i] * 0.62 + + villageInfluence[i] * 0.38 + featurePull + valleyMouth + - basinField[i] * 0.10 + - plain[i] * 0.09 - - slope[i] * 0.20 - - ridgeField[i] * 0.10 + basinField[i] * 0.12 + + plain[i] * 0.14 + + coastalLowland[i] * 0.08 - + slope[i] * 0.18 - + ridgeField[i] * 0.08 ); } } @@ -446,7 +449,7 @@ export function generateMapFeatures(seed, terrain) { Math.log10(capacity + 1) * 0.72 + townSuitability[i] * 1.40 + developable[i] * 1.05 + - confluenceField[i] * 0.50 + + confluenceField[i] * 0.22 + (p.candidateKind === "port" ? 0.48 : 0) + (p.candidateKind === "castleTown" ? 0.22 : 0) + hash2(p.x, p.y, seed + 12000) * 0.16; @@ -523,9 +526,10 @@ export function generateMapFeatures(seed, terrain) { city.isRegionalCapital = isRegionalCapital; city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; city.kind = city.rank; - city.urbanRadius = clamp(4.5 + Math.sqrt(city.population) / 95, 7, isPrefecturalCapital ? 32 : isRegionalCapital ? 27 : 22); + city.urbanRadius = clamp(5.8 + Math.sqrt(city.population) / 72, 8, isPrefecturalCapital ? 40 : isRegionalCapital ? 32 : 24); city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isPrefecturalCapital ? 8.5 : 6.5); - city.urbanWeight = clamp(0.85 + Math.log10(Math.max(10000, city.population)) * 0.25, 1.0, 2.2); + city.sprawlRadius = clamp(city.urbanRadius * (isPrefecturalCapital ? 1.65 : isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isPrefecturalCapital ? 56 : isRegionalCapital ? 42 : 30); + city.urbanWeight = clamp(0.95 + Math.log10(Math.max(10000, city.population)) * 0.29, 1.08, 2.5); } function cityPopulationCap(city) { @@ -715,7 +719,7 @@ export function generateMapFeatures(seed, terrain) { const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9); const populationDensity = new Float32Array(SIZE); - function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true) { + function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") { const r = Math.ceil(radius); for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { @@ -726,16 +730,18 @@ export function generateMapFeatures(seed, terrain) { if (sea[i]) continue; const d = Math.hypot(dx, dy); if (d > radius) continue; - const terrain = terrainWeighted ? clamp(0.20 + developable[i] * 1.05 + valleySettlement[i] * 0.18 + coastalSettlement[i] * 0.10 - slope[i] * 0.22 - ridgeField[i] * 0.14, 0, 1.28) : 1; + const terrain = terrainWeighted ? clamp(0.24 + developable[i] * 1.00 + valleySettlement[i] * 0.16 + coastalSettlement[i] * 0.10 - slope[i] * 0.20 - ridgeField[i] * 0.12 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.06, 0, 1.34) : 1; const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain; - if (v > grid[i]) grid[i] = v; + if (combine === "add") grid[i] = Math.min(3.4, grid[i] + v); + else if (v > grid[i]) grid[i] = v; } } } for (const city of modernCities) { - addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.25, true); - addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true); + addKernel(cityInfluence, city, city.sprawlRadius || Math.round((city.urbanRadius || 10) * 1.4), (city.urbanWeight || 1.0) * (city.isPrefecturalCapital ? 0.46 : city.isRegionalCapital ? 0.38 : 0.30), 2.75, true, "add"); + addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.18, true, "add"); + addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true, "max"); } const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25)); @@ -774,26 +780,106 @@ export function generateMapFeatures(seed, terrain) { function classifyLanduse() { landuse.fill(LANDUSE.RURAL); let maxDensity = 0; + const baseNoiseSeed = seed + 15000; + const urbanCapacity = new Float32Array(SIZE); 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 urban = cityInfluence[i] * 0.78 + stationInfluence[i] * 0.22 + roadInfluence[i] * 0.08; + const transport = Math.max(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); + const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.26 + roadInfluence[i] * 0.12 + railInfluence2[i] * 0.10; const core = coreInfluence[i]; - const oldTown = oldTownInfluence[i] * 0.72 + townInfluence[i] * 0.42; - const rural = villageInfluence[i] * 0.25 + ruralSuitability[i] * 0.30; - populationDensity[i] = clamp(urban * 0.74 + core * 0.32 + oldTown * 0.28 + townInfluence[i] * 0.18 + villageInfluence[i] * 0.16 + roadInfluence[i] * 0.05); + const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38; + const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30; + const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10); + urbanCapacity[i] = clamp( + developable[i] * 0.66 + + plain[i] * 0.16 + + basinField[i] * 0.16 + + valleyField[i] * 0.16 + + coastalLowland[i] * 0.12 + + transport * 0.18 + + riverUrban * 0.14 - + slope[i] * 0.18 - + ridgeField[i] * 0.12 - + floodplain[i] * 0.08 + ); + populationDensity[i] = clamp(urban * 0.66 + core * 0.46 + oldTown * 0.28 + townInfluence[i] * 0.16 + villageInfluence[i] * 0.14 + transport * 0.12); maxDensity = Math.max(maxDensity, populationDensity[i]); - if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) landuse[i] = LANDUSE.FOREST; - else if (industrialInfluence[i] > 0.22 && developable[i] > 0.08) landuse[i] = LANDUSE.INDUSTRIAL; - else if (core > 0.38 && developable[i] > 0.07) landuse[i] = LANDUSE.CBD; - else if (oldTown > 0.24 && developable[i] > 0.06) landuse[i] = LANDUSE.OLD_URBAN; - else if (urban > 0.22 && developable[i] > 0.08) landuse[i] = LANDUSE.SUBURB; - else if (roadInfluence[i] > 0.22 && developable[i] > 0.16 && townInfluence[i] > 0.07) landuse[i] = LANDUSE.ROADSIDE; - else if (agriculture[i] > 0.22 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.16)) landuse[i] = LANDUSE.FARMLAND; - else landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL; + + if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { + landuse[i] = LANDUSE.FOREST; + continue; + } + if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.INDUSTRIAL; + continue; + } + if (core > 0.38 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.CBD; + continue; + } + if (oldTown > 0.18 && urbanCapacity[i] > 0.09) { + landuse[i] = LANDUSE.OLD_URBAN; + continue; + } + + const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08; + const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadInfluence[i] * 0.10 + 0.28); + const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42); + const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12; + if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.SUBURB; + } else if (transport > 0.18 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.04 || cityInfluence[i] > 0.09)) { + landuse[i] = transport > 0.28 && stationInfluence[i] > 0.10 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE; + } else if (agriculture[i] > 0.24 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.18)) { + landuse[i] = LANDUSE.FARMLAND; + } else { + landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL; + } } } + + const baseLanduse = landuse.slice(); + const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE; + 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] || baseLanduse[i] === LANDUSE.FOREST) continue; + const transport = Math.max(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); + let urbanNeighbors = 0; + let cbdNeighbors = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const lu = baseLanduse[indexOf(x + dx, y + dy)]; + if (isBuilt(lu)) urbanNeighbors++; + if (lu === LANDUSE.CBD) cbdNeighbors++; + } + } + if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) { + landuse[i] = LANDUSE.CBD; + continue; + } + if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) { + const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08; + const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24; + if (fringeChance > 0.34 + noise) { + landuse[i] = urbanNeighbors >= 4 || transport > 0.28 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE; + } + } + if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) { + landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB; + } + if (landuse[i] === LANDUSE.SUBURB && urbanNeighbors <= 1) { + const keep = clamp(cityInfluence[i] * 0.52 + transport * 0.32 + stationInfluence[i] * 0.18 + 0.08); + if (hash2(x, y, seed + 15051) > keep) { + landuse[i] = agriculture[i] > 0.22 ? LANDUSE.FARMLAND : LANDUSE.RURAL; + } + } + } + } + if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); } classifyLanduse(); diff --git a/mapOutput.js b/mapOutput.js index 342a3b8..7f6d50c 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -27,84 +27,112 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) { export function finishMapOutput({ seed, options, - terrainTemplate, - seaLevel, - cityPopulationCap, - stationInfluence, - roadInfluence, - railInfluence2, - elevation, - moisture, - slope, - sea, - ocean, - lake, - river, - floodplain, - plain, - agriculture, - settlementCluster, - ridgeField, - valleyField, - visibleRavineField, - surfaceTextureField, - basinField, - coastalLowland, - flowAccum, - erosionField, - depositionField, - arcSpineField, - branchRidgeField, - depositionalLowland, - alluvialFanField, - deltaField, - naturalBarrierScore, - villages, - ports, - crossings, - passes, - markets, - castles, - castleTowns, - premodernRoads, - minorRoads, - modernCities, - populationDensity, - railways, - branchRailways, - ringRailways, - externalRailways, - stations, - industrialZones, - nationalRoads, - ringRoads, - expressways, - ringExpressways, - icAccessRoads, - externalRoads, - externalExpressways, - interchanges, - logisticsParks, - satelliteCities, - newTowns, - landuse, - adminCentersRaw, - adminId, - adminBorders, - adminDebug, - riverPaths, - mainRivers, - tributaryRivers, - smallStreams, - externalGateways, - transportDebug, - prefectureMask, - prefectureBorder, - prefectureRegionId, - regionalDebug, - terrainDebug, - regionalPrefectureBorders, + terrain, + features, + admin, }) { + const { + terrainTemplate, + seaLevel, + elevation, + moisture, + slope, + sea, + ocean, + lake, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + visibleRavineField, + surfaceTextureField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalDebug, + terrainDebug, + regionalPrefectureBorders, + } = terrain; + + const { + cityPopulationCap, + stationInfluence, + roadInfluence, + railInfluence2, + settlementCluster, + villages: inputVillages, + ports: inputPorts, + crossings: inputCrossings, + passes: inputPasses, + markets: inputMarkets, + castles: inputCastles, + castleTowns: inputCastleTowns, + premodernRoads, + minorRoads, + modernCities: inputModernCities, + populationDensity, + railways, + branchRailways, + ringRailways, + externalRailways, + stations: inputStations, + industrialZones: inputIndustrialZones, + nationalRoads, + ringRoads, + expressways, + ringExpressways, + icAccessRoads, + externalRoads, + externalExpressways, + interchanges: inputInterchanges, + logisticsParks: inputLogisticsParks, + satelliteCities: inputSatelliteCities, + newTowns: inputNewTowns, + landuse, + externalGateways: inputExternalGateways, + transportDebug, + } = features; + + const { + adminCentersRaw, + adminId, + adminBorders, + adminDebug, + } = admin; + + let villages = inputVillages; + let ports = inputPorts; + let crossings = inputCrossings; + let passes = inputPasses; + let markets = inputMarkets; + let castles = inputCastles; + let castleTowns = inputCastleTowns; + let modernCities = inputModernCities; + let stations = inputStations; + let industrialZones = inputIndustrialZones; + let interchanges = inputInterchanges; + let logisticsParks = inputLogisticsParks; + let satelliteCities = inputSatelliteCities; + let newTowns = inputNewTowns; + let externalGateways = inputExternalGateways; + const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step }); outputProgress("population recalculation"); // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. @@ -147,14 +175,8 @@ export function finishMapOutput({ return out; } - // Bridge and tunnel icon systems were removed from the visual model. - // Arrays remain empty for backward-compatible tests and downstream code. - const bridges = []; - const tunnels = []; const harborWorks = makeHarborWorks(ports); - const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); - const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity }; const usedNames = new Set(); const nameDebug = createNameDebug(); @@ -318,17 +340,13 @@ export function finishMapOutput({ logisticsParks, satelliteCities, newTowns, - bridges, - tunnels, harborWorks, landuse, adminCenters, adminId, adminBorders, adminDebug, - abandonedRailways, castleRuins, - preservedOldRoads, riverPaths, mainRivers, tributaryRivers, diff --git a/mapPipeline.js b/mapPipeline.js index f4f30c6..70729d7 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -30,57 +30,44 @@ export function generateMap(seedInput = 114514, options = {}) { const terrain = stage("terrain", "Terrain, rivers, and prefecture regions", () => generateTerrainAndRivers(seed)); const { - terrainTemplate, - seaLevel, elevation, - moisture, slope, sea, - ocean, - lake, river, - floodplain, plain, agriculture, ridgeField, valleyField, - visibleRavineField, - surfaceTextureField, basinField, coastalLowland, flowAccum, - erosionField, - depositionField, - arcSpineField, - branchRidgeField, - depositionalLowland, - alluvialFanField, - deltaField, naturalBarrierScore, - portSuitability, - crossingSuitability, - passSuitability, prefectureMask, - prefectureBorder, prefectureRegionId, adminPrefectureRegionId, - regionalDebug, - terrainDebug, - regionalPrefectureBorders, - riverPaths, - mainRivers, - tributaryRivers, - smallStreams, } = terrain; const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain)); const { - ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity, - railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, - interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug, + settlementScore, + villages, + markets, + modernCities, + populationDensity, + stations, + industrialZones, + logisticsParks, + satelliteCities, + newTowns, + ports, + landuse, + stationInfluence, + roadInfluence, + railInfluence2, + villageInfluence, } = features; - const { adminCentersRaw, adminId, adminBorders, adminDebug } = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ + const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({ seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, adminProgress: (event) => options?.onProgress?.({ @@ -96,14 +83,11 @@ export function generateMap(seedInput = 114514, options = {}) { })); const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({ - seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, - elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField, - arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, - villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity, - railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, - interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug, - riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders, - regionalDebug, terrainDebug, transportDebug, + seed, + options, + terrain, + features, + admin, })); output.generationTimings = generationTimings; output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); diff --git a/renderer.js b/renderer.js index d08051f..4648d13 100644 --- a/renderer.js +++ b/renderer.js @@ -407,16 +407,16 @@ function discreteColor(map, x, y, mode) { color = [160, 205, 239]; } else if (mode === "landuse") { const colors = { - 0: [242, 248, 238], // rural / natural land - 1: [238, 246, 222], // farmland - 2: [240, 238, 232], // old urban - 3: [245, 230, 220], // CBD / DID core - 4: [250, 248, 245], // suburb - 5: [235, 235, 240], // industrial - 6: [240, 245, 240], // logistics - 7: [245, 248, 252], // new town - 8: [250, 248, 240], // roadside - 9: [225, 238, 220], // forest / mountain land + 0: [244, 247, 240], // rural / natural land + 1: [222, 236, 188], // farmland + 2: [232, 222, 214], // old urban + 3: [221, 188, 184], // CBD / DID core + 4: [235, 225, 236], // suburb + 5: [223, 224, 232], // industrial + 6: [232, 237, 232], // logistics + 7: [231, 236, 246], // new town + 8: [243, 233, 210], // roadside + 9: [221, 236, 216], // forest / mountain land }; color = colors[map.landuse[i]] || colors[0]; } else if (mode === "admin") { @@ -573,24 +573,25 @@ function drawSegments(ctx, segments, color, width, dashed = false) { } function drawUrbanAreas(ctx, map, mode) { - const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"]; + const visibleModes = ["all", "modern", "development", "landuse", "admin"]; if (!visibleModes.includes(mode)) return; const colors = { - 2: "rgba(225, 222, 215, 0.6)", - 3: "rgba(240, 220, 205, 0.85)", - 4: "rgba(242, 240, 235, 0.5)", - 5: "rgba(220, 220, 225, 0.6)", - 6: "rgba(225, 230, 225, 0.5)", - 7: "rgba(235, 240, 245, 0.6)", - 8: "rgba(245, 242, 235, 0.5)", + 2: "rgba(223, 214, 206, 0.72)", + 3: "rgba(215, 175, 172, 0.88)", + 4: "rgba(231, 219, 231, 0.68)", + 5: "rgba(218, 218, 226, 0.64)", + 6: "rgba(225, 230, 225, 0.56)", + 7: "rgba(229, 234, 242, 0.64)", + 8: "rgba(244, 230, 205, 0.62)", }; ctx.save(); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); - if (!map.prefectureMask[i]) continue; + const areaMask = map.humanRegionMask || map.prefectureMask; + if (areaMask && !areaMask[i]) continue; const lu = map.landuse[i]; if (!colors[lu]) continue; @@ -678,6 +679,42 @@ function drawLabels(ctx, points, limit = Infinity) { for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); } +function drawScaleBar(ctx) { + const kmPerCell = 2; + const targetKm = 20; + const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell)); + const lengthPx = lengthCells * CELL_SIZE; + const margin = 14; + const x = margin; + const y = margin + 18; + + ctx.save(); + ctx.lineCap = "butt"; + ctx.strokeStyle = "rgba(0,0,0,0.78)"; + ctx.lineWidth = 2.2; + ctx.fillStyle = "rgba(255,255,255,0.92)"; + ctx.fillRect(x - 8, y - 18, lengthPx + 16, 30); + ctx.strokeStyle = "rgba(80,80,80,0.22)"; + ctx.strokeRect(x - 8, y - 18, lengthPx + 16, 30); + ctx.strokeStyle = "rgba(30,30,30,0.82)"; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + lengthPx, y); + ctx.stroke(); + for (let t = 0; t <= 2; t++) { + const tx = x + (lengthPx * t) / 2; + ctx.beginPath(); + ctx.moveTo(tx, y - 5); + ctx.lineTo(tx, y + 5); + ctx.stroke(); + } + ctx.fillStyle = "rgba(20,20,20,0.88)"; + ctx.font = "600 11px ui-sans-serif, system-ui, -apple-system, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(`${targetKm} km`, x + lengthPx / 2, y - 7); + ctx.restore(); +} + export function drawMap(canvas, map, options) { const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -749,9 +786,9 @@ export function drawMap(canvas, map, options) { } const showHistory = ["history", "all", "terrain"].includes(mode); - const showModern = ["modern", "all", "development", "landuse", "roads", "admin-debug", "borders-debug"].includes(mode); - const showRoads = ["roads", "all", "development"].includes(mode); - const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode); + const showModern = ["modern", "all", "development", "landuse", "admin-debug", "borders-debug"].includes(mode); + const showRoads = ["all", "development"].includes(mode); + const showMinorRoads = ["all", "modern", "development"].includes(mode); const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); // 3. Borders @@ -829,11 +866,15 @@ export function drawMap(canvas, map, options) { if (showModern) { for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444"); + const allLayerTowns = mode === "all" + ? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)) + : []; + for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)"); for (const p of map.modernCities) { - const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5; + const popRadius = p.population ? Math.min(9.4, 3.8 + Math.sqrt(p.population) / 360) : 4.8; dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)"); - if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)"); - else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.2, "transparent", "rgba(190,95,95,0.62)"); + if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.4, "transparent", "rgba(200,80,80,0.9)"); + else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.4, "transparent", "rgba(190,95,95,0.62)"); } for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)"); if (mode === "admin-debug" || mode === "borders-debug") { @@ -845,17 +886,24 @@ export function drawMap(canvas, map, options) { if (showLabels) { if (mode === "admin") { drawLabels(ctx, map.adminCenters || [], Infinity); + drawScaleBar(ctx); return; } if (mode === "admin-debug" || mode === "borders-debug") { drawLabels(ctx, map.adminCenters || [], Infinity); + drawScaleBar(ctx); return; } + const allLayerTowns = mode === "all" + ? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)).map((p) => ({ ...p, labelPriorityBase: 120 })) + : []; const important = [ ...map.modernCities, ...map.ports, ...(map.satelliteCities || []), - ].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway"); - drawLabels(ctx, important, 60); + ...allLayerTowns, + ].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 25000); + drawLabels(ctx, important, mode === "all" ? 85 : 60); } + drawScaleBar(ctx); } diff --git a/test.js b/test.js index f560706..7487b6d 100644 --- a/test.js +++ b/test.js @@ -531,7 +531,7 @@ try { assert(map.terrainDebug.depositionLowlandArea > 0, "depositional lowland area is tracked"); assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed"); assert(map.settlementCluster.length === size, "settlement cluster field matches map size"); - assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist"); + assert(Array.isArray(map.harborWorks), "harbor arrays exist"); assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist"); assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists"); assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells");