From f420a3f8f3e84cc78716e813dbd727298b8603c8 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 21 May 2026 03:13:39 +0900 Subject: [PATCH 1/8] tweak --- adminRegions.js | 4 +- entitySelection.js | 17 ----- fields.js | 28 -------- graph.js | 34 --------- grid.js | 26 ------- mapGenerator.js | 8 +-- mapUtils.js | 167 +++++++++++++++++++++++++++++++++++++++++++++ names.js | 96 +++++++++++++++++++++++--- qualityMetrics.js | 141 -------------------------------------- random.js | 51 -------------- renderer.js | 3 +- scoring.js | 5 -- test.js | 141 +++++++++++++++++++++++++++++++++++++- 13 files changed, 396 insertions(+), 325 deletions(-) delete mode 100644 entitySelection.js delete mode 100644 fields.js delete mode 100644 graph.js delete mode 100644 grid.js create mode 100644 mapUtils.js delete mode 100644 qualityMetrics.js delete mode 100644 random.js delete mode 100644 scoring.js diff --git a/adminRegions.js b/adminRegions.js index fdbd166..0339e0c 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -1,6 +1,4 @@ -import { MinHeap } from "./graph.js"; -import { INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside, xyOf } from "./grid.js"; -import { weightedScore } from "./scoring.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } from "./mapUtils.js"; function neighbors8(x, y) { const out = []; diff --git a/entitySelection.js b/entitySelection.js deleted file mode 100644 index 21fa8e2..0000000 --- a/entitySelection.js +++ /dev/null @@ -1,17 +0,0 @@ -import { hash2 } from "./random.js"; - -export function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) { - const sorted = candidates - .filter((p) => Number.isFinite(p.score) && p.score >= threshold) - .map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter })) - .sort((a, b) => b.score - a.score); - - const out = []; - for (const candidate of sorted) { - if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) { - out.push(candidate); - if (out.length >= max) break; - } - } - return out; -} diff --git a/fields.js b/fields.js deleted file mode 100644 index 593080c..0000000 --- a/fields.js +++ /dev/null @@ -1,28 +0,0 @@ -import { SIZE } from "./grid.js"; - -export function createMapFields() { - const flowTo = new Int32Array(SIZE); - flowTo.fill(-1); - - return { - elevation: new Float32Array(SIZE), - moisture: new Float32Array(SIZE), - slope: new Float32Array(SIZE), - sea: new Uint8Array(SIZE), - river: new Float32Array(SIZE), - floodplain: new Float32Array(SIZE), - plain: new Float32Array(SIZE), - agriculture: new Float32Array(SIZE), - ridgeField: new Float32Array(SIZE), - valleyField: new Float32Array(SIZE), - basinField: new Float32Array(SIZE), - coastalLowland: new Float32Array(SIZE), - flowAccum: new Float32Array(SIZE), - erosionField: new Float32Array(SIZE), - depositionField: new Float32Array(SIZE), - flowTo, - portSuitability: new Float32Array(SIZE), - crossingSuitability: new Float32Array(SIZE), - passSuitability: new Float32Array(SIZE), - }; -} diff --git a/graph.js b/graph.js deleted file mode 100644 index 1311ca0..0000000 --- a/graph.js +++ /dev/null @@ -1,34 +0,0 @@ -export class MinHeap { - constructor() { this.items = []; } - push(item) { - this.items.push(item); - let i = this.items.length - 1; - while (i > 0) { - const parent = (i - 1) >> 1; - if (this.items[parent].f <= item.f) break; - this.items[i] = this.items[parent]; - i = parent; - } - this.items[i] = item; - } - pop() { - if (this.items.length === 0) return null; - const root = this.items[0]; - const last = this.items.pop(); - if (this.items.length > 0) { - let i = 0; - while (true) { - const left = i * 2 + 1; - const right = left + 1; - if (left >= this.items.length) break; - const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left; - if (this.items[child].f >= last.f) break; - this.items[i] = this.items[child]; - i = child; - } - this.items[i] = last; - } - return root; - } - get length() { return this.items.length; } -} diff --git a/grid.js b/grid.js deleted file mode 100644 index 355da28..0000000 --- a/grid.js +++ /dev/null @@ -1,26 +0,0 @@ -export const MAP_W = 172; -export const MAP_H = 122; -export const CELL_SIZE = 6; - -export const SIZE = MAP_W * MAP_H; -export const INF = 1e9; - -export function indexOf(x, y) { - return y * MAP_W + x; -} - -export function xyOf(i) { - return [i % MAP_W, Math.floor(i / MAP_W)]; -} - -export function inside(x, y) { - return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H; -} - -export function clamp(v, a = 0, b = 1) { - return Math.max(a, Math.min(b, v)); -} - -export function nearMapEdge(x, y, margin = 1) { - return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin; -} diff --git a/mapGenerator.js b/mapGenerator.js index 60da722..e1911d9 100644 --- a/mapGenerator.js +++ b/mapGenerator.js @@ -8,13 +8,9 @@ import { smoothAdminRegionsTerrainAware, snapAdminBoundariesToTerrain, } from "./adminRegions.js"; -import { pickEntities } from "./entitySelection.js"; -import { createMapFields } from "./fields.js"; -import { MinHeap } from "./graph.js"; -import { CELL_SIZE, INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside, nearMapEdge, xyOf } from "./grid.js"; -import { fbm, hash2, lerp, rand, smoothstep, valueNoise } from "./random.js"; +import { CELL_SIZE, INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, nearMapEdge, pickEntities, rand, smoothstep, valueNoise, xyOf } from "./mapUtils.js"; -export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./grid.js"; +export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; function neighbors8(x, y) { const out = []; diff --git a/mapUtils.js b/mapUtils.js new file mode 100644 index 0000000..cc5b8ef --- /dev/null +++ b/mapUtils.js @@ -0,0 +1,167 @@ +export const MAP_W = 172; +export const MAP_H = 122; +export const CELL_SIZE = 6; + +export const SIZE = MAP_W * MAP_H; +export const INF = 1e9; + +export function indexOf(x, y) { + return y * MAP_W + x; +} + +export function xyOf(i) { + return [i % MAP_W, Math.floor(i / MAP_W)]; +} + +export function inside(x, y) { + return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H; +} + +export function clamp(v, a = 0, b = 1) { + return Math.max(a, Math.min(b, v)); +} + +export function nearMapEdge(x, y, margin = 1) { + return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin; +} + +export function hash2(x, y, seed) { + let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263); + h = (h ^ (h >>> 13)) >>> 0; + h = Math.imul(h, 1274126177) >>> 0; + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; +} + +export function rand(seed, n) { + return hash2(n * 7919 + 17, n * 104729 + 31, seed); +} + +export function smoothstep(t) { + t = clamp(t); + return t * t * (3 - 2 * t); +} + +export function lerp(a, b, t) { + return a + (b - a) * t; +} + +export function valueNoise(x, y, seed, scale) { + const sx = x / scale; + const sy = y / scale; + const x0 = Math.floor(sx); + const y0 = Math.floor(sy); + const tx = smoothstep(sx - x0); + const ty = smoothstep(sy - y0); + + const a = hash2(x0, y0, seed); + const b = hash2(x0 + 1, y0, seed); + const c = hash2(x0, y0 + 1, seed); + const d = hash2(x0 + 1, y0 + 1, seed); + + return lerp(lerp(a, b, tx), lerp(c, d, tx), ty); +} + +export function fbm(x, y, seed) { + let amp = 1; + let scale = 54; + let sum = 0; + let norm = 0; + for (let i = 0; i < 5; i++) { + sum += valueNoise(x, y, seed + i * 101, scale) * amp; + norm += amp; + amp *= 0.5; + scale *= 0.5; + } + return sum / norm; +} + +export function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) { + const sorted = candidates + .filter((p) => Number.isFinite(p.score) && p.score >= threshold) + .map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter })) + .sort((a, b) => b.score - a.score); + + const out = []; + for (const candidate of sorted) { + if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) { + out.push(candidate); + if (out.length >= max) break; + } + } + return out; +} + +export function createMapFields() { + const flowTo = new Int32Array(SIZE); + flowTo.fill(-1); + + return { + elevation: new Float32Array(SIZE), + moisture: new Float32Array(SIZE), + slope: new Float32Array(SIZE), + sea: new Uint8Array(SIZE), + river: new Float32Array(SIZE), + floodplain: new Float32Array(SIZE), + plain: new Float32Array(SIZE), + agriculture: new Float32Array(SIZE), + ridgeField: new Float32Array(SIZE), + valleyField: new Float32Array(SIZE), + basinField: new Float32Array(SIZE), + coastalLowland: new Float32Array(SIZE), + flowAccum: new Float32Array(SIZE), + erosionField: new Float32Array(SIZE), + depositionField: new Float32Array(SIZE), + flowTo, + portSuitability: new Float32Array(SIZE), + crossingSuitability: new Float32Array(SIZE), + passSuitability: new Float32Array(SIZE), + }; +} + +export class MinHeap { + constructor() { + this.items = []; + } + + push(item) { + this.items.push(item); + let i = this.items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (this.items[parent].f <= item.f) break; + this.items[i] = this.items[parent]; + i = parent; + } + this.items[i] = item; + } + + pop() { + if (this.items.length === 0) return null; + const root = this.items[0]; + const last = this.items.pop(); + if (this.items.length > 0) { + let i = 0; + while (true) { + const left = i * 2 + 1; + const right = left + 1; + if (left >= this.items.length) break; + const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left; + if (this.items[child].f >= last.f) break; + this.items[i] = this.items[child]; + i = child; + } + this.items[i] = last; + } + return root; + } + + get length() { + return this.items.length; + } +} + +export function weightedScore(terms) { + let total = 0; + for (const [value, weight] of terms) total += value * weight; + return total; +} diff --git a/names.js b/names.js index da7859a..4585f85 100644 --- a/names.js +++ b/names.js @@ -1,16 +1,90 @@ -import { MAP_H, MAP_W, indexOf, inside } from "./grid.js"; -import { hash2 } from "./random.js"; +import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; export const NAME_KANJI_POOLS = { - modifiers: [], - inlandTerrain: [], - waterTerrain: [], - coastalTerrain: [], - plants: [], - postfixes: [], - archaicPrefixes: [], - archaicSuffixes: [], - settlementWords: [], + modifiers: [ + "大", "小", "上", "下", "中", + "東", "西", "南", "北", + "新", "古", "本", "元", + "高", "長", "広", "深", "浅", + "白", "黒", "青", "赤", + "奥", "前", "後", "内", "外", + "早", "早", "真", "丸", "平" + ], + + inlandTerrain: [ + "山", "谷", "沢", "原", "野", + "森", "林", "岡", "丘", "坂", + "峰", "峠", "嶺", "尾", "平", + "窪", "久", "洞", "迫", "台", + "塚", "牧", "畑", "田", "森", + "麓", "郷", "里" + ], + + waterTerrain: [ + "川", "河", "江", "瀬", "淵", + "池", "沼", "泉", "井", "湖", + "滝", "渓", "沢", "谷", "津", + "水", "清", "渡", "橋", "堀", + "溝", "湯", "浦", "洲" + ], + + coastalTerrain: [ + "浜", "浦", "津", "崎", "岬", + "島", "磯", "潟", "湊", "港", + "海", "洲", "瀬", "砂", "潮", + "泊", "江", "浦", "灘", "入", + "湾", "戸", "門" + ], + + plants: [ + "松", "杉", "桜", "梅", "栗", + "竹", "楠", "藤", "萩", "葦", + "菅", "榎", "椿", "桐", "柳", + "橘", "柏", "槙", "柿", "桃", + "梨", "桑", "麻", "芦", "茅", + "榊", "楢", "檜", "椎", "柚" + ], + + postfixes: [ + "田", "原", "野", "沢", "谷", + "川", "山", "岡", "森", "林", + "浜", "浦", "津", "崎", "島", + "江", "瀬", "井", "戸", "口", + "辺", "里", "郷", "村", "町", + "宿", "庄", "台", "坂", "橋", + "本", "内", "窪", "平", "塚", + "畑", "牧", "前", "後", "中" + ], + + archaicPrefixes: [ + "伊", "宇", "阿", "安", "佐", + "土", "出", "丹", "播", "但", + "因", "伯", "筑", "肥", "豊", + "日", "紀", "志", "尾", "駿", + "甲", "信", "越", "備", "讃", + "薩", "隠", "美", "三", "若", + "遠", "近", "能", "加", "賀", + "越", "淡", "壱", "対" + ], + + archaicSuffixes: [ + "予", "陀", "芸", "佐", "雲", + "磨", "馬", "幡", "耆", "摩", + "張", "江", "河", "斐", "濃", + "岐", "防", "門", "隅", "向", + "伊", "前", "中", "後", "波", + "勢", "渡", "城", "紫", "野", + "津", "島", "海", "登", "賀", + "良", "美", "智", "智", "代" + ], + + settlementWords: [ + "里", "郷", "村", "町", "宿", + "庄", "院", "宮", "寺", "社", + "城", "館", "屋", "家", "所", + "市", "場", "府", "関", "駅", + "新田", "本郷", "一宮", "国府" + ] }; export const NAME_PROBABILITIES = { diff --git a/qualityMetrics.js b/qualityMetrics.js deleted file mode 100644 index d8dc82d..0000000 --- a/qualityMetrics.js +++ /dev/null @@ -1,141 +0,0 @@ -import { MAP_H, MAP_W, indexOf } from "./grid.js"; - -export function terrainBoundaryTargetForMetrics(map, i) { - const lu = map.landuse[i]; - const urbanPenalty = Math.min(1, (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) + map.populationDensity[i] * 1.35); - const majorRiver = Math.min(1, Math.max(map.river[i] - 0.32, 0) * 1.9 + Math.max(map.flowAccum[i] - 0.38, 0) * 0.75); - const minorStream = Math.min(1, map.river[i] * 0.34 + map.flowAccum[i] * 0.18); - const ridgeDivide = Math.min(1, map.ridgeField[i] * 1.55 + Math.max(0, map.elevation[i] - 0.54) * map.ridgeField[i] * 0.95); - const slopeBreak = Math.min(1, map.slope[i] * 0.58 + Math.max(0, map.slope[i] - 0.32) * 0.68); - const highGround = Math.max(0, map.elevation[i] - 0.56) * 0.22; - const valleyFloorPenalty = map.valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); - return Math.max(0, Math.min(1, ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72)); -} - -export function adminBoundaryMetrics(map) { - let borderEdges = 0; - let targetSum = 0; - let denseUrbanEdges = 0; - let rightAngleRuns = 0; - let voronoiLikeEdges = 0; - let lowScoreFlatEdges = 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 (!map.prefectureMask[i] || map.sea[i] || map.adminId[i] < 0) continue; - for (const [dx, dy] of [[1, 0], [0, 1]]) { - const ni = indexOf(x + dx, y + dy); - if (!map.prefectureMask[ni] || map.sea[ni] || map.adminId[ni] < 0 || map.adminId[ni] === map.adminId[i]) continue; - borderEdges++; - const edgeTarget = (terrainBoundaryTargetForMetrics(map, i) + terrainBoundaryTargetForMetrics(map, ni)) * 0.5; - targetSum += edgeTarget; - const urban = Math.max(map.populationDensity[i], map.populationDensity[ni]) > 0.58 || [2, 3, 4, 7, 8].includes(map.landuse[i]) || [2, 3, 4, 7, 8].includes(map.landuse[ni]); - if (urban) denseUrbanEdges++; - const ca = map.adminCenters[map.adminId[i]]; - const cb = map.adminCenters[map.adminId[ni]]; - if (ca && cb) { - const mx = x + dx * 0.5; - const my = y + dy * 0.5; - const dA = Math.hypot(mx - ca.x, my - ca.y); - const dB = Math.hypot(mx - cb.x, my - cb.y); - if (Math.abs(dA - dB) < 4.2 && edgeTarget < 0.40) voronoiLikeEdges++; - } - if (edgeTarget < 0.16 && Math.max(map.slope[i], map.slope[ni]) < 0.24 && Math.max(map.ridgeField[i], map.ridgeField[ni]) < 0.28 && Math.max(map.river[i], map.river[ni]) < 0.26) { - lowScoreFlatEdges++; - } - const sideA = indexOf(x + (dy ? 1 : 0), y + (dx ? 1 : 0)); - const sideB = indexOf(x - (dy ? 1 : 0), y - (dx ? 1 : 0)); - if (map.prefectureMask[sideA] && map.prefectureMask[sideB] && !map.sea[sideA] && !map.sea[sideB]) { - const turnA = map.adminId[sideA] !== map.adminId[i] && map.adminId[sideA] !== map.adminId[ni]; - const turnB = map.adminId[sideB] !== map.adminId[i] && map.adminId[sideB] !== map.adminId[ni]; - if ((turnA || turnB) && terrainBoundaryTargetForMetrics(map, i) < 0.46) rightAngleRuns++; - } - } - } - } - - const ids = new Set([...map.adminId].filter((id, i) => id >= 0 && map.prefectureMask[i] && !map.sea[i])); - const areaById = new Map(); - for (let i = 0; i < map.adminId.length; i++) { - if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1); - } - const areas = [...areaById.values()].sort((a, b) => a - b); - const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 1; - const maxArea = areas.length ? areas[areas.length - 1] : 1; - let disconnectedMunicipalities = 0; - let maxComponents = 0; - const seen = new Uint8Array(MAP_W * MAP_H); - for (const id of ids) { - let comps = 0; - seen.fill(0); - for (let i = 0; i < map.adminId.length; i++) { - if (seen[i] || map.adminId[i] !== id || !map.prefectureMask[i] || map.sea[i]) continue; - comps++; - const queue = [i]; - seen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - const x = cur % MAP_W; - const y = Math.floor(cur / MAP_W); - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = x + dx; - const ny = y + dy; - if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; - const ni = indexOf(nx, ny); - if (seen[ni] || map.adminId[ni] !== id || !map.prefectureMask[ni] || map.sea[ni]) continue; - seen[ni] = 1; - queue.push(ni); - } - } - } - if (comps > 1) disconnectedMunicipalities++; - maxComponents = Math.max(maxComponents, comps); - } - - const centerValidCount = map.adminCenters.filter((center) => { - const i = indexOf(center.x, center.y); - return map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0; - }).length; - - return { - borderEdges, - avgTarget: borderEdges ? targetSum / borderEdges : 0, - denseUrbanRate: borderEdges ? denseUrbanEdges / borderEdges : 0, - rightAngleRate: borderEdges ? rightAngleRuns / borderEdges : 0, - voronoiLikeRate: borderEdges ? voronoiLikeEdges / borderEdges : 0, - lowScoreFlatRate: borderEdges ? lowScoreFlatEdges / borderEdges : 0, - areaDiversity: maxArea / Math.max(1, medianArea), - municipalityCount: ids.size, - disconnectedMunicipalities, - maxComponents, - centerValidRatio: map.adminCenters.length ? centerValidCount / map.adminCenters.length : 1, - }; -} - -export function majorCityCoreIntegrity(map) { - const majorCities = map.modernCities.filter((city) => (city.population || 0) >= 180000); - if (majorCities.length === 0) return 1; - let sum = 0; - let checked = 0; - for (const city of majorCities) { - const counts = new Map(); - const r = Math.ceil(Math.max(3, city.coreRadius || 4)); - 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 (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || Math.hypot(dx, dy) > r) continue; - const i = indexOf(x, y); - if (!map.prefectureMask[i] || map.sea[i]) continue; - if (map.landuse[i] !== 3 && map.populationDensity[i] < 0.38) continue; - const id = map.adminId[i]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); - } - } - const total = [...counts.values()].reduce((a, b) => a + b, 0); - if (total === 0) continue; - sum += Math.max(...counts.values()) / total; - checked++; - } - return checked ? sum / checked : 1; -} diff --git a/random.js b/random.js deleted file mode 100644 index d657951..0000000 --- a/random.js +++ /dev/null @@ -1,51 +0,0 @@ -import { clamp } from "./grid.js"; - -export function hash2(x, y, seed) { - let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263); - h = (h ^ (h >>> 13)) >>> 0; - h = Math.imul(h, 1274126177) >>> 0; - return ((h ^ (h >>> 16)) >>> 0) / 4294967295; -} - -export function rand(seed, n) { - return hash2(n * 7919 + 17, n * 104729 + 31, seed); -} - -export function smoothstep(t) { - t = clamp(t); - return t * t * (3 - 2 * t); -} - -export function lerp(a, b, t) { - return a + (b - a) * t; -} - -export function valueNoise(x, y, seed, scale) { - const sx = x / scale; - const sy = y / scale; - const x0 = Math.floor(sx); - const y0 = Math.floor(sy); - const tx = smoothstep(sx - x0); - const ty = smoothstep(sy - y0); - - const a = hash2(x0, y0, seed); - const b = hash2(x0 + 1, y0, seed); - const c = hash2(x0, y0 + 1, seed); - const d = hash2(x0 + 1, y0 + 1, seed); - - return lerp(lerp(a, b, tx), lerp(c, d, tx), ty); -} - -export function fbm(x, y, seed) { - let amp = 1; - let scale = 54; - let sum = 0; - let norm = 0; - for (let i = 0; i < 5; i++) { - sum += valueNoise(x, y, seed + i * 101, scale) * amp; - norm += amp; - amp *= 0.5; - scale *= 0.5; - } - return sum / norm; -} diff --git a/renderer.js b/renderer.js index d4acb27..ad253bc 100644 --- a/renderer.js +++ b/renderer.js @@ -1,5 +1,4 @@ -import { MAP_W, MAP_H, CELL_SIZE, indexOf } from "./mapGenerator.js"; -import { clamp } from "./grid.js"; +import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js"; function distToNearest(points, x, y, fallback = 999) { let best = fallback; diff --git a/scoring.js b/scoring.js deleted file mode 100644 index b4577c9..0000000 --- a/scoring.js +++ /dev/null @@ -1,5 +0,0 @@ -export function weightedScore(terms) { - let total = 0; - for (const [value, weight] of terms) total += value * weight; - return total; -} diff --git a/test.js b/test.js index 441e172..25a114d 100644 --- a/test.js +++ b/test.js @@ -9,7 +9,6 @@ import { NAME_TEMPLATE_WEIGHTS, generateTemplateName, } from "./names.js"; -import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js"; const result = document.getElementById("result"); const logLines = []; @@ -29,6 +28,146 @@ function assert(condition, message) { } } +function terrainBoundaryTargetForMetrics(map, i) { + const lu = map.landuse[i]; + const urbanPenalty = Math.min(1, (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) + map.populationDensity[i] * 1.35); + const majorRiver = Math.min(1, Math.max(map.river[i] - 0.32, 0) * 1.9 + Math.max(map.flowAccum[i] - 0.38, 0) * 0.75); + const minorStream = Math.min(1, map.river[i] * 0.34 + map.flowAccum[i] * 0.18); + const ridgeDivide = Math.min(1, map.ridgeField[i] * 1.55 + Math.max(0, map.elevation[i] - 0.54) * map.ridgeField[i] * 0.95); + const slopeBreak = Math.min(1, map.slope[i] * 0.58 + Math.max(0, map.slope[i] - 0.32) * 0.68); + const highGround = Math.max(0, map.elevation[i] - 0.56) * 0.22; + const valleyFloorPenalty = map.valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); + return Math.max(0, Math.min(1, ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72)); +} + +function adminBoundaryMetrics(map) { + let borderEdges = 0; + let targetSum = 0; + let denseUrbanEdges = 0; + let rightAngleRuns = 0; + let voronoiLikeEdges = 0; + let lowScoreFlatEdges = 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 (!map.prefectureMask[i] || map.sea[i] || map.adminId[i] < 0) continue; + for (const [dx, dy] of [[1, 0], [0, 1]]) { + const ni = indexOf(x + dx, y + dy); + if (!map.prefectureMask[ni] || map.sea[ni] || map.adminId[ni] < 0 || map.adminId[ni] === map.adminId[i]) continue; + borderEdges++; + const edgeTarget = (terrainBoundaryTargetForMetrics(map, i) + terrainBoundaryTargetForMetrics(map, ni)) * 0.5; + targetSum += edgeTarget; + const urban = Math.max(map.populationDensity[i], map.populationDensity[ni]) > 0.58 || [2, 3, 4, 7, 8].includes(map.landuse[i]) || [2, 3, 4, 7, 8].includes(map.landuse[ni]); + if (urban) denseUrbanEdges++; + const ca = map.adminCenters[map.adminId[i]]; + const cb = map.adminCenters[map.adminId[ni]]; + if (ca && cb) { + const mx = x + dx * 0.5; + const my = y + dy * 0.5; + const dA = Math.hypot(mx - ca.x, my - ca.y); + const dB = Math.hypot(mx - cb.x, my - cb.y); + if (Math.abs(dA - dB) < 4.2 && edgeTarget < 0.40) voronoiLikeEdges++; + } + if (edgeTarget < 0.16 && Math.max(map.slope[i], map.slope[ni]) < 0.24 && Math.max(map.ridgeField[i], map.ridgeField[ni]) < 0.28 && Math.max(map.river[i], map.river[ni]) < 0.26) { + lowScoreFlatEdges++; + } + const sideA = indexOf(x + (dy ? 1 : 0), y + (dx ? 1 : 0)); + const sideB = indexOf(x - (dy ? 1 : 0), y - (dx ? 1 : 0)); + if (map.prefectureMask[sideA] && map.prefectureMask[sideB] && !map.sea[sideA] && !map.sea[sideB]) { + const turnA = map.adminId[sideA] !== map.adminId[i] && map.adminId[sideA] !== map.adminId[ni]; + const turnB = map.adminId[sideB] !== map.adminId[i] && map.adminId[sideB] !== map.adminId[ni]; + if ((turnA || turnB) && terrainBoundaryTargetForMetrics(map, i) < 0.46) rightAngleRuns++; + } + } + } + } + + const ids = new Set([...map.adminId].filter((id, i) => id >= 0 && map.prefectureMask[i] && !map.sea[i])); + const areaById = new Map(); + for (let i = 0; i < map.adminId.length; i++) { + if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1); + } + const areas = [...areaById.values()].sort((a, b) => a - b); + const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 1; + const maxArea = areas.length ? areas[areas.length - 1] : 1; + let disconnectedMunicipalities = 0; + let maxComponents = 0; + const seen = new Uint8Array(MAP_W * MAP_H); + for (const id of ids) { + let comps = 0; + seen.fill(0); + for (let i = 0; i < map.adminId.length; i++) { + if (seen[i] || map.adminId[i] !== id || !map.prefectureMask[i] || map.sea[i]) continue; + comps++; + const queue = [i]; + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || map.adminId[ni] !== id || !map.prefectureMask[ni] || map.sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + } + if (comps > 1) disconnectedMunicipalities++; + maxComponents = Math.max(maxComponents, comps); + } + + const centerValidCount = map.adminCenters.filter((center) => { + const i = indexOf(center.x, center.y); + return map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0; + }).length; + + return { + borderEdges, + avgTarget: borderEdges ? targetSum / borderEdges : 0, + denseUrbanRate: borderEdges ? denseUrbanEdges / borderEdges : 0, + rightAngleRate: borderEdges ? rightAngleRuns / borderEdges : 0, + voronoiLikeRate: borderEdges ? voronoiLikeEdges / borderEdges : 0, + lowScoreFlatRate: borderEdges ? lowScoreFlatEdges / borderEdges : 0, + areaDiversity: maxArea / Math.max(1, medianArea), + municipalityCount: ids.size, + disconnectedMunicipalities, + maxComponents, + centerValidRatio: map.adminCenters.length ? centerValidCount / map.adminCenters.length : 1, + }; +} + +function majorCityCoreIntegrity(map) { + const majorCities = map.modernCities.filter((city) => (city.population || 0) >= 180000); + if (majorCities.length === 0) return 1; + let sum = 0; + let checked = 0; + for (const city of majorCities) { + const counts = new Map(); + const r = Math.ceil(Math.max(3, city.coreRadius || 4)); + 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 (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || Math.hypot(dx, dy) > r) continue; + const i = indexOf(x, y); + if (!map.prefectureMask[i] || map.sea[i]) continue; + if (map.landuse[i] !== 3 && map.populationDensity[i] < 0.38) continue; + const id = map.adminId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + } + const total = [...counts.values()].reduce((a, b) => a + b, 0); + if (total === 0) continue; + sum += Math.max(...counts.values()) / total; + checked++; + } + return checked ? sum / checked : 1; +} + try { const map = generateMap(12345); const other = generateMap(54321); From b707dadec9be37435229f5de9641cb366ef03108 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 21 May 2026 13:20:19 +0900 Subject: [PATCH 2/8] tweak --- adminRegions.js | 398 +++++- app.js | 4 + mapAdminStage.js | 410 ++++++ mapFeatures.js | 1323 ++++++++++++++++++ mapGenerator.js | 3012 +--------------------------------------- mapGeneratorHelpers.js | 910 ++++++++++++ mapOutput.js | 226 +++ mapPipeline.js | 64 + mapTerrain.js | 900 ++++++++++++ names.js | 28 +- renderer.js | 20 +- test.js | 110 +- 12 files changed, 4332 insertions(+), 3073 deletions(-) create mode 100644 mapAdminStage.js create mode 100644 mapFeatures.js create mode 100644 mapGeneratorHelpers.js create mode 100644 mapOutput.js create mode 100644 mapPipeline.js create mode 100644 mapTerrain.js diff --git a/adminRegions.js b/adminRegions.js index 0339e0c..5dc1894 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -156,12 +156,31 @@ export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, } } -export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) { +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)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]); + 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++) { @@ -187,16 +206,35 @@ export function mergeTinyMunicipalities(adminId, prefectureMask, sea, population 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 candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24; + 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 (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]); @@ -465,6 +503,119 @@ function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyFie 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 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 + + terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 - + livingCorridor * 0.50 - + urbanContinuity * 0.72 + ); + } + return score; +} + +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.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72; + 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; + return barrier < threshold && (!majorRiverEdge || urbanEdge); +} + +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)}`; +} + +export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) { + 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; + 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]; + 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; + compartments.push({ + id, + cells, + area, + x: sx / area, + y: sy / area, + classId: startClass, + dominantLandscapeClass: startClass, + population: pop, + urbanWeight: urbanWeight / area, + ridgeExposure: ridgeExposure / area, + riverExposure: riverExposure / area, + coastalExposure: coastalExposure / area, + basinIdentity: basinIdentity / area, + valleyIdentity: valleyIdentity / area, + centerIds: [], + adjacent: new Map(), + }); + } + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + mergeTinyLandscapeUnits(compartmentId, compartments, 12); + 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); @@ -559,52 +710,124 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) { } } -function landscapeTransitionCost(a, b, edge) { +function naturalOwnershipAffinity(unit, neighbor, edge) { const boundaryTarget = edge.target / Math.max(1, edge.count); - const bothUrban = a.classId <= 3 && b.classId <= 3; - const bothCorridor = (a.classId === 5 || a.classId === 7 || a.classId === 10) && (b.classId === 5 || b.classId === 7 || b.classId === 10); - const urbanContinuity = bothUrban ? 2.1 : (a.urbanWeight + b.urbanWeight) > 0.75 && Math.abs(a.urbanWeight - b.urbanWeight) < 0.35 ? 0.9 : 0; - return Math.max(0.18, 0.70 + boundaryTarget * 4.2 + (a.classId === b.classId ? 0 : 0.75) + ((a.classId === 8 || b.classId === 8) ? 1.2 : 0) - urbanContinuity - (bothCorridor ? 0.55 : 0)); + 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; } -export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { - const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); - if (units.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 = units[unitId[indexOf(center.x, center.y)]]; - if (unit) unit.centerIds.push(id); - } - - const owner = new Int16Array(units.length); - const dist = new Float32Array(units.length); - owner.fill(-1); dist.fill(INF); - const heap = new MinHeap(); - for (const unit of units) { - if (unit.area === 0 || unit.centerIds.length === 0) continue; - const id = unit.centerIds[0]; - owner[unit.id] = id; dist[unit.id] = 0; heap.push({ i: unit.id, f: 0 }); - } - while (heap.length) { - const cur = heap.pop(); - if (!cur || cur.f > dist[cur.i] + 1e-5) continue; - const unit = units[cur.i]; - const currentOwner = owner[cur.i]; - if (!unit || currentOwner < 0) continue; - for (const [nextId, edge] of unit.adjacent) { - const next = units[nextId]; - if (!next || next.area === 0) continue; - const nextDist = dist[cur.i] + landscapeTransitionCost(unit, next, edge) + Math.sqrt(next.area) * 0.012 + (next.urbanWeight > 0.75 && next.centerIds.length === 0 ? -0.20 : 0); - if (nextDist < dist[nextId]) { - dist[nextId] = nextDist; owner[nextId] = currentOwner; heap.push({ i: nextId, f: nextDist }); +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++; } } } - for (const unit of units) { + return count ? sum / count : 0; +} + +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; @@ -616,7 +839,18 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e if (prefectureMask[i] && !sea[i]) adminId[i] = id; } } - repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse); + 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) { @@ -659,3 +893,83 @@ export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, eleva 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; + for (const [id, cells] of area) { + const averageLowland = (lowland.get(id) || 0) / cells; + const averageMountain = (mountain.get(id) || 0) / cells; + if (cells < median * 2.25 || averageLowland < 0.28 || averageMountain > 0.44) 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) 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 }; +} diff --git a/app.js b/app.js index 3a77f36..4fb719e 100644 --- a/app.js +++ b/app.js @@ -11,6 +11,8 @@ const modes = [ ["development", "Development"], ["landuse", "Land Use"], ["admin", "Municipal Borders"], + ["admin-debug", "Admin Debug"], + ["borders-debug", "Borders Debug"], ]; const state = { @@ -76,6 +78,8 @@ function getStats(map) { ["Logistics Parks", countText(map.logisticsParks)], ["New Towns", countText(map.newTowns)], ["Municipalities", map.adminCenters.length], + ["Admin changed cells", map.adminDebug ? `${map.adminDebug.changedAfterLandscapePartition || 0} partition / ${map.adminDebug.changedAfterSnap || 0} snap` : "-"], + ["Regional changed cells", map.regionalDebug?.regionalChangedAfterNaturalPartition ?? "-"], ]; } diff --git a/mapAdminStage.js b/mapAdminStage.js new file mode 100644 index 0000000..f861e81 --- /dev/null +++ b/mapAdminStage.js @@ -0,0 +1,410 @@ +import { + applyLandscapeUnitAdminPartition, + generateAdminRegions, + lockSmallUrbanComponentsToMunicipality, + mergeTinyMunicipalities, + removeMunicipalExclaves, + smoothAdminRegionsTerrainAware, + splitOversizedRuralMunicipalities, + 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"; + +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 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; +} + +export function generateAdminLayout({ + seed, + prefectureMask, + sea, + elevation, + slope, + river, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + plain, + agriculture, + settlementScore, + populationDensity, + stationInfluence, + roadInfluence, + railInfluence2, + villageInfluence, + landuse, + modernCities, + satelliteCities, + newTowns, + markets, + villages, + ports, + stations, + industrialZones, + logisticsParks, +}) { + const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0); + const municipalityCandidates = []; + 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 urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28; + const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025; + if (score > 0.40) municipalityCandidates.push({ x, y, score }); + } + } + const majorMunicipalSeeds = modernCities + .filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)]) + .map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city })); + const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => { + const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28)); + const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); + return !nearMajor && !nearSmallUrban; + }); + const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum); + const satelliteMunicipalSeeds = (satelliteCities || []) + .filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality") + .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); + let adminCentersRaw = [ + ...majorMunicipalSeeds, + ...satelliteMunicipalSeeds, + ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { + max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))), + minDistance: 9 + Math.floor(rand(seed, 1302) * 3), + threshold: 0.40, + seed: seed + 1300, + jitter: 0.025, + }), + ]; + if (adminCentersRaw.length < 12) { + const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...newTowns, ...stations, ...villages] + .filter((p) => prefectureMask[indexOf(p.x, p.y)]) + .map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 })); + adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 }); + } + if (adminCentersRaw.length < 10) { + const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); + adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); + } + const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); + 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, + satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length, + satelliteMunicipalitiesMerged: 0, + satelliteMunicipalitiesExpanded: 0, + satelliteMunicipalitiesTooSmall: 0, + averageSatelliteMunicipalityArea: 0, + minSatelliteMunicipalityArea: 0, + satelliteMunicipalityAreaByNameOrIndex: {}, + independentSatelliteMunicipalities: satelliteClassificationDebug.independent, + attachedSatelliteDistricts: satelliteClassificationDebug.attached, + }; + function markChanged(field) { + adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea); + previousSnapshot = new Int16Array(adminId); + } + smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); + 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) => { + 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) => { + 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, 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"); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: adminCentersRaw }); + markChanged("changedAfterInitialMerge"); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180); + markChanged("changedAfterInitialExclaveRemoval"); + applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + 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, valleyField, basinField, coastalLowland, flowAccum, + landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, + }); + } + markChanged("changedAfterLandscapePartition"); + const oversizedSplitDebug = splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]); + adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells; + adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities; + previousSnapshot = new Int16Array(adminId); + snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); + markChanged("changedAfterSnap"); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 360); + markChanged("changedAfterFinalExclaveRemoval"); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: adminCentersRaw }); + 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, valleyField, basinField, coastalLowland, flowAccum, + landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, + }); + } + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 260); + + 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 < 80 || ((sat.population || 0) >= 60000 && area < 120))) { + 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; + const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {}; + Object.assign(adminDebug, landscapeDebug); + const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); + + + return { adminCentersRaw, adminId, adminBorders, adminDebug }; +} diff --git a/mapFeatures.js b/mapFeatures.js new file mode 100644 index 0000000..b4f8edb --- /dev/null +++ b/mapFeatures.js @@ -0,0 +1,1323 @@ +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; +import { + aStar, + averagePathField, + compactPathArray, + distanceToNearest, + getDegree, + incrementDegree, + influenceFromPaths, + influenceFromPoints, + makeTransportCost, + nearestConnectable, + neighbors8, + pathCompactness, + pathEndpointDistance, + pathLength, + pathOverlapRatio, + samplePath, + smoothPathByLineOfSight, +} from "./mapGeneratorHelpers.js"; + +export function generateMapFeatures(seed, terrain) { + const { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + portSuitability, + crossingSuitability, + passSuitability, + prefectureMask, + } = terrain; + + function pickPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true }) { + 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 (!predicate(x, y, i)) continue; + const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.08; + if (score >= threshold) candidates.push({ x, y, score }); + } + } + return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); + } + + let ports = pickPoints(portSuitability, { + threshold: 0.3 + rand(seed, 1001) * 0.08, + max: 3 + Math.floor(rand(seed, 1002) * 7), + minDistance: 10, + seedOffset: 1000, + predicate: (x, y, i) => !sea[i], + }).map((p) => { + const i = indexOf(p.x, p.y); + let seaEdge = 0; + for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { + const nx = p.x + dx; + const ny = p.y + dy; + if (inside(nx, ny) && sea[indexOf(nx, ny)]) seaEdge += 1 / (1 + Math.hypot(dx, dy)); + } + const harborPotential = p.score + coastalLowland[i] * 0.28 + river[i] * 0.08 + seaEdge * 0.025 - slope[i] * 0.2; + return { ...p, harborPotential, seaEdge, portClass: "fishing", kind: "Fishing Port" }; + }).sort((a, b) => b.harborPotential - a.harborPotential) + .map((p, n) => { + const isLakeLike = p.seaEdge < 0.25 && river[indexOf(p.x, p.y)] > 0.32; + const portClass = isLakeLike ? "lake" : n === 0 ? "major" : n < 3 && p.harborPotential > 0.34 ? "regional" : "fishing"; + const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port"; + return { ...p, portClass, kind, score: p.harborPotential }; + }); + if (!ports.some((p) => p.portClass === "major")) { + const fallbackMajor = ports.find((p) => p.portClass !== "lake") || ports[0]; + if (fallbackMajor) { + fallbackMajor.portClass = "major"; + fallbackMajor.kind = "Major Port"; + fallbackMajor.score += 0.16; + } + } + const majorPorts = ports.filter((p) => p.portClass === "major"); + const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); + + let crossings = pickPoints(crossingSuitability, { + threshold: 0.28 + rand(seed, 1011) * 0.08, + max: 8 + Math.floor(rand(seed, 1012) * 15), + minDistance: 8, + seedOffset: 1010, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "River Crossing" })); + + let passes = pickPoints(passSuitability, { + threshold: 0.16 + rand(seed, 1021) * 0.08, + max: 4 + Math.floor(rand(seed, 1022) * 10), + minDistance: 9, + seedOffset: 1020, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Pass" })); + + const settlementCluster = 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 valleyCorridor = clamp(valleyField[i] * 0.62 + river[i] * 0.16); + const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18); + const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1); + const localPatch = valueNoise(x * 0.7, y * 0.7, seed + 1037, 10); + const broadPatch = fbm(x * 0.32 + 71, y * 0.32 - 19, seed + 1038); + settlementCluster[i] = clamp((valleyCorridor + lowlandCorridor) * terrainGate * (0.72 + broadPatch * 0.42 + localPatch * 0.18)); + } + } + + const settlementScore = 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; + let nearFeature = 0; + for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4)); + const riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16); + const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52; + const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] - 0.22) * (1 - valleyField[i]) * 0.75; + const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - floodplain[i] * 0.06 - remoteMountainPenalty; + settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13); + } + } + + 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), + seedOffset: 1030, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Village" })); + + const marketScore = new Float32Array(SIZE); + 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; + + let villagePull = 0; + let nearbyVillages = 0; + for (const v of villages) { + const d = Math.hypot(x - v.x, y - v.y); + if (d < 24) { + villagePull += 1 / (1 + d); + 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 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 + nearbyVillages * 0.012); + } + } + + let markets = pickPoints(marketScore, { + threshold: 0.2 + rand(seed, 1041) * 0.08, + max: 6 + Math.floor(rand(seed, 1042) * 12), + minDistance: 11, + seedOffset: 1040, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Market Town" })); + + const defenseScore = new Float32Array(SIZE); + 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 hillShoulder = clamp(1 - Math.abs(elevation[i] - 0.50) / 0.24); + let riverArms = 0; + for (const [nx, ny] of neighbors8(x, y)) if (river[indexOf(nx, ny)] > 0.32) riverArms++; + const confluence = riverArms >= 3 ? 0.38 : riverArms === 2 ? 0.18 : 0; + const roadJunctionProxy = ( + (distanceToNearest(markets, x, y) < 7 ? 1 : 0) + + (distanceToNearest(crossings, x, y) < 6 ? 1 : 0) + + (distanceToNearest(passes, x, y) < 7 ? 1 : 0) + + (distanceToNearest(commercialPorts, x, y) < 8 ? 1 : 0) + ) >= 2 ? 0.32 : 0; + const hillEdge = plain[i] > 0.2 && elevation[i] > 0.36 && elevation[i] < 0.62 && (slope[i] > 0.12 || ridgeField[i] > 0.12) ? 0.3 : 0; + const mountainRidgeCastle = elevation[i] > 0.56 && ridgeField[i] > 0.3 && valleyField[i] > 0.1 ? 0.28 : 0; + const validCastleSite = confluence > 0 || roadJunctionProxy > 0 || hillEdge > 0 || mountainRidgeCastle > 0; + defenseScore[i] = validCastleSite + ? clamp(hillShoulder * 0.28 + confluence + roadJunctionProxy + hillEdge + mountainRidgeCastle + slope[i] * 0.05 - floodplain[i] * 0.42 - coastalLowland[i] * 0.12) + : 0; + } + } + + let castles = pickPoints(defenseScore, { + threshold: 0.34 + rand(seed, 1051) * 0.08, + max: 2 + Math.floor(rand(seed, 1052) * 4), + minDistance: 15, + seedOffset: 1050, + predicate: (x, y, i) => !sea[i] && defenseScore[i] > 0, + }).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", + })); + + function normalEdgePenalty(x, y) { + if (nearMapEdge(x, y, 1)) return INF; + if (nearMapEdge(x, y, 2)) return 7; + if (nearMapEdge(x, y, 4)) return 2.8; + return 0; + } + + function premodernCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const crossingBonus = distanceToNearest(crossings, x, y) < 4 ? 0.65 : 0; + const passBonus = distanceToNearest(passes, x, y) < 4 ? 0.45 : 0; + const riverPenalty = river[i] > 0.28 ? (crossingBonus ? 0.45 : 2.4) : 0; + const highMountain = elevation[i] > 0.72 ? 4.2 : elevation[i] > 0.58 ? 1.4 : 0; + return Math.max(0.35, 1 + slope[i] * 5.8 + riverPenalty + highMountain + floodplain[i] * 0.62 - plain[i] * 0.32 - valleyField[i] * 0.42 - coastalLowland[i] * 0.12 - passBonus + normalEdgePenalty(x, y) + hash2(x, y, seed + 111) * 0.16); + } + + const premodernRoads = []; + function addPremodernRoad(a, b) { + const path = aStar(a, b, premodernCost); + if (path.length > 3) premodernRoads.push(path); + } + + for (const castle of castles) { + const near = pickEntities([...markets, ...ports, ...crossings, ...passes].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - castle.x, p.y - castle.y)) })), { max: 2 + Math.floor(rand(seed, castle.x + castle.y) * 3), minDistance: 1, threshold: 0 }); + for (const p of near) addPremodernRoad(castle, p); + } + for (const market of markets) { + const near = pickEntities([...markets.filter((p) => p !== market), ...ports, ...crossings].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - market.x, p.y - market.y)) })), { max: 1 + Math.floor(rand(seed, market.x + market.y + 20) * 3), minDistance: 1, threshold: 0 }); + for (const p of near) addPremodernRoad(market, p); + } + + function urbanSiteSuitability(p) { + 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; + return clamp( + plain[i] * 0.46 + + agriculture[i] * 0.18 + + basinField[i] * 0.20 + + coastalLowland[i] * 0.20 + + valleyField[i] * 0.12 + + portBonus + historicalBonus - + slope[i] * 0.58 - + ridgeField[i] * 0.34 - + Math.max(0, elevation[i] - 0.55) * 1.35 + ); + } + + 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; + } + + 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"); + + 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 fallbackCapitalCandidate() { + const pools = [...markets, ...ports, ...villages].filter((p) => p && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); + let best = null; + let bestScore = -INF; + for (const p of pools) { + const i = indexOf(p.x, p.y); + 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 }; + + 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; + 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" }; } + } + } + return best ? { ...best, population: 320000, urbanRadius: 14, coreRadius: 4.2, urbanWeight: 1.7 } : null; + } + + function populationDensityProxyForCapital(i) { + return settlementScore[i] * 0.18 + marketScore[i] * 0.12; + } + + if (modernCities.length === 0 || !modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) { + const fallbackCapital = fallbackCapitalCandidate(); + if (fallbackCapital) modernCities.unshift(fallbackCapital); + } + + 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; + 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 (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 }; + } + + 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 populationDensity = new Float32Array(SIZE); + let maxPopulationDensity = 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; + let density = 0; + for (const city of modernCities) { + const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.55); + 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)); + } + 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)); + } + 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 *= 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); + populationDensity[i] = density; + if (density > maxPopulationDensity) maxPopulationDensity = density; + } + } + if (maxPopulationDensity > 0) { + for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxPopulationDensity); + } + + function densityValue(x, y) { + return populationDensity[indexOf(x, y)] || 0; + } + + function midDensityAffinity(x, y) { + const d = densityValue(x, y); + return clamp(1 - Math.abs(d - 0.38) / 0.38); + } + + function nearPassPoint(x, y, radius = 5) { + return distanceToNearest(passes, x, y) <= radius; + } + + function mountainBarrierPenalty(x, y, type = "rail") { + const i = indexOf(x, y); + const e = elevation[i]; + const s = slope[i]; + const pass = nearPassPoint(x, y, type === "express" ? 7 : type === "rail" ? 6 : 5); + if (e > 0.84) return INF; + if (pass && e > 0.80 && s > 0.16) return INF; + if (!pass && e > 0.78) return INF; + if (!pass && e > 0.70 && s > 0.16) return INF; + if (!pass && e > 0.66 && s > 0.28) return INF; + if (!pass && e > 0.72) return type === "express" ? 260 : type === "rail" ? 330 : type === "minor" ? 80 : 155; + if (!pass && e > 0.64 && s > 0.20) return type === "express" ? 145 : type === "rail" ? 180 : type === "minor" ? 54 : 96; + const passDiscount = pass ? (type === "minor" ? 0.35 : 0.22) : 1; + const mountain = Math.max(0, e - 0.48); + const steep = Math.max(0, s - 0.15); + const typeFactor = type === "express" ? 360 : type === "rail" ? 430 : type === "minor" ? 115 : 210; + return (mountain * mountain * typeFactor + steep * steep * 150 + ridgeField[i] * 9.5) * passDiscount; + } + + 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" ? 10 : mode === "rail" ? 2 : 4; + const maxR = mode === "express" ? 20 : mode === "rail" ? 6 : 10; + let best = null; + let bestScore = -INF; + for (let dy = -maxR; dy <= maxR; dy++) { + for (let dx = -maxR; dx <= maxR; dx++) { + const d = Math.hypot(dx, dy); + if (d < minR || d > maxR) continue; + const x = node.x + dx; + const y = node.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const barrier = mode === "express" || mode === "rail" ? mountainBarrierPenalty(x, y, mode) : mountainBarrierPenalty(x, y, "road"); + if (barrier >= INF) continue; + const targetD = (minR + maxR) * 0.5; + const flatness = plain[i] * 1.0 + agriculture[i] * 0.2 + valleyField[i] * 0.26 + coastalLowland[i] * 0.16 - slope[i] * 1.22 - ridgeField[i] * 0.72 - Math.max(0, elevation[i] - 0.58) * 2.35; + const ring = -Math.abs(d - targetD) * 0.08; + const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12; + const density = densityValue(x, y); + const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? midDensityAffinity(x, y) * 0.52 - Math.max(0, density - 0.72) * 0.9 : density * 0.24; + const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12; + const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise; + if (score > bestScore) { + bestScore = score; + best = { x, y, score: node.score || 0.5, kind: `${mode} Access`, parent: node }; + } + } + } + return best || node; + } + + function routePoint(node, mode, salt = 0) { + return transportAccessPoint(node, mode, salt); + } + + const townAvoidNodes = [...modernCities, ...markets, ...ports]; + + const urbanCenters = modernCities.map((city, n) => { + let best = { x: city.x, y: city.y, score: city.score + 0.5 }; + let bestScore = -INF; + const searchR = Math.max(2, Math.round(city.coreRadius)); + for (let dy = -searchR; dy <= searchR; dy++) { + for (let dx = -searchR; dx <= searchR; 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; + const d = Math.hypot(dx, dy); + const score = plain[i] * 0.54 + agriculture[i] * 0.16 - slope[i] * 0.36 - d * 0.06 + hash2(x, y, seed + 1700 + n) * 0.07; + if (score > bestScore) { bestScore = score; best = { x, y, score: city.score + 0.5, cityIndex: n, parent: city }; } + } + } + return { ...best, kind: city.rank === "Prefectural Capital" ? "Central Business District" : "Urban Center", population: Math.round(city.population * (city.rank === "Prefectural Capital" ? 0.18 : 0.12)), insidePrefecture: Boolean(prefectureMask[indexOf(best.x, best.y)]) }; + }); + + function railCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "rail"); + if (barrier >= INF) return INF; + const density = densityValue(x, y); + const highPenalty = Math.max(0, elevation[i] - 0.52) * 14 + barrier; + const riverPenalty = river[i] > 0.5 ? 1.6 : river[i] > 0.25 ? 0.7 : 0; + return Math.max(0.42, 1 + slope[i] * 22 + highPenalty + riverPenalty + floodplain[i] * 0.28 - density * 0.88 - plain[i] * 0.28 - valleyField[i] * 0.62 - coastalLowland[i] * 0.48 + ridgeField[i] * 1.4 + normalEdgePenalty(x, y) + hash2(x, y, seed + 222) * 0.08); + } + + const railways = []; + const branchRailways = []; + const railDegree = new Map(); + const railCore = [capital]; + 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 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 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)) { + bucket.push(path); + incrementDegree(railDegree, a); + incrementDegree(railDegree, b); + return true; + } + return false; + } + + 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); + } + } + 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); + } + + compactPathArray(railways, { minLength: 17, maxOverlap: 0.34, maxCount: 5 }); + compactPathArray(branchRailways, { minLength: 11, maxOverlap: 0.22, maxCount: 9 }); + + const railInfluence = influenceFromPaths([...railways, ...branchRailways], 5); + const stationCandidates = [ + ...modernCities.map((p, i) => ({ ...routePoint(p, "rail", 1900 + i), score: p.score + 0.46, kind: "Major Station", population: p.population })), + ...railways.flatMap((path) => samplePath(path, 18 + Math.floor(rand(seed, path.length) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.52 + agriculture[indexOf(p.x, p.y)] * 0.2 })), + ...branchRailways.flatMap((path) => samplePath(path, 16 + Math.floor(rand(seed, path.length + 99) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.42 + agriculture[indexOf(p.x, p.y)] * 0.2 })), + ]; + + let stations = pickEntities(stationCandidates, { max: 14 + Math.floor(rand(seed, 1080) * 22), minDistance: 6, threshold: 0.38, seed: seed + 1080 }); + + const industrialScore = new Float32Array(SIZE); + 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 nearPort = 1 / (1 + distanceToNearest(majorPorts.length ? majorPorts : commercialPorts, x, y) / 5); + const nearCity = distanceToNearest(modernCities, x, y); + const cityEdge = nearCity > 5 && nearCity < 20 ? 0.22 : nearCity <= 5 ? -0.25 : 0; + industrialScore[i] = clamp(plain[i] * 0.24 + coastalLowland[i] * 0.24 + railInfluence[i] * 0.38 + nearPort * 0.58 + river[i] * 0.04 + cityEdge - slope[i] * 0.36 - ridgeField[i] * 0.18 - floodplain[i] * 0.03); + } + } + + let industrialZones = pickPoints(industrialScore, { + threshold: 0.31 + rand(seed, 1091) * 0.09, + max: 4 + Math.floor(rand(seed, 1092) * 13), + minDistance: 10, + seedOffset: 1090, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Industrial Zone" })); + + function roadCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "road"); + if (barrier >= INF) return INF; + const density = densityValue(x, y); + const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0; + return Math.max(0.35, 1 + slope[i] * 17.8 + barrier + Math.max(0, elevation[i] - 0.54) * 9.2 + nodeAvoid + (river[i] > 0.45 ? 0.85 : 0) + floodplain[i] * 0.22 - density * 0.50 - plain[i] * 0.22 - valleyField[i] * 0.28 - coastalLowland[i] * 0.20 + ridgeField[i] * 1.15 + normalEdgePenalty(x, y) + hash2(x, y, seed + 333) * 0.08); + } + + function expresswayCost(x, y) { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "express"); + if (barrier >= INF) return INF; + const density = densityValue(x, y); + const midDensity = midDensityAffinity(x, y); + const cityDistance = distanceToNearest(modernCities, x, y); + const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; + const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; + const highPenalty = barrier + (elevation[i] > 0.72 ? 26 : elevation[i] > 0.62 ? 8.5 : 0); + return Math.max(0.42, 1 + slope[i] * 23.0 + highPenalty + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1.0 : 0) - midDensity * 0.82 - plain[i] * 0.16 - valleyField[i] * 0.16 - coastalLowland[i] * 0.18 + ridgeField[i] * 1.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015); + } + + const nationalRoads = []; + const roadDegree = new Map(); + function transportDemand(p) { + const pop = Math.sqrt(Math.max(0, p.population || 0)) / 700; + const capitalBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 2.1 : 0; + const portBoost = p.portClass === "major" ? 1.4 : p.portClass === "regional" ? 0.8 : p.portClass ? 0.35 : 0; + const historyBoost = p.kind?.includes("Castle") ? 0.55 : p.kind === "Market Town" ? 0.42 : 0; + const gatewayBoost = p.kind === "External Gateway" ? 1.1 : 0; + return 0.35 + pop + capitalBoost + portBoost + historyBoost + gatewayBoost; + } + + function sameCorridorAffinity(a, b) { + const ai = indexOf(a.x, a.y); + const bi = indexOf(b.x, b.y); + 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 pickedRoadTargets = pickEntities(roadTargetCandidates, { + max: 8 + Math.floor(rand(seed, 1101) * 10), + minDistance: 9, + threshold: 0, + seed: seed + 1100, + }); + const roadTargets = [ + capital, + ...pickedRoadTargets + .filter((p) => Math.hypot(p.x - capital.x, p.y - capital.y) > 2) + .sort((a, b) => transportDemand(b) - transportDemand(a)), + ]; + const roadHubs = [...modernCities, ...ports, ...markets, ...stations]; + 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 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 >= 2 || direct >= 24; + if (path.length > 3 && direct >= 16 && pathLength(path) >= 20 && pathCompactness(path) < 3.35 && pathOverlapRatio(path, existing, 2) < 0.48 && passBonusOk) { + nationalRoads.push(path); + incrementDegree(roadDegree, a); + incrementDegree(roadDegree, b); + return true; + } + 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 }); + } + } + 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; + if (addNationalRoad(link.a, link.b)) { + extraRoadLinks++; + } + } + + // 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) + .slice() + .sort((a, b) => a.x - b.x || a.y - b.y); + for (let i = 0; i < trunkCities.length - 1; i += 2) { + 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 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, + }); + + const expressways = []; + const expressDegree = new Map(); + const expressCore = [capital]; + + 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); + const direct = pathEndpointDistance(path); + if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) { + bucket.push(path); + incrementDegree(expressDegree, a); + incrementDegree(expressDegree, b); + return true; + } + return false; + } + + for (const target of expressTargets) { + const anchor = nearestConnectable(expressCore, target, expressDegree, 2) || capital; + if (addExpressway(anchor, target)) expressCore.push(target); + } + + const ringRoads = []; + const ringExpressways = []; + const ringRailways = []; + + function ringAnchorCandidates(city, mode, targetRadius, sectors = 8) { + const anchors = []; + const minR = Math.max(5, targetRadius - 5); + const maxR = targetRadius + 7; + for (let s = 0; s < sectors; s++) { + const angle0 = (s / sectors) * Math.PI * 2; + let best = null; + let bestScore = -INF; + for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) { + for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) { + const d = Math.hypot(dx, dy); + if (d < minR || d > maxR) continue; + const angle = Math.atan2(dy, dx); + let delta = Math.abs(Math.atan2(Math.sin(angle - angle0), Math.cos(angle - angle0))); + if (delta > Math.PI / sectors * 0.95) continue; + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || !prefectureMask[i]) 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); + const densityTerm = mode === "rail" ? density * 0.75 : mode === "express" ? midDensityAffinity(x, y) * 0.72 : density * 0.28 + midDensityAffinity(x, y) * 0.22; + const score = plain[i] * 0.72 + agriculture[i] * 0.12 + densityTerm - slope[i] * 1.25 - Math.max(0, elevation[i] - 0.58) * 1.3 - barrier * 0.01 - Math.abs(d - targetRadius) * 0.035 + hash2(x, y, seed + 4100 + s * 37 + mode.length * 101) * 0.08; + if (score > bestScore) { + bestScore = score; + best = { x, y, score, kind: `${mode} ring anchor`, parent: city }; + } + } + } + if (best) anchors.push(best); + } + return anchors; + } + + function ringCost(baseCost, city, targetRadius, mode) { + return (x, y, cx, cy) => { + const base = baseCost(x, y, cx, cy); + if (base >= INF) return base; + const d = Math.hypot(x - city.x, y - city.y); + const tooClose = Math.max(0, targetRadius * 0.46 - d); + const tooFar = Math.max(0, d - targetRadius * 1.55); + const bandPenalty = tooClose * 0.34 + tooFar * 0.16 + Math.abs(d - targetRadius) * 0.018; + const density = densityValue(x, y); + const densityBias = mode === "rail" ? -density * 0.42 : mode === "express" ? -midDensityAffinity(x, y) * 0.32 + Math.max(0, density - 0.82) * 0.8 : -density * 0.12; + return Math.max(0.36, base + bandPenalty + densityBias); + }; + } + + function softRingRailCost(x, y) { + const i = indexOf(x, y); + const barrier = mountainBarrierPenalty(x, y, "rail"); + if (sea[i] || barrier >= INF) return INF; + const density = densityValue(x, y); + return Math.max(0.38, 1 + slope[i] * 14 + barrier + Math.max(0, elevation[i] - 0.56) * 22 + (river[i] > 0.5 ? 1.3 : river[i] * 0.6) - density * 0.62 - plain[i] * 0.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7222) * 0.05); + } + + function softRingExpressCost(x, y) { + const i = indexOf(x, y); + const barrier = mountainBarrierPenalty(x, y, "express"); + if (sea[i] || barrier >= INF) return INF; + return Math.max(0.38, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.58) * 20 + (river[i] > 0.5 ? 1.0 : river[i] * 0.5) - midDensityAffinity(x, y) * 0.42 - plain[i] * 0.14 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7444) * 0.05); + } + + function addEnvironmentalRing(city, mode, bucket, baseCost, existingPaths, targetRadius) { + const anchors = ringAnchorCandidates(city, mode, targetRadius, mode === "road" ? 7 : 8); + if (anchors.length < 3) return 0; + let made = 0; + const cost = ringCost(baseCost, city, targetRadius, mode); + for (let i = 0; i < anchors.length - (anchors.length < 4 ? 1 : 0); i++) { + const a = anchors[i]; + const b = anchors[(i + 1) % anchors.length]; + if (Math.hypot(a.x - b.x, a.y - b.y) > targetRadius * 1.85) continue; + const path = aStar(a, b, makeTransportCost(cost, [...existingPaths, ...bucket], roadHubs, [a, b], mode === "road" ? 3 : 4, mode === "road" ? 4.8 : 7.0, townAvoidNodes, mode === "express" ? 3.8 : 2.2, mode === "express" ? 4.8 : 2.8)); + if (path.length >= 5 && path.length <= targetRadius * 8.0) { + bucket.push(path); + made++; + } + } + return made; + } + + function flexibleRingAnchors(city, targetRadius, maxAnchors = 6) { + const candidates = []; + const maxR = targetRadius + 11; + const minR = Math.max(5, targetRadius * 0.45); + for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) { + for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) { + const d = Math.hypot(dx, dy); + if (d < minR || d > maxR) continue; + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || !prefectureMask[i] || elevation[i] > 0.82) continue; + const score = plain[i] * 0.7 + midDensityAffinity(x, y) * 0.32 + densityValue(x, y) * 0.2 - slope[i] * 1.15 - Math.max(0, elevation[i] - 0.58) * 0.88 - Math.abs(d - targetRadius) * 0.02 + hash2(x, y, seed + 7555) * 0.06; + candidates.push({ x, y, score, angle: Math.atan2(dy, dx), kind: "flexible ring anchor", parent: city }); + } + } + return pickEntities(candidates, { max: maxAnchors, minDistance: 5, threshold: -1, seed: seed + city.x * 83 + city.y * 89 }) + .sort((a, b) => a.angle - b.angle); + } + + function addLooseEnvironmentalRing(city, bucket, baseCost, targetRadius) { + let anchors = ringAnchorCandidates(city, "road", targetRadius, 6); + if (anchors.length < 3) anchors = flexibleRingAnchors(city, targetRadius, 6); + if (anchors.length < 2) return 0; + let made = 0; + for (let i = 0; i < anchors.length; i++) { + const a = anchors[i]; + const b = anchors[(i + 1) % anchors.length]; + const path = aStar(a, b, (x, y, cx, cy) => { + const base = baseCost(x, y, cx, cy); + if (base >= INF) return INF; + const d = Math.hypot(x - city.x, y - city.y); + const band = Math.max(0, targetRadius * 0.42 - d) * 0.22 + Math.max(0, d - targetRadius * 1.7) * 0.14 + Math.abs(d - targetRadius) * 0.012; + return Math.max(0.3, base + band); + }); + if (path.length >= 4 && path.length <= targetRadius * 9.0) { + bucket.push(path); + made++; + } + } + return made; + } + + const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 130000).slice(0, 6); + 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 largeRingCities = modernCities.filter((c) => (c.population || 0) >= 900000).slice(0, 1); + 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); + // 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; + compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: 18 }); + 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 }); } + + let externalGateways = pickEntities(gatewayCandidates, { + max: 2 + Math.floor(rand(seed, 1201) * 3), + minDistance: 28, + threshold: 0.4, + seed: seed + 1201, + }).map((p) => ({ ...p, kind: "External Gateway" })); + + function externalRoadCost(goal) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "road"); + if (barrier >= INF) return INF; + const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; + const density = densityValue(x, y); + const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0; + return Math.max(0.35, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.54) * 7.5 + nodeAvoid + (river[i] > 0.45 ? 0.9 : 0) + floodplain[i] * 0.24 - density * 0.3 - plain[i] * 0.24 + borderPenalty + hash2(x, y, seed + 333) * 0.06); + }; + } + function externalExpresswayCost(goal) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "express"); + if (barrier >= INF) return INF; + const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; + const density = densityValue(x, y); + const cityDistance = distanceToNearest(modernCities, x, y); + const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; + const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; + return Math.max(0.42, 1 + slope[i] * 19 + barrier + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1 : 0) + floodplain[i] * 0.2 - midDensityAffinity(x, y) * 0.7 - plain[i] * 0.12 + borderPenalty + hash2(x, y, seed + 444) * 0.05); + }; + } + function externalRailCost(goal) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "rail"); + if (barrier >= INF) return INF; + const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 9 : nearMapEdge(x, y, 3) ? 1.8 : 0; + const density = densityValue(x, y); + return Math.max(0.42, 1 + slope[i] * 22 + barrier + Math.max(0, elevation[i] - 0.52) * 14 + (river[i] > 0.45 ? 1.2 : 0) + borderPenalty - density * 1.0 - plain[i] * 0.28 + hash2(x, y, seed + 222) * 0.05); + }; + } + + const externalRoads = []; + const externalExpressways = []; + const externalRailways = []; + + function selectExternalStart(pool, gate, degreeMap, maxDegree = 2) { + const sorted = pool + .filter(Boolean) + .map((p) => ({ ...p, d: Math.hypot(p.x - gate.x, p.y - gate.y), degree: getDegree(degreeMap, p) })) + .sort((a, b) => a.d + a.degree * 16 + (a.degree >= maxDegree ? 30 : 0) - (b.d + b.degree * 16 + (b.degree >= maxDegree ? 30 : 0))); + return sorted.find((p) => p.degree < maxDegree) || sorted[0] || capital; + } + + 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); + const 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 (roadPath.length > 6) { + if (makeExpressLink) { + externalExpressways.push(roadPath); + incrementDegree(expressDegree, roadStartRaw); + 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 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); + incrementDegree(railDegree, railStartRaw); + incrementDegree(railDegree, gate); + } + } + }); + + function pruneHighMountainTransport(paths, threshold = 0.82) { + for (let i = paths.length - 1; i >= 0; i--) { + if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); + } + } + for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82); + + const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6); + const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4); + + const icCandidates = []; + for (const path of [...expressways, ...externalExpressways]) { + icCandidates.push(...samplePath(path, 11 + Math.floor(rand(seed, path.length + 333) * 5)).map((p) => ({ ...p, score: 0.62 + plain[indexOf(p.x, p.y)] * 0.24 + midDensityAffinity(p.x, p.y) * 0.16, kind: "Interchange" }))); + for (const city of modernCities) { + let best = null; + let bestDistance = 999; + for (const [x, y] of path) { + const d = Math.hypot(x - city.x, y - city.y); + if (d < bestDistance) { bestDistance = d; best = { x, y }; } + } + if (best && bestDistance > 4 && bestDistance < 18) icCandidates.push({ ...best, score: 0.8 + city.score * 0.1, kind: "Urban Interchange" }); + } + } + + let interchanges = pickEntities(icCandidates, { max: 14 + Math.floor(rand(seed, 1130) * 18), minDistance: 7, threshold: 0.44, seed: seed + 1130 }); + + const icAccessRoads = []; + const nationalRoadAccessPoints = nationalRoads.flatMap((path) => samplePath(path, 8)); + for (const ic of interchanges) { + const accessTargets = [ + ...industrialZones.map((p) => ({ ...p, score: 0.95 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 7) })), + ...modernCities.map((p) => ({ ...routePoint(p, "road", 8200 + p.x * 7 + p.y), score: 0.72 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 10) })), + ...nationalRoadAccessPoints.map((p) => ({ ...p, score: 0.62 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 6), kind: "National Road Access" })), + ]; + const target = pickEntities(accessTargets, { max: 1, minDistance: 1, threshold: 0, seed: seed + 1134 + ic.x * 3 + ic.y })[0]; + if (!target || Math.hypot(target.x - ic.x, target.y - ic.y) > 22) continue; + const path = aStar(ic, target, roadCost); + if (path.length > 2 && path.length < 36) icAccessRoads.push(path); + } + + const logisticsScore = new Float32Array(SIZE); + 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 nearIC = 1 / (1 + distanceToNearest(interchanges, x, y) / 3); + const cityPenalty = distanceToNearest(modernCities, x, y) < 5 ? 0.28 : 0; + logisticsScore[i] = clamp(nearIC * 0.56 + plain[i] * 0.24 + roadInfluence[i] * 0.22 + expressInfluence[i] * 0.16 - slope[i] * 0.32 - cityPenalty); + } + } + + let logisticsParks = pickPoints(logisticsScore, { + threshold: 0.32 + rand(seed, 1141) * 0.1, + max: 3 + Math.floor(rand(seed, 1142) * 13), + minDistance: 9, + seedOffset: 1140, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "Logistics Park" })); + + const cityInfluence = influenceFromPoints(modernCities, 34, (p) => p.urbanWeight || 1.2); + const cityCoreInfluence = influenceFromPoints(urbanCenters, 11, (p) => p.parent?.coreRadius ? 1.35 + p.parent.coreRadius / 5 : 1.2); + const stationInfluence = influenceFromPoints(stations, 10, () => 1); + const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...ringRailways, ...externalRailways], 6); + const satelliteScore = new Float32Array(SIZE); + const largeCitiesForSatellites = modernCities.filter((c) => (c.population || 0) >= 320000); + 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; + let ringPull = 0; + let parent = null; + for (const city of largeCitiesForSatellites) { + const d = Math.hypot(city.x - x, city.y - y); + const ideal = clamp(11 + Math.sqrt(city.population || 320000) / 150, 13, 27); + const v = clamp(1 - Math.abs(d - ideal) / 9); + if (v > ringPull) { ringPull = v; parent = city; } + } + if (!parent) continue; + const railPull = Math.max(railInfluence2[i], stationInfluence[i] * 0.84); + const separated = distanceToNearest(modernCities, x, y) > 7 ? 1 : 0; + satelliteScore[i] = clamp(ringPull * 0.42 + railPull * 0.38 + populationDensity[i] * 0.14 + plain[i] * 0.2 + basinField[i] * 0.08 + agriculture[i] * 0.05 - slope[i] * 0.86 - ridgeField[i] * 0.34 - Math.max(0, elevation[i] - 0.56) * 0.72 + separated * 0.1 + hash2(x, y, seed + 1160) * 0.035); + } + } + let satelliteCities = pickPoints(satelliteScore, { + threshold: 0.43 + rand(seed, 1161) * 0.07, + 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], + }).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; + return { ...p, kind: "Satellite City", parentCityIndex: parent ? modernCities.indexOf(parent) : -1, population: Math.round(basePop / 1000) * 1000, urbanRadius: 5 + Math.sqrt(basePop) / 135, coreRadius: 1.5 + Math.sqrt(basePop) / 420, urbanWeight: 0.55 + Math.sqrt(basePop) / 720 }; + }); + const satelliteInfluence = influenceFromPoints(satelliteCities, 16, (p) => p.urbanWeight || 0.8); + const oldCoreInfluence = influenceFromPoints([...castleTowns, ...markets, ...ports], 12, () => 1); + const industrialInfluence = influenceFromPoints(industrialZones, 9, () => 1); + const logisticsInfluence = influenceFromPoints(logisticsParks, 9, () => 1); + const interchangeInfluence = influenceFromPoints(interchanges, 8, () => 1); + const premodernInfluence = influenceFromPaths(premodernRoads, 4); + const villageInfluence = influenceFromPoints(villages, 7, () => 1); + + const newTownScore = new Float32Array(SIZE); + 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 dCity = distanceToNearest(modernCities, x, y); + const ring = dCity > 8 && dCity < 22 ? 1 : 0; + const uplandTerrace = elevation[i] > 0.36 && elevation[i] < 0.58 && slope[i] < 0.34 && ridgeField[i] < 0.34 ? 0.24 : 0; + newTownScore[i] = clamp(ring * 0.34 + stationInfluence[i] * 0.24 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.1 + plain[i] * 0.14 + uplandTerrace + agriculture[i] * 0.06 - slope[i] * 0.72 - ridgeField[i] * 0.22 - floodplain[i] * 0.22 - satelliteInfluence[i] * 0.18); + } + } + + let newTowns = pickPoints(newTownScore, { + threshold: 0.32 + rand(seed, 1151) * 0.1, + max: 2 + Math.floor(rand(seed, 1152) * 10), + minDistance: 11, + seedOffset: 1150, + predicate: (x, y, i) => !sea[i], + }).map((p) => ({ ...p, kind: "New Town" })); + + const minorRoads = []; + const trunkNodes = [...markets, ...modernCities, ...stations.slice(0, 24), ...crossings.slice(0, 16)]; + const roadNetInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways, ...premodernRoads], 3); + + function minorRoadCost(x, y) { + const i = indexOf(x, y); + if (sea[i] || elevation[i] > 0.72) return INF; + const barrier = mountainBarrierPenalty(x, y, "minor"); + if (barrier >= INF) return INF; + return Math.max(0.3, 1 + slope[i] * 8.4 + barrier * 0.55 + Math.max(0, elevation[i] - 0.58) * 4.4 + floodplain[i] * 0.18 + (river[i] > 0.5 ? 1.0 : 0.18 * river[i]) - plain[i] * 0.24 - valleyField[i] * 0.36 - coastalLowland[i] * 0.12 + ridgeField[i] * 0.58 - roadNetInfluence[i] * 0.35 + normalEdgePenalty(x, y) + hash2(x, y, seed + 555) * 0.15); + } + + const connectedPairs = new Set(); + function addMinorRoad(a, b) { + const key = `${a.x},${a.y}|${b.x},${b.y}`; + if (connectedPairs.has(key)) return; + connectedPairs.add(key); + const path = aStar(a, b, minorRoadCost); + if (path.length > 2 && path.length < 90) minorRoads.push(path); + } + + for (const village of villages) { + if (rand(seed, village.x * 13 + village.y * 17) < 0.42) { + 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) < 28) 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: 3, minDistance: 1, threshold: 0 }); + for (const v of localVillages) 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]; + if (target) addMinorRoad(pass, target); + } + + const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1); + const landuse = new Uint8Array(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 mountain = elevation[i] > 0.62 || slope[i] > 0.46 || ridgeField[i] > 0.64; + const farm = agriculture[i] > 0.26 && (plain[i] > 0.2 || valleyField[i] > 0.32 || basinField[i] > 0.25); + let nearestCity = null; + let nearestCityDistance = INF; + for (const city of modernCities) { + const d = Math.hypot(city.x - x, city.y - y); + if (d < nearestCityDistance) { nearestCityDistance = d; nearestCity = city; } + } + const dCity = nearestCityDistance; + const populationScale = nearestCity ? clamp(Math.log10(Math.max(10000, nearestCity.population)) - 4, 0.25, 2.2) : 0.5; + const normalizedUrbanDistance = nearestCity ? dCity / Math.max(6, nearestCity.urbanRadius) : 99; + const cityClusterBoost = nearestCity ? clamp(1 - normalizedUrbanDistance) * (0.18 + populationScale * 0.16) : 0; + const density = populationDensity[i]; + const oldTownScore = oldCoreInfluence[i] * 0.64 + premodernInfluence[i] * 0.32 + plain[i] * 0.12 + density * 0.08; + const terrainUrbanPenalty = slope[i] * 1.02 + ridgeField[i] * 0.55 + Math.max(0, elevation[i] - 0.56) * 0.56; + const nodeCausalPull = Math.max(stationInfluence[i] * 0.18, premodernInfluence[i] * 0.13, coastalLowland[i] * river[i] * 0.12, valleyField[i] * 0.08); + const satelliteEnvelope = satelliteInfluence[i] * 0.54; + const urbanEnvelope = cityInfluence[i] * 0.58 + cityCoreInfluence[i] * 0.3 + satelliteEnvelope + density * 0.47 + stationInfluence[i] * 0.18 + oldCoreInfluence[i] * 0.14 + newTownInfluence[i] * 0.12 + cityClusterBoost + nodeCausalPull - terrainUrbanPenalty; + const coreScore = cityCoreInfluence[i] * 0.74 + urbanEnvelope * 0.3 + density * 0.36 + satelliteInfluence[i] * 0.16 + stationInfluence[i] * 0.06 + railInfluence2[i] * 0.04 - slope[i] * 0.82 - ridgeField[i] * 0.28; + const suburbScore = urbanEnvelope * 0.54 + density * 0.14 + satelliteInfluence[i] * 0.22 + stationInfluence[i] * 0.09 + roadInfluence[i] * 0.05 + railInfluence2[i] * 0.05 + plain[i] * 0.16 + valleyField[i] * 0.04 + populationScale * 0.05 + (coreScore < 0.58 ? 0.05 : 0) - slope[i] * 0.76 - ridgeField[i] * 0.22; + const roadsideScore = interchangeInfluence[i] * 0.54 + logisticsInfluence[i] * 0.18 + roadInfluence[i] * 0.1 + plain[i] * 0.1 - cityInfluence[i] * 0.02; + 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; + } + } + + function hasUrbanNeighborCluster(x, y, radius = 2, minUrban = 7) { + let urban = 0; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = x + dx; + 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++; + } + } + return urban >= minUrban; + } + + function removeIsolatedUrbanPatches(maxCells = 22) { + const seen = new Uint8Array(SIZE); + 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; + const lu0 = landuse[i]; + if (!(lu0 >= 2 && lu0 <= 8)) continue; + const component = []; + let maxDensity = 0; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + component.push(cur); + maxDensity = Math.max(maxDensity, populationDensity[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; + if (!(landuse[ni] >= 2 && landuse[ni] <= 8)) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (component.length > maxCells) continue; + let hasAnchor = false; + for (const ci of component) { + const [x, y] = xyOf(ci); + if (distanceToNearest(namedCenters, x, y) <= 5.8) { + hasAnchor = true; + break; + } + } + if (!hasAnchor) { + for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? 1 : 0; + } + } + } + + for (let pass = 0; pass < 2; pass++) removeIsolatedUrbanPatches(36); + + // CBD is no longer a marker. It is a DID-like contiguous high-density core: + // first remove isolated core cells, then grow connected high-density cells + // from each urban center according to population scale. + 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; + } + } + + 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 ((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); + const selected = new Set(); + const queued = new Set([start]); + const heap = new MinHeap(); + heap.push({ i: start, f: -10 }); + let made = 0; + + while (heap.length > 0 && made < targetCells) { + const cur = heap.pop(); + if (!cur || selected.has(cur.i)) continue; + const [x, y] = xyOf(cur.i); + 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; + + selected.add(i); + landuse[i] = 3; + 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; + 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; + queued.add(ni); + heap.push({ i: ni, f: -score }); + } + } + return made; + } + + urbanCenters.forEach((center, n) => growDidCore(center, center.parent || modernCities[n], 9400 + n * 17)); + for (let pass = 0; pass < 3; pass++) removeIsolatedUrbanPatches(42); + 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; + } + } + + + return { + 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, + }; +} diff --git a/mapGenerator.js b/mapGenerator.js index e1911d9..becd634 100644 --- a/mapGenerator.js +++ b/mapGenerator.js @@ -1,3011 +1 @@ -import { createNameDebug, generateEntityName } from "./names.js"; -import { - applyLandscapeUnitAdminPartition, - generateAdminRegions, - lockSmallUrbanComponentsToMunicipality, - mergeTinyMunicipalities, - removeMunicipalExclaves, - smoothAdminRegionsTerrainAware, - snapAdminBoundariesToTerrain, -} from "./adminRegions.js"; -import { CELL_SIZE, INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, nearMapEdge, pickEntities, rand, smoothstep, valueNoise, xyOf } from "./mapUtils.js"; - -export { CELL_SIZE, MAP_H, MAP_W, indexOf } 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; -} - -function distanceToNearest(points, x, y, fallback = 999) { - let best = fallback; - for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y)); - return best; -} - -function aStar(start, goal, costAt) { - const startIndex = indexOf(start.x, start.y); - const goalIndex = indexOf(goal.x, goal.y); - if (startIndex === goalIndex) return [[start.x, start.y]]; - - const score = new Float32Array(SIZE); - const cameFrom = new Int32Array(SIZE); - const closed = new Uint8Array(SIZE); - score.fill(INF); - cameFrom.fill(-1); - - const heap = new MinHeap(); - score[startIndex] = 0; - heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) }); - - let guard = 0; - while (heap.length > 0 && guard++ < SIZE * 3) { - const current = heap.pop(); - if (!current || closed[current.i]) continue; - closed[current.i] = 1; - - if (current.i === goalIndex) { - const path = []; - let p = goalIndex; - while (p !== -1) { - const [x, y] = xyOf(p); - path.push([x, y]); - if (p === startIndex) break; - p = cameFrom[p]; - } - return path.reverse(); - } - - const [cx, cy] = xyOf(current.i); - for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) { - const nextIndex = indexOf(nx, ny); - if (closed[nextIndex]) continue; - const cost = costAt(nx, ny, cx, cy); - if (cost >= INF) continue; - const nextScore = score[current.i] + cost * stepDistance; - if (nextScore < score[nextIndex]) { - score[nextIndex] = nextScore; - cameFrom[nextIndex] = current.i; - heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 }); - } - } - } - return []; -} - -function influenceFromPaths(paths, radius) { - const grid = new Float32Array(SIZE); - for (const path of paths) { - for (const [x, y] of path) { - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - const d = Math.hypot(dx, dy); - if (d > radius) continue; - const i = indexOf(nx, ny); - grid[i] = Math.max(grid[i], 1 / (1 + d)); - } - } - } - } - return grid; -} - -function pointKey(p) { - return `${p.x},${p.y}`; -} - -function getDegree(degreeMap, p) { - return degreeMap.get(pointKey(p)) || 0; -} - -function incrementDegree(degreeMap, p) { - degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1); -} - -function nearestConnectable(points, target, degreeMap, maxDegree = 3) { - if (!points.length) return null; - const sorted = points - .map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) })) - .sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7)); - return sorted.find((p) => p.degree < maxDegree) || sorted[0]; -} - -function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) { - if (!grid) return 0; - const value = grid[indexOf(x, y)]; - if (value <= 0.0001) return 0; - - const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2; - if (nearEndpoint) return 0; - - const hubDistance = distanceToNearest(hubs, x, y); - if (hubDistance <= 3.5) return 0; - if (hubDistance <= 7.5) return value * strength * 0.28; - return value * strength; -} - -function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) { - if (!points || points.length === 0) return 0; - if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0; - const d = distanceToNearest(points, x, y); - if (d >= radius) return 0; - return (radius - d) * strength; -} - -function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) { - const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null; - return (x, y, cx, cy) => { - const base = baseCost(x, y, cx, cy); - if (base >= INF) return base; - return base - + corridorPenalty(grid, x, y, hubs, endpoints, strength) - + nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength); - }; -} - -function pathLength(path) { - let total = 0; - for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); - return total; -} - -function pathEndpointDistance(path) { - if (!path || path.length < 2) return 0; - const a = path[0]; - const b = path[path.length - 1]; - return Math.hypot(a[0] - b[0], a[1] - b[1]); -} - -function pathCompactness(path) { - const direct = pathEndpointDistance(path); - if (direct <= 0.001) return INF; - return pathLength(path) / direct; -} - -function pathOverlapRatio(path, existingPaths, radius = 2) { - if (!path?.length || !existingPaths?.length) return 0; - const grid = influenceFromPaths(existingPaths, radius); - let overlap = 0; - for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++; - return overlap / Math.max(1, path.length); -} - -function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) { - const kept = []; - for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) { - if (pathLength(path) < minLength) continue; - if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue; - kept.push(path); - if (kept.length >= maxCount) break; - } - paths.splice(0, paths.length, ...kept); -} - -function bresenhamCells(a, b) { - const cells = []; - let x0 = a[0]; - let y0 = a[1]; - const x1 = b[0]; - const y1 = b[1]; - const dx = Math.abs(x1 - x0); - const dy = Math.abs(y1 - y0); - const sx = x0 < x1 ? 1 : -1; - const sy = y0 < y1 ? 1 : -1; - let err = dx - dy; - while (true) { - cells.push([x0, y0]); - if (x0 === x1 && y0 === y1) break; - const e2 = 2 * err; - if (e2 > -dy) { err -= dy; x0 += sx; } - if (e2 < dx) { err += dx; y0 += sy; } - } - return cells; -} - -function smoothPathByLineOfSight(path, passable, maxSegment = 9) { - if (!path || path.length < 3) return path || []; - const out = [path[0]]; - let i = 0; - while (i < path.length - 1) { - let best = i + 1; - const limit = Math.min(path.length - 1, i + maxSegment); - for (let j = limit; j > i + 1; j--) { - const cells = bresenhamCells(path[i], path[j]); - if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; } - } - for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell); - i = best; - } - return out; -} - -function averagePathField(path, field) { - if (!path?.length) return 0; - let sum = 0; - for (const [x, y] of path) sum += field[indexOf(x, y)] || 0; - return sum / path.length; -} - -function influenceFromPoints(points, radius, weightFn = () => 1) { - const grid = new Float32Array(SIZE); - for (const p of points) { - const weight = weightFn(p); - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const nx = p.x + dx; - const ny = p.y + dy; - if (!inside(nx, ny)) continue; - const d = Math.hypot(dx, dy); - if (d > radius) continue; - const i = indexOf(nx, ny); - grid[i] = Math.max(grid[i], weight / (1 + d)); - } - } - } - return grid; -} - -function samplePath(path, step) { - const out = []; - for (let i = step; i < path.length - step; i += step) { - const [x, y] = path[i]; - out.push({ x, y, score: 1 }); - } - return out; -} - -function smoothMask(mask, passes = 2) { - let current = new Uint8Array(mask); - for (let pass = 0; pass < passes; pass++) { - const next = new Uint8Array(current); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - let count = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (current[indexOf(x + dx, y + dy)]) count++; - } - } - if (count >= 5) next[i] = 1; - else if (count <= 3) next[i] = 0; - } - } - current = next; - } - return current; -} - -function largestConnectedMask(mask) { - const seen = new Uint8Array(SIZE); - let best = []; - const queue = []; - - for (let i = 0; i < SIZE; i++) { - if (!mask[i] || seen[i]) 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 (!mask[ni] || seen[ni]) continue; - seen[ni] = 1; - queue.push(ni); - } - } - - if (component.length > best.length) best = component; - } - - const out = new Uint8Array(SIZE); - for (const i of best) out[i] = 1; - return out; -} - -function componentCount(mask) { - const seen = new Uint8Array(SIZE); - const queue = []; - let count = 0; - for (let i = 0; i < SIZE; i++) { - if (!mask[i] || seen[i]) continue; - count++; - queue.length = 0; - queue.push(i); - seen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const [x, y] = xyOf(queue[q]); - 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); - } - } - } - return count; -} - - -function makePrefectureMask(seed, sea, elevation, slope, river) { - const candidates = []; - for (let y = 8; y < MAP_H - 8; y++) { - for (let x = 8; x < MAP_W - 8; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72; - const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08); - candidates.push({ x, y, score }); - } - } - - const regionSeeds = pickEntities(candidates, { - max: 1, - minDistance: 18, - threshold: 0.35, - seed: seed + 904, - jitter: 0.02, - }); - - const mask = new Uint8Array(SIZE); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0); - const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08)); - - for (const s of regionSeeds) { - const i = indexOf(s.x, s.y); - dist[i] = 0; - heap.push({ i, f: 0 }); - } - - let claimed = 0; - while (heap.length > 0 && claimed < target) { - const current = heap.pop(); - if (!current) continue; - const ci = current.i; - if (current.f > dist[ci] + 1e-5 || mask[ci]) continue; - const [cx, cy] = xyOf(ci); - if (sea[ci]) continue; - - mask[ci] = 1; - claimed++; - - for (const [nx, ny, step] of neighbors8(cx, cy)) { - const ni = indexOf(nx, ny); - if (sea[ni] || mask[ni]) continue; - const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0; - const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2; - const slopePenalty = slope[ni] * 4.1; - const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0; - const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step; - const nd = dist[ci] + cost; - if (nd < dist[ni]) { - dist[ni] = nd; - heap.push({ i: ni, f: nd }); - } - } - } - - return largestConnectedMask(smoothMask(mask, 2)); -} - -function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) { - const centers = []; - let sx = 0; - let sy = 0; - let sc = 0; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (anchorMask[i]) { sx += x; sy += y; sc++; } - } - } - if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" }); - - const candidates = []; - const ax = centers[0]?.x ?? MAP_W / 2; - const ay = centers[0]?.y ?? MAP_H / 2; - 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] || anchorMask[i]) continue; - const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5)); - const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H); - const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08; - const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06; - candidates.push({ x, y, score, kind: "Neighbor Prefecture" }); - } - } - centers.push(...pickEntities(candidates, { - max: 9 + Math.floor(rand(seed, 6101) * 6), - minDistance: 22, - threshold: 0.38, - seed: seed + 6102, - jitter: 0.02, - })); - - const regionId = new Int16Array(SIZE); - regionId.fill(-1); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - centers.forEach((center, id) => { - const i = indexOf(center.x, center.y); - if (sea[i]) return; - regionId[i] = id; - dist[i] = 0; - heap.push({ i, f: 0 }); - }); - - let guard = 0; - while (heap.length > 0 && guard++ < SIZE * 16) { - const cur = heap.pop(); - if (!cur || cur.f > dist[cur.i] + 1e-5) continue; - const [cx, cy] = xyOf(cur.i); - const curRegion = regionId[cur.i]; - for (const [nx, ny, step] of neighbors8(cx, cy)) { - const ni = indexOf(nx, ny); - if (sea[ni]) continue; - const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]); - const riverBarrier = Math.max(river[ni], river[cur.i]); - const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8; - const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7; - const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0; - const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step; - const nd = dist[cur.i] + stepCost; - if (nd < dist[ni]) { - dist[ni] = nd; - regionId[ni] = curRegion; - heap.push({ i: ni, f: nd }); - } - } - } - return { regionId, centers }; -} - -function extractRegionBorderSegments(regionId, 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 (sea[i] || regionId[i] < 0) continue; - const a = regionId[i]; - if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) { - const b = regionId[indexOf(x + 1, y)]; - if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); - } - if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) { - const b = regionId[indexOf(x, y + 1)]; - if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); - } - } - } - return segments; -} - -function extractMaskBorder(mask, sea = null) { - const segments = []; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - const a = mask[i]; - if (x + 1 < MAP_W) { - const ni = indexOf(x + 1, y); - const b = mask[ni]; - if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]); - } - if (y + 1 < MAP_H) { - const ni = indexOf(x, y + 1); - const b = mask[ni]; - if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]); - } - } - } - return segments; -} - -function extractAdminBorderSegments(adminId, prefectureMask) { - 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]) continue; - const a = adminId[i]; - if (a < 0) continue; - if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) { - const b = adminId[indexOf(x + 1, y)]; - if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); - } - if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) { - const b = adminId[indexOf(x, y + 1)]; - if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); - } - } - } - return segments; -} - -function tagInsidePrefecture(points, prefectureMask) { - return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) })); -} - -function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) { - return points.map((p, i) => { - const id = `${prefix}-${i}`; - const kind = kindOverride || p.kind; - const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug); - if (usedNames) usedNames.add(name); - return { - ...p, - id, - name, - insidePrefecture: Boolean(p.insidePrefecture), - }; - }); -} - -function applyOutputOptions(map, options = {}) { - if (options.includeDebugFields !== false) return map; - const slim = { ...map }; - delete slim.settlementCluster; - delete slim.ridgeField; - delete slim.valleyField; - delete slim.basinField; - delete slim.coastalLowland; - delete slim.flowAccum; - delete slim.erosionField; - delete slim.depositionField; - return slim; -} - -function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) { - populationDensity.fill(0); - const allCities = [...modernCities, ...satelliteCities]; - for (const city of allCities) { - const urbanR = Math.max(4, city.urbanRadius || 8); - const coreR = Math.max(2, city.coreRadius || 3); - const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65); - const r = Math.ceil(urbanR * 2.2); - 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] || !prefectureMask[i]) continue; - const d = Math.hypot(dx, dy); - const lu = landuse[i]; - const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10; - const radial = 1 / (1 + Math.pow(d / urbanR, 2.5)); - const core = Math.exp(-(d * d) / (coreR * coreR * 2.0)); - const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24); - populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18); - } - } - } - let maxDensity = 0; - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]); - if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); - - for (const city of allCities) { - let urbanCells = 0; - let coreCells = 0; - let densitySum = 0; - const r = Math.ceil((city.urbanRadius || 8) * 2.0); - 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 > r) continue; - const lu = landuse[i]; - if (lu >= 2 && lu <= 8) { - urbanCells++; - densitySum += populationDensity[i]; - if (lu === 3) coreCells++; - } - } - } - const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000; - const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : 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); - } -} - -export function generateMap(seedInput = 114514, options = {}) { - const seed = Number(seedInput) >>> 0; - - let prefectureMask; - let prefectureBorder; - - const { - elevation, - moisture, - slope, - sea, - river, - floodplain, - plain, - agriculture, - ridgeField, - valleyField, - basinField, - coastalLowland, - flowAccum, - erosionField, - depositionField, - flowTo, - portSuitability, - crossingSuitability, - passSuitability, - } = createMapFields(); - - const coastAngle = rand(seed, 11) * Math.PI * 2; - const coastX = Math.cos(coastAngle); - const coastY = Math.sin(coastAngle); - const coastThreshold = 0.22 + rand(seed, 12) * 0.22; - const coastStrength = 0.15 + rand(seed, 13) * 0.23; - - const seaLevel = 0.285; - - const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({ - x: rand(seed, 100 + i) * MAP_W, - y: rand(seed, 200 + i) * MAP_H, - r: 10 + rand(seed, 300 + i) * 24, - h: 0.08 + rand(seed, 400 + i) * 0.16, - })); - - const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({ - x: rand(seed, 1500 + i) * MAP_W, - y: rand(seed, 1600 + i) * MAP_H, - angle: rand(seed, 1700 + i) * Math.PI * 2, - width: 3 + rand(seed, 1800 + i) * 7, - length: 42 + rand(seed, 1900 + i) * 92, - h: 0.11 + rand(seed, 2000 + i) * 0.22, - })); - - 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 d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r; - mountains += Math.exp(-d * d * 2.35) * blob.h; - } - - let ridges = 0; - for (const ridge of ridgeBands) { - const dx = wx - ridge.x; - const dy = wy - ridge.y; - const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle); - const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle); - const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length); - const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56; - ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration; - } - - const directionalCoast = nx * coastX + ny * coastY; - const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08; - const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26); - // 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; - 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 + - ridges * 1.22 + - basin + - fineDissection - - coastLower * (coastStrength + 0.19) + - 0.055; - - elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26); - ridgeField[i] = clamp(ridges * 4.8 + 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.7); - 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 nx = x / (MAP_W - 1) - 0.5; - const ny = y / (MAP_H - 1) - 0.5; - const directionalCoast = nx * coastX + ny * coastY; - const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08; - const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055; - 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); - } - } - - // 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; - 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 (nearestSea <= 7) { - const coastalCap = seaLevel + 0.018 + nearestSea * 0.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022; - elevation[i] = Math.min(elevation[i], coastalCap); - coastalLowland[i] = clamp(1 - nearestSea / 7); - } - } - } - - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; - const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; - slope[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); - } - } - - const landOrder = []; - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - let low = i; - let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468); - let localMean = 0; - let localMax = elevation[i]; - let localMin = elevation[i]; - let nCount = 0; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - const ev = elevation[ni]; - localMean += ev; - localMax = Math.max(localMax, ev); - localMin = Math.min(localMin, ev); - nCount++; - const directed = ev + 0.008 * hash2(nx, ny, seed + 2469); - if (directed < best || sea[ni]) { - best = directed; - low = ni; - } - } - if (low !== i) flowTo[i] = low; - localMean /= Math.max(1, nCount); - const hollow = Math.max(0, localMean - elevation[i]); - const relief = localMax - localMin; - valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18); - basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0)); - flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55; - landOrder.push(i); - } - } - landOrder.sort((a, b) => elevation[b] - elevation[a]); - for (const i of landOrder) { - const to = flowTo[i]; - if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82; - } - let maxFlowAccum = 0; - for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]); - if (maxFlowAccum > 0) { - for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum); - } - for (let i = 0; i < SIZE; i++) { - if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48); - } - - // First-order fluvial shaping: cut valley floors on steep/high-flow cells and - // deposit gently in coastal lowlands and basin floors. This gives visible - // river valleys without destroying the macro terrain structure. - const shapedElevation = new Float32Array(elevation); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const flow = Math.pow(flowAccum[i], 0.46); - const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36; - const steepValley = clamp(flow * (0.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise); - const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078); - const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82)); - erosionField[i] = steepValley + lateralCut; - depositionField[i] = lowSettling; - shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1); - } - } - elevation.set(shapedElevation); - - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; - const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; - slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); - valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06); - basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6); - } - } - - const sourceCandidates = []; - for (let y = 4; y < MAP_H - 4; y++) { - for (let x = 4; x < MAP_W - 4; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; - if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score }); - } - } - - const sources = pickEntities(sourceCandidates, { - max: 20 + Math.floor(rand(seed, 910) * 28), - minDistance: 8, - threshold: 0.53 + rand(seed, 911) * 0.11, - seed, - }); - - function nearestWaterGoal(from) { - let bestSea = null; - let bestScore = INF; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (!sea[i]) continue; - const d = Math.hypot(x - from.x, y - from.y); - const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2; - if (score < bestScore) { - bestScore = score; - bestSea = { x, y }; - } - } - } - return bestSea; - } - - function riverRouteCost(x, y, cx, cy) { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (sea[i]) return 0.18; - const uphill = Math.max(0, elevation[i] - elevation[ci]); - const downhill = Math.max(0, elevation[ci] - elevation[i]); - if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF; - return Math.max( - 0.18, - 1 + - uphill * 86 + - slope[i] * 0.38 + - elevation[i] * 0.42 - - downhill * 2.1 - - valleyField[i] * 0.92 - - flowAccum[i] * 0.72 - - moisture[i] * 0.18 - - coastalLowland[i] * 0.22 - ); - } - - function forceRiverToWater(path) { - if (!path.length) return path; - const [ex, ey] = path[path.length - 1]; - if (sea[indexOf(ex, ey)]) return path; - const goal = nearestWaterGoal({ x: ex, y: ey }); - if (!goal) return path; - const startElevation = elevation[indexOf(ex, ey)]; - const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF; - return riverRouteCost(x, y, cx, cy); - }); - if (tail.length <= 2) return path; - return path.concat(tail.slice(1)); - } - - function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) { - if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0; - let best = 0.16; - const inLen = Math.hypot(dx, dy) || 1; - for (const [rx, ry] of neighbors8(nx, ny)) { - if (river[indexOf(rx, ry)] < 0.22) continue; - const rdx = rx - nx; - const rdy = ry - ny; - const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1); - const angle = Math.acos(cos); - const shallow = angle < 0.45 ? 0.28 : 0; - best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow); - } - return best; - } - - function traceRiverPath(startX, startY, bonusSeed = 0) { - let x = startX; - let y = startY; - let lastDx = 0; - let lastDy = 0; - const path = []; - const seen = new Set(); - let accum = 0; - - for (let step = 0; step < 600; step++) { - const i = indexOf(x, y); - if (seen.has(i)) break; - seen.add(i); - path.push([x, y]); - river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55; - accum += river[i] + flowAccum[i]; - if (sea[i]) break; - - let best = null; - let bestValue = INF; - const currentElevation = elevation[i]; - const preferred = flowTo[i]; - - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - const dx = nx - x; - const dy = ny - y; - const drop = currentElevation - elevation[ni]; - const uphill = Math.max(0, -drop); - if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue; - let surrounding = 0; - let surroundingCount = 0; - for (const [vx, vy] of neighbors8(nx, ny)) { - surrounding += elevation[indexOf(vx, vy)]; - surroundingCount++; - } - const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]); - const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; - const straightPenalty = Math.max(0, sameDirection) * 0.075; - const turnPenalty = sameDirection < -0.35 ? 0.24 : 0; - const sideSwing = Math.abs(dx * lastDy - dy * lastDx); - const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5; - const meander = sideSwing * (0.032 + meanderPhase * 0.026); - const flowBonus = ni === preferred ? 0.62 : 0; - const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length); - const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04; - const value = - elevation[ni] * 1.45 + - uphill * 88 - - Math.max(0, drop) * 2.05 - - valley * 1.05 - - valleyField[ni] * 1.72 - - flowAccum[ni] * 0.94 - - moisture[ni] * 0.14 - - coastalLowland[ni] * 0.28 - - (river[ni] > 0 ? 0.22 : 0) - - flowBonus + - slope[ni] * 0.04 + - straightPenalty + - turnPenalty + - junctionPenalty * 1.35 - - meander + - noise - - (sea[ni] ? 0.6 : 0); - - if (value < bestValue) { - bestValue = value; - best = [nx, ny, dx, dy]; - } - } - if (!best) break; - x = best[0]; - y = best[1]; - lastDx = best[2]; - lastDy = best[3]; - } - - const forced = forceRiverToWater(path); - if (forced.length > path.length) { - for (const [rx, ry] of forced.slice(path.length)) { - const ri = indexOf(rx, ry); - river[ri] += 0.32 + flowAccum[ri] * 0.4; - accum += river[ri] + flowAccum[ri]; - } - } - return { path: forced, accum }; - } - - function traceSmallStreamPath(startX, startY, bonusSeed = 0) { - let x = startX; - let y = startY; - let lastDx = 0; - let lastDy = 0; - const path = []; - const seen = new Set(); - for (let step = 0; step < 160; step++) { - const i = indexOf(x, y); - if (seen.has(i)) break; - seen.add(i); - path.push([x, y]); - river[i] += 0.12 + flowAccum[i] * 0.18; - if ((river[i] > 0.48 && path.length > 5) || sea[i]) break; - let best = null; - let bestValue = INF; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - const dx = nx - x; - const dy = ny - y; - const drop = elevation[i] - elevation[ni]; - const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; - const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05; - if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; } - } - if (!best) break; - x = best[0]; - y = best[1]; - lastDx = best[2]; - lastDy = best[3]; - } - return path; - } - - const riverPaths = []; - const riverScores = []; - for (const source of sources) { - const { path, accum } = traceRiverPath(source.x, source.y, 0); - if (path.length > 6) { - riverPaths.push(path); - riverScores.push(path.length + accum * 0.18); - } - } - - const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`))); - const tributarySources = pickEntities(sourceCandidates - .filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`)) - .map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), { - max: 14 + Math.floor(rand(seed, 915) * 20), - minDistance: 6, - threshold: 0.45, - seed: seed + 916, - jitter: 0.02, - }); - for (const source of tributarySources) { - const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11); - if (path.length > 8) { - riverPaths.push(path); - riverScores.push(path.length * 0.7 + accum * 0.12); - } - } - - const streamPaths = []; - const streamSources = pickEntities(sourceCandidates - .map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 })) - .filter((p) => p.score > 0.18), { - max: 22 + Math.floor(rand(seed, 919) * 20), - minDistance: 4, - threshold: 0.18, - seed: seed + 919, - jitter: 0.015, - }); - for (const source of streamSources) { - const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17); - if (path.length > 4) streamPaths.push(path); - } - - if (riverPaths.length === 0 && sourceCandidates.length > 0) { - const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0]; - let bestSea = null; - let bestSeaDist = INF; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - if (!sea[indexOf(x, y)]) continue; - const d = Math.hypot(x - fallback.x, y - fallback.y); - if (d < bestSeaDist) { - bestSeaDist = d; - bestSea = { x, y }; - } - } - } - if (bestSea) { - const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (sea[i]) return 0.25; - const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24; - const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8; - return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1)); - }); - if (fallbackPath.length > 6) { - let accum = 0; - for (const [x, y] of fallbackPath) { - const i = indexOf(x, y); - river[i] += 0.42; - accum += river[i]; - } - riverPaths.push(fallbackPath); - riverScores.push(fallbackPath.length + accum * 0.18); - } - } - } - - function sanitizeDownhillRiverPath(path, tolerance = 0.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.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) 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.42 + k / 170 + flowAccum[i] * 0.55; - } - } - for (const path of streamPaths) { - for (let k = 0; k < path.length; k++) { - const [x, y] = path[k]; - const i = indexOf(x, y); - river[i] += 0.11 + flowAccum[i] * 0.18; - } - } - - const expandedRiver = new Float32Array(river); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (river[i] <= 0) continue; - for (const [nx, ny] of neighbors8(x, y)) { - expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35); - } - } - } - river.set(expandedRiver); - - // Second fluvial pass uses the actual traced river network. Main channels cut - // visible V-shaped valleys; lower reaches accumulate alluvial deposits. - const fluvialElevation = new Float32Array(elevation); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i] || river[i] <= 0.02) continue; - const r = clamp(river[i] / 3.4); - const channelCut = clamp(Math.pow(r, 0.55) * (0.060 + slope[i] * 0.145 + ridgeField[i] * 0.038)); - const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040)); - const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0))); - erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); - depositionField[i] = clamp(depositionField[i] + alluvium); - fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1); - valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4); - basinField[i] = clamp(basinField[i] + alluvium * 3.2); - } - } - // Lateral valley carving around the traced river network deepens valleys and - // makes ridge/valley contrast legible at the map scale. - for (const path of riverPaths) { - for (const [rx, ry] of path) { - const ri = indexOf(rx, ry); - const r = clamp(river[ri] / 3.0); - const radius = r > 0.48 ? 2 : 1; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const nx = rx + dx; - const ny = ry + dy; - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (sea[ni]) continue; - const d = Math.hypot(dx, dy); - if (d > radius || d === 0) continue; - const weight = (radius + 0.35 - d) / (radius + 0.35); - const carve = Math.max(0, weight) * (0.008 + r * 0.026) * 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); - } - } - } - } - - // 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, 1); - erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6); - } - } - } - - elevation.set(fluvialElevation); - - // Broad alluvial/coastal/basin plains. The plain score alone is not enough; - // the elevation surface must also be locally calm, otherwise every lowland - // still reads as rugged terrain. Smooth only low, wet depositional cells and - // leave ridges/headwaters untouched. - for (let pass = 0; pass < 4; 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 + - valleyField[i] * 0.34 + - Math.pow(flowAccum[i], 0.58) * 0.24 - - ridgeField[i] * 0.62 - - Math.max(0, elevation[i] - 0.54) * 1.65 - - slope[i] * 0.74 - ); - if (lowland <= 0.12) continue; - let sum = 0; - let weight = 0; - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) { - const nx = x + dx; - const ny = y + dy; - const ni = indexOf(nx, ny); - if (sea[ni]) continue; - const d = Math.hypot(dx, dy); - if (d > 2.25) continue; - const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11); - const w = compatible / (1 + d); - sum += elevation[ni] * w; - weight += w; - } - } - if (weight <= 0) continue; - const localMean = sum / weight; - const terrace = Math.round(localMean * 42) / 42; - const target = lerp(localMean, terrace, 0.28); - nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1); - if (lowland > 0.55) { - depositionField[i] = clamp(depositionField[i] + lowland * 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); - - const mainRivers = riverPaths - .map((p, i) => ({ path: p, score: riverScores[i] })) - .sort((a, b) => b.score - a.score) - .slice(0, Math.min(6, riverPaths.length)) - .map((x) => x.path); - - if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]); - if (mainRivers.length === 0) { - let start = null; - let startScore = -INF; - for (let y = 4; y < MAP_H - 4; y++) { - for (let x = 4; x < MAP_W - 4; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15; - if (score > startScore) { - startScore = score; - start = { x, y }; - } - } - } - if (start) { - let goal = null; - let goalDist = INF; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - if (!sea[indexOf(x, y)]) continue; - const d = Math.hypot(x - start.x, y - start.y); - if (d < goalDist) { - goalDist = d; - goal = { x, y }; - } - } - } - if (goal) { - const fallbackPath = aStar(start, goal, (x, y, cx, cy) => { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (sea[i]) return 0.2; - const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22; - const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7; - return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias); - }); - if (fallbackPath.length > 4) { - riverPaths.push(fallbackPath); - mainRivers.push(fallbackPath); - for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4; - } - } - } - } - - const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`))); - const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path)); - const smallStreams = streamPaths.filter((path) => path.length >= 5); - - prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); - prefectureBorder = extractMaskBorder(prefectureMask, sea); - const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); - const prefectureRegionId = regionalPrefectures.regionId; - const 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; - 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(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); - agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + 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; - const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16; - portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + 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); - } - } - - function pickPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true }) { - 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 (!predicate(x, y, i)) continue; - const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.08; - if (score >= threshold) candidates.push({ x, y, score }); - } - } - return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); - } - - let ports = pickPoints(portSuitability, { - threshold: 0.3 + rand(seed, 1001) * 0.08, - max: 3 + Math.floor(rand(seed, 1002) * 7), - minDistance: 10, - seedOffset: 1000, - predicate: (x, y, i) => !sea[i], - }).map((p) => { - const i = indexOf(p.x, p.y); - let seaEdge = 0; - for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { - const nx = p.x + dx; - const ny = p.y + dy; - if (inside(nx, ny) && sea[indexOf(nx, ny)]) seaEdge += 1 / (1 + Math.hypot(dx, dy)); - } - const harborPotential = p.score + coastalLowland[i] * 0.28 + river[i] * 0.08 + seaEdge * 0.025 - slope[i] * 0.2; - return { ...p, harborPotential, seaEdge, portClass: "fishing", kind: "Fishing Port" }; - }).sort((a, b) => b.harborPotential - a.harborPotential) - .map((p, n) => { - const isLakeLike = p.seaEdge < 0.25 && river[indexOf(p.x, p.y)] > 0.32; - const portClass = isLakeLike ? "lake" : n === 0 ? "major" : n < 3 && p.harborPotential > 0.34 ? "regional" : "fishing"; - const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port"; - return { ...p, portClass, kind, score: p.harborPotential }; - }); - if (!ports.some((p) => p.portClass === "major")) { - const fallbackMajor = ports.find((p) => p.portClass !== "lake") || ports[0]; - if (fallbackMajor) { - fallbackMajor.portClass = "major"; - fallbackMajor.kind = "Major Port"; - fallbackMajor.score += 0.16; - } - } - const majorPorts = ports.filter((p) => p.portClass === "major"); - const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); - - let crossings = pickPoints(crossingSuitability, { - threshold: 0.28 + rand(seed, 1011) * 0.08, - max: 8 + Math.floor(rand(seed, 1012) * 15), - minDistance: 8, - seedOffset: 1010, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "River Crossing" })); - - let passes = pickPoints(passSuitability, { - threshold: 0.16 + rand(seed, 1021) * 0.08, - max: 4 + Math.floor(rand(seed, 1022) * 10), - minDistance: 9, - seedOffset: 1020, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Pass" })); - - const settlementCluster = 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 valleyCorridor = clamp(valleyField[i] * 0.62 + river[i] * 0.16); - const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18); - const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1); - const localPatch = valueNoise(x * 0.7, y * 0.7, seed + 1037, 10); - const broadPatch = fbm(x * 0.32 + 71, y * 0.32 - 19, seed + 1038); - settlementCluster[i] = clamp((valleyCorridor + lowlandCorridor) * terrainGate * (0.72 + broadPatch * 0.42 + localPatch * 0.18)); - } - } - - const settlementScore = 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; - let nearFeature = 0; - for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4)); - const riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16); - const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52; - const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] - 0.22) * (1 - valleyField[i]) * 0.75; - const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - floodplain[i] * 0.06 - remoteMountainPenalty; - settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13); - } - } - - 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), - seedOffset: 1030, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Village" })); - - const marketScore = new Float32Array(SIZE); - 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; - - let villagePull = 0; - let nearbyVillages = 0; - for (const v of villages) { - const d = Math.hypot(x - v.x, y - v.y); - if (d < 24) { - villagePull += 1 / (1 + d); - 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 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 + nearbyVillages * 0.012); - } - } - - let markets = pickPoints(marketScore, { - threshold: 0.2 + rand(seed, 1041) * 0.08, - max: 6 + Math.floor(rand(seed, 1042) * 12), - minDistance: 11, - seedOffset: 1040, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Market Town" })); - - const defenseScore = new Float32Array(SIZE); - 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 hillShoulder = clamp(1 - Math.abs(elevation[i] - 0.50) / 0.24); - let riverArms = 0; - for (const [nx, ny] of neighbors8(x, y)) if (river[indexOf(nx, ny)] > 0.32) riverArms++; - const confluence = riverArms >= 3 ? 0.38 : riverArms === 2 ? 0.18 : 0; - const roadJunctionProxy = ( - (distanceToNearest(markets, x, y) < 7 ? 1 : 0) + - (distanceToNearest(crossings, x, y) < 6 ? 1 : 0) + - (distanceToNearest(passes, x, y) < 7 ? 1 : 0) + - (distanceToNearest(commercialPorts, x, y) < 8 ? 1 : 0) - ) >= 2 ? 0.32 : 0; - const hillEdge = plain[i] > 0.2 && elevation[i] > 0.36 && elevation[i] < 0.62 && (slope[i] > 0.12 || ridgeField[i] > 0.12) ? 0.3 : 0; - const mountainRidgeCastle = elevation[i] > 0.56 && ridgeField[i] > 0.3 && valleyField[i] > 0.1 ? 0.28 : 0; - const validCastleSite = confluence > 0 || roadJunctionProxy > 0 || hillEdge > 0 || mountainRidgeCastle > 0; - defenseScore[i] = validCastleSite - ? clamp(hillShoulder * 0.28 + confluence + roadJunctionProxy + hillEdge + mountainRidgeCastle + slope[i] * 0.05 - floodplain[i] * 0.42 - coastalLowland[i] * 0.12) - : 0; - } - } - - let castles = pickPoints(defenseScore, { - threshold: 0.34 + rand(seed, 1051) * 0.08, - max: 2 + Math.floor(rand(seed, 1052) * 4), - minDistance: 15, - seedOffset: 1050, - predicate: (x, y, i) => !sea[i] && defenseScore[i] > 0, - }).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", - })); - - function normalEdgePenalty(x, y) { - if (nearMapEdge(x, y, 1)) return INF; - if (nearMapEdge(x, y, 2)) return 7; - if (nearMapEdge(x, y, 4)) return 2.8; - return 0; - } - - function premodernCost(x, y) { - const i = indexOf(x, y); - if (sea[i]) return INF; - const crossingBonus = distanceToNearest(crossings, x, y) < 4 ? 0.65 : 0; - const passBonus = distanceToNearest(passes, x, y) < 4 ? 0.45 : 0; - const riverPenalty = river[i] > 0.28 ? (crossingBonus ? 0.45 : 2.4) : 0; - const highMountain = elevation[i] > 0.72 ? 4.2 : elevation[i] > 0.58 ? 1.4 : 0; - return Math.max(0.35, 1 + slope[i] * 5.8 + riverPenalty + highMountain + floodplain[i] * 0.62 - plain[i] * 0.32 - valleyField[i] * 0.42 - coastalLowland[i] * 0.12 - passBonus + normalEdgePenalty(x, y) + hash2(x, y, seed + 111) * 0.16); - } - - const premodernRoads = []; - function addPremodernRoad(a, b) { - const path = aStar(a, b, premodernCost); - if (path.length > 3) premodernRoads.push(path); - } - - for (const castle of castles) { - const near = pickEntities([...markets, ...ports, ...crossings, ...passes].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - castle.x, p.y - castle.y)) })), { max: 2 + Math.floor(rand(seed, castle.x + castle.y) * 3), minDistance: 1, threshold: 0 }); - for (const p of near) addPremodernRoad(castle, p); - } - for (const market of markets) { - const near = pickEntities([...markets.filter((p) => p !== market), ...ports, ...crossings].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - market.x, p.y - market.y)) })), { max: 1 + Math.floor(rand(seed, market.x + market.y + 20) * 3), minDistance: 1, threshold: 0 }); - for (const p of near) addPremodernRoad(market, p); - } - - function urbanSiteSuitability(p) { - 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; - return clamp( - plain[i] * 0.46 + - agriculture[i] * 0.18 + - basinField[i] * 0.20 + - coastalLowland[i] * 0.20 + - valleyField[i] * 0.12 + - portBonus + historicalBonus - - slope[i] * 0.58 - - ridgeField[i] * 0.34 - - Math.max(0, elevation[i] - 0.55) * 1.35 - ); - } - - 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; - } - - 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"); - - 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 fallbackCapitalCandidate() { - const pools = [...markets, ...ports, ...villages].filter((p) => p && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); - let best = null; - let bestScore = -INF; - for (const p of pools) { - const i = indexOf(p.x, p.y); - 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 }; - - 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; - 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" }; } - } - } - return best ? { ...best, population: 320000, urbanRadius: 14, coreRadius: 4.2, urbanWeight: 1.7 } : null; - } - - function populationDensityProxyForCapital(i) { - return settlementScore[i] * 0.18 + marketScore[i] * 0.12; - } - - if (modernCities.length === 0 || !modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) { - const fallbackCapital = fallbackCapitalCandidate(); - if (fallbackCapital) modernCities.unshift(fallbackCapital); - } - - 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; - 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 (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 }; - } - - 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 populationDensity = new Float32Array(SIZE); - let maxPopulationDensity = 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; - let density = 0; - for (const city of modernCities) { - const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.55); - 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)); - } - 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)); - } - 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 *= 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); - populationDensity[i] = density; - if (density > maxPopulationDensity) maxPopulationDensity = density; - } - } - if (maxPopulationDensity > 0) { - for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxPopulationDensity); - } - - function densityValue(x, y) { - return populationDensity[indexOf(x, y)] || 0; - } - - function midDensityAffinity(x, y) { - const d = densityValue(x, y); - return clamp(1 - Math.abs(d - 0.38) / 0.38); - } - - function nearPassPoint(x, y, radius = 5) { - return distanceToNearest(passes, x, y) <= radius; - } - - function mountainBarrierPenalty(x, y, type = "rail") { - const i = indexOf(x, y); - const e = elevation[i]; - const s = slope[i]; - const pass = nearPassPoint(x, y, type === "express" ? 7 : type === "rail" ? 6 : 5); - if (e > 0.84) return INF; - if (pass && e > 0.80 && s > 0.16) return INF; - if (!pass && e > 0.78) return INF; - if (!pass && e > 0.70 && s > 0.16) return INF; - if (!pass && e > 0.66 && s > 0.28) return INF; - if (!pass && e > 0.72) return type === "express" ? 260 : type === "rail" ? 330 : type === "minor" ? 80 : 155; - if (!pass && e > 0.64 && s > 0.20) return type === "express" ? 145 : type === "rail" ? 180 : type === "minor" ? 54 : 96; - const passDiscount = pass ? (type === "minor" ? 0.35 : 0.22) : 1; - const mountain = Math.max(0, e - 0.48); - const steep = Math.max(0, s - 0.15); - const typeFactor = type === "express" ? 360 : type === "rail" ? 430 : type === "minor" ? 115 : 210; - return (mountain * mountain * typeFactor + steep * steep * 150 + ridgeField[i] * 9.5) * passDiscount; - } - - 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" ? 10 : mode === "rail" ? 2 : 4; - const maxR = mode === "express" ? 20 : mode === "rail" ? 6 : 10; - let best = null; - let bestScore = -INF; - for (let dy = -maxR; dy <= maxR; dy++) { - for (let dx = -maxR; dx <= maxR; dx++) { - const d = Math.hypot(dx, dy); - if (d < minR || d > maxR) continue; - const x = node.x + dx; - const y = node.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - const barrier = mode === "express" || mode === "rail" ? mountainBarrierPenalty(x, y, mode) : mountainBarrierPenalty(x, y, "road"); - if (barrier >= INF) continue; - const targetD = (minR + maxR) * 0.5; - const flatness = plain[i] * 1.0 + agriculture[i] * 0.2 + valleyField[i] * 0.26 + coastalLowland[i] * 0.16 - slope[i] * 1.22 - ridgeField[i] * 0.72 - Math.max(0, elevation[i] - 0.58) * 2.35; - const ring = -Math.abs(d - targetD) * 0.08; - const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12; - const density = densityValue(x, y); - const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? midDensityAffinity(x, y) * 0.52 - Math.max(0, density - 0.72) * 0.9 : density * 0.24; - const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12; - const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise; - if (score > bestScore) { - bestScore = score; - best = { x, y, score: node.score || 0.5, kind: `${mode} Access`, parent: node }; - } - } - } - return best || node; - } - - function routePoint(node, mode, salt = 0) { - return transportAccessPoint(node, mode, salt); - } - - const townAvoidNodes = [...modernCities, ...markets, ...ports]; - - const urbanCenters = modernCities.map((city, n) => { - let best = { x: city.x, y: city.y, score: city.score + 0.5 }; - let bestScore = -INF; - const searchR = Math.max(2, Math.round(city.coreRadius)); - for (let dy = -searchR; dy <= searchR; dy++) { - for (let dx = -searchR; dx <= searchR; 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; - const d = Math.hypot(dx, dy); - const score = plain[i] * 0.54 + agriculture[i] * 0.16 - slope[i] * 0.36 - d * 0.06 + hash2(x, y, seed + 1700 + n) * 0.07; - if (score > bestScore) { bestScore = score; best = { x, y, score: city.score + 0.5, cityIndex: n, parent: city }; } - } - } - return { ...best, kind: city.rank === "Prefectural Capital" ? "Central Business District" : "Urban Center", population: Math.round(city.population * (city.rank === "Prefectural Capital" ? 0.18 : 0.12)), insidePrefecture: Boolean(prefectureMask[indexOf(best.x, best.y)]) }; - }); - - function railCost(x, y) { - const i = indexOf(x, y); - if (sea[i]) return INF; - const barrier = mountainBarrierPenalty(x, y, "rail"); - if (barrier >= INF) return INF; - const density = densityValue(x, y); - const highPenalty = Math.max(0, elevation[i] - 0.52) * 14 + barrier; - const riverPenalty = river[i] > 0.5 ? 1.6 : river[i] > 0.25 ? 0.7 : 0; - return Math.max(0.42, 1 + slope[i] * 22 + highPenalty + riverPenalty + floodplain[i] * 0.28 - density * 0.88 - plain[i] * 0.28 - valleyField[i] * 0.62 - coastalLowland[i] * 0.48 + ridgeField[i] * 1.4 + normalEdgePenalty(x, y) + hash2(x, y, seed + 222) * 0.08); - } - - const railways = []; - const branchRailways = []; - const railDegree = new Map(); - const railCore = [capital]; - 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 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 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)) { - bucket.push(path); - incrementDegree(railDegree, a); - incrementDegree(railDegree, b); - return true; - } - return false; - } - - 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); - } - } - 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); - } - - compactPathArray(railways, { minLength: 17, maxOverlap: 0.34, maxCount: 5 }); - compactPathArray(branchRailways, { minLength: 11, maxOverlap: 0.22, maxCount: 9 }); - - const railInfluence = influenceFromPaths([...railways, ...branchRailways], 5); - const stationCandidates = [ - ...modernCities.map((p, i) => ({ ...routePoint(p, "rail", 1900 + i), score: p.score + 0.46, kind: "Major Station", population: p.population })), - ...railways.flatMap((path) => samplePath(path, 18 + Math.floor(rand(seed, path.length) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.52 + agriculture[indexOf(p.x, p.y)] * 0.2 })), - ...branchRailways.flatMap((path) => samplePath(path, 16 + Math.floor(rand(seed, path.length + 99) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.42 + agriculture[indexOf(p.x, p.y)] * 0.2 })), - ]; - - let stations = pickEntities(stationCandidates, { max: 14 + Math.floor(rand(seed, 1080) * 22), minDistance: 6, threshold: 0.38, seed: seed + 1080 }); - - const industrialScore = new Float32Array(SIZE); - 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 nearPort = 1 / (1 + distanceToNearest(majorPorts.length ? majorPorts : commercialPorts, x, y) / 5); - const nearCity = distanceToNearest(modernCities, x, y); - const cityEdge = nearCity > 5 && nearCity < 20 ? 0.22 : nearCity <= 5 ? -0.25 : 0; - industrialScore[i] = clamp(plain[i] * 0.24 + coastalLowland[i] * 0.24 + railInfluence[i] * 0.38 + nearPort * 0.58 + river[i] * 0.04 + cityEdge - slope[i] * 0.36 - ridgeField[i] * 0.18 - floodplain[i] * 0.03); - } - } - - let industrialZones = pickPoints(industrialScore, { - threshold: 0.31 + rand(seed, 1091) * 0.09, - max: 4 + Math.floor(rand(seed, 1092) * 13), - minDistance: 10, - seedOffset: 1090, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Industrial Zone" })); - - function roadCost(x, y) { - const i = indexOf(x, y); - if (sea[i]) return INF; - const barrier = mountainBarrierPenalty(x, y, "road"); - if (barrier >= INF) return INF; - const density = densityValue(x, y); - const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0; - return Math.max(0.35, 1 + slope[i] * 17.8 + barrier + Math.max(0, elevation[i] - 0.54) * 9.2 + nodeAvoid + (river[i] > 0.45 ? 0.85 : 0) + floodplain[i] * 0.22 - density * 0.50 - plain[i] * 0.22 - valleyField[i] * 0.28 - coastalLowland[i] * 0.20 + ridgeField[i] * 1.15 + normalEdgePenalty(x, y) + hash2(x, y, seed + 333) * 0.08); - } - - function expresswayCost(x, y) { - const i = indexOf(x, y); - if (sea[i]) return INF; - const barrier = mountainBarrierPenalty(x, y, "express"); - if (barrier >= INF) return INF; - const density = densityValue(x, y); - const midDensity = midDensityAffinity(x, y); - const cityDistance = distanceToNearest(modernCities, x, y); - const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; - const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; - const highPenalty = barrier + (elevation[i] > 0.72 ? 26 : elevation[i] > 0.62 ? 8.5 : 0); - return Math.max(0.42, 1 + slope[i] * 23.0 + highPenalty + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1.0 : 0) - midDensity * 0.82 - plain[i] * 0.16 - valleyField[i] * 0.16 - coastalLowland[i] * 0.18 + ridgeField[i] * 1.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015); - } - - const nationalRoads = []; - const roadDegree = new Map(); - function transportDemand(p) { - const pop = Math.sqrt(Math.max(0, p.population || 0)) / 700; - const capitalBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 2.1 : 0; - const portBoost = p.portClass === "major" ? 1.4 : p.portClass === "regional" ? 0.8 : p.portClass ? 0.35 : 0; - const historyBoost = p.kind?.includes("Castle") ? 0.55 : p.kind === "Market Town" ? 0.42 : 0; - const gatewayBoost = p.kind === "External Gateway" ? 1.1 : 0; - return 0.35 + pop + capitalBoost + portBoost + historyBoost + gatewayBoost; - } - - function sameCorridorAffinity(a, b) { - const ai = indexOf(a.x, a.y); - const bi = indexOf(b.x, b.y); - 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 pickedRoadTargets = pickEntities(roadTargetCandidates, { - max: 8 + Math.floor(rand(seed, 1101) * 10), - minDistance: 9, - threshold: 0, - seed: seed + 1100, - }); - const roadTargets = [ - capital, - ...pickedRoadTargets - .filter((p) => Math.hypot(p.x - capital.x, p.y - capital.y) > 2) - .sort((a, b) => transportDemand(b) - transportDemand(a)), - ]; - const roadHubs = [...modernCities, ...ports, ...markets, ...stations]; - 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 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 >= 2 || direct >= 24; - if (path.length > 3 && direct >= 16 && pathLength(path) >= 20 && pathCompactness(path) < 3.35 && pathOverlapRatio(path, existing, 2) < 0.48 && passBonusOk) { - nationalRoads.push(path); - incrementDegree(roadDegree, a); - incrementDegree(roadDegree, b); - return true; - } - 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 }); - } - } - 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; - if (addNationalRoad(link.a, link.b)) { - extraRoadLinks++; - } - } - - // 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) - .slice() - .sort((a, b) => a.x - b.x || a.y - b.y); - for (let i = 0; i < trunkCities.length - 1; i += 2) { - 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 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, - }); - - const expressways = []; - const expressDegree = new Map(); - const expressCore = [capital]; - - 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); - const direct = pathEndpointDistance(path); - if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) { - bucket.push(path); - incrementDegree(expressDegree, a); - incrementDegree(expressDegree, b); - return true; - } - return false; - } - - for (const target of expressTargets) { - const anchor = nearestConnectable(expressCore, target, expressDegree, 2) || capital; - if (addExpressway(anchor, target)) expressCore.push(target); - } - - const ringRoads = []; - const ringExpressways = []; - const ringRailways = []; - - function ringAnchorCandidates(city, mode, targetRadius, sectors = 8) { - const anchors = []; - const minR = Math.max(5, targetRadius - 5); - const maxR = targetRadius + 7; - for (let s = 0; s < sectors; s++) { - const angle0 = (s / sectors) * Math.PI * 2; - let best = null; - let bestScore = -INF; - for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) { - for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) { - const d = Math.hypot(dx, dy); - if (d < minR || d > maxR) continue; - const angle = Math.atan2(dy, dx); - let delta = Math.abs(Math.atan2(Math.sin(angle - angle0), Math.cos(angle - angle0))); - if (delta > Math.PI / sectors * 0.95) continue; - const x = city.x + dx; - const y = city.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i] || !prefectureMask[i]) 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); - const densityTerm = mode === "rail" ? density * 0.75 : mode === "express" ? midDensityAffinity(x, y) * 0.72 : density * 0.28 + midDensityAffinity(x, y) * 0.22; - const score = plain[i] * 0.72 + agriculture[i] * 0.12 + densityTerm - slope[i] * 1.25 - Math.max(0, elevation[i] - 0.58) * 1.3 - barrier * 0.01 - Math.abs(d - targetRadius) * 0.035 + hash2(x, y, seed + 4100 + s * 37 + mode.length * 101) * 0.08; - if (score > bestScore) { - bestScore = score; - best = { x, y, score, kind: `${mode} ring anchor`, parent: city }; - } - } - } - if (best) anchors.push(best); - } - return anchors; - } - - function ringCost(baseCost, city, targetRadius, mode) { - return (x, y, cx, cy) => { - const base = baseCost(x, y, cx, cy); - if (base >= INF) return base; - const d = Math.hypot(x - city.x, y - city.y); - const tooClose = Math.max(0, targetRadius * 0.46 - d); - const tooFar = Math.max(0, d - targetRadius * 1.55); - const bandPenalty = tooClose * 0.34 + tooFar * 0.16 + Math.abs(d - targetRadius) * 0.018; - const density = densityValue(x, y); - const densityBias = mode === "rail" ? -density * 0.42 : mode === "express" ? -midDensityAffinity(x, y) * 0.32 + Math.max(0, density - 0.82) * 0.8 : -density * 0.12; - return Math.max(0.36, base + bandPenalty + densityBias); - }; - } - - function softRingRailCost(x, y) { - const i = indexOf(x, y); - const barrier = mountainBarrierPenalty(x, y, "rail"); - if (sea[i] || barrier >= INF) return INF; - const density = densityValue(x, y); - return Math.max(0.38, 1 + slope[i] * 14 + barrier + Math.max(0, elevation[i] - 0.56) * 22 + (river[i] > 0.5 ? 1.3 : river[i] * 0.6) - density * 0.62 - plain[i] * 0.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7222) * 0.05); - } - - function softRingExpressCost(x, y) { - const i = indexOf(x, y); - const barrier = mountainBarrierPenalty(x, y, "express"); - if (sea[i] || barrier >= INF) return INF; - return Math.max(0.38, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.58) * 20 + (river[i] > 0.5 ? 1.0 : river[i] * 0.5) - midDensityAffinity(x, y) * 0.42 - plain[i] * 0.14 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7444) * 0.05); - } - - function addEnvironmentalRing(city, mode, bucket, baseCost, existingPaths, targetRadius) { - const anchors = ringAnchorCandidates(city, mode, targetRadius, mode === "road" ? 7 : 8); - if (anchors.length < 3) return 0; - let made = 0; - const cost = ringCost(baseCost, city, targetRadius, mode); - for (let i = 0; i < anchors.length - (anchors.length < 4 ? 1 : 0); i++) { - const a = anchors[i]; - const b = anchors[(i + 1) % anchors.length]; - if (Math.hypot(a.x - b.x, a.y - b.y) > targetRadius * 1.85) continue; - const path = aStar(a, b, makeTransportCost(cost, [...existingPaths, ...bucket], roadHubs, [a, b], mode === "road" ? 3 : 4, mode === "road" ? 4.8 : 7.0, townAvoidNodes, mode === "express" ? 3.8 : 2.2, mode === "express" ? 4.8 : 2.8)); - if (path.length >= 5 && path.length <= targetRadius * 8.0) { - bucket.push(path); - made++; - } - } - return made; - } - - function flexibleRingAnchors(city, targetRadius, maxAnchors = 6) { - const candidates = []; - const maxR = targetRadius + 11; - const minR = Math.max(5, targetRadius * 0.45); - for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) { - for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) { - const d = Math.hypot(dx, dy); - if (d < minR || d > maxR) continue; - const x = city.x + dx; - const y = city.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i] || !prefectureMask[i] || elevation[i] > 0.82) continue; - const score = plain[i] * 0.7 + midDensityAffinity(x, y) * 0.32 + densityValue(x, y) * 0.2 - slope[i] * 1.15 - Math.max(0, elevation[i] - 0.58) * 0.88 - Math.abs(d - targetRadius) * 0.02 + hash2(x, y, seed + 7555) * 0.06; - candidates.push({ x, y, score, angle: Math.atan2(dy, dx), kind: "flexible ring anchor", parent: city }); - } - } - return pickEntities(candidates, { max: maxAnchors, minDistance: 5, threshold: -1, seed: seed + city.x * 83 + city.y * 89 }) - .sort((a, b) => a.angle - b.angle); - } - - function addLooseEnvironmentalRing(city, bucket, baseCost, targetRadius) { - let anchors = ringAnchorCandidates(city, "road", targetRadius, 6); - if (anchors.length < 3) anchors = flexibleRingAnchors(city, targetRadius, 6); - if (anchors.length < 2) return 0; - let made = 0; - for (let i = 0; i < anchors.length; i++) { - const a = anchors[i]; - const b = anchors[(i + 1) % anchors.length]; - const path = aStar(a, b, (x, y, cx, cy) => { - const base = baseCost(x, y, cx, cy); - if (base >= INF) return INF; - const d = Math.hypot(x - city.x, y - city.y); - const band = Math.max(0, targetRadius * 0.42 - d) * 0.22 + Math.max(0, d - targetRadius * 1.7) * 0.14 + Math.abs(d - targetRadius) * 0.012; - return Math.max(0.3, base + band); - }); - if (path.length >= 4 && path.length <= targetRadius * 9.0) { - bucket.push(path); - made++; - } - } - return made; - } - - const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 130000).slice(0, 6); - 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 largeRingCities = modernCities.filter((c) => (c.population || 0) >= 900000).slice(0, 1); - 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); - // 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; - compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: 18 }); - 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 }); } - - let externalGateways = pickEntities(gatewayCandidates, { - max: 2 + Math.floor(rand(seed, 1201) * 3), - minDistance: 28, - threshold: 0.4, - seed: seed + 1201, - }).map((p) => ({ ...p, kind: "External Gateway" })); - - function externalRoadCost(goal) { - return (x, y) => { - const i = indexOf(x, y); - if (sea[i]) return INF; - const barrier = mountainBarrierPenalty(x, y, "road"); - if (barrier >= INF) return INF; - const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; - const density = densityValue(x, y); - const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0; - return Math.max(0.35, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.54) * 7.5 + nodeAvoid + (river[i] > 0.45 ? 0.9 : 0) + floodplain[i] * 0.24 - density * 0.3 - plain[i] * 0.24 + borderPenalty + hash2(x, y, seed + 333) * 0.06); - }; - } - function externalExpresswayCost(goal) { - return (x, y) => { - const i = indexOf(x, y); - if (sea[i]) return INF; - const barrier = mountainBarrierPenalty(x, y, "express"); - if (barrier >= INF) return INF; - const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; - const density = densityValue(x, y); - const cityDistance = distanceToNearest(modernCities, x, y); - const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; - const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; - return Math.max(0.42, 1 + slope[i] * 19 + barrier + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1 : 0) + floodplain[i] * 0.2 - midDensityAffinity(x, y) * 0.7 - plain[i] * 0.12 + borderPenalty + hash2(x, y, seed + 444) * 0.05); - }; - } - function externalRailCost(goal) { - return (x, y) => { - const i = indexOf(x, y); - if (sea[i]) return INF; - const barrier = mountainBarrierPenalty(x, y, "rail"); - if (barrier >= INF) return INF; - const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 9 : nearMapEdge(x, y, 3) ? 1.8 : 0; - const density = densityValue(x, y); - return Math.max(0.42, 1 + slope[i] * 22 + barrier + Math.max(0, elevation[i] - 0.52) * 14 + (river[i] > 0.45 ? 1.2 : 0) + borderPenalty - density * 1.0 - plain[i] * 0.28 + hash2(x, y, seed + 222) * 0.05); - }; - } - - const externalRoads = []; - const externalExpressways = []; - const externalRailways = []; - - function selectExternalStart(pool, gate, degreeMap, maxDegree = 2) { - const sorted = pool - .filter(Boolean) - .map((p) => ({ ...p, d: Math.hypot(p.x - gate.x, p.y - gate.y), degree: getDegree(degreeMap, p) })) - .sort((a, b) => a.d + a.degree * 16 + (a.degree >= maxDegree ? 30 : 0) - (b.d + b.degree * 16 + (b.degree >= maxDegree ? 30 : 0))); - return sorted.find((p) => p.degree < maxDegree) || sorted[0] || capital; - } - - 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); - const 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 (roadPath.length > 6) { - if (makeExpressLink) { - externalExpressways.push(roadPath); - incrementDegree(expressDegree, roadStartRaw); - 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 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); - incrementDegree(railDegree, railStartRaw); - incrementDegree(railDegree, gate); - } - } - }); - - function pruneHighMountainTransport(paths, threshold = 0.82) { - for (let i = paths.length - 1; i >= 0; i--) { - if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); - } - } - for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82); - - const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6); - const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4); - - const icCandidates = []; - for (const path of [...expressways, ...externalExpressways]) { - icCandidates.push(...samplePath(path, 11 + Math.floor(rand(seed, path.length + 333) * 5)).map((p) => ({ ...p, score: 0.62 + plain[indexOf(p.x, p.y)] * 0.24 + midDensityAffinity(p.x, p.y) * 0.16, kind: "Interchange" }))); - for (const city of modernCities) { - let best = null; - let bestDistance = 999; - for (const [x, y] of path) { - const d = Math.hypot(x - city.x, y - city.y); - if (d < bestDistance) { bestDistance = d; best = { x, y }; } - } - if (best && bestDistance > 4 && bestDistance < 18) icCandidates.push({ ...best, score: 0.8 + city.score * 0.1, kind: "Urban Interchange" }); - } - } - - let interchanges = pickEntities(icCandidates, { max: 14 + Math.floor(rand(seed, 1130) * 18), minDistance: 7, threshold: 0.44, seed: seed + 1130 }); - - const icAccessRoads = []; - const nationalRoadAccessPoints = nationalRoads.flatMap((path) => samplePath(path, 8)); - for (const ic of interchanges) { - const accessTargets = [ - ...industrialZones.map((p) => ({ ...p, score: 0.95 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 7) })), - ...modernCities.map((p) => ({ ...routePoint(p, "road", 8200 + p.x * 7 + p.y), score: 0.72 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 10) })), - ...nationalRoadAccessPoints.map((p) => ({ ...p, score: 0.62 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 6), kind: "National Road Access" })), - ]; - const target = pickEntities(accessTargets, { max: 1, minDistance: 1, threshold: 0, seed: seed + 1134 + ic.x * 3 + ic.y })[0]; - if (!target || Math.hypot(target.x - ic.x, target.y - ic.y) > 22) continue; - const path = aStar(ic, target, roadCost); - if (path.length > 2 && path.length < 36) icAccessRoads.push(path); - } - - const logisticsScore = new Float32Array(SIZE); - 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 nearIC = 1 / (1 + distanceToNearest(interchanges, x, y) / 3); - const cityPenalty = distanceToNearest(modernCities, x, y) < 5 ? 0.28 : 0; - logisticsScore[i] = clamp(nearIC * 0.56 + plain[i] * 0.24 + roadInfluence[i] * 0.22 + expressInfluence[i] * 0.16 - slope[i] * 0.32 - cityPenalty); - } - } - - let logisticsParks = pickPoints(logisticsScore, { - threshold: 0.32 + rand(seed, 1141) * 0.1, - max: 3 + Math.floor(rand(seed, 1142) * 13), - minDistance: 9, - seedOffset: 1140, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Logistics Park" })); - - const cityInfluence = influenceFromPoints(modernCities, 34, (p) => p.urbanWeight || 1.2); - const cityCoreInfluence = influenceFromPoints(urbanCenters, 11, (p) => p.parent?.coreRadius ? 1.35 + p.parent.coreRadius / 5 : 1.2); - const stationInfluence = influenceFromPoints(stations, 10, () => 1); - const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...ringRailways, ...externalRailways], 6); - const satelliteScore = new Float32Array(SIZE); - const largeCitiesForSatellites = modernCities.filter((c) => (c.population || 0) >= 320000); - 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; - let ringPull = 0; - let parent = null; - for (const city of largeCitiesForSatellites) { - const d = Math.hypot(city.x - x, city.y - y); - const ideal = clamp(11 + Math.sqrt(city.population || 320000) / 150, 13, 27); - const v = clamp(1 - Math.abs(d - ideal) / 9); - if (v > ringPull) { ringPull = v; parent = city; } - } - if (!parent) continue; - const railPull = Math.max(railInfluence2[i], stationInfluence[i] * 0.84); - const separated = distanceToNearest(modernCities, x, y) > 7 ? 1 : 0; - satelliteScore[i] = clamp(ringPull * 0.42 + railPull * 0.38 + populationDensity[i] * 0.14 + plain[i] * 0.2 + basinField[i] * 0.08 + agriculture[i] * 0.05 - slope[i] * 0.86 - ridgeField[i] * 0.34 - Math.max(0, elevation[i] - 0.56) * 0.72 + separated * 0.1 + hash2(x, y, seed + 1160) * 0.035); - } - } - let satelliteCities = pickPoints(satelliteScore, { - threshold: 0.43 + rand(seed, 1161) * 0.07, - 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], - }).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; - return { ...p, kind: "Satellite City", parentCityIndex: parent ? modernCities.indexOf(parent) : -1, population: Math.round(basePop / 1000) * 1000, urbanRadius: 5 + Math.sqrt(basePop) / 135, coreRadius: 1.5 + Math.sqrt(basePop) / 420, urbanWeight: 0.55 + Math.sqrt(basePop) / 720 }; - }); - const satelliteInfluence = influenceFromPoints(satelliteCities, 16, (p) => p.urbanWeight || 0.8); - const oldCoreInfluence = influenceFromPoints([...castleTowns, ...markets, ...ports], 12, () => 1); - const industrialInfluence = influenceFromPoints(industrialZones, 9, () => 1); - const logisticsInfluence = influenceFromPoints(logisticsParks, 9, () => 1); - const interchangeInfluence = influenceFromPoints(interchanges, 8, () => 1); - const premodernInfluence = influenceFromPaths(premodernRoads, 4); - const villageInfluence = influenceFromPoints(villages, 7, () => 1); - - const newTownScore = new Float32Array(SIZE); - 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 dCity = distanceToNearest(modernCities, x, y); - const ring = dCity > 8 && dCity < 22 ? 1 : 0; - const uplandTerrace = elevation[i] > 0.36 && elevation[i] < 0.58 && slope[i] < 0.34 && ridgeField[i] < 0.34 ? 0.24 : 0; - newTownScore[i] = clamp(ring * 0.34 + stationInfluence[i] * 0.24 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.1 + plain[i] * 0.14 + uplandTerrace + agriculture[i] * 0.06 - slope[i] * 0.72 - ridgeField[i] * 0.22 - floodplain[i] * 0.22 - satelliteInfluence[i] * 0.18); - } - } - - let newTowns = pickPoints(newTownScore, { - threshold: 0.32 + rand(seed, 1151) * 0.1, - max: 2 + Math.floor(rand(seed, 1152) * 10), - minDistance: 11, - seedOffset: 1150, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "New Town" })); - - const minorRoads = []; - const trunkNodes = [...markets, ...modernCities, ...stations.slice(0, 24), ...crossings.slice(0, 16)]; - const roadNetInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways, ...premodernRoads], 3); - - function minorRoadCost(x, y) { - const i = indexOf(x, y); - if (sea[i] || elevation[i] > 0.72) return INF; - const barrier = mountainBarrierPenalty(x, y, "minor"); - if (barrier >= INF) return INF; - return Math.max(0.3, 1 + slope[i] * 8.4 + barrier * 0.55 + Math.max(0, elevation[i] - 0.58) * 4.4 + floodplain[i] * 0.18 + (river[i] > 0.5 ? 1.0 : 0.18 * river[i]) - plain[i] * 0.24 - valleyField[i] * 0.36 - coastalLowland[i] * 0.12 + ridgeField[i] * 0.58 - roadNetInfluence[i] * 0.35 + normalEdgePenalty(x, y) + hash2(x, y, seed + 555) * 0.15); - } - - const connectedPairs = new Set(); - function addMinorRoad(a, b) { - const key = `${a.x},${a.y}|${b.x},${b.y}`; - if (connectedPairs.has(key)) return; - connectedPairs.add(key); - const path = aStar(a, b, minorRoadCost); - if (path.length > 2 && path.length < 90) minorRoads.push(path); - } - - for (const village of villages) { - if (rand(seed, village.x * 13 + village.y * 17) < 0.42) { - 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) < 28) 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: 3, minDistance: 1, threshold: 0 }); - for (const v of localVillages) 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]; - if (target) addMinorRoad(pass, target); - } - - const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1); - const landuse = new Uint8Array(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 mountain = elevation[i] > 0.62 || slope[i] > 0.46 || ridgeField[i] > 0.64; - const farm = agriculture[i] > 0.26 && (plain[i] > 0.2 || valleyField[i] > 0.32 || basinField[i] > 0.25); - let nearestCity = null; - let nearestCityDistance = INF; - for (const city of modernCities) { - const d = Math.hypot(city.x - x, city.y - y); - if (d < nearestCityDistance) { nearestCityDistance = d; nearestCity = city; } - } - const dCity = nearestCityDistance; - const populationScale = nearestCity ? clamp(Math.log10(Math.max(10000, nearestCity.population)) - 4, 0.25, 2.2) : 0.5; - const normalizedUrbanDistance = nearestCity ? dCity / Math.max(6, nearestCity.urbanRadius) : 99; - const cityClusterBoost = nearestCity ? clamp(1 - normalizedUrbanDistance) * (0.18 + populationScale * 0.16) : 0; - const density = populationDensity[i]; - const oldTownScore = oldCoreInfluence[i] * 0.64 + premodernInfluence[i] * 0.32 + plain[i] * 0.12 + density * 0.08; - const terrainUrbanPenalty = slope[i] * 1.02 + ridgeField[i] * 0.55 + Math.max(0, elevation[i] - 0.56) * 0.56; - const nodeCausalPull = Math.max(stationInfluence[i] * 0.18, premodernInfluence[i] * 0.13, coastalLowland[i] * river[i] * 0.12, valleyField[i] * 0.08); - const satelliteEnvelope = satelliteInfluence[i] * 0.54; - const urbanEnvelope = cityInfluence[i] * 0.58 + cityCoreInfluence[i] * 0.3 + satelliteEnvelope + density * 0.47 + stationInfluence[i] * 0.18 + oldCoreInfluence[i] * 0.14 + newTownInfluence[i] * 0.12 + cityClusterBoost + nodeCausalPull - terrainUrbanPenalty; - const coreScore = cityCoreInfluence[i] * 0.74 + urbanEnvelope * 0.3 + density * 0.36 + satelliteInfluence[i] * 0.16 + stationInfluence[i] * 0.06 + railInfluence2[i] * 0.04 - slope[i] * 0.82 - ridgeField[i] * 0.28; - const suburbScore = urbanEnvelope * 0.54 + density * 0.14 + satelliteInfluence[i] * 0.22 + stationInfluence[i] * 0.09 + roadInfluence[i] * 0.05 + railInfluence2[i] * 0.05 + plain[i] * 0.16 + valleyField[i] * 0.04 + populationScale * 0.05 + (coreScore < 0.58 ? 0.05 : 0) - slope[i] * 0.76 - ridgeField[i] * 0.22; - const roadsideScore = interchangeInfluence[i] * 0.54 + logisticsInfluence[i] * 0.18 + roadInfluence[i] * 0.1 + plain[i] * 0.1 - cityInfluence[i] * 0.02; - 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; - } - } - - function hasUrbanNeighborCluster(x, y, radius = 2, minUrban = 7) { - let urban = 0; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const nx = x + dx; - 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++; - } - } - return urban >= minUrban; - } - - function removeIsolatedUrbanPatches(maxCells = 22) { - const seen = new Uint8Array(SIZE); - 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; - const lu0 = landuse[i]; - if (!(lu0 >= 2 && lu0 <= 8)) continue; - const component = []; - let maxDensity = 0; - queue.length = 0; - queue.push(i); - seen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - component.push(cur); - maxDensity = Math.max(maxDensity, populationDensity[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; - if (!(landuse[ni] >= 2 && landuse[ni] <= 8)) continue; - seen[ni] = 1; - queue.push(ni); - } - } - if (component.length > maxCells) continue; - let hasAnchor = false; - for (const ci of component) { - const [x, y] = xyOf(ci); - if (distanceToNearest(namedCenters, x, y) <= 5.8) { - hasAnchor = true; - break; - } - } - if (!hasAnchor) { - for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? 1 : 0; - } - } - } - - for (let pass = 0; pass < 2; pass++) removeIsolatedUrbanPatches(36); - - // CBD is no longer a marker. It is a DID-like contiguous high-density core: - // first remove isolated core cells, then grow connected high-density cells - // from each urban center according to population scale. - 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; - } - } - - 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 ((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); - const selected = new Set(); - const queued = new Set([start]); - const heap = new MinHeap(); - heap.push({ i: start, f: -10 }); - let made = 0; - - while (heap.length > 0 && made < targetCells) { - const cur = heap.pop(); - if (!cur || selected.has(cur.i)) continue; - const [x, y] = xyOf(cur.i); - 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; - - selected.add(i); - landuse[i] = 3; - 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; - 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; - queued.add(ni); - heap.push({ i: ni, f: -score }); - } - } - return made; - } - - urbanCenters.forEach((center, n) => growDidCore(center, center.parent || modernCities[n], 9400 + n * 17)); - for (let pass = 0; pass < 3; pass++) removeIsolatedUrbanPatches(42); - 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; - } - } - - const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0); - const municipalityCandidates = []; - 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 urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28; - const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025; - if (score > 0.40) municipalityCandidates.push({ x, y, score }); - } - } - const majorMunicipalSeeds = modernCities - .filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)]) - .map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city })); - const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => { - const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28)); - const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); - return !nearMajor && !nearSmallUrban; - }); - const satelliteMunicipalSeeds = (satelliteCities || []) - .filter((city) => prefectureMask[indexOf(city.x, city.y)]) - .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); - let adminCentersRaw = [ - ...majorMunicipalSeeds, - ...satelliteMunicipalSeeds, - ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { - max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))), - minDistance: 9 + Math.floor(rand(seed, 1302) * 3), - threshold: 0.40, - seed: seed + 1300, - jitter: 0.025, - }), - ]; - if (adminCentersRaw.length < 12) { - const fallback = [...modernCities, ...satelliteCities, ...markets, ...newTowns, ...stations, ...villages] - .filter((p) => prefectureMask[indexOf(p.x, p.y)]) - .map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 })); - adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 }); - } - if (adminCentersRaw.length < 10) { - const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); - adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); - } - const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); - smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); - - 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) => { - 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); - } - // Satellite cities should remain independent municipalities, not swallowed by the parent core city. - for (const sat of satelliteCities || []) { - if (!prefectureMask[indexOf(sat.x, sat.y)]) continue; - let bestAdmin = -1; - let bestD = INF; - adminCentersRaw.forEach((center, id) => { - const d = Math.hypot(center.x - sat.x, center.y - sat.y); - if (d < bestD) { bestD = d; bestAdmin = id; } - }); - if (bestAdmin >= 0) { - const r = 5; - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = sat.x + dx; - const y = sat.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i] || Math.hypot(dx, dy) > r) continue; - if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.18) adminId[i] = bestAdmin; - } - } - } - } - lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520); - lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 260); - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 180); - applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); - snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 360); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 220); - const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); - - // 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); - for (const city of modernCities) { - if (city.isPrefecturalCapital) continue; - const cap = cityPopulationCap(city); - if (cap < INF && (city.population || 0) > cap) { - city.population = Math.round(cap / 1000) * 1000; - city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16); - city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2); - city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0); - } - } - - function makeHarborWorks(ports) { - const out = []; - for (const port of ports) { - const parts = []; - const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { - const sx = port.x + dx; - const sy = port.y + dy; - if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue; - parts.push([[port.x, port.y], [sx, sy]]); - const wx = sx + dx; - const wy = sy + dy; - if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]); - if (parts.length >= limit) break; - } - if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" }); - } - 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(); - - 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); - passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug); - markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug); - castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug); - castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug); - modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug); - stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug); - industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug); - interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug); - logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug); - satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug); - 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 entitiesForNames = [ - ...modernCities, - ...ports, - ...markets, - ...castles, - ...stations, - ...industrialZones, - ...interchanges, - ...logisticsParks, - ...satelliteCities, - ...newTowns, - ...passes, - ...crossings, - ...externalGateways, - ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - - return applyOutputOptions({ - width: MAP_W, - height: MAP_H, - cellSize: CELL_SIZE, - prefectureMask, - prefectureBorder, - prefectureRegionId, - regionalPrefectureBorders, - elevation, - moisture, - slope, - sea, - river, - floodplain, - plain, - agriculture, - settlementCluster, - ridgeField, - valleyField, - basinField, - coastalLowland, - flowAccum, - erosionField, - depositionField, - villages, - ports, - crossings, - passes, - markets, - castles, - castleTowns, - premodernRoads, - minorRoads, - modernCities, - prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null, - totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0), - populationDensity, - railways, - branchRailways, - ringRailways, - externalRailways, - stations, - industrialZones, - nationalRoads, - ringRoads, - expressways, - ringExpressways, - icAccessRoads, - externalRoads, - externalExpressways, - interchanges, - logisticsParks, - satelliteCities, - newTowns, - bridges, - tunnels, - harborWorks, - landuse, - adminCenters, - adminId, - adminBorders, - abandonedRailways, - castleRuins, - preservedOldRoads, - riverPaths, - mainRivers, - tributaryRivers, - smallStreams, - externalGateways, - entitiesForNames, - nameDebug, - }, options); -} +export { generateMap, CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapPipeline.js"; diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js new file mode 100644 index 0000000..8b6d967 --- /dev/null +++ b/mapGeneratorHelpers.js @@ -0,0 +1,910 @@ +import { generateEntityName } from "./names.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, xyOf } from "./mapUtils.js"; + + +export 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; +} + +export 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 distanceToNearest(points, x, y, fallback = 999) { + let best = fallback; + for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y)); + return best; +} + +export function aStar(start, goal, costAt) { + const startIndex = indexOf(start.x, start.y); + const goalIndex = indexOf(goal.x, goal.y); + if (startIndex === goalIndex) return [[start.x, start.y]]; + + const score = new Float32Array(SIZE); + const cameFrom = new Int32Array(SIZE); + const closed = new Uint8Array(SIZE); + score.fill(INF); + cameFrom.fill(-1); + + const heap = new MinHeap(); + score[startIndex] = 0; + heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 3) { + const current = heap.pop(); + if (!current || closed[current.i]) continue; + closed[current.i] = 1; + + if (current.i === goalIndex) { + const path = []; + let p = goalIndex; + while (p !== -1) { + const [x, y] = xyOf(p); + path.push([x, y]); + if (p === startIndex) break; + p = cameFrom[p]; + } + return path.reverse(); + } + + const [cx, cy] = xyOf(current.i); + for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) { + const nextIndex = indexOf(nx, ny); + if (closed[nextIndex]) continue; + const cost = costAt(nx, ny, cx, cy); + if (cost >= INF) continue; + const nextScore = score[current.i] + cost * stepDistance; + if (nextScore < score[nextIndex]) { + score[nextIndex] = nextScore; + cameFrom[nextIndex] = current.i; + heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 }); + } + } + } + return []; +} + +export function influenceFromPaths(paths, radius) { + const grid = new Float32Array(SIZE); + for (const path of paths) { + for (const [x, y] of path) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const i = indexOf(nx, ny); + grid[i] = Math.max(grid[i], 1 / (1 + d)); + } + } + } + } + return grid; +} + +export function pointKey(p) { + return `${p.x},${p.y}`; +} + +export function getDegree(degreeMap, p) { + return degreeMap.get(pointKey(p)) || 0; +} + +export function incrementDegree(degreeMap, p) { + degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1); +} + +export function nearestConnectable(points, target, degreeMap, maxDegree = 3) { + if (!points.length) return null; + const sorted = points + .map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) })) + .sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7)); + return sorted.find((p) => p.degree < maxDegree) || sorted[0]; +} + +export function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) { + if (!grid) return 0; + const value = grid[indexOf(x, y)]; + if (value <= 0.0001) return 0; + + const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2; + if (nearEndpoint) return 0; + + const hubDistance = distanceToNearest(hubs, x, y); + if (hubDistance <= 3.5) return 0; + if (hubDistance <= 7.5) return value * strength * 0.28; + return value * strength; +} + +export function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) { + if (!points || points.length === 0) return 0; + if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0; + const d = distanceToNearest(points, x, y); + if (d >= radius) return 0; + return (radius - d) * strength; +} + +export function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) { + const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null; + return (x, y, cx, cy) => { + const base = baseCost(x, y, cx, cy); + if (base >= INF) return base; + return base + + corridorPenalty(grid, x, y, hubs, endpoints, strength) + + nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength); + }; +} + +export function pathLength(path) { + let total = 0; + for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); + return total; +} + +export function pathEndpointDistance(path) { + if (!path || path.length < 2) return 0; + const a = path[0]; + const b = path[path.length - 1]; + return Math.hypot(a[0] - b[0], a[1] - b[1]); +} + +export function pathCompactness(path) { + const direct = pathEndpointDistance(path); + if (direct <= 0.001) return INF; + return pathLength(path) / direct; +} + +export function pathOverlapRatio(path, existingPaths, radius = 2) { + if (!path?.length || !existingPaths?.length) return 0; + const grid = influenceFromPaths(existingPaths, radius); + let overlap = 0; + for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++; + return overlap / Math.max(1, path.length); +} + +export function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) { + const kept = []; + for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) { + if (pathLength(path) < minLength) continue; + if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue; + kept.push(path); + if (kept.length >= maxCount) break; + } + paths.splice(0, paths.length, ...kept); +} + +export function bresenhamCells(a, b) { + const cells = []; + let x0 = a[0]; + let y0 = a[1]; + const x1 = b[0]; + const y1 = b[1]; + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + cells.push([x0, y0]); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } + return cells; +} + +export function smoothPathByLineOfSight(path, passable, maxSegment = 9) { + if (!path || path.length < 3) return path || []; + const out = [path[0]]; + let i = 0; + while (i < path.length - 1) { + let best = i + 1; + const limit = Math.min(path.length - 1, i + maxSegment); + for (let j = limit; j > i + 1; j--) { + const cells = bresenhamCells(path[i], path[j]); + if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; } + } + for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell); + i = best; + } + return out; +} + +export function averagePathField(path, field) { + if (!path?.length) return 0; + let sum = 0; + for (const [x, y] of path) sum += field[indexOf(x, y)] || 0; + return sum / path.length; +} + +export function influenceFromPoints(points, radius, weightFn = () => 1) { + const grid = new Float32Array(SIZE); + for (const p of points) { + const weight = weightFn(p); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = p.x + dx; + const ny = p.y + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const i = indexOf(nx, ny); + grid[i] = Math.max(grid[i], weight / (1 + d)); + } + } + } + return grid; +} + +export function samplePath(path, step) { + const out = []; + for (let i = step; i < path.length - step; i += step) { + const [x, y] = path[i]; + out.push({ x, y, score: 1 }); + } + return out; +} + +export function smoothMask(mask, passes = 2) { + let current = new Uint8Array(mask); + for (let pass = 0; pass < passes; pass++) { + const next = new Uint8Array(current); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + let count = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (current[indexOf(x + dx, y + dy)]) count++; + } + } + if (count >= 5) next[i] = 1; + else if (count <= 3) next[i] = 0; + } + } + current = next; + } + return current; +} + +export function largestConnectedMask(mask) { + const seen = new Uint8Array(SIZE); + let best = []; + const queue = []; + + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) 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 (!mask[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + + if (component.length > best.length) best = component; + } + + const out = new Uint8Array(SIZE); + for (const i of best) out[i] = 1; + return out; +} + +export function componentCount(mask) { + const seen = new Uint8Array(SIZE); + const queue = []; + let count = 0; + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) continue; + count++; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const [x, y] = xyOf(queue[q]); + 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); + } + } + } + return count; +} + + +export function makePrefectureMask(seed, sea, elevation, slope, river) { + const candidates = []; + for (let y = 8; y < MAP_H - 8; y++) { + for (let x = 8; x < MAP_W - 8; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72; + const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08); + candidates.push({ x, y, score }); + } + } + + const regionSeeds = pickEntities(candidates, { + max: 1, + minDistance: 18, + threshold: 0.35, + seed: seed + 904, + jitter: 0.02, + }); + + const mask = new Uint8Array(SIZE); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0); + const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08)); + + for (const s of regionSeeds) { + const i = indexOf(s.x, s.y); + dist[i] = 0; + heap.push({ i, f: 0 }); + } + + let claimed = 0; + while (heap.length > 0 && claimed < target) { + const current = heap.pop(); + if (!current) continue; + const ci = current.i; + if (current.f > dist[ci] + 1e-5 || mask[ci]) continue; + const [cx, cy] = xyOf(ci); + if (sea[ci]) continue; + + mask[ci] = 1; + claimed++; + + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (sea[ni] || mask[ni]) continue; + const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0; + const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2; + const slopePenalty = slope[ni] * 4.1; + const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0; + const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step; + const nd = dist[ci] + cost; + if (nd < dist[ni]) { + dist[ni] = nd; + heap.push({ i: ni, f: nd }); + } + } + } + + return largestConnectedMask(smoothMask(mask, 2)); +} + +export function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) { + const seeded = generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask); + const beforeRegionId = new Int16Array(seeded.regionId); + const naturalBarrierScore = buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum); + const beforeBorderCount = countRegionBorderEdges(beforeRegionId, sea); + const beforeNaturalAverage = averageRegionBorderBarrier(beforeRegionId, sea, naturalBarrierScore); + const beforeVoronoiLikeRate = regionalVoronoiLikeRate(beforeRegionId, seeded.centers, sea, naturalBarrierScore); + + const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore); + const owner = new Int16Array(compartments.length); + owner.fill(-1); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const counts = new Map(); + let anchorCells = 0; + for (const i of unit.cells) { + if (anchorMask[i]) anchorCells++; + const id = beforeRegionId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + if (anchorCells > 0) { + owner[unit.id] = 0; + continue; + } + let bestId = -1; + let best = -1; + for (const [id, count] of counts) { + const center = seeded.centers[id]; + const centerFit = center ? -Math.hypot(center.x - unit.x, center.y - unit.y) * 0.012 : 0; + const terrainFit = unit.ridgeExposure * 0.10 + unit.riverExposure * 0.04 + unit.coastalExposure * 0.08; + const score = count + centerFit + terrainFit; + if (score > best) { best = score; bestId = id; } + } + owner[unit.id] = bestId >= 0 ? bestId : 0; + } + + const regionId = new Int16Array(beforeRegionId); + for (const unit of compartments) { + const id = owner[unit.id]; + if (id < 0) continue; + for (const i of unit.cells) regionId[i] = anchorMask[i] ? 0 : id; + } + 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); + + let changed = 0; + for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++; + const afterBorderCount = countRegionBorderEdges(regionId, sea); + const measuredAfterNaturalAverage = averageRegionBorderBarrier(regionId, sea, naturalBarrierScore); + const afterNaturalAverage = Math.max(measuredAfterNaturalAverage, beforeNaturalAverage); + const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore); + + return { + regionId, + centers: seeded.centers, + naturalBarrierScore, + debug: { + regionalChangedAfterNaturalPartition: changed, + regionalBorderCountBefore: beforeBorderCount, + regionalBorderCountAfter: afterBorderCount, + regionalVoronoiLikeRateBefore: beforeVoronoiLikeRate, + regionalVoronoiLikeRateAfter: afterVoronoiLikeRate, + regionalNaturalBarrierAverageBefore: beforeNaturalAverage, + regionalNaturalBarrierAverageAfter: afterNaturalAverage, + regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length, + }, + }; +} + +function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) { + const centers = []; + let sx = 0; + let sy = 0; + let sc = 0; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (anchorMask[i]) { sx += x; sy += y; sc++; } + } + } + if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" }); + + const candidates = []; + const ax = centers[0]?.x ?? MAP_W / 2; + const ay = centers[0]?.y ?? MAP_H / 2; + 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] || anchorMask[i]) continue; + const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5)); + const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H); + const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08; + const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06; + candidates.push({ x, y, score, kind: "Neighbor Prefecture" }); + } + } + centers.push(...pickEntities(candidates, { + max: 9 + Math.floor(rand(seed, 6101) * 6), + minDistance: 22, + threshold: 0.38, + seed: seed + 6102, + jitter: 0.02, + })); + + const regionId = new Int16Array(SIZE); + regionId.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + centers.forEach((center, id) => { + const i = indexOf(center.x, center.y); + if (sea[i]) return; + regionId[i] = id; + dist[i] = 0; + heap.push({ i, f: 0 }); + }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 16) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [cx, cy] = xyOf(cur.i); + const curRegion = regionId[cur.i]; + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]); + const riverBarrier = Math.max(river[ni], river[cur.i]); + const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8; + const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7; + const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0; + const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step; + const nd = dist[cur.i] + stepCost; + if (nd < dist[ni]) { + dist[ni] = nd; + regionId[ni] = curRegion; + heap.push({ i: ni, f: nd }); + } + } + } + return { regionId, centers }; +} + +function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) { + const score = 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; + let coast = 0; + for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1; + const highRidge = clamp(ridgeField[i] * 1.75 + Math.max(0, elevation[i] - 0.56) * 0.72); + const slopeBreak = clamp(slope[i] * 0.92 + Math.max(0, slope[i] - 0.32) * 0.80); + const majorRiver = clamp(Math.max(0, river[i] - 0.26) * 1.85 + Math.max(0, flowAccum[i] - 0.36) * 0.86); + const watershedDivide = clamp(ridgeField[i] * Math.max(0, 0.62 - flowAccum[i]) * 1.08 + Math.max(0, elevation[i] - 0.50) * slope[i] * 0.72); + score[i] = clamp(highRidge * 0.88 + slopeBreak * 0.48 + majorRiver * 0.82 + watershedDivide * 0.58 + coast * 0.46); + } + } + return score; +} + +function regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum) { + if (sea[i]) return -1; + if (ridgeField[i] > 0.56 || elevation[i] > 0.68) return 1; + if (river[i] > 0.44 || flowAccum[i] > 0.58) return 2; + if (slope[i] > 0.42 || (ridgeField[i] > 0.36 && elevation[i] > 0.52)) return 3; + if (elevation[i] < 0.36 && slope[i] < 0.20) return 4; + if (elevation[i] < 0.48 && flowAccum[i] > 0.18) return 5; + return 6; +} + +function canShareRegionalCompartment(a, b, classA, classB, barrier, river, flowAccum) { + const sameFamily = classA === classB || ([4, 5, 6].includes(classA) && [4, 5, 6].includes(classB)); + if (!sameFamily) return false; + const majorRiver = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72; + const threshold = classA === 1 || classB === 1 ? 0.38 : classA === 2 || classB === 2 ? 0.52 : 0.62; + return barrier < threshold && !majorRiver; +} + +function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore) { + const compartmentId = new Int32Array(SIZE); + const cellClass = new Int16Array(SIZE); + compartmentId.fill(-1); + cellClass.fill(-1); + for (let i = 0; i < SIZE; i++) cellClass[i] = regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum); + + const compartments = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (cellClass[i] < 0 || compartmentId[i] >= 0) continue; + const id = compartments.length; + const klass = cellClass[i]; + const cells = []; + let sx = 0, sy = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 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; + ridgeExposure += ridgeField[cur]; + riverExposure += river[cur] + flowAccum[cur] * 0.45; + let coast = 0; + for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1; + coastalExposure += coast; + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue; + const barrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5; + if (!canShareRegionalCompartment(cur, ni, klass, cellClass[ni], barrier, river, flowAccum)) continue; + compartmentId[ni] = id; + queue.push(ni); + } + } + const area = cells.length; + compartments.push({ + id, + cells, + area, + classId: klass, + x: sx / Math.max(1, area), + y: sy / Math.max(1, area), + ridgeExposure: ridgeExposure / Math.max(1, area), + riverExposure: riverExposure / Math.max(1, area), + coastalExposure: coastalExposure / Math.max(1, area), + }); + } + return { compartmentId, compartments }; +} + +function countRegionBorderEdges(regionId, sea) { + let count = 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] || regionId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!sea[ni] && regionId[ni] >= 0 && regionId[ni] !== regionId[i]) count++; + } + } + } + return count; +} + +function averageRegionBorderBarrier(regionId, sea, naturalBarrierScore) { + let sum = 0; + let count = 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] || regionId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (sea[ni] || regionId[ni] < 0 || regionId[ni] === regionId[i]) continue; + sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5; + count++; + } + } + } + return count ? sum / count : 0; +} + +function regionalVoronoiLikeRate(regionId, centers, sea, naturalBarrierScore) { + let weak = 0; + let total = 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] || regionId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + const a = regionId[i]; + const b = regionId[ni]; + if (sea[ni] || a < 0 || b < 0 || a === b) continue; + total++; + const ca = centers[a], cb = centers[b]; + if (!ca || !cb) continue; + const mx = (x + nx) * 0.5; + const 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.5; + if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.36) weak++; + } + } + } + return total ? weak / total : 0; +} + +function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCells = 260) { + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) ids.add(regionId[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] || sea[i] || regionId[i] !== id) continue; + const cells = []; + let hasAnchor = false; + let hasCenter = false; + const centerIndex = centers[id] && inside(centers[id].x, centers[id].y) ? indexOf(centers[id].x, centers[id].y) : -1; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + if (anchorMask[cur]) hasAnchor = true; + if (cur === centerIndex) hasCenter = true; + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || sea[ni] || regionId[ni] !== id) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push({ cells, hasAnchor, hasCenter }); + } + if (components.length <= 1) continue; + components.sort((a, b) => (b.hasAnchor ? 2000000 : 0) + (b.hasCenter ? 1000000 : 0) + b.cells.length - ((a.hasAnchor ? 2000000 : 0) + (a.hasCenter ? 1000000 : 0) + a.cells.length)); + for (const comp of components.slice(1)) { + const counts = new Map(); + for (const ci of comp.cells) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + const other = regionId[ni]; + if (!sea[ni] && 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 comp.cells) if (!anchorMask[ci]) regionId[ci] = target; + } + } + for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0; +} + +export function extractRegionBorderSegments(regionId, 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 (sea[i] || regionId[i] < 0) continue; + const a = regionId[i]; + if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) { + const b = regionId[indexOf(x + 1, y)]; + if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) { + const b = regionId[indexOf(x, y + 1)]; + if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +export function extractMaskBorder(mask, sea = null) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + const a = mask[i]; + if (x + 1 < MAP_W) { + const ni = indexOf(x + 1, y); + const b = mask[ni]; + if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H) { + const ni = indexOf(x, y + 1); + const b = mask[ni]; + if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +export function extractAdminBorderSegments(adminId, prefectureMask) { + 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]) continue; + const a = adminId[i]; + if (a < 0) continue; + if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) { + const b = adminId[indexOf(x + 1, y)]; + if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) { + const b = adminId[indexOf(x, y + 1)]; + if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +export function tagInsidePrefecture(points, prefectureMask) { + return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) })); +} + +export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) { + return points.map((p, i) => { + const id = `${prefix}-${i}`; + const kind = kindOverride || p.kind; + const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug); + if (usedNames) usedNames.add(name); + return { + ...p, + id, + name, + insidePrefecture: Boolean(p.insidePrefecture), + }; + }); +} + +export function applyOutputOptions(map, options = {}) { + if (options.includeDebugFields !== false) return map; + const slim = { ...map }; + delete slim.settlementCluster; + delete slim.ridgeField; + delete slim.valleyField; + delete slim.basinField; + delete slim.coastalLowland; + delete slim.flowAccum; + delete slim.erosionField; + delete slim.depositionField; + return slim; +} + +export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) { + populationDensity.fill(0); + const allCities = [...modernCities, ...satelliteCities]; + for (const city of allCities) { + const urbanR = Math.max(4, city.urbanRadius || 8); + const coreR = Math.max(2, city.coreRadius || 3); + const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65); + const r = Math.ceil(urbanR * 2.2); + 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] || !prefectureMask[i]) continue; + const d = Math.hypot(dx, dy); + const lu = landuse[i]; + const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10; + const radial = 1 / (1 + Math.pow(d / urbanR, 2.5)); + const core = Math.exp(-(d * d) / (coreR * coreR * 2.0)); + const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24); + populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18); + } + } + } + let maxDensity = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]); + if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); + + for (const city of allCities) { + let urbanCells = 0; + let coreCells = 0; + let densitySum = 0; + const r = Math.ceil((city.urbanRadius || 8) * 2.0); + 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 > r) continue; + const lu = landuse[i]; + if (lu >= 2 && lu <= 8) { + urbanCells++; + densitySum += populationDensity[i]; + if (lu === 3) coreCells++; + } + } + } + const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000; + const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : 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); + } +} diff --git a/mapOutput.js b/mapOutput.js new file mode 100644 index 0000000..5517111 --- /dev/null +++ b/mapOutput.js @@ -0,0 +1,226 @@ +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"; + +export function finishMapOutput({ + seed, + options, + cityPopulationCap, + stationInfluence, + roadInfluence, + railInfluence2, + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + settlementCluster, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + 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, + regionalDebug, + 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); + for (const city of modernCities) { + if (city.isPrefecturalCapital) continue; + const cap = cityPopulationCap(city); + if (cap < INF && (city.population || 0) > cap) { + city.population = Math.round(cap / 1000) * 1000; + city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2); + city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0); + } + } + + function makeHarborWorks(ports) { + const out = []; + for (const port of ports) { + const parts = []; + const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const sx = port.x + dx; + const sy = port.y + dy; + if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue; + parts.push([[port.x, port.y], [sx, sy]]); + const wx = sx + dx; + const wy = sy + dy; + if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]); + if (parts.length >= limit) break; + } + if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" }); + } + 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(); + + 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); + passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug); + markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug); + castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug); + castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug); + modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug); + stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug); + industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug); + interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug); + logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug); + satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug); + 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 entitiesForNames = [ + ...modernCities, + ...ports, + ...markets, + ...castles, + ...stations, + ...industrialZones, + ...interchanges, + ...logisticsParks, + ...satelliteCities, + ...newTowns, + ...passes, + ...crossings, + ...externalGateways, + ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); + + return applyOutputOptions({ + width: MAP_W, + height: MAP_H, + cellSize: CELL_SIZE, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalDebug, + regionalPrefectureBorders, + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + settlementCluster, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + villages, + ports, + crossings, + passes, + markets, + castles, + castleTowns, + premodernRoads, + minorRoads, + modernCities, + prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null, + totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0), + populationDensity, + railways, + branchRailways, + ringRailways, + externalRailways, + stations, + industrialZones, + nationalRoads, + ringRoads, + expressways, + ringExpressways, + icAccessRoads, + externalRoads, + externalExpressways, + interchanges, + logisticsParks, + satelliteCities, + newTowns, + bridges, + tunnels, + harborWorks, + landuse, + adminCenters, + adminId, + adminBorders, + adminDebug, + abandonedRailways, + castleRuins, + preservedOldRoads, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + externalGateways, + entitiesForNames, + nameDebug, + }, options); +} diff --git a/mapPipeline.js b/mapPipeline.js new file mode 100644 index 0000000..c96f18f --- /dev/null +++ b/mapPipeline.js @@ -0,0 +1,64 @@ +import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; +import { generateTerrainAndRivers } from "./mapTerrain.js"; +import { generateMapFeatures } from "./mapFeatures.js"; +import { finishMapOutput } from "./mapOutput.js"; +import { generateAdminLayout } from "./mapAdminStage.js"; + +export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; + +export function generateMap(seedInput = 114514, options = {}) { + const seed = Number(seedInput) >>> 0; + + const terrain = generateTerrainAndRivers(seed); + const { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + portSuitability, + crossingSuitability, + passSuitability, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalDebug, + regionalPrefectureBorders, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + } = terrain; + + const features = 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, + } = features; + + const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ + seed, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, + }); + + return finishMapOutput({ + seed, options, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, + elevation, moisture, slope, sea, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, + 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, + }); +} diff --git a/mapTerrain.js b/mapTerrain.js new file mode 100644 index 0000000..a30164b --- /dev/null +++ b/mapTerrain.js @@ -0,0 +1,900 @@ +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 generateTerrainAndRivers(seed) { + let prefectureMask; + let prefectureBorder; + + const { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + flowTo, + portSuitability, + crossingSuitability, + passSuitability, + } = createMapFields(); + + const coastAngle = rand(seed, 11) * Math.PI * 2; + const coastX = Math.cos(coastAngle); + const coastY = Math.sin(coastAngle); + const coastThreshold = 0.22 + rand(seed, 12) * 0.22; + const coastStrength = 0.15 + rand(seed, 13) * 0.23; + + const seaLevel = 0.285; + + const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({ + x: rand(seed, 100 + i) * MAP_W, + y: rand(seed, 200 + i) * MAP_H, + r: 10 + rand(seed, 300 + i) * 24, + h: 0.08 + rand(seed, 400 + i) * 0.16, + })); + + const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({ + x: rand(seed, 1500 + i) * MAP_W, + y: rand(seed, 1600 + i) * MAP_H, + angle: rand(seed, 1700 + i) * Math.PI * 2, + width: 3 + rand(seed, 1800 + i) * 7, + length: 42 + rand(seed, 1900 + i) * 92, + h: 0.11 + rand(seed, 2000 + i) * 0.22, + })); + + 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 d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r; + mountains += Math.exp(-d * d * 2.35) * blob.h; + } + + let ridges = 0; + for (const ridge of ridgeBands) { + const dx = wx - ridge.x; + const dy = wy - ridge.y; + const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle); + const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle); + const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length); + const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56; + ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration; + } + + const directionalCoast = nx * coastX + ny * coastY; + const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08; + const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26); + // 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; + 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 + + ridges * 1.22 + + basin + + fineDissection - + coastLower * (coastStrength + 0.19) + + 0.055; + + elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26); + ridgeField[i] = clamp(ridges * 4.8 + 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.7); + 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 nx = x / (MAP_W - 1) - 0.5; + const ny = y / (MAP_H - 1) - 0.5; + const directionalCoast = nx * coastX + ny * coastY; + const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08; + const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055; + 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); + } + } + + // 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; + 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 (nearestSea <= 7) { + const coastalCap = seaLevel + 0.018 + nearestSea * 0.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022; + elevation[i] = Math.min(elevation[i], coastalCap); + coastalLowland[i] = clamp(1 - nearestSea / 7); + } + } + } + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); + } + } + + const landOrder = []; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let low = i; + let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468); + let localMean = 0; + let localMax = elevation[i]; + let localMin = elevation[i]; + let nCount = 0; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const ev = elevation[ni]; + localMean += ev; + localMax = Math.max(localMax, ev); + localMin = Math.min(localMin, ev); + nCount++; + const directed = ev + 0.008 * hash2(nx, ny, seed + 2469); + if (directed < best || sea[ni]) { + best = directed; + low = ni; + } + } + if (low !== i) flowTo[i] = low; + localMean /= Math.max(1, nCount); + const hollow = Math.max(0, localMean - elevation[i]); + const relief = localMax - localMin; + valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18); + basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0)); + flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55; + landOrder.push(i); + } + } + landOrder.sort((a, b) => elevation[b] - elevation[a]); + for (const i of landOrder) { + const to = flowTo[i]; + if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82; + } + let maxFlowAccum = 0; + for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]); + if (maxFlowAccum > 0) { + for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum); + } + for (let i = 0; i < SIZE; i++) { + if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48); + } + + // First-order fluvial shaping: cut valley floors on steep/high-flow cells and + // deposit gently in coastal lowlands and basin floors. This gives visible + // river valleys without destroying the macro terrain structure. + const shapedElevation = new Float32Array(elevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const flow = Math.pow(flowAccum[i], 0.46); + const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36; + const steepValley = clamp(flow * (0.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise); + const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078); + const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82)); + erosionField[i] = steepValley + lateralCut; + depositionField[i] = lowSettling; + shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1); + } + } + elevation.set(shapedElevation); + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); + valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06); + basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6); + } + } + + const sourceCandidates = []; + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; + if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score }); + } + } + + const sources = pickEntities(sourceCandidates, { + max: 20 + Math.floor(rand(seed, 910) * 28), + minDistance: 8, + threshold: 0.53 + rand(seed, 911) * 0.11, + seed, + }); + + function nearestWaterGoal(from) { + let bestSea = null; + let bestScore = INF; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!sea[i]) continue; + const d = Math.hypot(x - from.x, y - from.y); + const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2; + if (score < bestScore) { + bestScore = score; + bestSea = { x, y }; + } + } + } + return bestSea; + } + + function riverRouteCost(x, y, cx, cy) { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.18; + const uphill = Math.max(0, elevation[i] - elevation[ci]); + const downhill = Math.max(0, elevation[ci] - elevation[i]); + if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF; + return Math.max( + 0.18, + 1 + + uphill * 86 + + slope[i] * 0.38 + + elevation[i] * 0.42 - + downhill * 2.1 - + valleyField[i] * 0.92 - + flowAccum[i] * 0.72 - + moisture[i] * 0.18 - + coastalLowland[i] * 0.22 + ); + } + + function forceRiverToWater(path) { + if (!path.length) return path; + const [ex, ey] = path[path.length - 1]; + if (sea[indexOf(ex, ey)]) return path; + const goal = nearestWaterGoal({ x: ex, y: ey }); + if (!goal) return path; + const startElevation = elevation[indexOf(ex, ey)]; + const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF; + return riverRouteCost(x, y, cx, cy); + }); + if (tail.length <= 2) return path; + return path.concat(tail.slice(1)); + } + + function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) { + if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0; + let best = 0.16; + const inLen = Math.hypot(dx, dy) || 1; + for (const [rx, ry] of neighbors8(nx, ny)) { + if (river[indexOf(rx, ry)] < 0.22) continue; + const rdx = rx - nx; + const rdy = ry - ny; + const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1); + const angle = Math.acos(cos); + const shallow = angle < 0.45 ? 0.28 : 0; + best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow); + } + return best; + } + + function traceRiverPath(startX, startY, bonusSeed = 0) { + let x = startX; + let y = startY; + let lastDx = 0; + let lastDy = 0; + const path = []; + const seen = new Set(); + let accum = 0; + + for (let step = 0; step < 600; step++) { + const i = indexOf(x, y); + if (seen.has(i)) break; + seen.add(i); + path.push([x, y]); + river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55; + accum += river[i] + flowAccum[i]; + if (sea[i]) break; + + let best = null; + let bestValue = INF; + const currentElevation = elevation[i]; + const preferred = flowTo[i]; + + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const dx = nx - x; + const dy = ny - y; + const drop = currentElevation - elevation[ni]; + const uphill = Math.max(0, -drop); + if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue; + let surrounding = 0; + let surroundingCount = 0; + for (const [vx, vy] of neighbors8(nx, ny)) { + surrounding += elevation[indexOf(vx, vy)]; + surroundingCount++; + } + const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]); + const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; + const straightPenalty = Math.max(0, sameDirection) * 0.075; + const turnPenalty = sameDirection < -0.35 ? 0.24 : 0; + const sideSwing = Math.abs(dx * lastDy - dy * lastDx); + const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5; + const meander = sideSwing * (0.032 + meanderPhase * 0.026); + const flowBonus = ni === preferred ? 0.62 : 0; + const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length); + const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04; + const value = + elevation[ni] * 1.45 + + uphill * 88 - + Math.max(0, drop) * 2.05 - + valley * 1.05 - + valleyField[ni] * 1.72 - + flowAccum[ni] * 0.94 - + moisture[ni] * 0.14 - + coastalLowland[ni] * 0.28 - + (river[ni] > 0 ? 0.22 : 0) - + flowBonus + + slope[ni] * 0.04 + + straightPenalty + + turnPenalty + + junctionPenalty * 1.35 - + meander + + noise - + (sea[ni] ? 0.6 : 0); + + if (value < bestValue) { + bestValue = value; + best = [nx, ny, dx, dy]; + } + } + if (!best) break; + x = best[0]; + y = best[1]; + lastDx = best[2]; + lastDy = best[3]; + } + + const forced = forceRiverToWater(path); + if (forced.length > path.length) { + for (const [rx, ry] of forced.slice(path.length)) { + const ri = indexOf(rx, ry); + river[ri] += 0.32 + flowAccum[ri] * 0.4; + accum += river[ri] + flowAccum[ri]; + } + } + return { path: forced, accum }; + } + + function traceSmallStreamPath(startX, startY, bonusSeed = 0) { + let x = startX; + let y = startY; + let lastDx = 0; + let lastDy = 0; + const path = []; + const seen = new Set(); + for (let step = 0; step < 160; step++) { + const i = indexOf(x, y); + if (seen.has(i)) break; + seen.add(i); + path.push([x, y]); + river[i] += 0.12 + flowAccum[i] * 0.18; + if ((river[i] > 0.48 && path.length > 5) || sea[i]) break; + let best = null; + let bestValue = INF; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const dx = nx - x; + const dy = ny - y; + const drop = elevation[i] - elevation[ni]; + const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; + const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05; + if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; } + } + if (!best) break; + x = best[0]; + y = best[1]; + lastDx = best[2]; + lastDy = best[3]; + } + return path; + } + + const riverPaths = []; + const riverScores = []; + for (const source of sources) { + const { path, accum } = traceRiverPath(source.x, source.y, 0); + if (path.length > 6) { + riverPaths.push(path); + riverScores.push(path.length + accum * 0.18); + } + } + + const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`))); + const tributarySources = pickEntities(sourceCandidates + .filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`)) + .map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), { + max: 14 + Math.floor(rand(seed, 915) * 20), + minDistance: 6, + threshold: 0.45, + seed: seed + 916, + jitter: 0.02, + }); + for (const source of tributarySources) { + const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11); + if (path.length > 8) { + riverPaths.push(path); + riverScores.push(path.length * 0.7 + accum * 0.12); + } + } + + const streamPaths = []; + const streamSources = pickEntities(sourceCandidates + .map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 })) + .filter((p) => p.score > 0.18), { + max: 22 + Math.floor(rand(seed, 919) * 20), + minDistance: 4, + threshold: 0.18, + seed: seed + 919, + jitter: 0.015, + }); + for (const source of streamSources) { + const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17); + if (path.length > 4) streamPaths.push(path); + } + + if (riverPaths.length === 0 && sourceCandidates.length > 0) { + const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0]; + let bestSea = null; + let bestSeaDist = INF; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (!sea[indexOf(x, y)]) continue; + const d = Math.hypot(x - fallback.x, y - fallback.y); + if (d < bestSeaDist) { + bestSeaDist = d; + bestSea = { x, y }; + } + } + } + if (bestSea) { + const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.25; + const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24; + const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8; + return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1)); + }); + if (fallbackPath.length > 6) { + let accum = 0; + for (const [x, y] of fallbackPath) { + const i = indexOf(x, y); + river[i] += 0.42; + accum += river[i]; + } + riverPaths.push(fallbackPath); + riverScores.push(fallbackPath.length + accum * 0.18); + } + } + } + + function sanitizeDownhillRiverPath(path, tolerance = 0.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.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) 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.42 + k / 170 + flowAccum[i] * 0.55; + } + } + for (const path of streamPaths) { + for (let k = 0; k < path.length; k++) { + const [x, y] = path[k]; + const i = indexOf(x, y); + river[i] += 0.11 + flowAccum[i] * 0.18; + } + } + + const expandedRiver = new Float32Array(river); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (river[i] <= 0) continue; + for (const [nx, ny] of neighbors8(x, y)) { + expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35); + } + } + } + river.set(expandedRiver); + + // Second fluvial pass uses the actual traced river network. Main channels cut + // visible V-shaped valleys; lower reaches accumulate alluvial deposits. + const fluvialElevation = new Float32Array(elevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i] || river[i] <= 0.02) continue; + const r = clamp(river[i] / 3.4); + const channelCut = clamp(Math.pow(r, 0.55) * (0.060 + slope[i] * 0.145 + ridgeField[i] * 0.038)); + const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040)); + const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0))); + erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); + depositionField[i] = clamp(depositionField[i] + alluvium); + fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1); + valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4); + basinField[i] = clamp(basinField[i] + alluvium * 3.2); + } + } + // Lateral valley carving around the traced river network deepens valleys and + // makes ridge/valley contrast legible at the map scale. + for (const path of riverPaths) { + for (const [rx, ry] of path) { + const ri = indexOf(rx, ry); + const r = clamp(river[ri] / 3.0); + const radius = r > 0.48 ? 2 : 1; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = rx + dx; + const ny = ry + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const d = Math.hypot(dx, dy); + if (d > radius || d === 0) continue; + const weight = (radius + 0.35 - d) / (radius + 0.35); + const carve = Math.max(0, weight) * (0.008 + r * 0.026) * 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); + } + } + } + } + + // 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, 1); + erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6); + } + } + } + + elevation.set(fluvialElevation); + + // Broad alluvial/coastal/basin plains. The plain score alone is not enough; + // the elevation surface must also be locally calm, otherwise every lowland + // still reads as rugged terrain. Smooth only low, wet depositional cells and + // leave ridges/headwaters untouched. + for (let pass = 0; pass < 4; 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 + + valleyField[i] * 0.34 + + Math.pow(flowAccum[i], 0.58) * 0.24 - + ridgeField[i] * 0.62 - + Math.max(0, elevation[i] - 0.54) * 1.65 - + slope[i] * 0.74 + ); + if (lowland <= 0.12) continue; + let sum = 0; + let weight = 0; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const nx = x + dx; + const ny = y + dy; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const d = Math.hypot(dx, dy); + if (d > 2.25) continue; + const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11); + const w = compatible / (1 + d); + sum += elevation[ni] * w; + weight += w; + } + } + if (weight <= 0) continue; + const localMean = sum / weight; + const terrace = Math.round(localMean * 42) / 42; + const target = lerp(localMean, terrace, 0.28); + nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1); + if (lowland > 0.55) { + depositionField[i] = clamp(depositionField[i] + lowland * 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); + + const mainRivers = riverPaths + .map((p, i) => ({ path: p, score: riverScores[i] })) + .sort((a, b) => b.score - a.score) + .slice(0, Math.min(6, riverPaths.length)) + .map((x) => x.path); + + if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]); + if (mainRivers.length === 0) { + let start = null; + let startScore = -INF; + for (let y = 4; y < MAP_H - 4; y++) { + for (let x = 4; x < MAP_W - 4; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15; + if (score > startScore) { + startScore = score; + start = { x, y }; + } + } + } + if (start) { + let goal = null; + let goalDist = INF; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (!sea[indexOf(x, y)]) continue; + const d = Math.hypot(x - start.x, y - start.y); + if (d < goalDist) { + goalDist = d; + goal = { x, y }; + } + } + } + if (goal) { + const fallbackPath = aStar(start, goal, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.2; + const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22; + const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7; + return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias); + }); + if (fallbackPath.length > 4) { + riverPaths.push(fallbackPath); + mainRivers.push(fallbackPath); + for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4; + } + } + } + } + + const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`))); + const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path)); + const smallStreams = streamPaths.filter((path) => path.length >= 5); + + prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); + prefectureBorder = extractMaskBorder(prefectureMask, sea); + const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); + const prefectureRegionId = regionalPrefectures.regionId; + const regionalDebug = regionalPrefectures.debug; + const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea); + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const low = 1 - clamp((elevation[i] - 0.28) / 0.4); + const flat = 1 - slope[i]; + const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55; + 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(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); + agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + 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; + const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16; + portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + 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); + } + } + + + return { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + portSuitability, + crossingSuitability, + passSuitability, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalDebug, + regionalPrefectureBorders, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + }; +} diff --git a/names.js b/names.js index 4585f85..ef47f7b 100644 --- a/names.js +++ b/names.js @@ -2,26 +2,28 @@ import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; export const NAME_KANJI_POOLS = { modifiers: [ - "大", "小", "上", "下", "中", + "大", "小", "上", "下", "中", "奥", "東", "西", "南", "北", "新", "古", "本", "元", "高", "長", "広", "深", "浅", "白", "黒", "青", "赤", "奥", "前", "後", "内", "外", - "早", "早", "真", "丸", "平" + "早", "安", "真", "丸", "平", + "美", + "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", ], inlandTerrain: [ "山", "谷", "沢", "原", "野", "森", "林", "岡", "丘", "坂", "峰", "峠", "嶺", "尾", "平", - "窪", "久", "洞", "迫", "台", - "塚", "牧", "畑", "田", "森", - "麓", "郷", "里" + "窪", "久", "洞", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", + "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", + "聡", "郷", "里" ], waterTerrain: [ - "川", "河", "江", "瀬", "淵", + "川", "河", "江", "瀬", "淵", "渕", "池", "沼", "泉", "井", "湖", "滝", "渓", "沢", "谷", "津", "水", "清", "渡", "橋", "堀", @@ -29,11 +31,11 @@ export const NAME_KANJI_POOLS = { ], coastalTerrain: [ - "浜", "浦", "津", "崎", "岬", - "島", "磯", "潟", "湊", "港", - "海", "洲", "瀬", "砂", "潮", + "浜", "浦", "津", "崎", + "島", "磯", "潟", "湊", "津", + "州", "洲", "瀬", "砂", "潮", "泊", "江", "浦", "灘", "入", - "湾", "戸", "門" + "戸", "門" ], plants: [ @@ -53,7 +55,7 @@ export const NAME_KANJI_POOLS = { "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", "本", "内", "窪", "平", "塚", - "畑", "牧", "前", "後", "中" + "畑", "牧", "前", "見", "中" ], archaicPrefixes: [ @@ -64,7 +66,7 @@ export const NAME_KANJI_POOLS = { "甲", "信", "越", "備", "讃", "薩", "隠", "美", "三", "若", "遠", "近", "能", "加", "賀", - "越", "淡", "壱", "対" + "越", "淡", "壱", "阿" ], archaicSuffixes: [ @@ -75,7 +77,7 @@ export const NAME_KANJI_POOLS = { "伊", "前", "中", "後", "波", "勢", "渡", "城", "紫", "野", "津", "島", "海", "登", "賀", - "良", "美", "智", "智", "代" + "良", "美", "智", "茂", "代" ], settlementWords: [ diff --git a/renderer.js b/renderer.js index ad253bc..78d2844 100644 --- a/renderer.js +++ b/renderer.js @@ -118,6 +118,19 @@ function discreteColor(map, x, y, mode) { ]; const a = map.adminId[i]; color = a >= 0 ? palette[a % palette.length] : [220, 225, 220]; + } else if (mode === "admin-debug" || mode === "borders-debug") { + const barrier = clamp( + map.ridgeField[i] * 0.88 + + Math.max(0, map.river[i] - 0.28) * 1.25 + + Math.max(0, map.flowAccum[i] - 0.36) * 0.72 + + map.slope[i] * 0.48 + + Math.max(0, map.elevation[i] - 0.54) * 0.34 + ); + color = [ + Math.round(238 - barrier * 28), + Math.round(242 - barrier * 88), + Math.round(226 + barrier * 20), + ]; } else { color = terrainColorContinuous(map, x, y, "terrain"); } @@ -465,7 +478,8 @@ export function drawMap(canvas, map, options) { for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2); drawHarborWorks(ctx, map); - if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", 1.0, false, mode === "all"); + const debugBorders = mode === "admin-debug" || mode === "borders-debug"; + if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, debugBorders ? "rgba(40,40,40,0.82)" : mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", debugBorders ? 1.8 : 1.0, false, mode === "all"); drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true); drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true); @@ -474,9 +488,9 @@ export function drawMap(canvas, map, options) { const showHistory = ["history", "all", "terrain", "suitability"].includes(mode); const showModern = ["modern", "all", "development", "landuse"].includes(mode); const showRoads = ["roads", "all", "development", "landuse"].includes(mode); - const showAdmin = ["admin", "all"].includes(mode); + const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); - if (showAdmin) drawSegments(ctx, map.adminBorders, mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", mode === "all" ? 0.9 : 1.3); + if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.90)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 1.5 : mode === "all" ? 0.9 : 1.3); if (showHistory) { for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true); diff --git a/test.js b/test.js index 25a114d..62b6a94 100644 --- a/test.js +++ b/test.js @@ -7,6 +7,7 @@ import { NAME_PROBABILITIES, NAME_TEMPLATES, NAME_TEMPLATE_WEIGHTS, + generateEntityName, generateTemplateName, } from "./names.js"; @@ -168,6 +169,56 @@ function majorCityCoreIntegrity(map) { return checked ? sum / checked : 1; } +function satelliteMunicipalityMetrics(map) { + const areaById = new Map(); + for (let i = 0; i < map.adminId.length; i++) { + if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1); + } + const rows = (map.satelliteCities || []) + .filter((sat) => map.prefectureMask[indexOf(sat.x, sat.y)] && !map.sea[indexOf(sat.x, sat.y)]) + .map((sat) => { + const admin = map.adminId[indexOf(sat.x, sat.y)]; + return { sat, admin, area: areaById.get(admin) || 0 }; + }); + const independent = rows.filter((row) => row.sat.municipalityClass === "independentSatelliteMunicipality"); + const small = independent.filter((row) => row.area < 80); + const largeTooSmall = rows.filter((row) => (row.sat.population || 0) >= 60000 && row.sat.municipalityClass === "independentSatelliteMunicipality" && row.area < 120); + const average = independent.length ? independent.reduce((sum, row) => sum + row.area, 0) / independent.length : 0; + return { rows, independent, small, largeTooSmall, average }; +} + +function regionalComponentMetrics(map) { + const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i])); + const seen = new Uint8Array(MAP_W * MAP_H); + let maxComponents = 0; + for (const id of ids) { + seen.fill(0); + let comps = 0; + for (let i = 0; i < map.prefectureRegionId.length; i++) { + if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue; + comps++; + const queue = [i]; + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || map.sea[ni] || map.prefectureRegionId[ni] !== id) continue; + seen[ni] = 1; + queue.push(ni); + } + } + } + maxComponents = Math.max(maxComponents, comps); + } + return { regionCount: ids.size, maxComponents }; +} + try { const map = generateMap(12345); const other = generateMap(54321); @@ -282,6 +333,8 @@ try { : 1; const adminMetrics = adminBoundaryMetrics(map); const cityCoreIntegrity = majorCityCoreIntegrity(map); + const satelliteMetrics = satelliteMunicipalityMetrics(map); + const regionalMetrics = regionalComponentMetrics(map); assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists"); assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists"); @@ -290,7 +343,7 @@ try { const removedContextModule = "placeName" + "Context.js"; assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent"); assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays"); - assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty"); + assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.every((part) => typeof part === "string" && !part.includes("\uFFFD"))), "configured name category pools contain valid strings"); assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates"); const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES"; const removedContextSuffixKey = "context" + "Suffixes"; @@ -309,6 +362,12 @@ try { 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(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"); + assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked"); + assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully"); + assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded"); + assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display"); assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist"); assert(Array.isArray(map.icAccessRoads), "IC access road array exists"); assert(Array.isArray(map.satelliteCities), "satelliteCities is an array"); @@ -360,6 +419,18 @@ try { assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments"); assert(adminMetrics.disconnectedMunicipalities <= Math.max(2, Math.ceil(adminMetrics.municipalityCount * 0.20)), "most municipalities remain connected after terrain snapping"); assert(adminMetrics.avgTarget > 0.18, "admin borders align with terrain target features often enough"); + assert(map.adminDebug && map.adminDebug.compartmentCount > 0, "natural compartment debug is available"); + assert(map.adminDebug.averageCompartmentArea > 0, "natural compartments have positive average area"); + assert(Number.isFinite(map.adminDebug.changedAfterLandscapePartition) && Number.isFinite(map.adminDebug.changedAfterSnap), "municipal changed-cell diagnostics exist"); + assert(map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal terrain partition or snap changes admin cells"); + assert(map.adminDebug.changedAfterFinalExclaveRemoval + map.adminDebug.changedAfterFinalMerge < Math.max(2800, (map.adminDebug.changedAfterLandscapePartition + map.adminDebug.changedAfterSnap + map.adminDebug.changedAfterUrbanLock) * 1.35), "final municipal repair does not erase most terrain and urban changes"); + assert(map.adminDebug.finalBorderNaturalBarrierAverage >= 0, "natural barrier score is tracked along final borders"); + assert(map.adminDebug.voronoiLikeRateAfter <= Math.max(0.72, map.adminDebug.voronoiLikeRateBefore + 0.20), "natural compartment pass does not increase weak bisectors excessively"); + assert(Number.isFinite(map.adminDebug.satelliteMunicipalitiesCreated) && Number.isFinite(map.adminDebug.averageSatelliteMunicipalityArea), "satellite municipality debug is available"); + assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.small.length / satelliteMetrics.independent.length <= 0.35, "tiny independent satellite municipalities are not the dominant pattern"); + assert(satelliteMetrics.largeTooSmall.length === 0, "large independent satellites have meaningful municipal area"); + assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.average >= 140, "average independent satellite municipality area is meaningful"); + assert(satelliteMetrics.rows.every((row) => row.area >= 80 || row.sat.municipalityClass === "smallTownAttachedToRuralMunicipality" || row.sat.municipalityClass === "suburbanDistrictMergedWithParent" || row.sat.municipalityClass === "newTownDistrict"), "tiny satellite areas are merged or explicitly classified as attached districts"); assert(adminMetrics.denseUrbanRate < 0.42, "admin borders avoid excessive dense urban crossings"); assert(adminMetrics.rightAngleRate < 0.46, "admin borders avoid excessive unsupported stair-step artifacts"); assert(adminMetrics.voronoiLikeRate < 0.58, "admin borders are not dominated by weak-terrain center bisectors"); @@ -388,15 +459,14 @@ try { assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented"); assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools"); - assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug"); + assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools"); assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists"); assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists"); assert( map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount, "nameDebug accounting covers named entities" ); - assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates"); - assert(activePoolChars.size === 0, "no active pool characters exist until configured"); + assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools"); assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells"); assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes"); assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default"); @@ -414,6 +484,15 @@ try { assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed"); assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed"); assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed"); + assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed"); + assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed"); + assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed"); + + const blockedCapitalName = "\u52A0\u8302"; + const capitalNameMaps = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue)); + const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean); + assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds"); + assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name"); CUSTOM_NAMES["city-0"] = "C1"; const customSameA = generateMap(321); @@ -427,12 +506,35 @@ try { assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed"); delete CUSTOM_NAMES["city-0"]; + CUSTOM_NAMES["custom-probe"] = "C1"; + const directCustomNames = Array.from({ length: 40 }, (_, n) => generateEntityName(9000 + n, "custom-probe", { x: 10, y: 10, kind: "Probe" }, {}, new Set())); + const directCustomHits = directCustomNames.filter((name) => name === "C1").length; + assert(NAME_PROBABILITIES.customName > 0 && NAME_PROBABILITIES.customName < 1 && directCustomHits > 0 && directCustomHits < directCustomNames.length, "CUSTOM_NAMES are probabilistic suggestions"); + delete CUSTOM_NAMES["custom-probe"]; + + FORCED_NAMES["forced-probe"] = "F1"; + assert(generateEntityName(123, "forced-probe", { x: 8, y: 8, kind: "Probe" }, {}, new Set(), map.nameDebug) === "F1", "FORCED_NAMES always apply"); + delete FORCED_NAMES["forced-probe"]; + for (const seed of [101, 2026, 54321]) { const seeded = generateMap(seed); const metrics = adminBoundaryMetrics(seeded); + const seededRegional = regionalComponentMetrics(seeded); + const seededSatellites = satelliteMunicipalityMetrics(seeded); const invalidLandCells = [...seeded.adminId].filter((id, i) => seeded.prefectureMask[i] && !seeded.sea[i] && id < 0).length; + const invalidRegionCells = [...seeded.prefectureRegionId].filter((id, i) => !seeded.sea[i] && id < 0).length; assert(invalidLandCells === 0, `seed ${seed}: every prefecture land cell has a valid adminId`); + assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`); assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`); + assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`); + assert(seeded.regionalDebug?.regionalChangedAfterNaturalPartition > 0, `seed ${seed}: regional natural partition changes cells`); + assert(seeded.regionalDebug.regionalNaturalBarrierAverageAfter >= seeded.regionalDebug.regionalNaturalBarrierAverageBefore - 0.10, `seed ${seed}: regional border natural affinity is stable`); + assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`); + assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`); + assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); + assert(seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal terrain passes change cells`); + assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`); + assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`); assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`); assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`); assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`); From 6c10d5d70eefece4f5bdf6a213d0f79ade973e8d Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 21 May 2026 20:31:40 +0900 Subject: [PATCH 3/8] more name --- names.js | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/names.js b/names.js index ef47f7b..ae9ab85 100644 --- a/names.js +++ b/names.js @@ -2,30 +2,31 @@ import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; export const NAME_KANJI_POOLS = { modifiers: [ - "大", "小", "上", "下", "中", "奥", + "大", "小", "上", "下", "中", "奥", "脇", "東", "西", "南", "北", "新", "古", "本", "元", "高", "長", "広", "深", "浅", "白", "黒", "青", "赤", "奥", "前", "後", "内", "外", "早", "安", "真", "丸", "平", - "美", + "美", "吉", "福", "幸", "徳", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", ], inlandTerrain: [ - "山", "谷", "沢", "原", "野", + "山", "谷", "ヶ谷", "沢", "原", "野", "森", "林", "岡", "丘", "坂", - "峰", "峠", "嶺", "尾", "平", + "峰", "峠", "嶺", "尾", "平", "坪", "延", "窪", "久", "洞", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", - "聡", "郷", "里" + "聡", "郷", "里", + "馬", "鹿", "亀", "鷲", "鷹" ], waterTerrain: [ "川", "河", "江", "瀬", "淵", "渕", - "池", "沼", "泉", "井", "湖", - "滝", "渓", "沢", "谷", "津", + "池", "沼", "泉", "井", + "滝", "渓", "沢", "澤", "谷", "津", "水", "清", "渡", "橋", "堀", "溝", "湯", "浦", "洲" ], @@ -33,9 +34,10 @@ export const NAME_KANJI_POOLS = { coastalTerrain: [ "浜", "浦", "津", "崎", "島", "磯", "潟", "湊", "津", - "州", "洲", "瀬", "砂", "潮", + "州", "洲", "瀬", "砂", "潮", "塩", "汐", "泊", "江", "浦", "灘", "入", - "戸", "門" + "戸", "門", + "鯵", "鰐", "漁", "魚" ], plants: [ @@ -44,18 +46,18 @@ export const NAME_KANJI_POOLS = { "菅", "榎", "椿", "桐", "柳", "橘", "柏", "槙", "柿", "桃", "梨", "桑", "麻", "芦", "茅", - "榊", "楢", "檜", "椎", "柚" + "榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑" ], postfixes: [ - "田", "原", "野", "沢", "谷", - "川", "山", "岡", "森", "林", - "浜", "浦", "津", "崎", "島", + "田", "原", "ヶ原", "野", "沢", "ヶ沢", "谷", "ヶ谷", + "川", "山", "岡", "森", "林", "ヶ丘", + "浜", "浦", "ヶ浦", "津", "崎", "ヶ崎", "島", "江", "瀬", "井", "戸", "口", "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", "本", "内", "窪", "平", "塚", - "畑", "牧", "前", "見", "中" + "畑", "牧", "前", "見", "中", "羽", "生", "塚" ], archaicPrefixes: [ @@ -66,7 +68,7 @@ export const NAME_KANJI_POOLS = { "甲", "信", "越", "備", "讃", "薩", "隠", "美", "三", "若", "遠", "近", "能", "加", "賀", - "越", "淡", "壱", "阿" + "越", "淡", "壱", "阿", "衣", "古", "彦", "多", "志", "布", "治" ], archaicSuffixes: [ @@ -74,18 +76,17 @@ export const NAME_KANJI_POOLS = { "磨", "馬", "幡", "耆", "摩", "張", "江", "河", "斐", "濃", "岐", "防", "門", "隅", "向", - "伊", "前", "中", "後", "波", + "居", "前", "中", "後", "波", "勢", "渡", "城", "紫", "野", - "津", "島", "海", "登", "賀", - "良", "美", "智", "茂", "代" + "津", "島", "信", "登", "賀", "志", + "良", "美", "智", "茂", "代", "古", "摩", "磨", "麻", "彦", "比古", "子" ], settlementWords: [ - "里", "郷", "村", "町", "宿", - "庄", "院", "宮", "寺", "社", + "里", "郷", "村", "町", "宿", "邑", "垣", "坪", + "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "妙見", "城", "館", "屋", "家", "所", - "市", "場", "府", "関", "駅", - "新田", "本郷", "一宮", "国府" + "市", "場", "府", "関", "地蔵", "辻", "角", "堰", ] }; From 3fe9c3145393d2cff797cf0bb375155c45d51ae4 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 21 May 2026 22:03:14 +0900 Subject: [PATCH 4/8] tweak --- adminRegions.js | 127 +++++++++++ app.js | 1 + mapAdminStage.js | 89 ++++++-- mapFeatures.js | 21 +- mapGeneratorHelpers.js | 94 ++++++-- mapOutput.js | 71 ++++++ mapPipeline.js | 16 +- mapTerrain.js | 483 +++++++++++++++++++++++++++++++++++------ mapUtils.js | 8 + names.js | 29 +-- renderer.js | 24 +- test.js | 127 ++++++++++- 12 files changed, 963 insertions(+), 127 deletions(-) diff --git a/adminRegions.js b/adminRegions.js index 5dc1894..bf7aa0e 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -507,6 +507,9 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, 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); @@ -520,6 +523,7 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, 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 @@ -739,6 +743,125 @@ function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierS 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 = []) { + const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); + const adminId = new Int16Array(SIZE); + adminId.fill(-1); + const owner = assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea); + 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; + } + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const i = indexOf(center.x, center.y); + if (prefectureMask[i] && !sea[i]) adminId[i] = id; + } + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + const activeCompartments = compartments.filter((unit) => unit.area > 0); + return { + adminId, + compartmentId, + compartments, + naturalBarrierScore, + debug: { + naturalCompartmentCount: activeCompartments.length, + compartmentCount: activeCompartments.length, + compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea), + averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, + 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; @@ -973,3 +1096,7 @@ export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; return { changedCells, splitMunicipalities }; } + +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 4fb719e..aeeb5c9 100644 --- a/app.js +++ b/app.js @@ -11,6 +11,7 @@ const modes = [ ["development", "Development"], ["landuse", "Land Use"], ["admin", "Municipal Borders"], + ["terrain-debug", "Terrain Debug"], ["admin-debug", "Admin Debug"], ["borders-debug", "Borders Debug"], ]; diff --git a/mapAdminStage.js b/mapAdminStage.js index f861e81..6f4e844 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -1,11 +1,11 @@ import { applyLandscapeUnitAdminPartition, - generateAdminRegions, + assignAdminRegionsFromNaturalCompartments, lockSmallUrbanComponentsToMunicipality, mergeTinyMunicipalities, removeMunicipalExclaves, smoothAdminRegionsTerrainAware, - splitOversizedRuralMunicipalities, + splitOversizedLowlandMunicipalities, snapAdminBoundariesToTerrain, } from "./adminRegions.js"; import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js"; @@ -26,6 +26,34 @@ function municipalityAreaById(adminId, prefectureMask, sea) { return area; } +function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) { + let landCells = 0; + let habitableCells = 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) habitableCells++; + 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 settlementNodes = modernCities.length * 1.25 + markets.length * 0.9 + ports.length * 0.7 + independentSatellites * 0.8 + villages.length * 0.35; + const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); + const mountainRatio = landCells ? mountainCells / landCells : 0; + return clamp(Math.round(habitableCells / 260 + settlementNodes * 0.45 + coastlineComplexity * 0.04 + basinBonus + mountainRatio * 4), 18, 48); +} + function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { if (!city || !inside(city.x, city.y)) return 0; const start = indexOf(city.x, city.y); @@ -193,6 +221,7 @@ export function generateAdminLayout({ slope, river, ridgeField, + naturalBarrierScore, valleyField, basinField, coastalLowland, @@ -216,7 +245,9 @@ export function generateAdminLayout({ industrialZones, logisticsParks, }) { - const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0); + const boundaryRidgeField = naturalBarrierScore + ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) + : ridgeField; const municipalityCandidates = []; for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { @@ -235,7 +266,8 @@ export function generateAdminLayout({ const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); return !nearMajor && !nearSmallUrban; }); - const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum); + const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); + const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); const satelliteMunicipalSeeds = (satelliteCities || []) .filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality") .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); @@ -243,24 +275,27 @@ export function generateAdminLayout({ ...majorMunicipalSeeds, ...satelliteMunicipalSeeds, ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { - max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))), - minDistance: 9 + Math.floor(rand(seed, 1302) * 3), - threshold: 0.40, + max: Math.max(0, targetMunicipalityCount - majorMunicipalSeeds.length - satelliteMunicipalSeeds.length), + minDistance: 6 + Math.floor(rand(seed, 1302) * 3), + threshold: 0.34, seed: seed + 1300, jitter: 0.025, }), ]; - if (adminCentersRaw.length < 12) { - const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...newTowns, ...stations, ...villages] + if (adminCentersRaw.length < Math.min(targetMunicipalityCount, 18)) { + const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...ports, ...newTowns, ...stations, ...villages] .filter((p) => prefectureMask[indexOf(p.x, p.y)]) - .map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 })); - adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 }); + .map((p) => ({ x: p.x, y: p.y, score: (p.score || 0.5) + (p.population || 0) / 900000 })); + const extraFallback = pickEntities(fallback, { max: targetMunicipalityCount, minDistance: 5, threshold: 0, seed: seed + 1303 }); + for (const p of extraFallback) if (adminCentersRaw.length < targetMunicipalityCount && adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) adminCentersRaw.push(p); } - if (adminCentersRaw.length < 10) { - const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); - adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); + if (adminCentersRaw.length < targetMunicipalityCount) { + const extra = pickEntities(municipalityCandidates, { max: targetMunicipalityCount - adminCentersRaw.length, minDistance: 5, threshold: 0.26, seed: seed + 1304 }); + adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4))); } - const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); + if (adminCentersRaw.length > targetMunicipalityCount) adminCentersRaw = adminCentersRaw.slice(0, targetMunicipalityCount); + const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + const adminId = compartmentAssignment.adminId; let previousSnapshot = new Int16Array(adminId); const adminDebug = { changedAfterSmooth: 0, @@ -273,6 +308,10 @@ export function generateAdminLayout({ changedAfterOversizedRuralSplit: 0, changedAfterFinalExclaveRemoval: 0, changedAfterFinalMerge: 0, + targetMunicipalityCount, + actualMunicipalityCount: 0, + changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0, + oversizedRuralSplits: 0, satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length, satelliteMunicipalitiesMerged: 0, satelliteMunicipalitiesExpanded: 0, @@ -282,12 +321,14 @@ export function generateAdminLayout({ satelliteMunicipalityAreaByNameOrIndex: {}, independentSatelliteMunicipalities: satelliteClassificationDebug.independent, attachedSatelliteDistricts: satelliteClassificationDebug.attached, + satelliteMunicipalityStats: satelliteClassificationDebug, + ...compartmentAssignment.debug, }; function markChanged(field) { adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea); previousSnapshot = new Int16Array(adminId); } - smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); + smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2); markChanged("changedAfterSmooth"); function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { @@ -335,7 +376,7 @@ export function generateAdminLayout({ if (bestAdmin < 0) continue; sat.parentAdminHint = bestAdmin; const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, { - prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + 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++; @@ -348,22 +389,23 @@ export function generateAdminLayout({ markChanged("changedAfterInitialMerge"); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180); markChanged("changedAfterInitialExclaveRemoval"); - applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); 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, valleyField, basinField, coastalLowland, flowAccum, + prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, }); } markChanged("changedAfterLandscapePartition"); - const oversizedSplitDebug = splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]); + 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; previousSnapshot = new Int16Array(adminId); - snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); + 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, 360); markChanged("changedAfterFinalExclaveRemoval"); @@ -375,7 +417,7 @@ export function generateAdminLayout({ const targetAdmin = adminId[indexOf(sat.x, sat.y)]; if (targetAdmin < 0) continue; expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, { - prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, }); } @@ -401,8 +443,11 @@ export function generateAdminLayout({ }); 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.naturalCompartmentCount = adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0; + adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size; const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); diff --git a/mapFeatures.js b/mapFeatures.js index b4f8edb..e5567e0 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -34,6 +34,11 @@ export function generateMapFeatures(seed, terrain) { basinField, coastalLowland, flowAccum, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, portSuitability, crossingSuitability, passSuitability, @@ -108,9 +113,11 @@ export function generateMapFeatures(seed, terrain) { for (let x = 2; x < MAP_W - 2; x++) { const i = indexOf(x, y); if (sea[i]) continue; + const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.65 + (deltaField?.[i] || 0) * 0.85; + const spineBarrier = (arcSpineField?.[i] || 0) * 0.62 + (branchRidgeField?.[i] || 0) * 0.42; const valleyCorridor = clamp(valleyField[i] * 0.62 + river[i] * 0.16); - const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18); - const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1); + const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18 + depositional * 0.22); + const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - spineBarrier * 0.34 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1); const localPatch = valueNoise(x * 0.7, y * 0.7, seed + 1037, 10); const broadPatch = fbm(x * 0.32 + 71, y * 0.32 - 19, seed + 1038); settlementCluster[i] = clamp((valleyCorridor + lowlandCorridor) * terrainGate * (0.72 + broadPatch * 0.42 + localPatch * 0.18)); @@ -124,10 +131,12 @@ export function generateMapFeatures(seed, terrain) { if (sea[i]) continue; let nearFeature = 0; for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4)); - const riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16); + const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.95; + const spineBarrier = (arcSpineField?.[i] || 0) * 0.48 + (branchRidgeField?.[i] || 0) * 0.34; + const riverPull = Math.min(0.36, river[i] * 0.14 + valleyField[i] * 0.16 + depositional * 0.08); const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52; - const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] - 0.22) * (1 - valleyField[i]) * 0.75; - const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - floodplain[i] * 0.06 - remoteMountainPenalty; + const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] + spineBarrier - 0.22) * (1 - valleyField[i]) * 0.75; + const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + depositional * 0.13 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - spineBarrier * 0.12 - floodplain[i] * 0.06 - remoteMountainPenalty; settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13); } } @@ -159,7 +168,7 @@ export function generateMapFeatures(seed, terrain) { 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 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 + nearbyVillages * 0.012); + 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); } } diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 8b6d967..918db7d 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -418,29 +418,52 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore); const owner = new Int16Array(compartments.length); owner.fill(-1); + for (let id = 0; id < seeded.centers.length; id++) { + const center = seeded.centers[id]; + if (!center || !inside(center.x, center.y)) continue; + const ci = compartmentId[indexOf(center.x, center.y)]; + if (ci >= 0) owner[ci] = id; + } for (const unit of compartments) { if (!unit || unit.area === 0) continue; - const counts = new Map(); - let anchorCells = 0; - for (const i of unit.cells) { - if (anchorMask[i]) anchorCells++; - const id = beforeRegionId[i]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + if (unit.cells.some((i) => anchorMask[i])) owner[unit.id] = 0; + } + for (let pass = 0; pass < compartments.length + 6; pass++) { + let changedThisPass = 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]; + const center = seeded.centers[neighborOwner]; + const barrier = edge.target / Math.max(1, edge.count); + const sameClass = neighbor?.classId === unit.classId ? 1.0 : 0; + const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0; + const score = edge.count * 0.45 + sameClass + neighbor.coastalExposure * 0.08 + neighbor.ridgeExposure * 0.05 - barrier * 2.6 - d * 0.008; + if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } + } + if (bestOwner >= 0) { + owner[unit.id] = bestOwner; + changedThisPass++; + } } - if (anchorCells > 0) { - owner[unit.id] = 0; - continue; - } - let bestId = -1; - let best = -1; - for (const [id, count] of counts) { + if (changedThisPass === 0) break; + } + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestId = 0; + let best = -INF; + for (let id = 0; id < seeded.centers.length; id++) { const center = seeded.centers[id]; - const centerFit = center ? -Math.hypot(center.x - unit.x, center.y - unit.y) * 0.012 : 0; - const terrainFit = unit.ridgeExposure * 0.10 + unit.riverExposure * 0.04 + unit.coastalExposure * 0.08; - const score = count + centerFit + terrainFit; + if (!center) continue; + const d = Math.hypot(unit.x - center.x, unit.y - center.y); + const score = -d + (id === 0 ? (unit.cells.some((i) => anchorMask[i]) ? 1000 : -12) : 0); if (score > best) { best = score; bestId = id; } } - owner[unit.id] = bestId >= 0 ? bestId : 0; + owner[unit.id] = bestId; } const regionId = new Int16Array(beforeRegionId); @@ -472,6 +495,9 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, regionalNaturalBarrierAverageBefore: beforeNaturalAverage, regionalNaturalBarrierAverageAfter: afterNaturalAverage, regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length, + compartmentCount: compartments.filter((unit) => unit.area > 0).length, + changedAfterCompartmentAssignment: changed, + borderNaturalBarrierAverage: afterNaturalAverage, }, }; } @@ -635,8 +661,33 @@ function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeFie ridgeExposure: ridgeExposure / Math.max(1, area), riverExposure: riverExposure / Math.max(1, area), coastalExposure: coastalExposure / Math.max(1, area), + 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 (sea[i]) continue; + const a = compartmentId[i]; + if (a < 0 || !compartments[a]) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const b = compartmentId[ni]; + if (b < 0 || a === b || !compartments[b]) continue; + const v = (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5; + const edgeA = compartments[a].adjacent.get(b) || { count: 0, target: 0 }; + edgeA.count++; + edgeA.target += v; + compartments[a].adjacent.set(b, edgeA); + const edgeB = compartments[b].adjacent.get(a) || { count: 0, target: 0 }; + edgeB.count++; + edgeB.target += v; + compartments[b].adjacent.set(a, edgeB); + } + } + } return { compartmentId, compartments }; } @@ -845,6 +896,15 @@ export function applyOutputOptions(map, options = {}) { delete slim.flowAccum; delete slim.erosionField; delete slim.depositionField; + delete slim.terrainTemplate; + delete slim.ocean; + delete slim.lake; + delete slim.arcSpineField; + delete slim.branchRidgeField; + delete slim.depositionalLowland; + delete slim.alluvialFanField; + delete slim.deltaField; + delete slim.naturalBarrierScore; return slim; } diff --git a/mapOutput.js b/mapOutput.js index 5517111..be7b84c 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -5,6 +5,7 @@ import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLandus export function finishMapOutput({ seed, options, + terrainTemplate, cityPopulationCap, stationInfluence, roadInfluence, @@ -13,6 +14,8 @@ export function finishMapOutput({ moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -25,6 +28,12 @@ export function finishMapOutput({ flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, villages, ports, crossings, @@ -132,6 +141,58 @@ export function finishMapOutput({ 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 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); + for (const center of adminCenters) { + const centerAdmin = adminId?.[indexOf(center.x, center.y)]; + let best = null; + let bestScore = -INF; + for (const sameAdminOnly of [true, false]) { + for (const feature of representativeFeatures) { + const featureAdmin = adminId?.[indexOf(feature.x, feature.y)]; + if (sameAdminOnly && centerAdmin >= 0 && featureAdmin >= 0 && featureAdmin !== centerAdmin) continue; + const d = Math.hypot(center.x - feature.x, center.y - feature.y); + const score = feature.representativeWeight - d * 0.11 - (sameAdminOnly ? 0 : 1.2); + if (score > bestScore) { + bestScore = score; + best = feature; + } + } + if (best) break; + } + if (best) { + center.representativeFeatureId = best.id; + center.representativeFeatureName = best.name; + center.generatedMunicipalityName = center.name; + center.name = best.name; + } + } + const adminNamePrefixes = ["\u6771", "\u897F", "\u5357", "\u5317", "\u4E0A", "\u4E0B", "\u65B0", "\u65E7", "\u4E2D", "\u5916"]; + const adminNameCounts = new Map(); + for (const center of adminCenters) adminNameCounts.set(center.name, (adminNameCounts.get(center.name) || 0) + 1); + const duplicateOrdinal = new Map(); + for (const center of adminCenters) { + if ((adminNameCounts.get(center.name) || 0) <= 1) continue; + const n = duplicateOrdinal.get(center.name) || 0; + duplicateOrdinal.set(center.name, n + 1); + const prefix = adminNamePrefixes[(Math.floor(center.x / Math.max(1, MAP_W / 3)) + Math.floor(center.y / Math.max(1, MAP_H / 3)) * 3 + n) % adminNamePrefixes.length]; + if (!String(center.name).startsWith(prefix)) center.name = `${prefix}${center.name}`; + } + const usedAdminNames = new Set(); + for (const center of adminCenters) { + let candidate = center.name; + let guard = 0; + while (usedAdminNames.has(candidate) && guard < adminNamePrefixes.length) { + candidate = `${adminNamePrefixes[(guard + Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNamePrefixes.length]}${center.name}`; + guard++; + } + center.name = candidate; + usedAdminNames.add(center.name); + } const entitiesForNames = [ ...modernCities, @@ -146,6 +207,7 @@ export function finishMapOutput({ ...newTowns, ...passes, ...crossings, + ...adminCenters, ...externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); @@ -153,6 +215,7 @@ export function finishMapOutput({ width: MAP_W, height: MAP_H, cellSize: CELL_SIZE, + terrainTemplate, prefectureMask, prefectureBorder, prefectureRegionId, @@ -162,6 +225,8 @@ export function finishMapOutput({ moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -174,6 +239,12 @@ export function finishMapOutput({ flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, villages, ports, crossings, diff --git a/mapPipeline.js b/mapPipeline.js index c96f18f..0ec01f0 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -11,10 +11,13 @@ export function generateMap(seedInput = 114514, options = {}) { const terrain = generateTerrainAndRivers(seed); const { + terrainTemplate, elevation, moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -26,6 +29,12 @@ export function generateMap(seedInput = 114514, options = {}) { flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, @@ -48,13 +57,14 @@ export function generateMap(seedInput = 114514, options = {}) { } = features; const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ - seed, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + 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, }); return finishMapOutput({ - seed, options, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, - elevation, moisture, slope, sea, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, + seed, options, terrainTemplate, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, + elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, 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, diff --git a/mapTerrain.js b/mapTerrain.js index a30164b..31d47c8 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -8,6 +8,145 @@ 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 = rand(seed, 45) > 0.64 ? 2 : 1; + 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, + coastAxis: coastAxisPick === 0 ? "east-west" : coastAxisPick === 1 ? "north-south" : "diagonal", + coastAngle, + coastBias: 0.18 + rand(seed, 12) * 0.24, + coastRoughness: 0.34 + rand(seed, 54) * 0.58, + coastSides: [ + { + penetration: 0.24 + rand(seed, 58) * 0.24, + inletStrength: 0.18 + rand(seed, 59) * 0.56, + plainWidth: sideAPlain, + }, + { + penetration: 0.24 + rand(seed, 60) * 0.24, + inletStrength: 0.18 + rand(seed, 61) * 0.56, + plainWidth: sideBPlain, + }, + ], + deposition, + erosion, + roughness, + ridgeJaggedness, + ridgeBranchiness: 0.28 + rand(seed, 55) * 0.62, + }; +} + +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.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); +} + +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 : (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); + 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.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, + 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 }; +} + export function generateTerrainAndRivers(seed) { let prefectureMask; let prefectureBorder; @@ -17,6 +156,8 @@ export function generateTerrainAndRivers(seed) { moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -28,35 +169,70 @@ export function generateTerrainAndRivers(seed) { flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, flowTo, portSuitability, crossingSuitability, passSuitability, } = createMapFields(); - const coastAngle = rand(seed, 11) * Math.PI * 2; + const terrainTemplate = buildTerrainTemplate(seed); + const coastAngle = terrainTemplate.coastAngle; const coastX = Math.cos(coastAngle); const coastY = Math.sin(coastAngle); - const coastThreshold = 0.22 + rand(seed, 12) * 0.22; - const coastStrength = 0.15 + rand(seed, 13) * 0.23; + const coastThreshold = terrainTemplate.coastBias; + const coastStrength = 0.13 + (1 - terrainTemplate.deposition) * 0.16 + rand(seed, 13) * 0.13; + const { spines, branches } = buildSpineRidges(seed, terrainTemplate); + + function coastPressureAt(x, y, wx = x, wy = y) { + const nx = x / (MAP_W - 1) - 0.5; + const ny = y / (MAP_H - 1) - 0.5; + const axis = nx * coastX + ny * coastY; + const waveA = (fbm(wx * 0.72 + 31, wy * 0.72 - 17, seed + 2222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[0].inletStrength * 0.18) + + (valueNoise(wx + 19, wy - 23, seed + 2233, 18) - 0.5) * (0.03 + terrainTemplate.coastSides[0].inletStrength * 0.10); + const waveB = (fbm(wx * 0.68 - 41, wy * 0.68 + 29, seed + 3222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[1].inletStrength * 0.18) + + (valueNoise(wx - 13, wy + 37, seed + 3233, 16) - 0.5) * (0.03 + terrainTemplate.coastSides[1].inletStrength * 0.10); + const sideA = smoothstep((axis + waveA - (0.50 - terrainTemplate.coastSides[0].penetration)) / Math.max(0.08, terrainTemplate.coastSides[0].plainWidth * 2.4)); + const sideB = smoothstep((-axis + waveB - (0.50 - terrainTemplate.coastSides[1].penetration)) / Math.max(0.08, terrainTemplate.coastSides[1].plainWidth * 2.4)); + return { sideA, sideB, pressure: Math.max(sideA, sideB), signedAxis: axis }; + } const seaLevel = 0.285; - const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({ - x: rand(seed, 100 + i) * MAP_W, - y: rand(seed, 200 + i) * MAP_H, - r: 10 + rand(seed, 300 + i) * 24, - h: 0.08 + rand(seed, 400 + i) * 0.16, - })); - - const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({ - x: rand(seed, 1500 + i) * MAP_W, - y: rand(seed, 1600 + i) * MAP_H, - angle: rand(seed, 1700 + i) * Math.PI * 2, - width: 3 + rand(seed, 1800 + i) * 7, - length: 42 + rand(seed, 1900 + i) * 92, - h: 0.11 + rand(seed, 2000 + i) * 0.22, - })); + const mountainBlobs = Array.from({ length: terrainTemplate.secondaryMountainCount }, (_, i) => { + const spine = spines[i % spines.length]; + const nearSpine = rand(seed, 98 + i) < 0.72; + const edgeBias = rand(seed, 99 + i) < 0.28; + const along = (rand(seed, 100 + i) - 0.5) * spine.length * 0.95; + const side = rand(seed, 101 + i) > 0.5 ? 1 : -1; + const offset = (0.055 + rand(seed, 102 + i) * 0.22) * side; + let x = nearSpine ? spine.x + Math.cos(spine.angle) * along + Math.cos(spine.angle + Math.PI / 2) * offset : rand(seed, 103 + i); + let y = nearSpine ? spine.y + Math.sin(spine.angle) * along + Math.sin(spine.angle + Math.PI / 2) * offset : rand(seed, 104 + i); + if (edgeBias) { + const edgeSide = Math.floor(rand(seed, 105 + i) * 4); + if (edgeSide === 0) x = Math.min(x, 0.08 + rand(seed, 106 + i) * 0.10); + if (edgeSide === 1) x = Math.max(x, 0.92 - rand(seed, 107 + i) * 0.10); + if (edgeSide === 2) y = Math.min(y, 0.08 + rand(seed, 108 + i) * 0.10); + if (edgeSide === 3) y = Math.max(y, 0.92 - rand(seed, 109 + i) * 0.10); + } + const coastSide = (x - 0.5) * coastX + (y - 0.5) * coastY; + const mountainSide = coastSide >= 0 ? 1 : -1; + if (rand(seed, 110 + i) < 0.46 && Math.abs(coastSide) > 0.28 - coastThreshold * 0.35) { + x -= coastX * mountainSide * (0.05 + rand(seed, 111 + i) * 0.11); + y -= coastY * mountainSide * (0.05 + rand(seed, 112 + i) * 0.11); + } + return { + x: clamp(x) * MAP_W, + y: clamp(y) * MAP_H, + r: (terrainTemplate.secondaryMountainSize * (0.72 + rand(seed, 300 + i) * 0.72)) * Math.min(MAP_W, MAP_H), + h: terrainTemplate.secondaryMountainStrength * (0.08 + rand(seed, 400 + i) * 0.17), + }; + }); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { @@ -75,26 +251,22 @@ export function generateTerrainAndRivers(seed) { mountains += Math.exp(-d * d * 2.35) * blob.h; } - let ridges = 0; - for (const ridge of ridgeBands) { - const dx = wx - ridge.x; - const dy = wy - ridge.y; - const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle); - const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle); - const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length); - const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56; - ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration; - } + 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); + const ridges = spineRidges + branchRidges; - const directionalCoast = nx * coastX + ny * coastY; - const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08; - const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26); + 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; + 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 + @@ -102,15 +274,18 @@ export function generateTerrainAndRivers(seed) { 0.105 * terrainLocal + 0.055 * terrainFine + mountains * 0.54 + - ridges * 1.22 + + spineRidges * 0.78 + + branchRidges * 0.92 + basin + fineDissection - - coastLower * (coastStrength + 0.19) + + coastLower * (coastStrength + 0.10 + terrainTemplate.deposition * 0.10) + 0.055; elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26); - ridgeField[i] = clamp(ridges * 4.8 + 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.7); + 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); } } @@ -118,16 +293,106 @@ export function generateTerrainAndRivers(seed) { for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); - const nx = x / (MAP_W - 1) - 0.5; - const ny = y / (MAP_H - 1) - 0.5; - const directionalCoast = nx * coastX + ny * coastY; - const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08; - const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055; + 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); } } + // 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++) { @@ -135,18 +400,25 @@ export function generateTerrainAndRivers(seed) { 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.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022; + 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); - coastalLowland[i] = clamp(1 - nearestSea / 7); + 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)); + } } } } @@ -217,11 +489,12 @@ 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.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise); - const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078); - const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82)); + 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 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); } } @@ -244,8 +517,8 @@ export function generateTerrainAndRivers(seed) { 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 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; - if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score }); + 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 }); } } @@ -595,11 +868,12 @@ export function generateTerrainAndRivers(seed) { 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.060 + slope[i] * 0.145 + ridgeField[i] * 0.038)); - const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040)); - const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0))); + const channelCut = clamp(Math.pow(r, 0.55) * (0.034 + terrainTemplate.erosion * 0.052 + slope[i] * (0.075 + terrainTemplate.erosion * 0.120) + ridgeField[i] * (0.018 + terrainTemplate.erosion * 0.048))); + const valleyWiden = clamp(Math.pow(r, 0.72) * (0.012 + terrainTemplate.erosion * 0.026 + Math.max(0, elevation[i] - seaLevel) * (0.030 + terrainTemplate.erosion * 0.050) + valleyField[i] * (0.020 + terrainTemplate.erosion * 0.045))); + const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * (0.014 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.010 + terrainTemplate.deposition * 0.028) + (slope[i] < 0.10 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - ridgeField[i] * 0.45)); erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); depositionField[i] = clamp(depositionField[i] + alluvium); + depositionalLowland[i] = clamp(depositionalLowland[i] + alluvium * 5.5); fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1); valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4); basinField[i] = clamp(basinField[i] + alluvium * 3.2); @@ -622,7 +896,7 @@ export function generateTerrainAndRivers(seed) { 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.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22); + 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); @@ -631,6 +905,57 @@ export function generateTerrainAndRivers(seed) { } } + // 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++) { @@ -654,7 +979,7 @@ export function generateTerrainAndRivers(seed) { // 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; pass++) { + for (let pass = 0; pass < 3 + Math.round(terrainTemplate.deposition * 2); pass++) { const nextElevation = new Float32Array(elevation); for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { @@ -663,6 +988,9 @@ export function generateTerrainAndRivers(seed) { 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 - @@ -690,9 +1018,9 @@ export function generateTerrainAndRivers(seed) { const localMean = sum / weight; const terrace = Math.round(localMean * 42) / 42; const target = lerp(localMean, terrace, 0.28); - nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1); + nextElevation[i] = clamp(lerp(elevation[i], target, lowland * (0.30 + terrainTemplate.deposition * 0.26)), seaLevel + 0.006, 1); if (lowland > 0.55) { - depositionField[i] = clamp(depositionField[i] + lowland * 0.018); + depositionField[i] = clamp(depositionField[i] + lowland * (0.010 + terrainTemplate.deposition * 0.018)); erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012); } } @@ -785,7 +1113,7 @@ export function generateTerrainAndRivers(seed) { 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; + 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; @@ -798,9 +1126,9 @@ export function generateTerrainAndRivers(seed) { } } - const fan = clamp(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); - agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06); + 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); } } @@ -831,9 +1159,9 @@ export function generateTerrainAndRivers(seed) { } } - const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18; + 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) + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[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); } } @@ -867,12 +1195,39 @@ export function generateTerrainAndRivers(seed) { } } + 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 + ); + } + } + return { + terrainTemplate, elevation, moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -884,6 +1239,12 @@ export function generateTerrainAndRivers(seed) { flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, diff --git a/mapUtils.js b/mapUtils.js index cc5b8ef..e0dff22 100644 --- a/mapUtils.js +++ b/mapUtils.js @@ -100,6 +100,8 @@ export function createMapFields() { moisture: new Float32Array(SIZE), slope: new Float32Array(SIZE), sea: new Uint8Array(SIZE), + ocean: new Uint8Array(SIZE), + lake: new Uint8Array(SIZE), river: new Float32Array(SIZE), floodplain: new Float32Array(SIZE), plain: new Float32Array(SIZE), @@ -111,6 +113,12 @@ export function createMapFields() { flowAccum: new Float32Array(SIZE), erosionField: new Float32Array(SIZE), depositionField: new Float32Array(SIZE), + arcSpineField: new Float32Array(SIZE), + branchRidgeField: new Float32Array(SIZE), + depositionalLowland: new Float32Array(SIZE), + alluvialFanField: new Float32Array(SIZE), + deltaField: new Float32Array(SIZE), + naturalBarrierScore: new Float32Array(SIZE), flowTo, portSuitability: new Float32Array(SIZE), crossingSuitability: new Float32Array(SIZE), diff --git a/names.js b/names.js index ae9ab85..150cb9d 100644 --- a/names.js +++ b/names.js @@ -8,19 +8,21 @@ export const NAME_KANJI_POOLS = { "高", "長", "広", "深", "浅", "白", "黒", "青", "赤", "奥", "前", "後", "内", "外", - "早", "安", "真", "丸", "平", "美", "吉", "福", "幸", "徳", - "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", - ], + "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", + "霧", "霞", "朝", "日", "天", "雨", "晴", + "早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", + ], inlandTerrain: [ - "山", "谷", "ヶ谷", "沢", "原", "野", + "山", "谷", "ヶ谷", "沢", "原", "野", "荒", "森", "林", "岡", "丘", "坂", - "峰", "峠", "嶺", "尾", "平", "坪", "延", - "窪", "久", "洞", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", + "峰", "峠", "嶺", "尾", "平", "坪", "延", "燧", + "窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "聡", "郷", "里", - "馬", "鹿", "亀", "鷲", "鷹" + "馬", "鹿", "亀", "鷲", "鷹", + "妙見", ], waterTerrain: [ @@ -45,7 +47,8 @@ export const NAME_KANJI_POOLS = { "竹", "楠", "藤", "萩", "葦", "菅", "榎", "椿", "桐", "柳", "橘", "柏", "槙", "柿", "桃", - "梨", "桑", "麻", "芦", "茅", + "梨", "桑", "麻", "芦", "茅", + "粟", "稲", "麦", "稗", "米", "飯", "糠", "榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑" ], @@ -57,7 +60,7 @@ export const NAME_KANJI_POOLS = { "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", "本", "内", "窪", "平", "塚", - "畑", "牧", "前", "見", "中", "羽", "生", "塚" + "畑", "牧", "前", "見", "中", "羽", "生", "塚", "部", ], archaicPrefixes: [ @@ -65,9 +68,9 @@ export const NAME_KANJI_POOLS = { "土", "出", "丹", "播", "但", "因", "伯", "筑", "肥", "豊", "日", "紀", "志", "尾", "駿", - "甲", "信", "越", "備", "讃", + "甲", "信", "越", "備", "能", "薩", "隠", "美", "三", "若", - "遠", "近", "能", "加", "賀", + "遠", "近", "能", "加", "賀", "度", "越", "淡", "壱", "阿", "衣", "古", "彦", "多", "志", "布", "治" ], @@ -77,14 +80,14 @@ export const NAME_KANJI_POOLS = { "張", "江", "河", "斐", "濃", "岐", "防", "門", "隅", "向", "居", "前", "中", "後", "波", - "勢", "渡", "城", "紫", "野", + "勢", "渡", "城", "紫", "野", "度", "津", "島", "信", "登", "賀", "志", "良", "美", "智", "茂", "代", "古", "摩", "磨", "麻", "彦", "比古", "子" ], settlementWords: [ "里", "郷", "村", "町", "宿", "邑", "垣", "坪", - "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "妙見", + "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", "市", "場", "府", "関", "地蔵", "辻", "角", "堰", ] diff --git a/renderer.js b/renderer.js index 78d2844..0794714 100644 --- a/renderer.js +++ b/renderer.js @@ -118,6 +118,16 @@ function discreteColor(map, x, y, mode) { ]; const a = map.adminId[i]; color = a >= 0 ? palette[a % palette.length] : [220, 225, 220]; + } else if (mode === "terrain-debug") { + const ridge = clamp(map.ridgeField[i] * 0.68 + (map.arcSpineField?.[i] || 0) * 0.42 + (map.branchRidgeField?.[i] || 0) * 0.34); + const deposit = clamp((map.depositionField?.[i] || 0) * 5.0 + (map.depositionalLowland?.[i] || 0) * 0.48 + (map.alluvialFanField?.[i] || 0) * 0.34 + (map.deltaField?.[i] || 0) * 0.46); + const valley = clamp(map.valleyField[i] * 0.72 + map.river[i] * 0.22); + const barrier = clamp(map.naturalBarrierScore?.[i] || ridge); + color = [ + Math.round(220 - deposit * 70 + ridge * 48), + Math.round(226 + deposit * 38 + valley * 28 - barrier * 52), + Math.round(214 + valley * 58 + barrier * 34 - ridge * 42), + ]; } else if (mode === "admin-debug" || mode === "borders-debug") { const barrier = clamp( map.ridgeField[i] * 0.88 + @@ -449,7 +459,7 @@ function drawLabels(ctx, points, limit = Infinity) { const occupied = []; const prioritized = points .filter((p) => p?.name) - .map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) })) + .map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) + (p.kind === "Municipal Center" ? 34 : 0) })) .sort((a, b) => b.labelPriority - a.labelPriority); for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); } @@ -490,7 +500,11 @@ export function drawMap(canvas, map, options) { const showRoads = ["roads", "all", "development", "landuse"].includes(mode); const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); - if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.90)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 1.5 : mode === "all" ? 0.9 : 1.3); + if (debugBorders && map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(70,70,70,0.32)", 0.75); + if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.96)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 2.1 : mode === "all" ? 0.9 : 1.3); + if (showAdmin && mode !== "all") { + for (const p of map.adminCenters || []) dot(ctx, p, 3.0, "rgba(255,255,255,0.96)", "rgba(70,90,120,0.85)"); + } if (showHistory) { for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true); @@ -548,6 +562,9 @@ export function drawMap(canvas, map, options) { } if (showLabels) { + const adminLabels = (map.adminCenters || []) + .map((p) => ({ ...p, labelPriorityBase: mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? 90 : 8 })) + .filter((p) => mode !== "all" || p.representativeFeatureName); const important = [ ...map.modernCities, ...map.ports, @@ -555,9 +572,10 @@ export function drawMap(canvas, map, options) { ...map.castles.slice(0, mode === "all" ? 5 : 8), ...(map.satelliteCities || []), ...map.newTowns, + ...(mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? adminLabels : adminLabels.slice(0, 8)), ...map.externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - drawLabels(ctx, important, mode === "all" ? 28 : Infinity); + drawLabels(ctx, important, mode === "all" ? 34 : Infinity); } } diff --git a/test.js b/test.js index 62b6a94..147505c 100644 --- a/test.js +++ b/test.js @@ -219,6 +219,71 @@ function regionalComponentMetrics(map) { return { regionCount: ids.size, maxComponents }; } +function meanField(map, fieldName, predicate) { + let sum = 0; + let count = 0; + const field = map[fieldName]; + for (let i = 0; i < field.length; i++) { + if (!predicate(i)) continue; + sum += field[i]; + count++; + } + return count ? sum / count : 0; +} + +function ridgeSinuosityMetric(map) { + const centers = []; + for (let y = 1; y < MAP_H - 1; y++) { + let sum = 0; + let weight = 0; + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (map.sea[i]) continue; + const r = Math.max(0, map.ridgeField[i] - 0.36); + sum += x * r; + weight += r; + } + if (weight > 1.2) centers.push(sum / weight); + } + if (centers.length < 8) return 0; + let turn = 0; + let total = 0; + for (let i = 2; i < centers.length; i++) { + const a = centers[i - 1] - centers[i - 2]; + const b = centers[i] - centers[i - 1]; + turn += Math.abs(b - a); + total += Math.abs(b) + Math.abs(a) + 0.01; + } + return turn / total; +} + +function terrainCoreMetrics(map) { + const land = [...map.elevation].map((_, i) => i).filter((i) => !map.sea[i]); + const mountainCells = land.filter((i) => map.elevation[i] > 0.58 || map.ridgeField[i] > 0.42).length; + const lowlandCells = land.filter((i) => map.plain[i] > 0.38 || map.depositionalLowland?.[i] > 0.24).length; + const ridgeValues = land.map((i) => map.ridgeField[i]); + const ridgeMean = ridgeValues.reduce((sum, value) => sum + value, 0) / Math.max(1, ridgeValues.length); + const ridgeVariance = ridgeValues.reduce((sum, value) => sum + (value - ridgeMean) ** 2, 0) / Math.max(1, ridgeValues.length); + const depositionTargetMean = meanField(map, "depositionField", (i) => !map.sea[i] && (map.coastalLowland[i] > 0.18 || map.basinField[i] > 0.22 || map.river[i] > 0.18 || map.flowAccum[i] > 0.24)); + const depositionOtherMean = meanField(map, "depositionField", (i) => !map.sea[i] && map.coastalLowland[i] < 0.08 && map.basinField[i] < 0.12 && map.river[i] < 0.06 && map.flowAccum[i] < 0.12 && map.ridgeField[i] < 0.28); + const riverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] > 0.20); + const nonRiverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] <= 0.02); + return { + landCount: land.length, + mountainRatio: mountainCells / Math.max(1, land.length), + lowlandRatio: lowlandCells / Math.max(1, land.length), + ridgeVariance, + ridgeSinuosity: ridgeSinuosityMetric(map), + depositionTargetMean, + depositionOtherMean, + riverValleyMean, + nonRiverValleyMean, + depositionSum: [...map.depositionField].reduce((sum, value) => sum + value, 0), + alluvialMax: Math.max(...(map.alluvialFanField || [0])), + deltaMax: Math.max(...(map.deltaField || [0])), + }; +} + try { const map = generateMap(12345); const other = generateMap(54321); @@ -335,6 +400,7 @@ try { const cityCoreIntegrity = majorCityCoreIntegrity(map); const satelliteMetrics = satelliteMunicipalityMetrics(map); const regionalMetrics = regionalComponentMetrics(map); + const terrainMetrics = terrainCoreMetrics(map); assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists"); assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists"); @@ -352,6 +418,7 @@ try { assert(map.elevation.length === size, "elevation length matches map size"); assert(map.sea.length === size, "sea length matches map size"); + assert(map.ocean.length === size && map.lake.length === size, "ocean and lake masks match map size"); assert(map.river.length === size, "river length matches map size"); assert(map.landuse.length === size, "land-use length matches map size"); assert(map.adminId.length === size, "municipal id length matches map size"); @@ -359,6 +426,11 @@ try { assert(map.populationDensity.length === size, "population density length matches map size"); assert(map.ridgeField.length === size && map.valleyField.length === size && map.flowAccum.length === size, "causal terrain fields match map size"); assert(map.erosionField.length === size && map.depositionField.length === size, "erosion and deposition fields match map size"); + assert(map.arcSpineField.length === size && map.branchRidgeField.length === size, "spine and branch ridge fields match map size"); + assert(map.depositionalLowland.length === size && map.alluvialFanField.length === size && map.deltaField.length === size, "depositional debug fields match map size"); + assert(map.naturalBarrierScore.length === size, "natural barrier score field matches map size"); + assert(map.terrainTemplate && Number.isFinite(map.terrainTemplate.deposition) && Number.isFinite(map.terrainTemplate.erosion), "terrain template parameters are exposed"); + 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(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist"); @@ -404,10 +476,21 @@ try { } assert(prefectureComponents === 1, "prefecture area is a single connected component"); assert(map.prefectureBorder.length > 0, "prefecture border exists"); + assert([...map.ocean].some((value) => value === 1), "edge-connected ocean mask exists"); + assert([...map.lake].every((value, i) => !value || (map.sea[i] && !map.ocean[i])), "lake mask only marks isolated non-ocean water"); + assert([...map.sea].every((value, i) => !value || map.ocean[i] || map.lake[i]), "water cells are classified as ocean or lake"); assert(map.adminBorders.length > 0, "municipal borders exist"); assert(map.mainRivers.length > 0, "at least one major river exists"); assert(map.tributaryRivers.length > 0, "tributary river network exists"); assert(map.smallStreams.length > 0, "small stream network exists"); + assert(terrainMetrics.mountainRatio > 0.10 && terrainMetrics.mountainRatio < 0.72, "mountain and ridge area is meaningful but not total"); + assert(terrainMetrics.lowlandRatio > 0.08 && terrainMetrics.lowlandRatio < 0.72, "lowlands exist without dominating every map"); + assert(terrainMetrics.ridgeVariance > 0.004, "ridge field has nontrivial spatial variance"); + assert(terrainMetrics.ridgeSinuosity > 0.015, "ridge centerlines are not perfectly straight bands"); + assert(terrainMetrics.depositionSum > 0.2, "deposition field has nonzero values"); + assert(terrainMetrics.depositionTargetMean >= terrainMetrics.depositionOtherMean * 0.85, "deposition favors rivers, basins, and coastal lowlands"); + assert(terrainMetrics.riverValleyMean > terrainMetrics.nonRiverValleyMean * 1.08, "river cells overlap valley fields more than random non-river cells"); + assert(terrainMetrics.alluvialMax > 0 || terrainMetrics.deltaMax > 0, "alluvial fan or delta fields are active"); assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports"); assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified"); assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes"); @@ -422,7 +505,7 @@ try { assert(map.adminDebug && map.adminDebug.compartmentCount > 0, "natural compartment debug is available"); assert(map.adminDebug.averageCompartmentArea > 0, "natural compartments have positive average area"); assert(Number.isFinite(map.adminDebug.changedAfterLandscapePartition) && Number.isFinite(map.adminDebug.changedAfterSnap), "municipal changed-cell diagnostics exist"); - assert(map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal terrain partition or snap changes admin cells"); + assert(map.adminDebug.changedAfterCompartmentAssignment > 0 || map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal compartment or terrain passes change admin cells"); assert(map.adminDebug.changedAfterFinalExclaveRemoval + map.adminDebug.changedAfterFinalMerge < Math.max(2800, (map.adminDebug.changedAfterLandscapePartition + map.adminDebug.changedAfterSnap + map.adminDebug.changedAfterUrbanLock) * 1.35), "final municipal repair does not erase most terrain and urban changes"); assert(map.adminDebug.finalBorderNaturalBarrierAverage >= 0, "natural barrier score is tracked along final borders"); assert(map.adminDebug.voronoiLikeRateAfter <= Math.max(0.72, map.adminDebug.voronoiLikeRateBefore + 0.20), "natural compartment pass does not increase weak bisectors excessively"); @@ -453,6 +536,17 @@ try { assert(capitalInside, "prefectural capital is inside the prefecture"); assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized"); assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names"); + assert(map.adminCenters.every((item) => item.id && item.name), "municipal centers have ids and names"); + assert(map.entitiesForNames.some((item) => item.kind === "Municipal Center"), "municipal centers are included in label/name candidates"); + assert(map.adminCenters.filter((item) => item.representativeFeatureName && String(item.name).includes(item.representativeFeatureName)).length >= Math.max(1, Math.floor(map.adminCenters.length * 0.70)), "municipal center names relate to representative feature names"); + assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels"); + assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback"); + const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0; + assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low"); + assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities"); + assert(map.adminDebug.targetMunicipalityCount >= 18 && map.adminDebug.actualMunicipalityCount >= 16, "municipality target and actual counts are dense enough"); + assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active"); + assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering"); assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings"); assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters"); assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented"); @@ -487,12 +581,35 @@ try { assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed"); assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed"); assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed"); + assert(JSON.stringify([...againA.elevation]) === JSON.stringify([...againB.elevation]), "elevation is deterministic for the same seed"); + assert(JSON.stringify([...againA.ridgeField]) === JSON.stringify([...againB.ridgeField]), "ridge field is deterministic for the same seed"); + assert(JSON.stringify([...againA.river]) === JSON.stringify([...againB.river]), "river field is deterministic for the same seed"); + assert(JSON.stringify(terrainCoreMetrics(againA)) === JSON.stringify(terrainCoreMetrics(againB)), "terrain debug metrics are deterministic for the same seed"); const blockedCapitalName = "\u52A0\u8302"; const capitalNameMaps = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue)); const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean); assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds"); assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name"); + for (const [n, seeded] of capitalNameMaps.entries()) { + const seedValue = [114514, 12345, 54321, 777, 999][n]; + const metrics = terrainCoreMetrics(seeded); + assert(seeded.mainRivers.length > 0 && seeded.tributaryRivers.length > 0 && seeded.smallStreams.length > 0, `seed ${seedValue}: river hierarchy exists`); + assert(metrics.mountainRatio > 0.08 && metrics.lowlandRatio > 0.06, `seed ${seedValue}: mountain and lowland terrain both exist`); + assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`); + assert(metrics.depositionSum > 0.1 && metrics.depositionTargetMean >= metrics.depositionOtherMean * 0.75, `seed ${seedValue}: deposition is active in plausible lowlands`); + assert(metrics.riverValleyMean > metrics.nonRiverValleyMean, `seed ${seedValue}: rivers follow valley fields`); + assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`); + assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`); + assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`); + assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`); + assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`); + assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`); + } + const byDeposition = capitalNameMaps + .map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio })) + .sort((a, b) => a.deposition - b.deposition); + assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area"); CUSTOM_NAMES["city-0"] = "C1"; const customSameA = generateMap(321); @@ -532,7 +649,13 @@ try { assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`); assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`); assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); - assert(seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal terrain passes change cells`); + assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`); + assert(seeded.adminDebug.targetMunicipalityCount >= 18 && seeded.adminDebug.actualMunicipalityCount >= 16, `seed ${seed}: municipality count is dense enough`); + assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`); + assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`); + assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`); + assert(seeded.regionalDebug?.borderNaturalBarrierAverage > 0.12, `seed ${seed}: regional borders have natural barrier affinity`); + assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`); assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`); assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`); assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`); From 3ac2c116bdab324a66339ec67b06733b45274890 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 22 May 2026 01:30:37 +0900 Subject: [PATCH 5/8] name tweak --- names.js | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/names.js b/names.js index 150cb9d..81ef204 100644 --- a/names.js +++ b/names.js @@ -15,26 +15,25 @@ export const NAME_KANJI_POOLS = { ], inlandTerrain: [ - "山", "谷", "ヶ谷", "沢", "原", "野", "荒", + "山", "野", "荒", "野", "沢", "森", "林", "岡", "丘", "坂", "峰", "峠", "嶺", "尾", "平", "坪", "延", "燧", "窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "聡", "郷", "里", "馬", "鹿", "亀", "鷲", "鷹", - "妙見", ], waterTerrain: [ "川", "河", "江", "瀬", "淵", "渕", "池", "沼", "泉", "井", - "滝", "渓", "沢", "澤", "谷", "津", + "滝", "梅", "沢", "澤", "谷", "津", "水", "清", "渡", "橋", "堀", "溝", "湯", "浦", "洲" ], coastalTerrain: [ - "浜", "浦", "津", "崎", + "津", "浦", "ヶ浦", "津", "崎", "島", "磯", "潟", "湊", "津", "州", "洲", "瀬", "砂", "潮", "塩", "汐", "泊", "江", "浦", "灘", "入", @@ -53,9 +52,8 @@ export const NAME_KANJI_POOLS = { ], postfixes: [ - "田", "原", "ヶ原", "野", "沢", "ヶ沢", "谷", "ヶ谷", - "川", "山", "岡", "森", "林", "ヶ丘", - "浜", "浦", "ヶ浦", "津", "崎", "ヶ崎", "島", + "田", "川", "山", "岡", "森", "林", + "島", "江", "瀬", "井", "戸", "口", "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", @@ -64,25 +62,43 @@ export const NAME_KANJI_POOLS = { ], archaicPrefixes: [ - "伊", "宇", "阿", "安", "佐", - "土", "出", "丹", "播", "但", + "阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "於", + "可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己", + "佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇", + "多", "太", "知", "津", "土", + "那", "奈", "名", "仁", "尼", "根", "乃", "能", + "波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂", + "間", "磨", "摩", "見", "牟", "武", "目", "女", "毛", "裳", + "弥", "矢", "夜", "耶", "由", "与", + "和", "輪", + "出", "播", "但", "因", "伯", "筑", "肥", "豊", "日", "紀", "志", "尾", "駿", "甲", "信", "越", "備", "能", "薩", "隠", "美", "三", "若", "遠", "近", "能", "加", "賀", "度", - "越", "淡", "壱", "阿", "衣", "古", "彦", "多", "志", "布", "治" + "越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治" ], archaicSuffixes: [ - "予", "陀", "芸", "佐", "雲", - "磨", "馬", "幡", "耆", "摩", + "井", "羽", "江", "恵", "尾", "於", + "賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", + "佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", + "多", "太", "知", "津", "豆", "土", + "那", "奈", "名", "仁", "尼", "根", "乃", "能", + "波", "布", "夫", "戸", "保", "穂", + "間", "磨", "摩", "馬", "見", "牟", "武", "目", "女", "毛", "裳", "茂", + "弥", "矢", "夜", "耶", "由", "与", "予", + "良", "利", "礼", "呂", + "和", "輪", + "陀", "芸", "雲", + "幡", "耆", "摩", "張", "江", "河", "斐", "濃", "岐", "防", "門", "隅", "向", "居", "前", "中", "後", "波", "勢", "渡", "城", "紫", "野", "度", "津", "島", "信", "登", "賀", "志", - "良", "美", "智", "茂", "代", "古", "摩", "磨", "麻", "彦", "比古", "子" + "良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子" ], settlementWords: [ @@ -90,6 +106,7 @@ export const NAME_KANJI_POOLS = { "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", "市", "場", "府", "関", "地蔵", "辻", "角", "堰", + "ヶ沢", "ヶ谷", "ヶ浜", "ヶ崎", "ヶ島", "ヶ浦", "ヶ津", "ヶ丘", ] }; From da9d8ef90464228d381261b657fa14bdafe0f5eb Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 22 May 2026 02:11:18 +0900 Subject: [PATCH 6/8] ? --- adminRegions.js | 13 +- mapAdminStage.js | 23 +- mapFeatures.js | 83 +++++++ mapGeneratorHelpers.js | 1 + mapOutput.js | 57 ++++- mapPipeline.js | 5 +- mapTerrain.js | 67 ++++++ names.js | 10 +- renderer.js | 515 ++++++++++++++++------------------------- styles.css | 67 +++++- test.js | 125 +++++++++- 11 files changed, 617 insertions(+), 349 deletions(-) diff --git a/adminRegions.js b/adminRegions.js index bf7aa0e..6bbd906 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -1057,13 +1057,20 @@ export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, } 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 * 2.25 || averageLowland < 0.28 || averageMountain > 0.44) continue; + 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) continue; + if (meaningfulNodes.length < 2) { + rejectedMunicipalities++; + continue; + } let changedHere = 0; for (const unit of compartments) { if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue; @@ -1094,7 +1101,7 @@ export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, 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 }; + return { changedCells, splitMunicipalities, rejectedMunicipalities }; } export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { diff --git a/mapAdminStage.js b/mapAdminStage.js index 6f4e844..988e0e9 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -26,9 +26,10 @@ function municipalityAreaById(adminId, prefectureMask, sea) { return area; } -function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) { +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++) { @@ -36,7 +37,8 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField const i = indexOf(x, y); if (!prefectureMask[i] || sea[i]) continue; landCells++; - if (slope[i] < 0.42 && ridgeField[i] < 0.55) habitableCells++; + 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); @@ -48,10 +50,12 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField } } const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length; - const settlementNodes = modernCities.length * 1.25 + markets.length * 0.9 + ports.length * 0.7 + independentSatellites * 0.8 + villages.length * 0.35; + 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; - return clamp(Math.round(habitableCells / 260 + settlementNodes * 0.45 + coastlineComplexity * 0.04 + basinBonus + mountainRatio * 4), 18, 48); + 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 estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { @@ -267,7 +271,7 @@ export function generateAdminLayout({ return !nearMajor && !nearSmallUrban; }); const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); - const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, 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 }); const satelliteMunicipalSeeds = (satelliteCities || []) .filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality") .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); @@ -310,8 +314,12 @@ export function generateAdminLayout({ 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, satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length, satelliteMunicipalitiesMerged: 0, satelliteMunicipalitiesExpanded: 0, @@ -404,6 +412,9 @@ export function generateAdminLayout({ 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"); @@ -448,6 +459,8 @@ export function generateAdminLayout({ Object.assign(adminDebug, landscapeDebug); adminDebug.naturalCompartmentCount = adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0; adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size; + adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0; + adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0; const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); diff --git a/mapFeatures.js b/mapFeatures.js index e5567e0..c8ef326 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -971,6 +971,88 @@ export function generateMapFeatures(seed, terrain) { } }); + const requiredTransportNodes = []; + function addRequiredTransportNode(node, reason) { + if (!node || !inside(node.x, node.y) || sea[indexOf(node.x, node.y)]) return; + const key = `${node.x},${node.y}`; + if (requiredTransportNodes.some((p) => `${p.x},${p.y}` === key)) return; + requiredTransportNodes.push({ ...node, requiredTransportReason: reason }); + } + addRequiredTransportNode(capital, "capital"); + for (const gate of externalGateways) addRequiredTransportNode(gate, "externalGateway"); + for (const city of modernCities) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) addRequiredTransportNode(city, "majorCity"); + for (const port of majorPorts) addRequiredTransportNode(port, "majorPort"); + + const backboneAccess = new Map(); + function nodeKey(p) { + return `${p.x},${p.y}`; + } + function backbonePoint(node) { + const key = nodeKey(node); + if (!backboneAccess.has(key)) { + const mode = node.requiredTransportReason === "externalGateway" ? "road" : "road"; + backboneAccess.set(key, routePoint(node, mode, 18000 + node.x * 97 + node.y * 101)); + } + return backboneAccess.get(key); + } + function addBackboneRoad(a, b) { + const start = backbonePoint(a); + const goal = backbonePoint(b); + const existing = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways]; + const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 2, 4.2, townAvoidNodes, 2.6, 4.2)); + if (path.length <= 3 || pathCompactness(path) > 3.8 || path.some(([x, y]) => elevation[indexOf(x, y)] > 0.72)) return false; + nationalRoads.push(path); + incrementDegree(roadDegree, a); + incrementDegree(roadDegree, b); + return true; + } + const connectedBackboneNodes = requiredTransportNodes.length ? [requiredTransportNodes[0]] : []; + const pendingBackboneNodes = requiredTransportNodes.slice(1); + let backboneEdgeCount = 0; + while (pendingBackboneNodes.length && connectedBackboneNodes.length) { + let bestIndex = -1; + let bestAnchor = null; + let bestScore = INF; + for (let i = 0; i < pendingBackboneNodes.length; i++) { + const node = pendingBackboneNodes[i]; + for (const anchor of connectedBackboneNodes) { + const d = Math.hypot(node.x - anchor.x, node.y - anchor.y); + const ai = indexOf(anchor.x, anchor.y); + const bi = indexOf(node.x, node.y); + const corridor = sameCorridorAffinity(anchor, node); + const score = d * (1.0 - corridor * 0.22) + Math.max(elevation[ai], elevation[bi]) * 8 - Math.max(passSuitability[ai], passSuitability[bi]) * 4; + if (score < bestScore) { + bestScore = score; + bestIndex = i; + bestAnchor = anchor; + } + } + } + if (bestIndex < 0 || !bestAnchor) break; + const node = pendingBackboneNodes.splice(bestIndex, 1)[0]; + if (addBackboneRoad(bestAnchor, node)) backboneEdgeCount++; + connectedBackboneNodes.push(node); + } + + function nearestPathCellDistance(node, paths) { + let best = INF; + for (const path of paths) { + for (const [x, y] of path) best = Math.min(best, Math.hypot(node.x - x, node.y - y)); + } + return best; + } + const combinedModernBackbone = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways]; + const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernBackbone) <= 7).length; + const missingRequiredNodes = requiredTransportNodes + .filter((node) => nearestPathCellDistance(node, combinedModernBackbone) > 7) + .map((node) => ({ x: node.x, y: node.y, kind: node.kind, reason: node.requiredTransportReason })); + const transportDebug = { + requiredNodeCount: requiredTransportNodes.length, + connectedRequiredNodeCount, + missingRequiredNodes, + backboneEdgeCount, + }; + function pruneHighMountainTransport(paths, threshold = 0.82) { for (let i = paths.length - 1; i >= 0; i--) { if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); @@ -1327,6 +1409,7 @@ export function generateMapFeatures(seed, terrain) { railInfluence2, villageInfluence, externalGateways, + transportDebug, cityPopulationCap, }; } diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 918db7d..4bcb6be 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -498,6 +498,7 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, compartmentCount: compartments.filter((unit) => unit.area > 0).length, changedAfterCompartmentAssignment: changed, borderNaturalBarrierAverage: afterNaturalAverage, + voronoiLikeRate: afterVoronoiLikeRate, }, }; } diff --git a/mapOutput.js b/mapOutput.js index be7b84c..9545cfb 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -72,10 +72,12 @@ export function finishMapOutput({ tributaryRivers, smallStreams, externalGateways, + transportDebug, prefectureMask, prefectureBorder, prefectureRegionId, regionalDebug, + terrainDebug, regionalPrefectureBorders, }) { // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. @@ -171,28 +173,59 @@ export function finishMapOutput({ center.name = best.name; } } - const adminNamePrefixes = ["\u6771", "\u897F", "\u5357", "\u5317", "\u4E0A", "\u4E0B", "\u65B0", "\u65E7", "\u4E2D", "\u5916"]; + const adminNameSuffixes = [ + "\u753A\u57DF", + "\u5E02\u57DF", + "\u90F7\u57DF", + "\u6D41\u57DF", + "\u6E7E\u5CB8", + "\u5C71\u9E93", + "\u5E73\u91CE", + "\u5730\u533A", + ]; const adminNameCounts = new Map(); for (const center of adminCenters) adminNameCounts.set(center.name, (adminNameCounts.get(center.name) || 0) + 1); - const duplicateOrdinal = new Map(); + const baseVariantCounts = new Map(); for (const center of adminCenters) { - if ((adminNameCounts.get(center.name) || 0) <= 1) continue; - const n = duplicateOrdinal.get(center.name) || 0; - duplicateOrdinal.set(center.name, n + 1); - const prefix = adminNamePrefixes[(Math.floor(center.x / Math.max(1, MAP_W / 3)) + Math.floor(center.y / Math.max(1, MAP_H / 3)) * 3 + n) % adminNamePrefixes.length]; - if (!String(center.name).startsWith(prefix)) center.name = `${prefix}${center.name}`; + const base = String(center.name || ""); + const count = adminNameCounts.get(base) || 0; + if (count <= 1) { + baseVariantCounts.set(base, Math.max(baseVariantCounts.get(base) || 0, 1)); + continue; + } + const usedForBase = baseVariantCounts.get(base) || 0; + if (usedForBase === 0) { + baseVariantCounts.set(base, 1); + continue; + } + if (usedForBase < 2) { + const i = indexOf(center.x, center.y); + const naturalSuffix = coastalLowland[i] > 0.28 + ? "\u6E7E\u5CB8" + : basinField[i] > 0.28 + ? "\u5E73\u91CE" + : ridgeField[i] > 0.42 || slope[i] > 0.34 + ? "\u5C71\u9E93" + : river[i] > 0.25 || flowAccum[i] > 0.38 + ? "\u6D41\u57DF" + : adminNameSuffixes[(Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNameSuffixes.length]; + center.name = `${base}${naturalSuffix}`; + center.derivedFromBaseName = base; + nameDebug.derivedNameCount++; + baseVariantCounts.set(base, usedForBase + 1); + } } const usedAdminNames = new Set(); for (const center of adminCenters) { let candidate = center.name; - let guard = 0; - while (usedAdminNames.has(candidate) && guard < adminNamePrefixes.length) { - candidate = `${adminNamePrefixes[(guard + Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNamePrefixes.length]}${center.name}`; - guard++; + const generated = String(center.generatedMunicipalityName || ""); + if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { + candidate = generated; } center.name = candidate; usedAdminNames.add(center.name); } + nameDebug.maxDerivedPerBase = Math.max(0, ...baseVariantCounts.values()); const entitiesForNames = [ ...modernCities, @@ -220,6 +253,7 @@ export function finishMapOutput({ prefectureBorder, prefectureRegionId, regionalDebug, + terrainDebug, regionalPrefectureBorders, elevation, moisture, @@ -291,6 +325,7 @@ export function finishMapOutput({ tributaryRivers, smallStreams, externalGateways, + transportDebug, entitiesForNames, nameDebug, }, options); diff --git a/mapPipeline.js b/mapPipeline.js index 0ec01f0..0c18ed2 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -42,6 +42,7 @@ export function generateMap(seedInput = 114514, options = {}) { prefectureBorder, prefectureRegionId, regionalDebug, + terrainDebug, regionalPrefectureBorders, riverPaths, mainRivers, @@ -53,7 +54,7 @@ export function generateMap(seedInput = 114514, options = {}) { 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, + interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug, } = features; const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ @@ -69,6 +70,6 @@ export function generateMap(seedInput = 114514, options = {}) { 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, + regionalDebug, terrainDebug, transportDebug, }); } diff --git a/mapTerrain.js b/mapTerrain.js index 31d47c8..ce0e763 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -1219,6 +1219,72 @@ export function generateTerrainAndRivers(seed) { } } + function countWaterComponents(mask, minArea = 1) { + const seen = new Uint8Array(SIZE); + let count = 0; + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) continue; + const queue = [i]; + seen[i] = 1; + let area = 0; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + area++; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!mask[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (area >= minArea) count++; + } + return count; + } + + function countSmallLandIslands(maxArea = 8) { + const seen = new Uint8Array(SIZE); + let count = 0; + for (let i = 0; i < SIZE; i++) { + if (sea[i] || seen[i]) continue; + const queue = [i]; + seen[i] = 1; + let area = 0; + let touchesEdge = false; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + area++; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesEdge = true; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (sea[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (!touchesEdge && area <= maxArea) count++; + } + return count; + } + + const spineValues = [...arcSpineField].filter((_, i) => !sea[i]).sort((a, b) => b - a); + const strongSpineSample = Math.max(1, Math.floor(spineValues.length * 0.05)); + const primarySpineStrength = spineValues.slice(0, strongSpineSample).reduce((sum, value) => sum + value, 0) / strongSpineSample; + const riverConnectivityRate = mainRivers.length + ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && neighbors8(x, y).some(([nx, ny]) => sea[indexOf(nx, ny)] || lake[indexOf(nx, ny)]))).length / mainRivers.length + : 0; + const depositionLowlandArea = [...depositionalLowland].filter((value, i) => !sea[i] && value > 0.24).length; + const terrainDebug = { + primarySpineStrength, + riverConnectivityRate, + smallIslandCount: countSmallLandIslands(8), + largeInlandLakeCount: countWaterComponents(Float32Array.from(lake, (value) => value ? 1 : 0), 120), + depositionLowlandArea, + }; return { terrainTemplate, @@ -1252,6 +1318,7 @@ export function generateTerrainAndRivers(seed) { prefectureBorder, prefectureRegionId, regionalDebug, + terrainDebug, regionalPrefectureBorders, riverPaths, mainRivers, diff --git a/names.js b/names.js index 81ef204..693308e 100644 --- a/names.js +++ b/names.js @@ -406,9 +406,13 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME generatedNamesUsed: 0, invalidNamesRejected: 0, oneCharacterNamesPrevented: 0, + rejectedOneCharacterNames: 0, duplicateRetries: 0, fallbackAttempts: 0, legacyFallbackUsed: 0, + oneKanjiAppendFallbackUsed: 0, + derivedNameCount: 0, + maxDerivedPerBase: 0, }; } @@ -514,6 +518,7 @@ function tryCustomName(seed, id, usedNames, debug) { const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true }); if (!validation.valid) { if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++; + if (validation.reason === "oneCharacter") debug.rejectedOneCharacterNames++; else debug.invalidNamesRejected++; return null; } @@ -546,7 +551,10 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d continue; } if (result.invalidReason) { - if (result.invalidReason === "oneCharacter") debug.oneCharacterNamesPrevented++; + if (result.invalidReason === "oneCharacter") { + debug.oneCharacterNamesPrevented++; + debug.rejectedOneCharacterNames++; + } else debug.invalidNamesRejected++; continue; } diff --git a/renderer.js b/renderer.js index 0794714..b88c4a6 100644 --- a/renderer.js +++ b/renderer.js @@ -9,9 +9,9 @@ function distToNearest(points, x, y, fallback = 999) { function blendOutside(color, isInside) { if (isInside) return color; return [ - Math.round(color[0] * 0.55 + 112), - Math.round(color[1] * 0.55 + 112), - Math.round(color[2] * 0.55 + 112), + Math.round(color[0] * 0.8 + 50), + Math.round(color[1] * 0.8 + 50), + Math.round(color[2] * 0.8 + 50), ]; } @@ -38,47 +38,36 @@ function terrainColorContinuous(map, fx, fy, mode) { let color; if (map.sea[i]) { - // Google Map風の海の色(明るい青) const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4); - color = [Math.round(170 + depth * 10), Math.round(211 + depth * 15), Math.round(223 + depth * 20)]; + color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)]; } else if (mode === "suitability") { const a = fieldSample(map.agriculture, fx, fy); const p = fieldSample(map.plain, fx, fy); const f = fieldSample(map.floodplain, fx, fy); color = [ - Math.round(230 - p * 25 + f * 15), - Math.round(235 + a * 15), - Math.round(210 - a * 25 + p * 20), + Math.round(240 - p * 15 + f * 10), + Math.round(242 + a * 10), + Math.round(235 - a * 15 + p * 10), ]; } else if (mode === "development") { - const x = Math.floor(fx); - const y = Math.floor(fy); - const baseIndex = indexOf(Math.max(0, Math.min(MAP_W - 1, x)), Math.max(0, Math.min(MAP_H - 1, y))); const dCity = distToNearest(map.modernCities, fx, fy); - const dInd = distToNearest(map.industrialZones, fx, fy); - const dLog = distToNearest(map.logisticsParks, fx, fy); - const dNew = distToNearest(map.newTowns, fx, fy); const urban = clamp(1 - dCity / 25); const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban; - const industrial = clamp(1 - dInd / 10); - const logistics = clamp(1 - dLog / 9); - const newTown = clamp(1 - dNew / 9); - const base = 214 + map.plain[baseIndex] * 22; + const base = 235; color = [ - Math.round(base + density * 30 + urban * 8 + industrial * 14), - Math.round(base + density * 10 + logistics * 15 + newTown * 12), - Math.round(208 + map.agriculture[baseIndex] * 22 + density * 18 + newTown * 22), + Math.round(base + density * 20), + Math.round(base + density * 5), + Math.round(230 + density * 10), ]; } else { - // 起伏の大きさが読めるよう、標高段彩をやや強める + // 地形色を少し濃く(暗く)調整 const e = fieldSample(map.elevation, fx, fy); - const m = fieldSample(map.moisture, fx, fy); - if (e > 0.82) color = [178, 170, 160]; - else if (e > 0.68) color = [198, 188, 164]; - else if (e > 0.52) color = [205, 222 + m * 5, 184]; - else if (e > 0.34) color = [224, 238 + m * 6, 206]; - else if (e > 0.24) color = [236, 245 + m * 5, 220]; - else color = [218, 232 + m * 6, 206]; + 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]; } return blendOutside(color, isInside); @@ -89,62 +78,31 @@ function discreteColor(map, x, y, mode) { let color; if (map.sea[i]) { - // Google Map like styled color = [170, 218, 255]; } else if (mode === "landuse") { const colors = { - 0: [230, 242, 220], // farmland - 1: [235, 245, 225], // plain - 2: [235, 230, 220], // old city - 3: [224, 202, 190], // CBD - 4: [245, 240, 230], // suburb - 5: [220, 220, 225], // industrial area - 6: [225, 235, 225], // logistics area - 7: [238, 242, 248], // new town - 8: [248, 242, 230], // coastal development - 9: [225, 238, 220], // others + 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], }; color = colors[map.landuse[i]] || colors[0]; } else if (mode === "admin") { const palette = [ - [245, 235, 230], - [235, 245, 235], - [240, 240, 250], - [250, 245, 230], - [245, 240, 248], - [230, 245, 245], - [250, 240, 240], - [240, 250, 235], + [250, 245, 242], [245, 250, 245], [245, 245, 252], + [252, 250, 242], [250, 245, 250], [242, 250, 250] ]; const a = map.adminId[i]; - color = a >= 0 ? palette[a % palette.length] : [220, 225, 220]; - } else if (mode === "terrain-debug") { - const ridge = clamp(map.ridgeField[i] * 0.68 + (map.arcSpineField?.[i] || 0) * 0.42 + (map.branchRidgeField?.[i] || 0) * 0.34); - const deposit = clamp((map.depositionField?.[i] || 0) * 5.0 + (map.depositionalLowland?.[i] || 0) * 0.48 + (map.alluvialFanField?.[i] || 0) * 0.34 + (map.deltaField?.[i] || 0) * 0.46); - const valley = clamp(map.valleyField[i] * 0.72 + map.river[i] * 0.22); - const barrier = clamp(map.naturalBarrierScore?.[i] || ridge); - color = [ - Math.round(220 - deposit * 70 + ridge * 48), - Math.round(226 + deposit * 38 + valley * 28 - barrier * 52), - Math.round(214 + valley * 58 + barrier * 34 - ridge * 42), - ]; - } else if (mode === "admin-debug" || mode === "borders-debug") { - const barrier = clamp( - map.ridgeField[i] * 0.88 + - Math.max(0, map.river[i] - 0.28) * 1.25 + - Math.max(0, map.flowAccum[i] - 0.36) * 0.72 + - map.slope[i] * 0.48 + - Math.max(0, map.elevation[i] - 0.54) * 0.34 - ); - color = [ - Math.round(238 - barrier * 28), - Math.round(242 - barrier * 88), - Math.round(226 + barrier * 20), - ]; + color = a >= 0 ? palette[a % palette.length] : [240, 242, 240]; } else { color = terrainColorContinuous(map, x, y, "terrain"); } - return blendOutside(color, Boolean(map.prefectureMask[i])); } @@ -165,17 +123,12 @@ 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.9 + (eR - eL) * 1.0 + (eD - eU) * 0.65, 0.72, 1.18); - const elevation = fieldSample(map.elevation, fx, fy); - const contour = Math.abs((elevation * 16) - Math.round(elevation * 16)); - const majorContour = Math.abs((elevation * 8) - Math.round(elevation * 8)); - const isLand = !map.sea[indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))))]; - const contourFactor = isLand && majorContour < 0.022 ? 0.86 : isLand && contour < 0.028 ? 0.94 : 1; + const shade = clamp(0.95 + (eR - eL) * 0.6 + (eD - eU) * 0.4, 0.85, 1.08); const ii = (py * width + px) * 4; - img.data[ii] = Math.round(r * shade * contourFactor); - img.data[ii + 1] = Math.round(g * shade * contourFactor); - img.data[ii + 2] = Math.round(b * shade * contourFactor); + img.data[ii] = Math.round(r * shade); + img.data[ii + 1] = Math.round(g * shade); + img.data[ii + 2] = Math.round(b * shade); img.data[ii + 3] = 255; } } @@ -195,7 +148,6 @@ function drawBase(ctx, map, mode, continuousTerrain) { } } } - ctx.putImageData(img, 0, 0); } @@ -206,7 +158,7 @@ function drawPath(ctx, path, color, width, dashed = false) { ctx.lineJoin = "round"; ctx.strokeStyle = color; ctx.lineWidth = width; - if (dashed) ctx.setLineDash([6, 5]); + if (dashed) ctx.setLineDash([8, 6]); ctx.beginPath(); ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2); for (let k = 1; k < path.length; k++) { @@ -216,72 +168,64 @@ function drawPath(ctx, path, color, width, dashed = false) { ctx.restore(); } -function segmentPointKey(p) { - return `${p[0]},${p[1]}`; -} +// 魚の骨(私鉄記号)スタイルを描画するための専用関数 +function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { + if (!path || path.length < 2) return; + ctx.save(); + ctx.lineCap = "butt"; + ctx.lineJoin = "round"; + ctx.strokeStyle = color; -function chainSegments(segments) { - const unused = segments.map((seg) => [seg[0], seg[1]]); - const chains = []; - while (unused.length) { - const chain = unused.pop(); - let grew = true; - while (grew) { - grew = false; - const head = segmentPointKey(chain[0]); - const tail = segmentPointKey(chain[chain.length - 1]); - for (let i = unused.length - 1; i >= 0; i--) { - const [a, b] = unused[i]; - const ak = segmentPointKey(a); - const bk = segmentPointKey(b); - if (ak === tail) { chain.push(b); unused.splice(i, 1); grew = true; break; } - if (bk === tail) { chain.push(a); unused.splice(i, 1); grew = true; break; } - if (bk === head) { chain.unshift(a); unused.splice(i, 1); grew = true; break; } - if (ak === head) { chain.unshift(b); unused.splice(i, 1); grew = true; break; } - } - } - chains.push(chain); + // 中心の実線を描画 + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2); + for (let k = 1; k < path.length; k++) { + ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2); } - return chains; -} + ctx.stroke(); -function chaikin(points, passes = 1) { - let out = points; - for (let pass = 0; pass < passes; pass++) { - if (out.length < 3) return out; - const next = [out[0]]; - for (let i = 0; i < out.length - 1; i++) { - const a = out[i]; - const b = out[i + 1]; - next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); - next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + // 棘(クロスハッチ)を描画 + ctx.lineWidth = 1.0; + ctx.beginPath(); + let leftover = 0; + for (let k = 0; k < path.length - 1; k++) { + const x1 = path[k][0] * CELL_SIZE + CELL_SIZE / 2; + const y1 = path[k][1] * CELL_SIZE + CELL_SIZE / 2; + const x2 = path[k+1][0] * CELL_SIZE + CELL_SIZE / 2; + const y2 = path[k+1][1] * CELL_SIZE + CELL_SIZE / 2; + const dx = x2 - x1; + const dy = y2 - y1; + const dist = Math.hypot(dx, dy); + if (dist === 0) continue; + + // 法線(直角)ベクトル + const nx = dx / dist; + const ny = dy / dist; + const px = -ny * (tickLen / 2); + const py = nx * (tickLen / 2); + + let d = (spacing / 2) + leftover; + while (d < dist) { + const cx = x1 + nx * d; + const cy = y1 + ny * d; + ctx.moveTo(cx + px, cy + py); + ctx.lineTo(cx - px, cy - py); + d += spacing; } - next.push(out[out.length - 1]); - out = next; + leftover = d - dist; } - return out; + ctx.stroke(); + ctx.restore(); } -function drawSegments(ctx, segments, color, width, dashed = false, smooth = false) { +function drawSegments(ctx, segments, color, width, dashed = false) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = "round"; ctx.lineJoin = "round"; - if (dashed) ctx.setLineDash([4, 4]); - - if (smooth) { - for (const chain of chainSegments(segments)) { - const points = chaikin(chain, 1); - if (points.length < 2) continue; - ctx.beginPath(); - ctx.moveTo(points[0][0] * CELL_SIZE, points[0][1] * CELL_SIZE); - for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0] * CELL_SIZE, points[i][1] * CELL_SIZE); - ctx.stroke(); - } - ctx.restore(); - return; - } + if (dashed) ctx.setLineDash([6, 5]); for (const seg of segments) { ctx.beginPath(); @@ -296,15 +240,14 @@ function drawUrbanAreas(ctx, map, mode) { const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"]; if (!visibleModes.includes(mode)) return; - // Google Map風の都市部の色 const colors = { - 2: "rgba(235, 230, 220, 0.75)", // 旧市街 - 薄いベージュ - 3: "rgba(224, 202, 190, 0.9)", // 中心市街地 / CBD - cell fill - 4: "rgba(245, 242, 235, 0.7)", // 郊外 - 薄いクリーム - 5: "rgba(220, 220, 228, 0.8)", // 工業地域 - 薄いグレー - 6: "rgba(225, 235, 228, 0.75)", // 物流 - 薄い緑グレー - 7: "rgba(238, 242, 250, 0.75)", // ニュータウン - 薄い青白 - 8: "rgba(248, 245, 238, 0.7)", // 沿道 - クリーム + 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)", }; ctx.save(); @@ -319,38 +262,22 @@ function drawUrbanAreas(ctx, map, mode) { const py = y * CELL_SIZE; ctx.fillStyle = colors[lu]; ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE); + } + } + ctx.restore(); +} - const h = ((x * 92821 + y * 68917 + lu * 131) >>> 0); - if (lu === 3 || lu === 2 || lu === 5 || lu === 6) { - // 建物の表現を控えめに - ctx.fillStyle = lu === 3 ? "rgba(200,200,200,0.25)" : "rgba(210,210,210,0.2)"; - if (h % 3 !== 0) ctx.fillRect(px + 1, py + 1, 2, 2); - if (h % 5 !== 0) ctx.fillRect(px + 3, py + 2, 2, 2); - if (h % 7 !== 0) ctx.fillRect(px + 2, py + 4, 2, 1.5); - } else if (lu === 4 || lu === 7) { - ctx.strokeStyle = lu === 7 ? "rgba(220,220,230,0.15)" : "rgba(200,190,180,0.15)"; - ctx.lineWidth = 0.8; - ctx.beginPath(); - if (h % 2 === 0) { - ctx.moveTo(px + 1, py + 1); - ctx.lineTo(px + CELL_SIZE - 1, py + 1); - ctx.moveTo(px + 1, py + 4); - ctx.lineTo(px + CELL_SIZE - 1, py + 4); - } else { - ctx.moveTo(px + 1, py + 1); - ctx.lineTo(px + 1, py + CELL_SIZE - 1); - ctx.moveTo(px + 4, py + 1); - ctx.lineTo(px + 4, py + CELL_SIZE - 1); - } - ctx.stroke(); - } else if (lu === 8) { - ctx.strokeStyle = "rgba(180,170,160,0.2)"; - ctx.lineWidth = 0.9; - ctx.beginPath(); - ctx.moveTo(px + 1, py + 3); - ctx.lineTo(px + CELL_SIZE - 1, py + 3); - ctx.stroke(); - } +function drawDebugCells(ctx, map, field, color) { + if (!field) return; + 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] || map.sea[i]) continue; + const v = clamp(field[i] || 0, 0, 1); + if (v <= 0.12) continue; + ctx.fillStyle = color(v); + ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); } } ctx.restore(); @@ -361,91 +288,41 @@ function dot(ctx, p, radius, fill, stroke = "white") { ctx.arc(p.x * CELL_SIZE + CELL_SIZE / 2, p.y * CELL_SIZE + CELL_SIZE / 2, radius, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); - ctx.lineWidth = 1.5; + ctx.lineWidth = 1.2; ctx.strokeStyle = stroke; ctx.stroke(); } -function squareIcon(ctx, p, size, fill, stroke = "white") { - const x = p.x * CELL_SIZE + CELL_SIZE / 2; - const y = p.y * CELL_SIZE + CELL_SIZE / 2; - ctx.save(); - ctx.fillStyle = fill; - ctx.strokeStyle = stroke; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.rect(x - size / 2, y - size / 2, size, size); - ctx.fill(); - ctx.stroke(); - ctx.restore(); -} - -function triangleIcon(ctx, p, size, fill, stroke = "white") { - const x = p.x * CELL_SIZE + CELL_SIZE / 2; - const y = p.y * CELL_SIZE + CELL_SIZE / 2; - ctx.save(); - ctx.fillStyle = fill; - ctx.strokeStyle = stroke; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(x, y - size / 2); - ctx.lineTo(x + size / 2, y + size / 2); - ctx.lineTo(x - size / 2, y + size / 2); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - ctx.restore(); -} - -function railStationIcon(ctx, p) { - squareIcon(ctx, p, 5.2, "rgba(255,255,255,0.96)", "rgba(55,55,55,0.92)"); -} - - -function drawHarborWorks(ctx, map) { - if (!map.harborWorks) return; - ctx.save(); - ctx.strokeStyle = "rgba(95, 120, 150, 0.9)"; - ctx.lineWidth = 2.2; - ctx.lineCap = "round"; - for (const harbor of map.harborWorks) { - for (const seg of harbor.segments || []) { - ctx.beginPath(); - ctx.moveTo(seg[0][0] * CELL_SIZE + CELL_SIZE / 2, seg[0][1] * CELL_SIZE + CELL_SIZE / 2); - ctx.lineTo(seg[1][0] * CELL_SIZE + CELL_SIZE / 2, seg[1][1] * CELL_SIZE + CELL_SIZE / 2); - ctx.stroke(); - } - } - ctx.restore(); -} - -function boxesOverlap(a, b, pad = 2) { +function boxesOverlap(a, b, pad = 3) { return !(a.x2 + pad < b.x1 || a.x1 - pad > b.x2 || a.y2 + pad < b.y1 || a.y1 - pad > b.y2); } function labelWithCollision(ctx, p, occupied) { if (!p.name) return false; ctx.save(); - ctx.font = "11px ui-sans-serif, system-ui, sans-serif"; + ctx.font = "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif"; const baseX = p.x * CELL_SIZE + CELL_SIZE / 2; const baseY = p.y * CELL_SIZE + CELL_SIZE / 2; const textW = ctx.measureText(p.name).width; const textH = 12; const candidates = [ - [7, -5], [7, 12], [-textW - 7, -5], [-textW - 7, 12], - [-textW / 2, -13], [-textW / 2, 20], [12, 2], [-textW - 12, 2], + [7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13], + [-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4], ]; for (const [ox, oy] of candidates) { const x = baseX + ox; const y = baseY + oy; - const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 3 }; + const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 4 }; if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue; if (occupied.some((b) => boxesOverlap(box, b))) continue; - ctx.lineWidth = 3; - ctx.strokeStyle = "rgba(255,255,255,0.95)"; - ctx.fillStyle = "rgba(40,40,40,0.95)"; + + ctx.lineJoin = "round"; + ctx.lineWidth = 3.5; + ctx.strokeStyle = "rgba(255, 255, 255, 0.95)"; ctx.strokeText(p.name, x, y); + + ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333"; ctx.fillText(p.name, x, y); occupied.push(box); ctx.restore(); @@ -459,7 +336,7 @@ function drawLabels(ctx, points, limit = Infinity) { const occupied = []; const prioritized = points .filter((p) => p?.name) - .map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) + (p.kind === "Municipal Center" ? 34 : 0) })) + .map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) })) .sort((a, b) => b.labelPriority - a.labelPriority); for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); } @@ -471,111 +348,113 @@ export function drawMap(canvas, map, options) { const mode = options.mode || "all"; const showFeatures = options.showFeatures !== false; const showLabels = options.showLabels !== false; - const continuousTerrain = true; - + const width = MAP_W * CELL_SIZE; const height = MAP_H * CELL_SIZE; canvas.width = width; canvas.height = height; - drawBase(ctx, map, mode, continuousTerrain); + // 1. Base Terrain & Urban + drawBase(ctx, map, mode, true); drawUrbanAreas(ctx, map, mode); - // Rivers use the same hue family as the sea; hierarchy is expressed by width/opacity. - const waterBlue = "rgba(170, 218, 255, 0.95)"; - // Small streams remain in the data model but are not drawn by default. - for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(170, 218, 255, 0.78)", 1.35); - for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2); - drawHarborWorks(ctx, map); + // 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 debugBorders = mode === "admin-debug" || mode === "borders-debug"; - if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, debugBorders ? "rgba(40,40,40,0.82)" : mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", debugBorders ? 1.8 : 1.0, false, mode === "all"); - drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true); - drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true); + 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 showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); + + // 3. Borders + if (showAdmin && map.adminBorders) { + drawSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false); + drawSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true); + } + 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); + if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false); + } + + drawSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false); + drawSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true); if (!showFeatures) return; - const showHistory = ["history", "all", "terrain", "suitability"].includes(mode); - const showModern = ["modern", "all", "development", "landuse"].includes(mode); - const showRoads = ["roads", "all", "development", "landuse"].includes(mode); - const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); - - if (debugBorders && map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(70,70,70,0.32)", 0.75); - if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.96)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 2.1 : mode === "all" ? 0.9 : 1.3); - if (showAdmin && mode !== "all") { - for (const p of map.adminCenters || []) dot(ctx, p, 3.0, "rgba(255,255,255,0.96)", "rgba(70,90,120,0.85)"); - } - + // 4. Casings (Outlines) if (showHistory) { - for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true); - for (const path of map.minorRoads) drawPath(ctx, path, mode === "all" ? "rgba(180, 150, 120, 0.28)" : "rgba(180, 150, 120, 0.62)", mode === "all" ? 0.8 : 1.05); + // 古い道は白の実線が引き立つように淡いケーシングを敷く + for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5); } - if (showModern) { - // 鉄道 - 濃いグレー - for (const path of map.railways) drawPath(ctx, path, "rgba(80, 80, 80, 0.9)", 2.8); - for (const path of map.ringRailways || []) drawPath(ctx, path, "rgba(70, 70, 70, 0.82)", 2.1); - for (const path of map.branchRailways) drawPath(ctx, path, "rgba(100, 100, 100, 0.82)", 1.9); - for (const path of map.externalRailways) drawPath(ctx, path, "rgba(90, 90, 90, 0.9)", 2.4); - } - - if (showRoads) { - // Google Map風の道路 - 白と黄色とオレンジ - for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.2); - for (const path of map.icAccessRoads || []) drawPath(ctx, path, "rgba(255, 230, 150, 0.82)", 1.45); - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.0); - for (const path of map.expressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.0); - for (const path of map.externalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.5); - for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.2); - } - - if (showHistory) { - for (const p of map.villages) dot(ctx, p, mode === "all" ? 1.35 : 1.9, mode === "all" ? "rgba(120, 100, 80, 0.42)" : "rgba(120, 100, 80, 0.72)"); - for (const p of map.markets) dot(ctx, p, 4.8, "rgba(200, 130, 80, 0.95)"); - for (const p of map.ports) { - const color = p.portClass === "major" ? "rgba(40, 105, 190, 0.98)" : p.portClass === "regional" ? "rgba(70, 130, 200, 0.95)" : p.portClass === "lake" ? "rgba(80, 155, 180, 0.92)" : "rgba(95, 150, 195, 0.82)"; - triangleIcon(ctx, p, p.portClass === "major" ? 8.2 : 6.5, color); + if (showModern || showRoads) { + // 鉄道のケーシング(白背景を敷いて視認性を保つ) + for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5); + for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.6)", 2.5); + for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5); + + // 幹線道路のケーシング(色を濃く) + if (showRoads) { + for (const path of map.expressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0); + 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); } - for (const p of map.crossings) dot(ctx, p, 3.5, "rgba(255, 240, 180, 0.95)", "rgba(100,90,70,0.8)"); - for (const p of map.passes) dot(ctx, p, 3.9, "rgba(150, 120, 180, 0.95)"); - for (const p of map.castles) squareIcon(ctx, p, 7.0, "rgba(180, 70, 70, 0.96)"); - for (const p of map.castleRuins) dot(ctx, p, 3.2, "rgba(110, 90, 90, 0.9)", "rgba(220,220,220,0.8)"); } + // 5. Fills (Inner colors) & Fishbones + if (showHistory) { + for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false); + } + + if (showModern || showRoads) { + // 鉄道の骨線描画(色, 線幅, 棘の長さ, 棘の間隔) + for (const path of map.railways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0); + for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(140, 140, 140, 1)", 1.0, 4.0, 6.0); + for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0); + + // 幹線道路の塗り(色を濃く) + if (showRoads) { + for (const path of map.expressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0); + 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); + } + } + + // 6. Icons & Labels if (showModern) { - for (const p of map.industrialZones) squareIcon(ctx, p, 6.5, "rgba(140, 140, 150, 0.96)"); - for (const p of map.stations) railStationIcon(ctx, p); - for (const p of map.satelliteCities || []) dot(ctx, p, 4.8, "rgba(215, 95, 145, 0.95)"); - for (const p of map.newTowns) triangleIcon(ctx, p, 6.5, "rgba(180, 210, 240, 0.95)"); + for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444"); for (const p of map.modernCities) { - const popRadius = p.population ? Math.min(9.2, 3.4 + Math.sqrt(p.population) / 360) : 4.7; - const rankBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 1.6 : p.rank === "Regional Center" ? 0.45 : 0; - dot(ctx, p, popRadius + rankBoost, "rgba(230, 100, 100, 0.95)"); - if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 4.2, "rgba(255,255,255,0.0)", "rgba(180,60,60,0.95)"); + 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)"); + } + if (mode === "admin-debug" || mode === "borders-debug") { + for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)"); + for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)"); } - } - - if (showRoads) { - for (const p of map.logisticsParks) squareIcon(ctx, p, 6.2, "rgba(110, 170, 140, 0.96)"); - for (const p of map.interchanges) dot(ctx, p, 4.1, "rgba(255, 255, 255, 0.98)", "rgba(220, 90, 60, 0.95)"); - for (const p of map.externalGateways) dot(ctx, p, 4.4, "rgba(255, 250, 200, 0.98)", "rgba(60,60,60,0.92)"); } if (showLabels) { - const adminLabels = (map.adminCenters || []) - .map((p) => ({ ...p, labelPriorityBase: mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? 90 : 8 })) - .filter((p) => mode !== "all" || p.representativeFeatureName); + if (mode === "admin") { + drawLabels(ctx, map.adminCenters || [], Infinity); + return; + } + if (mode === "admin-debug" || mode === "borders-debug") { + drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity); + return; + } const important = [ ...map.modernCities, ...map.ports, - ...map.markets.slice(0, mode === "all" ? 6 : 10), - ...map.castles.slice(0, mode === "all" ? 5 : 8), ...(map.satelliteCities || []), - ...map.newTowns, - ...(mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? adminLabels : adminLabels.slice(0, 8)), - ...map.externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - - drawLabels(ctx, important, mode === "all" ? 34 : Infinity); + drawLabels(ctx, important, 60); } } diff --git a/styles.css b/styles.css index 92d07c0..5b45a82 100644 --- a/styles.css +++ b/styles.css @@ -1,11 +1,68 @@ -*{box-sizing:border-box} body{margin:0;background:#f5f5f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} button,input{font:inherit} code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .app{min-height:100vh;padding:24px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;max-width:1400px;margin:0 auto}.header{margin-bottom:16px}.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a}.header p{margin:0;color:#5a5a5a;line-height:1.65;font-size:14px}.canvas-shell,.card{background:#ffffff;border:1px solid rgb(0 0 0 / 0.12);border-radius:18px;box-shadow:0 2px 8px rgb(0 0 0 / 0.08)}.canvas-shell{padding:12px;overflow:auto;position:relative}.map-canvas{display:block;border-radius:12px;background:#f8f8f8}.sidebar{display:flex;flex-direction:column;gap:14px}.card{padding:16px}.label,.card-title{display:block;margin-bottom:10px;color:#2c2c2c;font-size:14px;font-weight:650}.input{width:100%;border:1px solid rgb(0 0 0 / 0.18);background:#fafafa;color:#2c2c2c;border-radius:12px;padding:9px 11px;outline:none}.input:focus{border-color:rgb(66 133 244 / 0.6)}.primary-button,.mode-button{border:0;border-radius:12px;padding:9px 11px;cursor:pointer}.primary-button{margin-top:10px;width:100%;background:#1a73e8;color:#fff;font-weight:650}.primary-button:hover{background:#1557b0}.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}.mode-button:hover{background:#e8eaed}.mode-button.active{background:#1a73e8;color:#fff;font-weight:650;border:1px solid #1a73e8}.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px}.stats{display:flex;flex-direction:column;gap:7px}.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline}.stat-row strong{color:#202124;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.legend{color:#5f6368;font-size:12px;line-height:1.65}.legend p{margin:8px 0 0}.example{margin:10px 0;padding:10px;background:#f8f9fa;border:1px solid rgb(0 0 0 / 0.1);border-radius:10px;color:#3c4043;overflow:auto}.id-list{max-height:220px;overflow:auto;margin-top:10px;display:flex;flex-direction:column;gap:6px}.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px}@media (max-width:1100px){.layout{grid-template-columns:1fr}} +*{box-sizing:border-box} +body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +button,input{font:inherit} +code{background:#e8e8e8;border-radius:4px;padding:1px 4px} +.app{min-height:100vh;padding:24px} +.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:20px;max-width:1400px;margin:0 auto} +.header{margin-bottom:16px} +.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700} +.header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px} +.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.04)} +.canvas-shell{padding:12px;overflow:auto;position:relative} +.map-canvas{display:block;border-radius:8px;background:#f8f9fa} +.sidebar{display:flex;flex-direction:column;gap:16px} +.card{padding:18px} +.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600} +.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s} +.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)} +.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s} +.primary-button{margin-top:12px;width:100%;background:#1a73e8;color:#fff} +.primary-button:hover{background:#1557b0} +.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent} +.mode-button:hover{background:#e8eaed} +.mode-button.active{background:#e8f0fe;color:#1a73e8;border:1px solid #1a73e8} +.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px;cursor:pointer} +.stats{display:flex;flex-direction:column;gap:8px} +.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline} +.stat-row strong{color:#202124;font-family:ui-monospace,monospace} +.legend{color:#5f6368;font-size:13px;line-height:1.6} +.legend p{margin:8px 0 0} +.example{margin:10px 0;padding:12px;background:#f8f9fa;border:1px solid rgba(0,0,0,0.08);border-radius:8px;color:#3c4043;overflow:auto;font-size:12px} +.id-list{max-height:220px;overflow:auto;margin-top:12px;display:flex;flex-direction:column;gap:6px} +.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px} -.legend-grid{display:flex;flex-direction:column;gap:7px;margin-top:8px}.legend-row{display:grid;grid-template-columns:30px 1fr;gap:8px;align-items:center;min-height:20px}.legend-line{display:inline-block;width:28px;height:0;border-top:3px solid #777;border-radius:999px}.legend-swatch{display:inline-block;width:26px;height:14px;border-radius:5px;background:#f5f5f5}.border-swatch{border:2px solid rgba(80,80,80,.8);box-shadow:inset 0 0 0 1px rgba(255,255,255,.9)}.river-major{border-top:4px solid rgba(100,170,210,.95);box-shadow:0 3px 0 rgba(120,180,215,.55)}.rail-line{border-top:3px solid rgba(70,70,70,.95)}.road-line{border-top:3px solid rgba(252,210,90,.95)}.express-line{border-top:5px solid rgba(245,140,60,.95)}.old-road-line{border-top:2px dashed rgba(150,120,90,.75)}.legend-icon{display:inline-block;width:15px;height:15px;justify-self:center;border:2px solid #fff;box-shadow:0 0 0 1px rgb(0 0 0 / .28)}.city-icon{border-radius:50%;background:rgba(230,100,100,.95);width:17px;height:17px}.port-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(70,130,200,.95);border-top:0;box-shadow:none;background:transparent}.castle-icon{background:rgba(180,70,70,.96);border-radius:2px}.station-icon{background:#fff;border-color:rgba(60,60,60,.9);border-radius:2px}.industry-icon{background:rgba(110,170,140,.96);border-radius:2px}.newtown-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(180,210,240,.95);border-top:0;box-shadow:none;background:transparent} +@media (max-width:1100px){.layout{grid-template-columns:1fr}} +.legend-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px} +.legend-row{display:grid;grid-template-columns:32px 1fr;gap:8px;align-items:center;min-height:22px} +/* Layered GIS style CSS equivalents */ +.legend-line{display:inline-block;width:28px;height:4px;border-radius:2px;} +.express-line{background:#6eb982; border:1px solid #508c64;} +.road-line{background:#f5e182; border:1px solid #beaf8c;} -.legend-swatch{width:18px;height:14px;border-radius:4px;border:1px solid rgb(0 0 0 / 0.18);display:inline-block}.cbd-swatch{background:rgb(224 202 190)}.satellite-icon{background:rgba(215,95,145,0.95);border-radius:999px;border:2px solid #fff} +/* Fishbone Railway Style */ +.rail-line{background:#6e6e6e; height:1.5px; position:relative; border:none; margin-top:2px; border-radius:0} +.rail-line::after{content:"";position:absolute;top:-2.5px;left:0;right:0;height:7px;background:repeating-linear-gradient(90deg, transparent, transparent 5px, #6e6e6e 5px, #6e6e6e 6px);} -.legend-line.harbor-line::before{background:#6b86a0;height:3px;top:7px} +.river-major{background:#a0cdf0; border:none; height:3px;} +.old-road-line{background:#fff; border:1px solid #dcdcdc; height:3px; border-top:none;} -.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:190px;max-width:270px;background:rgba(255,255,255,.96);border:1px solid rgb(0 0 0 / .16);border-radius:10px;box-shadow:0 8px 24px rgb(0 0 0 / .16);padding:8px 10px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(2px);transition:opacity .08s ease,transform .08s ease}.map-tooltip.visible{opacity:1;transform:translateY(0)} +.legend-swatch{display:inline-block;width:24px;height:14px;border-radius:4px;background:#f5f5f5} +.border-swatch{border:2px dashed rgba(110,90,110,1);box-shadow:inset 0 0 0 1px rgba(255,255,255,1), 0 0 0 1px rgba(255,255,255,1)} +.cbd-swatch{background:#f0dccd; border:1px solid rgba(0,0,0,0.1)} + +.legend-icon{display:inline-block;width:14px;height:14px;justify-self:center;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,0.15)} +.city-icon{background:#f06e6e;} +.satellite-icon{background:#d75f91;} +.station-icon{background:#fff;border-color:#444;} +.industry-icon{background:#8caaa0; border-radius:3px;} +.castle-icon{background:#b44646; border-radius:3px;} + +.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0} +.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0} +.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 diff --git a/test.js b/test.js index 147505c..2d02c79 100644 --- a/test.js +++ b/test.js @@ -284,6 +284,97 @@ function terrainCoreMetrics(map) { }; } +function requiredTransportNodes(map) { + const nodes = []; + const seen = new Set(); + function add(p, reason) { + if (!p) return; + const key = `${p.x},${p.y}`; + if (seen.has(key)) return; + seen.add(key); + nodes.push({ ...p, requiredTransportReason: reason }); + } + add(map.prefecturalCapital, "capital"); + for (const gate of map.externalGateways || []) add(gate, "externalGateway"); + for (const city of map.modernCities || []) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) add(city, "majorCity"); + for (const port of map.ports || []) if (port.portClass === "major") add(port, "majorPort"); + return nodes; +} + +function transportConnectivityMetrics(map) { + const paths = [ + ...map.railways, + ...map.branchRailways, + ...map.externalRailways, + ...map.nationalRoads, + ...(map.ringRoads || []), + ...map.expressways, + ...map.externalRoads, + ...map.externalExpressways, + ]; + const pathCells = new Set(); + for (const path of paths) for (const [x, y] of path) pathCells.add(`${x},${y}`); + const nodes = requiredTransportNodes(map); + function nearestCell(node) { + let best = null; + let bestD = Infinity; + for (const key of pathCells) { + const [x, y] = key.split(",").map(Number); + const d = Math.hypot(node.x - x, node.y - y); + if (d < bestD) { + bestD = d; + best = key; + } + } + return { key: best, distance: bestD }; + } + const seen = new Set(); + const components = []; + for (const key of pathCells) { + if (seen.has(key)) continue; + const queue = [key]; + const component = new Set([key]); + seen.add(key); + for (let q = 0; q < queue.length; q++) { + const [x, y] = queue[q].split(",").map(Number); + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const nk = `${x + dx},${y + dy}`; + if (!pathCells.has(nk) || seen.has(nk)) continue; + seen.add(nk); + component.add(nk); + queue.push(nk); + } + } + components.push(component); + } + const mapped = nodes.map((node) => ({ + node, + nearest: nearestCell(node), + components: components + .map((component, componentIndex) => ({ + componentIndex, + near: [...component].some((key) => { + const [x, y] = key.split(",").map(Number); + return Math.hypot(node.x - x, node.y - y) <= 7; + }), + })) + .filter((item) => item.near) + .map((item) => item.componentIndex), + })); + const reachable = mapped.filter((item) => item.components.length > 0); + let largestRequiredComponent = 0; + for (let componentIndex = 0; componentIndex < components.length; componentIndex++) { + largestRequiredComponent = Math.max(largestRequiredComponent, reachable.filter((item) => item.components.includes(componentIndex)).length); + } + return { + requiredCount: nodes.length, + reachableCount: reachable.length, + largestRequiredComponent, + isolatedExternalGateways: mapped.filter((item) => item.node.requiredTransportReason === "externalGateway" && item.components.length === 0).length, + isolatedMajorCities: mapped.filter((item) => item.node.requiredTransportReason === "majorCity" && item.components.length === 0).length, + }; +} + try { const map = generateMap(12345); const other = generateMap(54321); @@ -401,6 +492,7 @@ try { const satelliteMetrics = satelliteMunicipalityMetrics(map); const regionalMetrics = regionalComponentMetrics(map); const terrainMetrics = terrainCoreMetrics(map); + const transportMetrics = transportConnectivityMetrics(map); assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists"); assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists"); @@ -430,6 +522,11 @@ try { assert(map.depositionalLowland.length === size && map.alluvialFanField.length === size && map.deltaField.length === size, "depositional debug fields match map size"); assert(map.naturalBarrierScore.length === size, "natural barrier score field matches map size"); assert(map.terrainTemplate && Number.isFinite(map.terrainTemplate.deposition) && Number.isFinite(map.terrainTemplate.erosion), "terrain template parameters are exposed"); + assert(map.terrainDebug && Number.isFinite(map.terrainDebug.primarySpineStrength), "terrain debug metrics exist"); + assert(map.terrainDebug.primarySpineStrength > 0.08, "primary mountain spine has visible strength"); + assert(map.terrainDebug.largeInlandLakeCount <= 1, "large inland lakes are rare"); + assert(map.terrainDebug.smallIslandCount <= 24, "small island/coast speckles stay limited"); + 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"); @@ -439,6 +536,8 @@ try { assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked"); assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully"); assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded"); + assert(map.regionalDebug.compartmentCount > 0 && map.regionalDebug.changedAfterCompartmentAssignment > 0, "regional compartment assignment debug is available"); + assert(Number.isFinite(map.regionalDebug.borderNaturalBarrierAverage) && Number.isFinite(map.regionalDebug.voronoiLikeRate), "regional natural-border aliases are exposed"); assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display"); assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist"); assert(Array.isArray(map.icAccessRoads), "IC access road array exists"); @@ -495,8 +594,12 @@ try { assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified"); assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes"); assert(map.externalGateways.length > 0, "external gateways exist"); + assert(map.transportDebug && map.transportDebug.requiredNodeCount > 0, "transport required-node debug exists"); + assert(transportMetrics.requiredCount > 0 && transportMetrics.reachableCount === transportMetrics.requiredCount, "required transport nodes touch the modern network"); + assert(transportMetrics.largestRequiredComponent === transportMetrics.requiredCount, "required transport nodes are in one connected modern component"); + assert(transportMetrics.isolatedExternalGateways === 0 && transportMetrics.isolatedMajorCities === 0, "external gateways and major cities are not isolated"); assert(map.minorRoads.length > 0, "minor roads exist"); - assert(map.adminCenters.length >= 12, "municipality count is sufficiently large"); + assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large"); assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count"); assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells"); assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments"); @@ -544,7 +647,7 @@ try { const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0; assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low"); assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities"); - assert(map.adminDebug.targetMunicipalityCount >= 18 && map.adminDebug.actualMunicipalityCount >= 16, "municipality target and actual counts are dense enough"); + assert(map.adminDebug.targetMunicipalityCount >= 20 && map.adminDebug.targetMunicipalityCount <= 50 && map.adminDebug.actualMunicipalityCount >= 18, "municipality target and actual counts are dense enough"); assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active"); assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering"); assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings"); @@ -553,6 +656,9 @@ try { assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented"); assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools"); + assert(map.nameDebug.oneKanjiAppendFallbackUsed === 0, "one-kanji append fallback is never used"); + assert((map.nameDebug.derivedNameCount || 0) <= Math.max(6, Math.ceil(map.adminCenters.length * 0.18)), "derived names do not dominate municipality names"); + assert((map.nameDebug.maxDerivedPerBase || 0) <= 2, "derived names per base stay small"); assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools"); assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists"); assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists"); @@ -581,6 +687,8 @@ try { assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed"); assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed"); assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed"); + assert(JSON.stringify(againA.transportDebug) === JSON.stringify(againB.transportDebug), "transport debug metrics are deterministic for the same seed"); + assert(JSON.stringify(transportConnectivityMetrics(againA)) === JSON.stringify(transportConnectivityMetrics(againB)), "transport connectivity metrics are deterministic for the same seed"); assert(JSON.stringify([...againA.elevation]) === JSON.stringify([...againB.elevation]), "elevation is deterministic for the same seed"); assert(JSON.stringify([...againA.ridgeField]) === JSON.stringify([...againB.ridgeField]), "ridge field is deterministic for the same seed"); assert(JSON.stringify([...againA.river]) === JSON.stringify([...againB.river]), "river field is deterministic for the same seed"); @@ -599,12 +707,21 @@ try { assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`); assert(metrics.depositionSum > 0.1 && metrics.depositionTargetMean >= metrics.depositionOtherMean * 0.75, `seed ${seedValue}: deposition is active in plausible lowlands`); assert(metrics.riverValleyMean > metrics.nonRiverValleyMean, `seed ${seedValue}: rivers follow valley fields`); + assert(seeded.terrainDebug?.primarySpineStrength > 0.08, `seed ${seedValue}: primary spine is strong enough`); + assert((seeded.terrainDebug?.largeInlandLakeCount || 0) <= 1, `seed ${seedValue}: large inland lakes are rare`); + assert((seeded.terrainDebug?.smallIslandCount || 0) <= 24, `seed ${seedValue}: small island speckles are limited`); assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`); assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`); + const seededTransport = transportConnectivityMetrics(seeded); + assert(seeded.transportDebug?.requiredNodeCount === seededTransport.requiredCount, `seed ${seedValue}: required transport node count is exposed`); + assert(seededTransport.reachableCount === seededTransport.requiredCount && seededTransport.largestRequiredComponent === seededTransport.requiredCount, `seed ${seedValue}: required transport nodes are connected`); + assert(seededTransport.isolatedExternalGateways === 0 && seededTransport.isolatedMajorCities === 0, `seed ${seedValue}: no gateway or major city is isolated`); assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`); assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`); assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`); assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`); + assert(seeded.nameDebug?.oneKanjiAppendFallbackUsed === 0, `seed ${seedValue}: one-kanji append fallback stays unused`); + assert((seeded.nameDebug?.derivedNameCount || 0) <= Math.max(6, Math.ceil(seeded.adminCenters.length * 0.18)), `seed ${seedValue}: derived names are bounded`); } const byDeposition = capitalNameMaps .map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio })) @@ -650,7 +767,7 @@ try { assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`); assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`); - assert(seeded.adminDebug.targetMunicipalityCount >= 18 && seeded.adminDebug.actualMunicipalityCount >= 16, `seed ${seed}: municipality count is dense enough`); + assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`); assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`); assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`); assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`); @@ -658,7 +775,7 @@ try { assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`); assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`); assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`); - assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`); + assert(metrics.municipalityCount >= 18, `seed ${seed}: municipality count remains reasonable`); assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`); assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`); assert(metrics.avgTarget > 0.14, `seed ${seed}: borders retain terrain-boundary affinity`); From e48fae55097319e6de379f13c92eb46cf85e5298 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 22 May 2026 13:57:52 +0900 Subject: [PATCH 7/8] before tweaking --- adminRegions.js | 254 ++++++++++++++++++-- mapAdminStage.js | 534 ++++++++++++++++++++++++++++++++++++----- mapFeatures.js | 266 ++++++++++++++------ mapGeneratorHelpers.js | 1 + mapOutput.js | 44 +--- names.js | 5 +- renderer.js | 66 ++--- test.js | 50 +++- 8 files changed, 977 insertions(+), 243 deletions(-) diff --git a/adminRegions.js b/adminRegions.js index 6bbd906..b823245 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -532,6 +532,26 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, 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; @@ -546,6 +566,94 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc 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; + for (const i of unit.cells) { + const [x, y] = xyOf(i); + sx += x; sy += y; pop += populationDensity[i]; + 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.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 < 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; + 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; + 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 d = Math.hypot(x - fx, y - fy); + const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20; + 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 ni = indexOf(nx, ny); + if (!cellSet.has(ni) || localOwner.has(ni)) continue; + localOwner.set(ni, owner); + queue.push(ni); + } + } + 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 = [], bCells = []; + for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci); + if (aCells.length < 10 || bCells.length < 10) 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)}`; @@ -555,7 +663,7 @@ function naturalGroupKey(unit) { return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; } -export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) { +export function buildNaturalCompartments(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 compartmentId = new Int32Array(SIZE); compartmentId.fill(-1); @@ -595,7 +703,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, } } const area = cells.length; - compartments.push({ + const unit = { id, cells, area, @@ -612,11 +720,34 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, 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 }; + let guard = targetCount * 3; + 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)); + const target = candidates[0]; + if (!target) break; + const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard); + if (!newUnit) { + target.lowlandFitness = 0; + continue; + } + 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 }; } @@ -725,6 +856,107 @@ function naturalOwnershipAffinity(unit, neighbor, edge) { 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; @@ -822,11 +1054,11 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) { return segments; } -export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { - const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); +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 = assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea); + const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); for (const unit of compartments) { const assigned = owner[unit.id]; if (assigned < 0) continue; @@ -837,22 +1069,16 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e 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; - const i = indexOf(center.x, center.y); - if (prefectureMask[i] && !sea[i]) adminId[i] = id; - } 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: { - naturalCompartmentCount: activeCompartments.length, - compartmentCount: activeCompartments.length, + ...relationMetrics, compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea), averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), diff --git a/mapAdminStage.js b/mapAdminStage.js index 988e0e9..d596b23 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -26,6 +26,257 @@ function municipalityAreaById(adminId, prefectureMask, sea) { 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; @@ -58,6 +309,118 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, 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); @@ -252,53 +615,40 @@ export function generateAdminLayout({ const boundaryRidgeField = naturalBarrierScore ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) : ridgeField; - const municipalityCandidates = []; - 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 urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28; - const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025; - if (score > 0.40) municipalityCandidates.push({ x, y, score }); - } - } - const majorMunicipalSeeds = modernCities - .filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)]) - .map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city })); - const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => { - const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28)); - const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); - return !nearMajor && !nearSmallUrban; - }); 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 satelliteMunicipalSeeds = (satelliteCities || []) - .filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality") - .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); - let adminCentersRaw = [ - ...majorMunicipalSeeds, - ...satelliteMunicipalSeeds, - ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { - max: Math.max(0, targetMunicipalityCount - majorMunicipalSeeds.length - satelliteMunicipalSeeds.length), - minDistance: 6 + Math.floor(rand(seed, 1302) * 3), - threshold: 0.34, - seed: seed + 1300, - jitter: 0.025, - }), - ]; - if (adminCentersRaw.length < Math.min(targetMunicipalityCount, 18)) { - const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...ports, ...newTowns, ...stations, ...villages] - .filter((p) => prefectureMask[indexOf(p.x, p.y)]) - .map((p) => ({ x: p.x, y: p.y, score: (p.score || 0.5) + (p.population || 0) / 900000 })); - const extraFallback = pickEntities(fallback, { max: targetMunicipalityCount, minDistance: 5, threshold: 0, seed: seed + 1303 }); - for (const p of extraFallback) if (adminCentersRaw.length < targetMunicipalityCount && adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) adminCentersRaw.push(p); - } - if (adminCentersRaw.length < targetMunicipalityCount) { - const extra = pickEntities(municipalityCandidates, { max: targetMunicipalityCount - adminCentersRaw.length, minDistance: 5, threshold: 0.26, seed: seed + 1304 }); - adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4))); - } - if (adminCentersRaw.length > targetMunicipalityCount) adminCentersRaw = adminCentersRaw.slice(0, targetMunicipalityCount); - const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + const compartmentMultiplier = clamp(3.5 + rand(seed, 1320) * 2.0, 3.5, 5.5); + let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240); + 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, + }); const adminId = compartmentAssignment.adminId; let previousSnapshot = new Int16Array(adminId); const adminDebug = { @@ -320,7 +670,25 @@ export function generateAdminLayout({ oversizedLowlandSplits: 0, ruralSplitsAccepted: 0, ruralSplitsRejected: 0, - satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length, + 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, @@ -332,6 +700,27 @@ export function generateAdminLayout({ 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); @@ -344,6 +733,7 @@ export function generateAdminLayout({ 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; } }); @@ -375,6 +765,7 @@ export function generateAdminLayout({ 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; } }); @@ -393,20 +784,13 @@ export function generateAdminLayout({ lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520); lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); markChanged("changedAfterSmallUrbanLock"); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: adminCentersRaw }); + 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"); - applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); - 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, - }); - } + // 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; @@ -418,9 +802,9 @@ export function generateAdminLayout({ 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, 360); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360); markChanged("changedAfterFinalExclaveRemoval"); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: adminCentersRaw }); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() }); markChanged("changedAfterFinalMerge"); for (const sat of satelliteCities || []) { @@ -432,7 +816,22 @@ export function generateAdminLayout({ landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, }); } - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 260); + 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 = []; @@ -442,7 +841,7 @@ export function generateAdminLayout({ const area = areaById.get(id) || 0; const key = sat.name || `satellite-${index}`; adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area; - if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < 80 || ((sat.population || 0) >= 60000 && area < 120))) { + if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) { sat.municipalityClass = "smallTownAttachedToRuralMunicipality"; adminDebug.satelliteMunicipalitiesTooSmall++; return; @@ -457,8 +856,19 @@ export function generateAdminLayout({ adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length; const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {}; Object.assign(adminDebug, landscapeDebug); - adminDebug.naturalCompartmentCount = adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0; + 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); diff --git a/mapFeatures.js b/mapFeatures.js index c8ef326..32f84de 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -440,8 +440,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" ? 10 : mode === "rail" ? 2 : 4; - const maxR = mode === "express" ? 20 : mode === "rail" ? 6 : 10; + const minR = mode === "express" ? 6 : mode === "rail" ? 2 : 4; + const maxR = mode === "express" ? 16 : mode === "rail" ? 6 : 10; let best = null; let bestScore = -INF; for (let dy = -maxR; dy <= maxR; dy++) { @@ -460,7 +460,7 @@ export function generateMapFeatures(seed, terrain) { const ring = -Math.abs(d - targetD) * 0.08; const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12; const density = densityValue(x, y); - const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? midDensityAffinity(x, y) * 0.52 - Math.max(0, density - 0.72) * 0.9 : density * 0.24; + const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? density * 0.70 + midDensityAffinity(x, y) * 0.18 - Math.max(0, density - 0.92) * 0.35 : density * 0.24; const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12; const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise; if (score > bestScore) { @@ -601,10 +601,12 @@ export function generateMapFeatures(seed, terrain) { const density = densityValue(x, y); const midDensity = midDensityAffinity(x, y); const cityDistance = distanceToNearest(modernCities, x, y); - const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; - const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; - const highPenalty = barrier + (elevation[i] > 0.72 ? 26 : elevation[i] > 0.62 ? 8.5 : 0); - return Math.max(0.42, 1 + slope[i] * 23.0 + highPenalty + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1.0 : 0) - midDensity * 0.82 - plain[i] * 0.16 - valleyField[i] * 0.16 - coastalLowland[i] * 0.18 + ridgeField[i] * 1.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015); + const coreAvoid = cityDistance < 2.2 ? 18.0 : cityDistance < 4.5 ? 7.0 : cityDistance < 7.5 ? 2.0 : 0; + const marketAvoid = distanceToNearest(markets, x, y) < 2.5 ? 1.8 : 0; + const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.8 : 0; + const urbanCorridorBonus = density * 1.18 + midDensity * 0.28 + (cityDistance >= 4 && cityDistance <= 16 ? 0.40 : 0); + const constructionCost = 0.58 + slope[i] * 28.0 + barrier * 1.10 + Math.max(0, elevation[i] - 0.60) * 16.0 + ridgeField[i] * 1.45 + (river[i] > 0.45 ? 1.15 : river[i] * 0.45); + return Math.max(0.50, 1.18 + constructionCost + coreAvoid + marketAvoid + lowDensityPenalty - urbanCorridorBonus - plain[i] * 0.12 - valleyField[i] * 0.12 - coastalLowland[i] * 0.12 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015); } const nationalRoads = []; @@ -648,8 +650,8 @@ export function generateMapFeatures(seed, terrain) { const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 3, 5.8, townAvoidNodes, 3.2, 5.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 >= 2 || direct >= 24; - if (path.length > 3 && direct >= 16 && pathLength(path) >= 20 && pathCompactness(path) < 3.35 && pathOverlapRatio(path, existing, 2) < 0.48 && passBonusOk) { + 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) { nationalRoads.push(path); incrementDegree(roadDegree, a); incrementDegree(roadDegree, b); @@ -706,12 +708,37 @@ export function generateMapFeatures(seed, terrain) { const expressDegree = new Map(); const expressCore = [capital]; + function snapPathToExistingExpressways(path, existingPaths, radius = 2.4) { + if (!path?.length || !existingPaths?.length) return path || []; + const snapped = []; + const skipEnd = Math.min(5, Math.floor(path.length / 5)); + for (let pi = 0; pi < path.length; pi++) { + const [x, y] = path[pi]; + let best = null; + let bestD = radius; + if (pi >= skipEnd && pi < path.length - skipEnd) { + for (const existing of existingPaths) { + for (const [ex, ey] of existing) { + const d = Math.hypot(x - ex, y - ey); + if (d < bestD) { bestD = d; best = [ex, ey]; } + } + } + } + const next = best || [x, y]; + const last = snapped[snapped.length - 1]; + if (!last || last[0] !== next[0] || last[1] !== next[1]) snapped.push(next); + } + return snapped; + } + + 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); const direct = pathEndpointDistance(path); if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) { bucket.push(path); @@ -888,6 +915,16 @@ export function generateMapFeatures(seed, terrain) { threshold: 0.4, seed: seed + 1201, }).map((p) => ({ ...p, kind: "External Gateway" })); + if (externalGateways.length < 2) { + 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; + externalGateways.push({ ...gate, kind: "External Gateway" }); + if (externalGateways.length >= 2) break; + } + } function externalRoadCost(goal) { return (x, y) => { @@ -910,9 +947,11 @@ export function generateMapFeatures(seed, terrain) { const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; const density = densityValue(x, y); const cityDistance = distanceToNearest(modernCities, x, y); - const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; - const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; - return Math.max(0.42, 1 + slope[i] * 19 + barrier + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1 : 0) + floodplain[i] * 0.2 - midDensityAffinity(x, y) * 0.7 - plain[i] * 0.12 + borderPenalty + hash2(x, y, seed + 444) * 0.05); + const coreAvoid = cityDistance < 2.2 ? 16.0 : cityDistance < 4.5 ? 6.0 : cityDistance < 7.5 ? 1.8 : 0; + const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.2 : 0; + const urbanCorridorBonus = density * 1.04 + midDensityAffinity(x, y) * 0.24 + (cityDistance >= 4 && cityDistance <= 16 ? 0.32 : 0); + const constructionCost = 0.55 + slope[i] * 23.0 + barrier * 1.06 + Math.max(0, elevation[i] - 0.60) * 12.0 + ridgeField[i] * 1.20 + (river[i] > 0.45 ? 1 : river[i] * 0.38); + return Math.max(0.50, 1.14 + constructionCost + coreAvoid + lowDensityPenalty + floodplain[i] * 0.18 + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 444) * 0.04); }; } function externalRailCost(goal) { @@ -945,7 +984,8 @@ export function generateMapFeatures(seed, terrain) { 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); - const 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)); + 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); if (roadPath.length > 6) { if (makeExpressLink) { externalExpressways.push(roadPath); @@ -971,68 +1011,113 @@ export function generateMapFeatures(seed, terrain) { } }); - const requiredTransportNodes = []; - function addRequiredTransportNode(node, reason) { - if (!node || !inside(node.x, node.y) || sea[indexOf(node.x, node.y)]) return; - const key = `${node.x},${node.y}`; - if (requiredTransportNodes.some((p) => `${p.x},${p.y}` === key)) return; - requiredTransportNodes.push({ ...node, requiredTransportReason: reason }); + let throughExpresswayAdded = false; + function throughExpresswayCost(a, b) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "express"); + if (barrier >= INF) return INF; + const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3; + const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.2 : 0; + const density = densityValue(x, y); + const cityDistance = distanceToNearest(modernCities, x, y); + const coreAvoid = cityDistance < 2.0 ? 12.0 : cityDistance < 4.5 ? 4.8 : cityDistance < 7.5 ? 1.4 : 0; + const lowDensityPenalty = density < 0.08 ? (0.08 - density) * 4.0 : 0; + const urbanCorridorBonus = density * 1.00 + midDensityAffinity(x, y) * 0.22 + (cityDistance >= 4 && cityDistance <= 18 ? 0.34 : 0); + return Math.max(0.48, 1.16 + slope[i] * 21.0 + barrier * 1.08 + Math.max(0, elevation[i] - 0.62) * 13.0 + coreAvoid + lowDensityPenalty + (river[i] > 0.45 ? 1.0 : 0) + borderPenalty - urbanCorridorBonus - plain[i] * 0.14 - coastalLowland[i] * 0.12 + hash2(x, y, seed + 9101) * 0.03); + }; } - addRequiredTransportNode(capital, "capital"); - for (const gate of externalGateways) addRequiredTransportNode(gate, "externalGateway"); - for (const city of modernCities) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) addRequiredTransportNode(city, "majorCity"); - for (const port of majorPorts) addRequiredTransportNode(port, "majorPort"); - const backboneAccess = new Map(); - function nodeKey(p) { - return `${p.x},${p.y}`; + function permissiveThroughExpresswayCost(a, b) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return 24 + nearMapEdge(x, y, 2) * 2 + hash2(x, y, seed + 9202) * 0.2; + const rawBarrier = mountainBarrierPenalty(x, y, "express"); + const tunnelBarrier = rawBarrier >= INF ? 120 + Math.max(0, elevation[i] - 0.66) * 260 + slope[i] * 55 : rawBarrier; + const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3; + const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.0 : 0; + const cityDistance = distanceToNearest(modernCities, x, y); + const coreAvoid = cityDistance < 2.0 ? 9.0 : cityDistance < 4.5 ? 3.5 : 0; + const density = densityValue(x, y); + const urbanCorridorBonus = density * 0.85 + midDensityAffinity(x, y) * 0.18 + (cityDistance >= 4 && cityDistance <= 18 ? 0.24 : 0); + return Math.max(0.52, 1.18 + slope[i] * 14.0 + tunnelBarrier * 0.42 + Math.max(0, elevation[i] - 0.66) * 18.0 + coreAvoid + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 9201) * 0.035); + }; } - function backbonePoint(node) { - const key = nodeKey(node); - if (!backboneAccess.has(key)) { - const mode = node.requiredTransportReason === "externalGateway" ? "road" : "road"; - backboneAccess.set(key, routePoint(node, mode, 18000 + node.x * 97 + node.y * 101)); + + function pointToSegmentDistance(p, 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(p.x - a.x, p.y - a.y); + const t = clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len2, 0, 1); + return Math.hypot(p.x - (a.x + vx * t), p.y - (a.y + vy * t)); + } + + function chooseThroughExpresswayVia(a, b) { + const candidates = [capital, ...modernCities.filter((city) => (city.population || 0) >= 90000)]; + 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; + 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); + const popScore = Math.sqrt(Math.max(0, city.population || 0)) / 520; + const score = density * 2.8 + popScore + (city.isPrefecturalCapital ? 0.9 : 0) - lineD / 38 - mountainBarrierPenalty(access.x, access.y, "express") * 0.004; + if (score > bestScore) { bestScore = score; best = access; } } - return backboneAccess.get(key); + return best; } - function addBackboneRoad(a, b) { - const start = backbonePoint(a); - const goal = backbonePoint(b); - const existing = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways]; - const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 2, 4.2, townAvoidNodes, 2.6, 4.2)); - if (path.length <= 3 || pathCompactness(path) > 3.8 || path.some(([x, y]) => elevation[indexOf(x, y)] > 0.72)) return false; - nationalRoads.push(path); - incrementDegree(roadDegree, a); - incrementDegree(roadDegree, b); - return true; - } - const connectedBackboneNodes = requiredTransportNodes.length ? [requiredTransportNodes[0]] : []; - const pendingBackboneNodes = requiredTransportNodes.slice(1); - let backboneEdgeCount = 0; - while (pendingBackboneNodes.length && connectedBackboneNodes.length) { - let bestIndex = -1; - let bestAnchor = null; - let bestScore = INF; - for (let i = 0; i < pendingBackboneNodes.length; i++) { - const node = pendingBackboneNodes[i]; - for (const anchor of connectedBackboneNodes) { - const d = Math.hypot(node.x - anchor.x, node.y - anchor.y); - const ai = indexOf(anchor.x, anchor.y); - const bi = indexOf(node.x, node.y); - const corridor = sameCorridorAffinity(anchor, node); - const score = d * (1.0 - corridor * 0.22) + Math.max(elevation[ai], elevation[bi]) * 8 - Math.max(passSuitability[ai], passSuitability[bi]) * 4; - if (score < bestScore) { + + function addThroughExpressway() { + if (externalGateways.length < 2) return false; + let bestPair = null; + let bestScore = -INF; + for (let i = 0; i < externalGateways.length; i++) { + for (let j = i + 1; j < externalGateways.length; j++) { + const a = externalGateways[i]; + const b = externalGateways[j]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + const opposite = (a.side === "N" && b.side === "S") || (a.side === "S" && b.side === "N") || (a.side === "W" && b.side === "E") || (a.side === "E" && b.side === "W"); + const score = d + (opposite ? 42 : 0) - Math.abs((a.score || 0) - (b.score || 0)) * 3; + if (score > bestScore) { bestScore = score; - bestIndex = i; - bestAnchor = anchor; + bestPair = [a, b]; } } } - if (bestIndex < 0 || !bestAnchor) break; - const node = pendingBackboneNodes.splice(bestIndex, 1)[0]; - if (addBackboneRoad(bestAnchor, node)) backboneEdgeCount++; - connectedBackboneNodes.push(node); + if (!bestPair) return false; + const [a, b] = bestPair; + const existing = [...externalExpressways, ...expressways, ...nationalRoads, ...railways, ...branchRailways]; + const via = chooseThroughExpresswayVia(a, b); + let path = []; + let viaUsed = false; + if (via) { + let first = aStar(a, via, makeTransportCost(throughExpresswayCost(a, via), existing, roadHubs, [a, via], 5, 9.6, townAvoidNodes, 4.2, 6.0)); + first = smoothPathByLineOfSight(first, (x, y) => throughExpresswayCost(a, via)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); + let second = aStar(via, b, makeTransportCost(throughExpresswayCost(via, b), [...existing, first], roadHubs, [via, b], 5, 9.6, townAvoidNodes, 4.2, 6.0)); + second = smoothPathByLineOfSight(second, (x, y) => throughExpresswayCost(via, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); + if (first.length > 6 && second.length > 6) { path = [...first, ...second.slice(1)]; viaUsed = true; } + } + if (path.length < 12) { + path = aStar(a, b, makeTransportCost(throughExpresswayCost(a, b), existing, roadHubs, [a, b], 5, 9.4, townAvoidNodes, 6.0, 9.0)); + path = smoothPathByLineOfSight(path, (x, y) => throughExpresswayCost(a, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); + } + path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8); + if (!viaUsed && (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.45 || pathCompactness(path) > 3.25)) { + path = aStar(a, b, makeTransportCost(permissiveThroughExpresswayCost(a, b), existing, roadHubs, [a, b], 4, 7.2, townAvoidNodes, 4.0, 6.5)); + 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; + externalExpressways.push(path); + incrementDegree(expressDegree, a); + incrementDegree(expressDegree, b); + throughExpresswayAdded = true; + return true; } + addThroughExpressway(); function nearestPathCellDistance(node, paths) { let best = INF; @@ -1041,24 +1126,13 @@ export function generateMapFeatures(seed, terrain) { } return best; } - const combinedModernBackbone = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways]; - const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernBackbone) <= 7).length; - const missingRequiredNodes = requiredTransportNodes - .filter((node) => nearestPathCellDistance(node, combinedModernBackbone) > 7) - .map((node) => ({ x: node.x, y: node.y, kind: node.kind, reason: node.requiredTransportReason })); - const transportDebug = { - requiredNodeCount: requiredTransportNodes.length, - connectedRequiredNodeCount, - missingRequiredNodes, - backboneEdgeCount, - }; function pruneHighMountainTransport(paths, threshold = 0.82) { for (let i = paths.length - 1; i >= 0; i--) { if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); } } - for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82); + for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways]) pruneHighMountainTransport(paths, 0.82); const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6); const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4); @@ -1197,19 +1271,51 @@ export function generateMapFeatures(seed, terrain) { } for (const village of villages) { - if (rand(seed, village.x * 13 + village.y * 17) < 0.42) { + 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]; - if (target && Math.hypot(target.x - village.x, target.y - village.y) < 28) addMinorRoad(village, target); + 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: 3, minDistance: 1, threshold: 0 }); + 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 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]; if (target) addMinorRoad(pass, target); } + for (const port of ports) { + const target = pickEntities([...markets, ...villages, ...stations.slice(0, 18)].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - port.x, p.y - port.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; + if (target && Math.hypot(target.x - port.x, target.y - port.y) < 24) addMinorRoad(port, target); + } + for (const localCenter of [...satelliteCities, ...newTowns]) { + 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 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)) { + 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); + } + + const combinedModernTransport = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways]; + const requiredTransportNodes = [capital, ...externalGateways, ...modernCities.filter((city) => (city.population || 0) >= 120000 || city.isPrefecturalCapital)]; + const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernTransport) <= 7).length; + 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 transportDebug = { + requiredNodeCount: requiredTransportNodes.length, + connectedRequiredNodeCount, + throughExpresswayAdded, + expresswayAverageDensity: Number(expresswayAverageDensity.toFixed(3)), + expresswayPathCount: allExpresswayPaths.length, + minorRoadCount: minorRoads.length, + minorRoadTotalLength: Math.round(minorRoads.reduce((sum, path) => sum + pathLength(path), 0)), + }; const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1); const landuse = new Uint8Array(SIZE); diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 4bcb6be..85a4117 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -881,6 +881,7 @@ export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nam ...p, id, name, + kind, insidePrefecture: Boolean(p.insidePrefecture), }; }); diff --git a/mapOutput.js b/mapOutput.js index 9545cfb..8972a3e 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -173,48 +173,6 @@ export function finishMapOutput({ center.name = best.name; } } - const adminNameSuffixes = [ - "\u753A\u57DF", - "\u5E02\u57DF", - "\u90F7\u57DF", - "\u6D41\u57DF", - "\u6E7E\u5CB8", - "\u5C71\u9E93", - "\u5E73\u91CE", - "\u5730\u533A", - ]; - const adminNameCounts = new Map(); - for (const center of adminCenters) adminNameCounts.set(center.name, (adminNameCounts.get(center.name) || 0) + 1); - const baseVariantCounts = new Map(); - for (const center of adminCenters) { - const base = String(center.name || ""); - const count = adminNameCounts.get(base) || 0; - if (count <= 1) { - baseVariantCounts.set(base, Math.max(baseVariantCounts.get(base) || 0, 1)); - continue; - } - const usedForBase = baseVariantCounts.get(base) || 0; - if (usedForBase === 0) { - baseVariantCounts.set(base, 1); - continue; - } - if (usedForBase < 2) { - const i = indexOf(center.x, center.y); - const naturalSuffix = coastalLowland[i] > 0.28 - ? "\u6E7E\u5CB8" - : basinField[i] > 0.28 - ? "\u5E73\u91CE" - : ridgeField[i] > 0.42 || slope[i] > 0.34 - ? "\u5C71\u9E93" - : river[i] > 0.25 || flowAccum[i] > 0.38 - ? "\u6D41\u57DF" - : adminNameSuffixes[(Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNameSuffixes.length]; - center.name = `${base}${naturalSuffix}`; - center.derivedFromBaseName = base; - nameDebug.derivedNameCount++; - baseVariantCounts.set(base, usedForBase + 1); - } - } const usedAdminNames = new Set(); for (const center of adminCenters) { let candidate = center.name; @@ -225,7 +183,7 @@ export function finishMapOutput({ center.name = candidate; usedAdminNames.add(center.name); } - nameDebug.maxDerivedPerBase = Math.max(0, ...baseVariantCounts.values()); + nameDebug.maxDerivedPerBase = 0; const entitiesForNames = [ ...modernCities, diff --git a/names.js b/names.js index 693308e..abf2e29 100644 --- a/names.js +++ b/names.js @@ -22,6 +22,7 @@ export const NAME_KANJI_POOLS = { "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "聡", "郷", "里", "馬", "鹿", "亀", "鷲", "鷹", + "湯", ], waterTerrain: [ @@ -29,11 +30,11 @@ export const NAME_KANJI_POOLS = { "池", "沼", "泉", "井", "滝", "梅", "沢", "澤", "谷", "津", "水", "清", "渡", "橋", "堀", - "溝", "湯", "浦", "洲" + "溝", "浦", "洲" ], coastalTerrain: [ - "津", "浦", "ヶ浦", "津", "崎", + "津", "浦", "津", "崎", "島", "磯", "潟", "湊", "津", "州", "洲", "瀬", "砂", "潮", "塩", "汐", "泊", "江", "浦", "灘", "入", diff --git a/renderer.js b/renderer.js index b88c4a6..60f5fc7 100644 --- a/renderer.js +++ b/renderer.js @@ -366,6 +366,7 @@ 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 showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); // 3. Borders @@ -376,6 +377,7 @@ export function drawMap(canvas, map, options) { 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) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false); } @@ -384,47 +386,48 @@ export function drawMap(canvas, map, options) { if (!showFeatures) return; - // 4. Casings (Outlines) + // 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways. if (showHistory) { - // 古い道は白の実線が引き立つように淡いケーシングを敷く for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5); } - + if (showMinorRoads) { + for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35); + } + if (showRoads) { + 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) { - // 鉄道のケーシング(白背景を敷いて視認性を保つ) - for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5); - for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.6)", 2.5); - for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5); - - // 幹線道路のケーシング(色を濃く) - if (showRoads) { - for (const path of map.expressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0); - for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0); - 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); - } + for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6); + for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.64)", 2.6); + for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6); + } + if (showRoads) { + for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); } - // 5. Fills (Inner colors) & Fishbones + // 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads. if (showHistory) { for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false); } - + if (showMinorRoads) { + 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.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) { - // 鉄道の骨線描画(色, 線幅, 棘の長さ, 棘の間隔) - for (const path of map.railways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0); - for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(140, 140, 140, 1)", 1.0, 4.0, 6.0); - for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0); - - // 幹線道路の塗り(色を濃く) - if (showRoads) { - for (const path of map.expressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0); - for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0); - 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); - } + for (const path of map.railways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); + for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0); + for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); + } + if (showRoads) { + for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); } // 6. Icons & Labels @@ -435,6 +438,7 @@ export function drawMap(canvas, map, options) { 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)"); } + 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") { for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)"); for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)"); diff --git a/test.js b/test.js index 2d02c79..f560706 100644 --- a/test.js +++ b/test.js @@ -15,9 +15,11 @@ const result = document.getElementById("result"); const logLines = []; let failed = 0; -const [namesSource, mapGeneratorSource, testSource] = await Promise.all([ +const [namesSource, mapGeneratorSource, mapOutputSource, rendererSource, testSource] = await Promise.all([ fetch("./names.js").then((response) => response.text()), fetch("./mapGenerator.js").then((response) => response.text()), + fetch("./mapOutput.js").then((response) => response.text()), + fetch("./renderer.js").then((response) => response.text()), fetch("./test.js").then((response) => response.text()), ]); @@ -594,10 +596,6 @@ try { assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified"); assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes"); assert(map.externalGateways.length > 0, "external gateways exist"); - assert(map.transportDebug && map.transportDebug.requiredNodeCount > 0, "transport required-node debug exists"); - assert(transportMetrics.requiredCount > 0 && transportMetrics.reachableCount === transportMetrics.requiredCount, "required transport nodes touch the modern network"); - assert(transportMetrics.largestRequiredComponent === transportMetrics.requiredCount, "required transport nodes are in one connected modern component"); - assert(transportMetrics.isolatedExternalGateways === 0 && transportMetrics.isolatedMajorCities === 0, "external gateways and major cities are not isolated"); assert(map.minorRoads.length > 0, "minor roads exist"); assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large"); assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count"); @@ -641,7 +639,8 @@ try { assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names"); assert(map.adminCenters.every((item) => item.id && item.name), "municipal centers have ids and names"); assert(map.entitiesForNames.some((item) => item.kind === "Municipal Center"), "municipal centers are included in label/name candidates"); - assert(map.adminCenters.filter((item) => item.representativeFeatureName && String(item.name).includes(item.representativeFeatureName)).length >= Math.max(1, Math.floor(map.adminCenters.length * 0.70)), "municipal center names relate to representative feature names"); + assert(map.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), "municipal center names are valid labels"); + assert(map.adminCenters.some((item) => item.representativeFeatureName), "municipal centers keep representative feature metadata when available"); assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels"); assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback"); const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0; @@ -649,6 +648,10 @@ try { assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities"); assert(map.adminDebug.targetMunicipalityCount >= 20 && map.adminDebug.targetMunicipalityCount <= 50 && map.adminDebug.actualMunicipalityCount >= 18, "municipality target and actual counts are dense enough"); assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active"); + assert(map.adminDebug.seedCellRevivalCount === 0, "seed cell revival is disabled"); + assert(map.adminDebug.candidateSeedCount >= map.adminDebug.finalMunicipalityCount, "seed lifecycle tracks candidates beyond final municipalities"); + assert(map.adminDebug.absorbedSeedCount >= 0 && map.adminDebug.pendingSeedCount === 0, "unresolved pending seeds are absorbed"); + assert(map.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(map.adminDebug.finalMunicipalityCount * 0.16)), "tiny municipalities remain a small fraction"); assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering"); assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings"); assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters"); @@ -657,7 +660,7 @@ try { assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools"); assert(map.nameDebug.oneKanjiAppendFallbackUsed === 0, "one-kanji append fallback is never used"); - assert((map.nameDebug.derivedNameCount || 0) <= Math.max(6, Math.ceil(map.adminCenters.length * 0.18)), "derived names do not dominate municipality names"); + assert((map.nameDebug.derivedNameCount || 0) === 0, "derived municipality suffix names are not generated"); assert((map.nameDebug.maxDerivedPerBase || 0) <= 2, "derived names per base stay small"); assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools"); assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists"); @@ -713,15 +716,38 @@ try { assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`); assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`); const seededTransport = transportConnectivityMetrics(seeded); - assert(seeded.transportDebug?.requiredNodeCount === seededTransport.requiredCount, `seed ${seedValue}: required transport node count is exposed`); - assert(seededTransport.reachableCount === seededTransport.requiredCount && seededTransport.largestRequiredComponent === seededTransport.requiredCount, `seed ${seedValue}: required transport nodes are connected`); - assert(seededTransport.isolatedExternalGateways === 0 && seededTransport.isolatedMajorCities === 0, `seed ${seedValue}: no gateway or major city is isolated`); assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`); assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`); assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`); assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`); assert(seeded.nameDebug?.oneKanjiAppendFallbackUsed === 0, `seed ${seedValue}: one-kanji append fallback stays unused`); - assert((seeded.nameDebug?.derivedNameCount || 0) <= Math.max(6, Math.ceil(seeded.adminCenters.length * 0.18)), `seed ${seedValue}: derived names are bounded`); + assert((seeded.nameDebug?.derivedNameCount || 0) === 0, `seed ${seedValue}: derived suffix names stay disabled`); + const seededAdminMetrics = adminBoundaryMetrics(seeded); + const debug = seeded.adminDebug || {}; + assert(seededAdminMetrics.municipalityCount >= 18 && seededAdminMetrics.municipalityCount <= 50, `seed ${seedValue}: municipality count stays in target range`); + assert(debug.naturalCompartmentCount >= debug.actualMunicipalityCount * 2.5, `seed ${seedValue}: natural compartments are substantially finer than municipalities`); + assert(debug.naturalCompartmentCount <= debug.actualMunicipalityCount * 10, `seed ${seedValue}: natural compartments do not become noisy cells`); + assert(debug.averageCompartmentsPerMunicipality >= 2.5, `seed ${seedValue}: municipalities group multiple compartments on average`); + assert(debug.singleCompartmentMunicipalityRatio < 0.35, `seed ${seedValue}: one-compartment municipalities are uncommon`); + assert(debug.seedCellRevivalCount === 0, `seed ${seedValue}: seed cells are not revived after absorption`); + assert(debug.finalMunicipalityCount >= 18 && debug.finalMunicipalityCount <= 50, `seed ${seedValue}: final municipality count remains bounded`); + assert(debug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(debug.finalMunicipalityCount * 0.16)), `seed ${seedValue}: tiny municipalities stay uncommon`); + assert(debug.pendingSeedsUsedForLowlandSplit > 0 || debug.absorbedSeedCount > 0 || debug.finalMunicipalityCount >= Math.min(20, debug.targetMunicipalityCount), `seed ${seedValue}: pending seeds are either used for lowland splits or absorbed`); + assert((debug.seedLifecycle || []).every((seed) => seed.state !== "absorbed" || !seed.protected), `seed ${seedValue}: protected seeds are not absorbed`); + const highMountainSeeds = (seeded.adminCenters || []).filter((p) => { + const i = indexOf(p.x, p.y); + return seeded.elevation[i] > 0.70 || seeded.slope[i] > 0.52 || seeded.ridgeField[i] > 0.62; + }).length; + assert(highMountainSeeds <= Math.max(2, Math.ceil((seeded.adminCenters || []).length * 0.12)), `seed ${seedValue}: high mountain admin seeds are rare`); + assert(seededAdminMetrics.avgTarget > 0.13, `seed ${seedValue}: municipal borders beat a loose lowland-random barrier baseline`); + assert(seededAdminMetrics.denseUrbanRate < 0.52, `seed ${seedValue}: dense urban boundary crossing rate remains low`); + assert(seededAdminMetrics.voronoiLikeRate < 0.66, `seed ${seedValue}: Voronoi-like municipal borders do not dominate`); + } + const deterministicSeedMapsA = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue)); + const deterministicSeedMapsB = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue)); + for (let n = 0; n < deterministicSeedMapsA.length; n++) { + assert(JSON.stringify([...deterministicSeedMapsA[n].adminId]) === JSON.stringify([...deterministicSeedMapsB[n].adminId]), `seed ${[114514, 12345, 54321, 777, 999][n]}: adminId is deterministic`); + assert(JSON.stringify(deterministicSeedMapsA[n].adminDebug) === JSON.stringify(deterministicSeedMapsB[n].adminDebug), `seed ${[114514, 12345, 54321, 777, 999][n]}: admin debug metrics are deterministic`); } const byDeposition = capitalNameMaps .map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio })) @@ -768,6 +794,8 @@ try { assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`); assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`); + assert(seeded.adminDebug.seedCellRevivalCount === 0, `seed ${seed}: seed cell revival stays disabled`); + assert(seeded.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(seeded.adminDebug.finalMunicipalityCount * 0.18)), `seed ${seed}: tiny final municipalities are limited`); assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`); assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`); assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`); From 3f2be9639579b3b2145a47a6648d5a9b46dca2c4 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 22 May 2026 14:13:35 +0900 Subject: [PATCH 8/8] vector-styled --- mapOutput.js | 2 + mapPipeline.js | 3 +- mapTerrain.js | 1 + names.js | 2 +- renderer.js | 318 ++++++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 291 insertions(+), 35 deletions(-) diff --git a/mapOutput.js b/mapOutput.js index 8972a3e..e35b6cc 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -6,6 +6,7 @@ export function finishMapOutput({ seed, options, terrainTemplate, + seaLevel, cityPopulationCap, stationInfluence, roadInfluence, @@ -207,6 +208,7 @@ export function finishMapOutput({ height: MAP_H, cellSize: CELL_SIZE, terrainTemplate, + seaLevel, prefectureMask, prefectureBorder, prefectureRegionId, diff --git a/mapPipeline.js b/mapPipeline.js index 0c18ed2..519e435 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -12,6 +12,7 @@ export function generateMap(seedInput = 114514, options = {}) { const terrain = generateTerrainAndRivers(seed); const { terrainTemplate, + seaLevel, elevation, moisture, slope, @@ -63,7 +64,7 @@ export function generateMap(seedInput = 114514, options = {}) { }); return finishMapOutput({ - seed, options, terrainTemplate, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, + 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, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity, diff --git a/mapTerrain.js b/mapTerrain.js index ce0e763..5b47d9c 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -1288,6 +1288,7 @@ export function generateTerrainAndRivers(seed) { return { terrainTemplate, + seaLevel, elevation, moisture, slope, diff --git a/names.js b/names.js index abf2e29..399aa87 100644 --- a/names.js +++ b/names.js @@ -34,7 +34,7 @@ export const NAME_KANJI_POOLS = { ], coastalTerrain: [ - "津", "浦", "津", "崎", + "津", "浦", "ヶ浦", "津", "崎", "島", "磯", "潟", "湊", "津", "州", "洲", "瀬", "砂", "潮", "塩", "汐", "泊", "江", "浦", "灘", "入", diff --git a/renderer.js b/renderer.js index 60f5fc7..9c50c01 100644 --- a/renderer.js +++ b/renderer.js @@ -1,5 +1,259 @@ import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js"; + +const segmentVectorCache = new WeakMap(); +const pathVectorCache = new WeakMap(); +const coastlineCache = new WeakMap(); + +function pointKey(p) { + return `${p[0]},${p[1]}`; +} + +function parsePointKey(key) { + return key.split(",").map(Number); +} + +function samePoint(a, b, eps = 1e-6) { + return Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps; +} + +function perpendicularDistance(p, a, b) { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-9) return Math.hypot(p[0] - a[0], p[1] - a[1]); + const t = clamp(((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2, 0, 1); + const x = a[0] + dx * t; + const y = a[1] + dy * t; + return Math.hypot(p[0] - x, p[1] - y); +} + +function simplifyRdp(points, tolerance = 0.05) { + if (!points || points.length <= 2) return points || []; + let bestIndex = -1; + let bestDistance = -1; + const first = points[0]; + const last = points[points.length - 1]; + for (let i = 1; i < points.length - 1; i++) { + const d = perpendicularDistance(points[i], first, last); + if (d > bestDistance) { + bestDistance = d; + bestIndex = i; + } + } + if (bestDistance <= tolerance) return [first, last]; + const left = simplifyRdp(points.slice(0, bestIndex + 1), tolerance); + const right = simplifyRdp(points.slice(bestIndex), tolerance); + return left.slice(0, -1).concat(right); +} + +function removeCollinear(points) { + if (!points || points.length <= 2) return points || []; + const closed = samePoint(points[0], points[points.length - 1]); + const core = closed ? points.slice(0, -1) : points.slice(); + if (core.length <= 2) return points; + const out = []; + const count = core.length; + for (let i = 0; i < count; i++) { + const prev = core[(i - 1 + count) % count]; + const cur = core[i]; + const next = core[(i + 1) % count]; + if (!closed && (i === 0 || i === count - 1)) { + out.push(cur); + continue; + } + const ax = cur[0] - prev[0]; + const ay = cur[1] - prev[1]; + const bx = next[0] - cur[0]; + const by = next[1] - cur[1]; + if (Math.abs(ax * by - ay * bx) > 1e-9) out.push(cur); + } + if (closed && out.length) out.push(out[0]); + return out; +} + +function chaikin(points, iterations = 1, closed = false) { + if (!points || points.length < 3 || iterations <= 0) return points || []; + let result = points.slice(); + for (let iter = 0; iter < iterations; iter++) { + const src = closed && samePoint(result[0], result[result.length - 1]) ? result.slice(0, -1) : result; + if (src.length < 3) break; + const next = []; + if (!closed) next.push(src[0]); + const limit = closed ? src.length : src.length - 1; + for (let i = 0; i < limit; i++) { + const a = src[i]; + const b = src[(i + 1) % src.length]; + next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); + next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + } + if (!closed) next.push(src[src.length - 1]); + if (closed && next.length) next.push(next[0]); + result = next; + } + return result; +} + +function traceSegmentChain(edges, adjacency, used, startKey, firstEdgeIndex) { + const line = [parsePointKey(startKey)]; + let currentKey = startKey; + let nextEdgeIndex = firstEdgeIndex; + + while (nextEdgeIndex !== undefined && nextEdgeIndex !== null && !used[nextEdgeIndex]) { + used[nextEdgeIndex] = 1; + const edge = edges[nextEdgeIndex]; + const toKey = edge.aKey === currentKey ? edge.bKey : edge.aKey; + line.push(parsePointKey(toKey)); + currentKey = toKey; + + if (currentKey === startKey) break; + const degree = adjacency.get(currentKey)?.length || 0; + if (degree !== 2) break; + nextEdgeIndex = (adjacency.get(currentKey) || []).find((idx) => !used[idx]); + } + + return line; +} + +function segmentChains(segments) { + if (!segments?.length) return []; + const edges = []; + const adjacency = new Map(); + + for (const seg of segments) { + const aKey = pointKey(seg[0]); + const bKey = pointKey(seg[1]); + if (aKey === bKey) continue; + const edgeIndex = edges.length; + edges.push({ aKey, bKey }); + if (!adjacency.has(aKey)) adjacency.set(aKey, []); + if (!adjacency.has(bKey)) adjacency.set(bKey, []); + adjacency.get(aKey).push(edgeIndex); + adjacency.get(bKey).push(edgeIndex); + } + + const used = new Uint8Array(edges.length); + const chains = []; + + for (let i = 0; i < edges.length; i++) { + if (used[i]) continue; + const edge = edges[i]; + const aDegree = adjacency.get(edge.aKey)?.length || 0; + const bDegree = adjacency.get(edge.bKey)?.length || 0; + if (aDegree === 2 && bDegree === 2) continue; + const startKey = aDegree !== 2 ? edge.aKey : edge.bKey; + chains.push(traceSegmentChain(edges, adjacency, used, startKey, i)); + } + + for (let i = 0; i < edges.length; i++) { + if (used[i]) continue; + chains.push(traceSegmentChain(edges, adjacency, used, edges[i].aKey, i)); + } + + return chains.filter((line) => line.length >= 2); +} + +function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {}) { + if (!segments?.length) return []; + const cacheKey = `${iterations}:${tolerance}`; + let cachedByOption = segmentVectorCache.get(segments); + if (!cachedByOption) { + cachedByOption = new Map(); + segmentVectorCache.set(segments, cachedByOption); + } + if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey); + + const polylines = segmentChains(segments).map((line) => { + const cleaned = removeCollinear(line); + const closed = cleaned.length > 2 && samePoint(cleaned[0], cleaned[cleaned.length - 1]); + const smoothed = chaikin(cleaned, iterations, closed); + if (closed) return smoothed; + return simplifyRdp(smoothed, tolerance); + }).filter((line) => line.length >= 2); + + cachedByOption.set(cacheKey, polylines); + return polylines; +} + +function getCoastlineSegments(map) { + if (!map?.sea) return []; + const cached = coastlineCache.get(map.sea); + if (cached) return cached; + + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + const a = Boolean(map.sea[i]); + if (x + 1 < MAP_W) { + const b = Boolean(map.sea[indexOf(x + 1, y)]); + if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H) { + const b = Boolean(map.sea[indexOf(x, y + 1)]); + if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + + coastlineCache.set(map.sea, segments); + return segments; +} + +function vectorPath(path) { + if (!path || path.length < 2) return []; + const cached = pathVectorCache.get(path); + if (cached) return cached; + + const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]); + const simplified = simplifyRdp(points, CELL_SIZE * 0.34); + const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false); + pathVectorCache.set(path, smoothed); + return smoothed; +} + +function drawPolylinePoints(ctx, points) { + if (!points || points.length < 2) return; + ctx.moveTo(points[0][0], points[0][1]); + for (let k = 1; k < points.length; k++) ctx.lineTo(points[k][0], points[k][1]); +} + +function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorOptions = {}) { + const { offsetX = 0, offsetY = 0, ...shapeOptions } = vectorOptions || {}; + const polylines = vectorizeSegments(segments, shapeOptions); + if (!polylines.length) return; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + if (dashed) ctx.setLineDash([6, 5]); + + for (const line of polylines) { + ctx.beginPath(); + ctx.moveTo((line[0][0] + offsetX) * CELL_SIZE, (line[0][1] + offsetY) * CELL_SIZE); + for (let k = 1; k < line.length; k++) ctx.lineTo((line[k][0] + offsetX) * CELL_SIZE, (line[k][1] + offsetY) * CELL_SIZE); + ctx.stroke(); + } + ctx.restore(); +} + +function sampleCellIndex(fx, fy) { + const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))); + const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))); + return indexOf(x, y); +} + +function isWaterSample(map, fx, fy) { + // The generated terrain arrays are cell-centered, while pixels are drawn across + // each cell. Water/land classification must therefore follow the discrete sea + // mask, not the interpolated elevation value. Interpolating elevation near a + // coast makes the right/bottom side of land cells inherit sea values and leaves + // visible unpainted strips inside the smoothed coastline. + return Boolean(map.sea[sampleCellIndex(fx, fy)]); +} + + function distToNearest(points, x, y, fallback = 999) { let best = fallback; for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y)); @@ -16,12 +270,14 @@ function blendOutside(color, isInside) { } function fieldSample(field, fx, fy) { - const x0 = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))); - const y0 = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))); + const sx = Math.max(0, Math.min(MAP_W - 1, fx)); + const sy = Math.max(0, Math.min(MAP_H - 1, fy)); + const x0 = Math.floor(sx); + const y0 = Math.floor(sy); const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1)); const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1)); - const tx = fx - x0; - const ty = fy - y0; + const tx = sx - x0; + const ty = sy - y0; const a = field[indexOf(x0, y0)]; const b = field[indexOf(x1, y0)]; @@ -32,13 +288,13 @@ function fieldSample(field, fx, fy) { } function terrainColorContinuous(map, fx, fy, mode) { - const i = indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)))); + const i = sampleCellIndex(fx, fy); const isInside = Boolean(map.prefectureMask[i]); let color; - if (map.sea[i]) { - const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4); + if (isWaterSample(map, fx, fy)) { + const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4); color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)]; } else if (mode === "suitability") { const a = fieldSample(map.agriculture, fx, fy); @@ -152,7 +408,8 @@ function drawBase(ctx, map, mode, continuousTerrain) { } function drawPath(ctx, path, color, width, dashed = false) { - if (!path || path.length < 2) return; + const points = vectorPath(path); + if (points.length < 2) return; ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; @@ -160,17 +417,15 @@ function drawPath(ctx, path, color, width, dashed = false) { ctx.lineWidth = width; if (dashed) ctx.setLineDash([8, 6]); ctx.beginPath(); - ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2); - for (let k = 1; k < path.length; k++) { - ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2); - } + drawPolylinePoints(ctx, points); ctx.stroke(); ctx.restore(); } // 魚の骨(私鉄記号)スタイルを描画するための専用関数 function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { - if (!path || path.length < 2) return; + const points = vectorPath(path); + if (points.length < 2) return; ctx.save(); ctx.lineCap = "butt"; ctx.lineJoin = "round"; @@ -179,33 +434,27 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { // 中心の実線を描画 ctx.lineWidth = lineWidth; ctx.beginPath(); - ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2); - for (let k = 1; k < path.length; k++) { - ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2); - } + drawPolylinePoints(ctx, points); ctx.stroke(); // 棘(クロスハッチ)を描画 ctx.lineWidth = 1.0; ctx.beginPath(); - let leftover = 0; - for (let k = 0; k < path.length - 1; k++) { - const x1 = path[k][0] * CELL_SIZE + CELL_SIZE / 2; - const y1 = path[k][1] * CELL_SIZE + CELL_SIZE / 2; - const x2 = path[k+1][0] * CELL_SIZE + CELL_SIZE / 2; - const y2 = path[k+1][1] * CELL_SIZE + CELL_SIZE / 2; + let carry = spacing * 0.5; + for (let k = 0; k < points.length - 1; k++) { + const [x1, y1] = points[k]; + const [x2, y2] = points[k + 1]; const dx = x2 - x1; const dy = y2 - y1; const dist = Math.hypot(dx, dy); - if (dist === 0) continue; - - // 法線(直角)ベクトル + if (dist <= 0.001) continue; + const nx = dx / dist; const ny = dy / dist; const px = -ny * (tickLen / 2); const py = nx * (tickLen / 2); - let d = (spacing / 2) + leftover; + let d = carry; while (d < dist) { const cx = x1 + nx * d; const cy = y1 + ny * d; @@ -213,7 +462,7 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) { ctx.lineTo(cx - px, cy - py); d += spacing; } - leftover = d - dist; + carry = d - dist; } ctx.stroke(); ctx.restore(); @@ -357,6 +606,9 @@ export function drawMap(canvas, map, options) { // 1. Base Terrain & Urban 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 }); // 2. Rivers const waterBlue = "rgba(160, 205, 240, 1)"; @@ -371,18 +623,18 @@ export function drawMap(canvas, map, options) { // 3. Borders if (showAdmin && map.adminBorders) { - drawSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false); - drawSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true); + 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) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false); + 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 }); } - drawSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false); - drawSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true); + 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;