diff --git a/mapFeatureContext.js b/mapFeatureContext.js new file mode 100644 index 0000000..cbfc2d6 --- /dev/null +++ b/mapFeatureContext.js @@ -0,0 +1,327 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, valueNoise } from "./mapUtils.js"; + +export function buildFeatureContext(seed, terrain) { + const { + elevation, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + portSuitability, + prefectureMask, + prefectureRegionId, + naturalBarrierScore, + } = terrain; + + const geography = terrain.geography || {}; + const geoHabitability = geography.habitability || null; + const geoAccessibility = geography.accessibility || null; + const geoNaturalCentrality = geography.naturalCentrality || geography.centrality || null; + const geoLowlandCapacity = geography.lowlandCapacity || null; + const geoValleyAccess = geography.valleyAccess || null; + const geoCoastalAccess = geography.coastalAccess || null; + const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null; + const geoCorridorSuitability = geography.corridorSuitability || null; + + function fieldValue(field, i, fallback = 0) { + const v = field?.[i]; + return Number.isFinite(v) ? v : fallback; + } + + function regionIdAt(x, y) { + if (!inside(x, y)) return -1; + const i = indexOf(x, y); + if (sea[i]) return -1; + if (prefectureMask?.[i]) return 0; + if (!prefectureRegionId) return 0; + const id = prefectureRegionId?.[i]; + return id !== undefined && id >= 0 ? id : -1; + } + + function inFocusedPrefecture(p) { + return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); + } + + function localConfluenceScore(x, y) { + let arms = 0; + let strong = 0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const rv = river[indexOf(nx, ny)]; + if (rv > 0.18) arms++; + if (rv > 0.34) strong++; + } + return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); + } + + // --- 1. Human context: one full raster pass ----------------------------- + const developable = new Float32Array(SIZE); + const ruralSuitability = new Float32Array(SIZE); + const townSuitability = new Float32Array(SIZE); + const valleySettlement = new Float32Array(SIZE); + const coastalSettlement = new Float32Array(SIZE); + const confluenceField = new Float32Array(SIZE); + const barrierCost = new Float32Array(SIZE); + const corridorCost = new Float32Array(SIZE); + const settlementCluster = new Float32Array(SIZE); + const settlementScore = new Float32Array(SIZE); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) { + barrierCost[i] = INF; + corridorCost[i] = INF; + continue; + } + const naturalBarrier = naturalBarrierScore?.[i] || 0; + const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; + const highPenalty = Math.max(0, elevation[i] - 0.56); + const lowSlope = clamp(1 - slope[i] * 2.3); + const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; + const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26); + const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; + confluenceField[i] = confluence; + + const geoH = fieldValue(geoHabitability, i, 0); + const geoLow = fieldValue(geoLowlandCapacity, i, 0); + const geoValley = fieldValue(geoValleyAccess, i, 0); + const geoCoast = fieldValue(geoCoastalAccess, i, 0); + const geoB = fieldValue(geoBarrier, i, naturalBarrier); + const localDevelopable = clamp( + plain[i] * 0.34 + + agriculture[i] * 0.24 + + basinField[i] * 0.24 + + valleyField[i] * 0.24 + + coastalLowland[i] * 0.18 + + depositional * 0.22 + + lowSlope * 0.10 - + slope[i] * 0.82 - + ridgeField[i] * 0.52 - + spine * 0.24 - + highPenalty * 1.14 - + floodplain[i] * 0.03 + ); + developable[i] = clamp(localDevelopable * 0.68 + geoH * 0.34 + geoLow * 0.16 - geoB * 0.05); + valleySettlement[i] = clamp(( + valleyField[i] * 0.52 + + river[i] * 0.08 + + confluence * 0.38 + + depositional * 0.20 + + basinField[i] * 0.16 + + plain[i] * 0.08 + + lowSlope * 0.12 - + slope[i] * 0.54 - + ridgeField[i] * 0.30 - + spine * 0.16 - + highPenalty * 0.70 - + floodplain[i] * 0.10 + ) * 0.74 + geoValley * 0.30 + geoH * 0.08 - geoB * 0.04); + coastalSettlement[i] = clamp(( + coastalLowland[i] * 0.50 + + (portSuitability?.[i] || 0) * 0.30 + + (deltaField?.[i] || 0) * 0.20 + + plain[i] * 0.10 - + slope[i] * 0.52 - + ridgeField[i] * 0.24 - + spine * 0.12 + ) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04); + const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; + settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise); + ruralSuitability[i] = clamp( + agriculture[i] * 0.54 + + developable[i] * 0.30 + + valleySettlement[i] * 0.18 + + coastalSettlement[i] * 0.20 + + openPlainPotential * 0.34 + + settlementCluster[i] * 0.30 - + Math.max(0, elevation[i] - 0.64) * 0.56 + ); + townSuitability[i] = clamp( + developable[i] * 0.38 + + agriculture[i] * 0.18 + + valleySettlement[i] * 0.16 + + coastalSettlement[i] * 0.30 + + confluence * 0.20 + + basinField[i] * 0.18 + + plain[i] * 0.26 + + openPlainPotential * 0.44 + + settlementCluster[i] * 0.22 - + slope[i] * 0.34 - + ridgeField[i] * 0.17 - + spine * 0.10 + ); + settlementScore[i] = clamp( + ruralSuitability[i] * 0.48 + + townSuitability[i] * 0.30 + + confluence * 0.08 + + fieldValue(geoHabitability, i, developable[i]) * 0.18 + + fieldValue(geoNaturalCentrality, i, 0) * 0.12 - + fieldValue(geoBarrier, i, 0) * 0.06 + ); + barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; + corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); + } + } + + // --- region statistics --------------------------------------------------- + const regionStats = new Map(); + function ensureRegion(regionId) { + let st = regionStats.get(regionId); + if (!st) { + st = { + id: regionId, + area: 0, + developableCells: 0, + developableSum: 0, + valleyCells: 0, + coastCells: 0, + townCells: 0, + plainCells: 0, + highCentralityCells: 0, + habitabilitySum: 0, + accessibilitySum: 0, + centralitySum: 0, + lowlandCapacitySum: 0, + minX: MAP_W, + minY: MAP_H, + maxX: 0, + maxY: 0, + }; + regionStats.set(regionId, st); + } + return st; + } + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const st = ensureRegion(regionId); + st.area++; + const gHabit = fieldValue(geoHabitability, i, developable[i]); + const gAccess = fieldValue(geoAccessibility, i, 0); + const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]); + const gLow = fieldValue(geoLowlandCapacity, i, plain[i]); + st.developableSum += developable[i]; + st.habitabilitySum += gHabit; + st.accessibilitySum += gAccess; + st.centralitySum += gCentral; + st.lowlandCapacitySum += gLow; + if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++; + if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++; + if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++; + if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++; + if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++; + if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++; + st.minX = Math.min(st.minX, x); + st.minY = Math.min(st.minY, y); + st.maxX = Math.max(st.maxX, x); + st.maxY = Math.max(st.maxY, y); + } + } + + function visibilityFactor(regionId, st) { + if (!st || st.area <= 0) return 0; + // Treat the focused prefecture and neighboring prefectures with the same + // density curve. Only genuinely clipped map-edge slivers are downscaled. + return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05); + } + + function pickRegionalPoints(scoreArray, { + stride = 1, + threshold = 0.25, + minDistance = 6, + totalMax = 100, + seedOffset = 0, + quotaForRegion, + predicate = () => true, + kind = "Point", + extraScore = () => 0, + }) { + const byRegion = new Map(); + for (let y = 2; y < MAP_H - 2; y += stride) { + for (let x = 2; x < MAP_W - 2; x += stride) { + const i = indexOf(x, y); + if (sea[i] || !predicate(x, y, i)) continue; + const regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055; + if (score < threshold) continue; + if (!byRegion.has(regionId)) byRegion.set(regionId, []); + byRegion.get(regionId).push({ x, y, score, kind, regionId }); + } + } + const out = []; + for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { + const st = regionStats.get(regionId); + const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0; + if (quota <= 0) continue; + out.push(...pickEntities(candidates, { + max: quota, + minDistance, + threshold, + seed: seed + seedOffset + regionId * 1009, + jitter: 0.04, + })); + } + return out.sort((a, b) => b.score - a.score).slice(0, totalMax); + } + + function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) { + const candidates = []; + for (let y = 2; y < MAP_H - 2; y += stride) { + for (let x = 2; x < MAP_W - 2; x += stride) { + const i = indexOf(x, y); + if (sea[i] || !predicate(x, y, i)) continue; + const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07; + if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); + } + } + return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); + } + + + return { + geography, + geoHabitability, + geoAccessibility, + geoNaturalCentrality, + geoLowlandCapacity, + geoValleyAccess, + geoCoastalAccess, + geoBarrier, + geoCorridorSuitability, + fieldValue, + regionIdAt, + inFocusedPrefecture, + developable, + ruralSuitability, + townSuitability, + valleySettlement, + coastalSettlement, + confluenceField, + barrierCost, + corridorCost, + settlementCluster, + settlementScore, + regionStats, + visibilityFactor, + pickRegionalPoints, + pickGlobalPoints, + }; +} \ No newline at end of file diff --git a/mapFeatureLanduse.js b/mapFeatureLanduse.js new file mode 100644 index 0000000..aaccc11 --- /dev/null +++ b/mapFeatureLanduse.js @@ -0,0 +1,214 @@ +import { MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js"; +import { LANDUSE } from "./landuseCodes.js"; + +export function buildFeatureLanduse(ctx) { + const { + seed, + elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, + developable, ruralSuitability, valleySettlement, coastalSettlement, + modernCities, logisticsParks, + roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence, + cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence, + } = ctx; + const populationDensity = new Float32Array(SIZE); + + const landuse = new Uint8Array(SIZE); + + // Re-run land-use classification after landuse allocation. The loop above is + // intentionally inside a helper to keep all thresholds in one place. + function classifyLanduse() { + landuse.fill(LANDUSE.RURAL); + let maxDensity = 0; + const baseNoiseSeed = seed + 15000; + const urbanCapacity = new Float32Array(SIZE); + const ruralDensityFloor = new Float32Array(SIZE); + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); + const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); + const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10; + const core = coreInfluence[i]; + const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38; + const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30; + const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10); + urbanCapacity[i] = clamp( + developable[i] * 0.66 + + plain[i] * 0.16 + + basinField[i] * 0.16 + + valleyField[i] * 0.16 + + coastalLowland[i] * 0.12 + + roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 + + riverUrban * 0.14 - + slope[i] * 0.18 - + ridgeField[i] * 0.12 - + floodplain[i] * 0.08 + ); + const highPenaltyDensity = Math.max(0, elevation[i] - 0.58); + const agrarianDensity = clamp( + agriculture[i] * 0.045 + + ruralSuitability[i] * 0.035 + + developable[i] * 0.028 + + plain[i] * 0.018 + + basinField[i] * 0.014 + + valleySettlement[i] * 0.014 + + coastalSettlement[i] * 0.012 + + villageInfluence[i] * 0.040 + + townInfluence[i] * 0.022 + + roadDensityInfluence[i] * 0.038 + + stationDensityInfluence[i] * 0.020 + + railInfluence2[i] * 0.012 - + slope[i] * 0.030 - + ridgeField[i] * 0.020 - + highPenaltyDensity * 0.058 + ); + const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025; + ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105); + populationDensity[i] = clamp( + urban * 0.66 + + core * 0.46 + + oldTown * 0.28 + + townInfluence[i] * 0.16 + + villageInfluence[i] * 0.14 + + roadDensityInfluence[i] * 0.42 + + stationDensityInfluence[i] * 0.34 + + railInfluence2[i] * 0.12 + + transport * 0.05 + + agrarianDensity * 0.34 + ); + maxDensity = Math.max(maxDensity, populationDensity[i]); + + if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { + landuse[i] = LANDUSE.FOREST; + continue; + } + if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.INDUSTRIAL; + continue; + } + if (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) { + landuse[i] = LANDUSE.LOGISTICS; + continue; + } + if (core > 0.38 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.CBD; + continue; + } + if (oldTown > 0.18 && urbanCapacity[i] > 0.09) { + landuse[i] = LANDUSE.OLD_URBAN; + continue; + } + + const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08; + const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28); + const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42); + const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12; + if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.SUBURB; + } else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) { + landuse[i] = LANDUSE.SUBURB; + } else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) { + landuse[i] = LANDUSE.FARMLAND; + } else { + const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18); + landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : LANDUSE.RURAL; + } + } + } + + const baseLanduse = landuse.slice(); + const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue; + const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); + const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); + let urbanNeighbors = 0; + let cbdNeighbors = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const lu = baseLanduse[i + dy * MAP_W + dx]; + if (isBuilt(lu)) urbanNeighbors++; + if (lu === LANDUSE.CBD) cbdNeighbors++; + } + } + if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) { + landuse[i] = LANDUSE.CBD; + continue; + } + if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) { + const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08; + const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24; + if (fringeChance > 0.34 + noise) { + landuse[i] = LANDUSE.SUBURB; + } + } + if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) { + landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB; + } + } + } + + for (const park of logisticsParks) { + const r = 3; + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = park.x + dx; + const y = park.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue; + if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) { + landuse[i] = LANDUSE.LOGISTICS; + } + } + } + } + + if (maxDensity > 0) { + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const lu = landuse[i]; + let floor = ruralDensityFloor[i]; + if (lu === LANDUSE.FARMLAND) { + floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110)); + } else if (lu === LANDUSE.LOGISTICS) { + floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060)); + } else if (lu === LANDUSE.RURAL) { + floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070)); + } else if (lu === LANDUSE.FOREST) { + floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0); + } + const normalized = populationDensity[i] / maxDensity; + populationDensity[i] = clamp(Math.max(normalized, floor)); + if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0; + } + } + } + classifyLanduse(); + + for (const city of modernCities) { + let urbanFootprintCells = 0; + let coreFootprintCells = 0; + const r = Math.ceil((city.urbanRadius || 8) * 1.3); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + if (Math.hypot(dx, dy) > r) continue; + if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++; + if (landuse[i] === LANDUSE.CBD) coreFootprintCells++; + } + } + city.urbanFootprintCells = urbanFootprintCells; + city.coreFootprintCells = coreFootprintCells; + } + + return { landuse, populationDensity }; +} diff --git a/mapFeatureSettlements.js b/mapFeatureSettlements.js new file mode 100644 index 0000000..2b48307 --- /dev/null +++ b/mapFeatureSettlements.js @@ -0,0 +1,47 @@ +import { MAP_H, MAP_W, SIZE, clamp, indexOf } from "./mapUtils.js"; +import { influenceFromPoints } from "./mapGeneratorHelpers.js"; + +export function buildSettlementDemandFields(ctx) { + const { + sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField, + modernCities, markets, commercialPorts, villages, + } = ctx; + + const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0)); + const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1)); + const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9)); + const settlementDemand = new Float32Array(SIZE); + const urbanEdge = new Float32Array(SIZE); + const logisticsPreSuitability = new Float32Array(SIZE); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16); + settlementDemand[i] = density; + urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32); + logisticsPreSuitability[i] = clamp( + agriculture[i] * 0.30 + + plain[i] * 0.24 + + basinField[i] * 0.14 + + coastalLowland[i] * 0.12 + + preliminaryTownInfluence[i] * 0.18 + + urbanEdge[i] * 0.34 - + preliminaryUrbanInfluence[i] * 0.20 - + slope[i] * 0.50 - + ridgeField[i] * 0.32 + ); + } + } + + + return { + preliminaryUrbanInfluence, + preliminaryTownInfluence, + preliminaryVillageInfluence, + settlementDemand, + urbanEdge, + logisticsPreSuitability, + }; +} \ No newline at end of file diff --git a/mapFeatureTransportTools.js b/mapFeatureTransportTools.js new file mode 100644 index 0000000..2d4cb92 --- /dev/null +++ b/mapFeatureTransportTools.js @@ -0,0 +1,191 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside } from "./mapUtils.js"; + +export function buildFeatureTransportCostFields(ctx) { + const { + seed, + sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, + portSuitability, passSuitability, crossingSuitability, naturalBarrierScore, + settlementDemand, urbanEdge, logisticsPreSuitability, + preliminaryTownInfluence, preliminaryVillageInfluence, + valleySettlement, coastalSettlement, developable, + } = ctx; + const expressway = new Float32Array(SIZE); + const rail = new Float32Array(SIZE); + const national = new Float32Array(SIZE); + const local = new Float32Array(SIZE); + const expresswayPotential = new Float32Array(SIZE); + const railPotential = new Float32Array(SIZE); + const nationalPotential = new Float32Array(SIZE); + const localPotential = new Float32Array(SIZE); + + function seaAdjacency(x, y, radius = 1) { + let sum = 0; + let total = 0; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + total++; + if (sea[indexOf(nx, ny)]) sum += 1; + } + } + return total > 0 ? sum / total : 0; + } + + function highAltitudeTransportClosed(i) { + // Above this contour the generator should treat mountains as no-road + // terrain. A strong mapped pass is the exception, so genuine saddle + // crossings can still exist without roads drilling through entire ranges. + return elevation[i] >= 0.70; + } + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i] || highAltitudeTransportClosed(i)) { + expressway[i] = rail[i] = national[i] = local[i] = INF; + expresswayPotential[i] = railPotential[i] = nationalPotential[i] = localPotential[i] = 0; + continue; + } + const density = settlementDemand[i]; + const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30); + const highDensity = clamp((density - 0.32) / 0.50); + const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16); + const pass = passSuitability?.[i] || 0; + const crossing = crossingSuitability?.[i] || 0; + const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0; + const seaNear = seaAdjacency(x, y, 1); + const seaBroad = seaAdjacency(x, y, 3); + const seaWide = seaAdjacency(x, y, 5); + // Roads should use coastal lowlands when there is a settlement/port reason, + // but should not casually trace beaches or hop over small bays. + const coastalTraversePenalty = clamp(seaBroad * 1.72 + seaWide * 0.82 - coastalLowland[i] * 0.48 - (portSuitability?.[i] || 0) * 0.30); + const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12); + const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80); + const denseCorePenalty = clamp((density - 0.66) / 0.28); + const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12); + const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2); + + if (extremeMountain > 0.92 && pass < 0.34) { + expressway[i] = rail[i] = INF; + national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty; + local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55; + expresswayPotential[i] = 0; + railPotential[i] = 0; + nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55); + localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28); + continue; + } + + expresswayPotential[i] = clamp( + mediumDensity * 0.62 + + urbanEdge[i] * 0.38 + + logisticsPreSuitability[i] * 0.54 + + lowland * 0.36 + + agriculture[i] * 0.16 - + denseCorePenalty * 0.54 - + slope[i] * 0.82 - + highMountain * 0.92 - + coastalTraversePenalty * 0.32 - + river[i] * 0.14 + ); + railPotential[i] = clamp( + highDensity * 0.80 + + preliminaryTownInfluence[i] * 0.22 + + lowland * 0.46 + + valleyField[i] * 0.22 + + coastalLowland[i] * 0.22 - + slope[i] * 1.28 - + highMountain * 1.10 - + coastalTraversePenalty * 0.26 - + ridgeField[i] * 0.34 + ); + nationalPotential[i] = clamp( + density * 0.46 + + preliminaryTownInfluence[i] * 0.32 + + preliminaryVillageInfluence[i] * 0.20 + + agriculture[i] * 0.24 + + valleyField[i] * 0.28 + + coastalLowland[i] * 0.26 + + pass * 0.18 + + crossing * 0.18 - + slope[i] * 0.48 - + highMountain * 0.24 - + coastalTraversePenalty * 0.16 - + ridgeField[i] * 0.18 + ); + localPotential[i] = clamp( + preliminaryVillageInfluence[i] * 0.52 + + agriculture[i] * 0.38 + + coastalSettlement[i] * 0.30 + + valleySettlement[i] * 0.30 + + developable[i] * 0.18 - + slope[i] * 0.34 - + coastalTraversePenalty * 0.08 - + ridgeField[i] * 0.10 + ); + + expressway[i] = Math.max(0.18, + 1.62 - expresswayPotential[i] * 0.96 + + denseCorePenalty * 1.30 + + slope[i] * 5.4 + + highMountain * 5.8 + + extremeMountain * 4.2 + + boundaryRidgePenalty * 4.2 + + waterCrossingPenalty * 2.1 + + coastalTraversePenalty * 2.65 + + seaNear * 2.35 + + seaWide * 1.10 + + openPlainParallelPenalty * 0.12 + + hash2(x, y, seed + 13301) * 0.04 + ); + rail[i] = Math.max(0.16, + 1.48 - railPotential[i] * 1.02 + + slope[i] * 7.2 + + highMountain * 7.0 + + extremeMountain * 4.8 + + boundaryRidgePenalty * 2.4 + + waterCrossingPenalty * 1.7 + + coastalTraversePenalty * 1.75 + + seaNear * 1.50 + + seaWide * 0.70 + + hash2(x, y, seed + 13302) * 0.03 + ); + national[i] = Math.max(0.16, + 1.28 - nationalPotential[i] * 0.84 + + slope[i] * 2.8 + + ridgeField[i] * 1.18 + + Math.max(0, elevation[i] - 0.62) * 3.0 + + highMountain * 2.9 + + boundaryRidgePenalty * 1.8 + + waterCrossingPenalty * 1.25 - + valleyField[i] * 0.18 - + coastalLowland[i] * 0.08 + + coastalTraversePenalty * 1.55 + + seaNear * 0.84 + + seaWide * 0.48 - + pass * 0.42 + + hash2(x, y, seed + 13303) * 0.05 + ); + local[i] = Math.max(0.14, + 1.12 - localPotential[i] * 0.86 + + slope[i] * 1.72 + + ridgeField[i] * 0.82 + + Math.max(0, elevation[i] - 0.68) * 1.9 + + highMountain * 1.24 + + boundaryRidgePenalty * 0.72 + + waterCrossingPenalty * 0.65 - + valleyField[i] * 0.22 - + coastalLowland[i] * 0.10 + + coastalTraversePenalty * 0.82 + + seaNear * 0.48 + + seaWide * 0.26 + + hash2(x, y, seed + 13304) * 0.07 + ); + } + } + return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential }; + } + diff --git a/mapFeatures.js b/mapFeatures.js index 254c77d..c5e4d86 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -1,8 +1,11 @@ -import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; -import { LANDUSE } from "./landuseCodes.js"; import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js"; import { buildUnifiedRailODNetwork } from "./mapTransportOD.js"; +import { buildFeatureContext } from "./mapFeatureContext.js"; +import { buildFeatureLanduse } from "./mapFeatureLanduse.js"; +import { buildSettlementDemandFields } from "./mapFeatureSettlements.js"; +import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js"; // Lightweight Human Geography V2 // -------------------------------- @@ -16,7 +19,6 @@ import { buildUnifiedRailODNetwork } from "./mapTransportOD.js"; export function generateMapFeatures(seed, terrain) { const { elevation, - moisture, slope, sea, river, @@ -28,290 +30,41 @@ export function generateMapFeatures(seed, terrain) { basinField, coastalLowland, flowAccum, - arcSpineField, - branchRidgeField, depositionalLowland, - alluvialFanField, deltaField, portSuitability, crossingSuitability, passSuitability, - prefectureMask, - prefectureRegionId, naturalBarrierScore, } = terrain; - const geography = terrain.geography || {}; - const geoHabitability = geography.habitability || null; - const geoAccessibility = geography.accessibility || null; - const geoNaturalCentrality = geography.naturalCentrality || geography.centrality || null; - const geoLowlandCapacity = geography.lowlandCapacity || null; - const geoValleyAccess = geography.valleyAccess || null; - const geoCoastalAccess = geography.coastalAccess || null; - const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null; - const geoCorridorSuitability = geography.corridorSuitability || null; - - function fieldValue(field, i, fallback = 0) { - const v = field?.[i]; - return Number.isFinite(v) ? v : fallback; - } - - function regionIdAt(x, y) { - if (!inside(x, y)) return -1; - const i = indexOf(x, y); - if (sea[i]) return -1; - if (prefectureMask?.[i]) return 0; - if (!prefectureRegionId) return 0; - const id = prefectureRegionId?.[i]; - return id !== undefined && id >= 0 ? id : -1; - } - - function inFocusedPrefecture(p) { - return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); - } - - function localConfluenceScore(x, y) { - let arms = 0; - let strong = 0; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - const rv = river[indexOf(nx, ny)]; - if (rv > 0.18) arms++; - if (rv > 0.34) strong++; - } - return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); - } - - // --- 1. Human context: one full raster pass ----------------------------- - const developable = new Float32Array(SIZE); - const ruralSuitability = new Float32Array(SIZE); - const townSuitability = new Float32Array(SIZE); - const valleySettlement = new Float32Array(SIZE); - const coastalSettlement = new Float32Array(SIZE); - const confluenceField = new Float32Array(SIZE); - const barrierCost = new Float32Array(SIZE); - const corridorCost = new Float32Array(SIZE); - const settlementCluster = new Float32Array(SIZE); - const settlementScore = new Float32Array(SIZE); - - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i]) { - barrierCost[i] = INF; - corridorCost[i] = INF; - continue; - } - const naturalBarrier = naturalBarrierScore?.[i] || 0; - const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; - const highPenalty = Math.max(0, elevation[i] - 0.56); - const lowSlope = clamp(1 - slope[i] * 2.3); - const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; - const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26); - const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; - confluenceField[i] = confluence; - - const geoH = fieldValue(geoHabitability, i, 0); - const geoLow = fieldValue(geoLowlandCapacity, i, 0); - const geoValley = fieldValue(geoValleyAccess, i, 0); - const geoCoast = fieldValue(geoCoastalAccess, i, 0); - const geoB = fieldValue(geoBarrier, i, naturalBarrier); - const localDevelopable = clamp( - plain[i] * 0.34 + - agriculture[i] * 0.24 + - basinField[i] * 0.24 + - valleyField[i] * 0.24 + - coastalLowland[i] * 0.18 + - depositional * 0.22 + - lowSlope * 0.10 - - slope[i] * 0.82 - - ridgeField[i] * 0.52 - - spine * 0.24 - - highPenalty * 1.14 - - floodplain[i] * 0.03 - ); - developable[i] = clamp(localDevelopable * 0.68 + geoH * 0.34 + geoLow * 0.16 - geoB * 0.05); - valleySettlement[i] = clamp(( - valleyField[i] * 0.52 + - river[i] * 0.08 + - confluence * 0.38 + - depositional * 0.20 + - basinField[i] * 0.16 + - plain[i] * 0.08 + - lowSlope * 0.12 - - slope[i] * 0.54 - - ridgeField[i] * 0.30 - - spine * 0.16 - - highPenalty * 0.70 - - floodplain[i] * 0.10 - ) * 0.74 + geoValley * 0.30 + geoH * 0.08 - geoB * 0.04); - coastalSettlement[i] = clamp(( - coastalLowland[i] * 0.50 + - (portSuitability?.[i] || 0) * 0.30 + - (deltaField?.[i] || 0) * 0.20 + - plain[i] * 0.10 - - slope[i] * 0.52 - - ridgeField[i] * 0.24 - - spine * 0.12 - ) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04); - const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; - settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise); - ruralSuitability[i] = clamp( - agriculture[i] * 0.54 + - developable[i] * 0.30 + - valleySettlement[i] * 0.18 + - coastalSettlement[i] * 0.20 + - openPlainPotential * 0.34 + - settlementCluster[i] * 0.30 - - Math.max(0, elevation[i] - 0.64) * 0.56 - ); - townSuitability[i] = clamp( - developable[i] * 0.38 + - agriculture[i] * 0.18 + - valleySettlement[i] * 0.16 + - coastalSettlement[i] * 0.30 + - confluence * 0.20 + - basinField[i] * 0.18 + - plain[i] * 0.26 + - openPlainPotential * 0.44 + - settlementCluster[i] * 0.22 - - slope[i] * 0.34 - - ridgeField[i] * 0.17 - - spine * 0.10 - ); - settlementScore[i] = clamp( - ruralSuitability[i] * 0.48 + - townSuitability[i] * 0.30 + - confluence * 0.08 + - fieldValue(geoHabitability, i, developable[i]) * 0.18 + - fieldValue(geoNaturalCentrality, i, 0) * 0.12 - - fieldValue(geoBarrier, i, 0) * 0.06 - ); - barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; - corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); - } - } - - // --- region statistics --------------------------------------------------- - const regionStats = new Map(); - function ensureRegion(regionId) { - let st = regionStats.get(regionId); - if (!st) { - st = { - id: regionId, - area: 0, - developableCells: 0, - developableSum: 0, - valleyCells: 0, - coastCells: 0, - townCells: 0, - plainCells: 0, - highCentralityCells: 0, - habitabilitySum: 0, - accessibilitySum: 0, - centralitySum: 0, - lowlandCapacitySum: 0, - minX: MAP_W, - minY: MAP_H, - maxX: 0, - maxY: 0, - }; - regionStats.set(regionId, st); - } - return st; - } - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const regionId = regionIdAt(x, y); - if (regionId < 0) continue; - const st = ensureRegion(regionId); - st.area++; - const gHabit = fieldValue(geoHabitability, i, developable[i]); - const gAccess = fieldValue(geoAccessibility, i, 0); - const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]); - const gLow = fieldValue(geoLowlandCapacity, i, plain[i]); - st.developableSum += developable[i]; - st.habitabilitySum += gHabit; - st.accessibilitySum += gAccess; - st.centralitySum += gCentral; - st.lowlandCapacitySum += gLow; - if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++; - if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++; - if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++; - if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++; - if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++; - if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++; - st.minX = Math.min(st.minX, x); - st.minY = Math.min(st.minY, y); - st.maxX = Math.max(st.maxX, x); - st.maxY = Math.max(st.maxY, y); - } - } - - function visibilityFactor(regionId, st) { - if (!st || st.area <= 0) return 0; - // Treat the focused prefecture and neighboring prefectures with the same - // density curve. Only genuinely clipped map-edge slivers are downscaled. - return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05); - } - - function pickRegionalPoints(scoreArray, { - stride = 1, - threshold = 0.25, - minDistance = 6, - totalMax = 100, - seedOffset = 0, - quotaForRegion, - predicate = () => true, - kind = "Point", - extraScore = () => 0, - }) { - const byRegion = new Map(); - for (let y = 2; y < MAP_H - 2; y += stride) { - for (let x = 2; x < MAP_W - 2; x += stride) { - const i = indexOf(x, y); - if (sea[i] || !predicate(x, y, i)) continue; - const regionId = regionIdAt(x, y); - if (regionId < 0) continue; - const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055; - if (score < threshold) continue; - if (!byRegion.has(regionId)) byRegion.set(regionId, []); - byRegion.get(regionId).push({ x, y, score, kind, regionId }); - } - } - const out = []; - for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { - const st = regionStats.get(regionId); - const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0; - if (quota <= 0) continue; - out.push(...pickEntities(candidates, { - max: quota, - minDistance, - threshold, - seed: seed + seedOffset + regionId * 1009, - jitter: 0.04, - })); - } - return out.sort((a, b) => b.score - a.score).slice(0, totalMax); - } - - function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) { - const candidates = []; - for (let y = 2; y < MAP_H - 2; y += stride) { - for (let x = 2; x < MAP_W - 2; x += stride) { - const i = indexOf(x, y); - if (sea[i] || !predicate(x, y, i)) continue; - const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07; - if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); - } - } - return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); - } - + const featureContext = buildFeatureContext(seed, terrain); + const { + geoHabitability, + geoAccessibility, + geoNaturalCentrality, + geoLowlandCapacity, + geoValleyAccess, + geoCoastalAccess, + geoBarrier, + fieldValue, + regionIdAt, + inFocusedPrefecture, + developable, + ruralSuitability, + townSuitability, + valleySettlement, + coastalSettlement, + confluenceField, + barrierCost, + corridorCost, + settlementCluster, + settlementScore, + regionStats, + visibilityFactor, + pickRegionalPoints, + pickGlobalPoints, + } = featureContext; // --- 2. Sparse points ---------------------------------------------------- let ports = pickGlobalPoints(portSuitability || coastalSettlement, { threshold: 0.30 + rand(seed, 1001) * 0.08, @@ -797,216 +550,25 @@ export function generateMapFeatures(seed, terrain) { } // --- 4. Field-derived transport corridors ------------------------------- - const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0)); - const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1)); - const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9)); - const settlementDemand = new Float32Array(SIZE); - const urbanEdge = new Float32Array(SIZE); - const logisticsPreSuitability = new Float32Array(SIZE); - - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16); - settlementDemand[i] = density; - urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32); - logisticsPreSuitability[i] = clamp( - agriculture[i] * 0.30 + - plain[i] * 0.24 + - basinField[i] * 0.14 + - coastalLowland[i] * 0.12 + - preliminaryTownInfluence[i] * 0.18 + - urbanEdge[i] * 0.34 - - preliminaryUrbanInfluence[i] * 0.20 - - slope[i] * 0.50 - - ridgeField[i] * 0.32 - ); - } - } - - function buildTransportCostFields() { - const expressway = new Float32Array(SIZE); - const rail = new Float32Array(SIZE); - const national = new Float32Array(SIZE); - const local = new Float32Array(SIZE); - const expresswayPotential = new Float32Array(SIZE); - const railPotential = new Float32Array(SIZE); - const nationalPotential = new Float32Array(SIZE); - const localPotential = new Float32Array(SIZE); - - function seaAdjacency(x, y, radius = 1) { - let sum = 0; - let total = 0; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - if (!dx && !dy) continue; - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - total++; - if (sea[indexOf(nx, ny)]) sum += 1; - } - } - return total > 0 ? sum / total : 0; - } - - function highAltitudeTransportClosed(i) { - // Above this contour the generator should treat mountains as no-road - // terrain. A strong mapped pass is the exception, so genuine saddle - // crossings can still exist without roads drilling through entire ranges. - return elevation[i] >= 0.70; - } - - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i] || highAltitudeTransportClosed(i)) { - expressway[i] = rail[i] = national[i] = local[i] = INF; - expresswayPotential[i] = railPotential[i] = nationalPotential[i] = localPotential[i] = 0; - continue; - } - const density = settlementDemand[i]; - const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30); - const highDensity = clamp((density - 0.32) / 0.50); - const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16); - const pass = passSuitability?.[i] || 0; - const crossing = crossingSuitability?.[i] || 0; - const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0; - const seaNear = seaAdjacency(x, y, 1); - const seaBroad = seaAdjacency(x, y, 3); - const seaWide = seaAdjacency(x, y, 5); - // Roads should use coastal lowlands when there is a settlement/port reason, - // but should not casually trace beaches or hop over small bays. - const coastalTraversePenalty = clamp(seaBroad * 1.72 + seaWide * 0.82 - coastalLowland[i] * 0.48 - (portSuitability?.[i] || 0) * 0.30); - const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12); - const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80); - const denseCorePenalty = clamp((density - 0.66) / 0.28); - const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12); - const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2); - - if (extremeMountain > 0.92 && pass < 0.34) { - expressway[i] = rail[i] = INF; - national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty; - local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55; - expresswayPotential[i] = 0; - railPotential[i] = 0; - nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55); - localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28); - continue; - } - - expresswayPotential[i] = clamp( - mediumDensity * 0.62 + - urbanEdge[i] * 0.38 + - logisticsPreSuitability[i] * 0.54 + - lowland * 0.36 + - agriculture[i] * 0.16 - - denseCorePenalty * 0.54 - - slope[i] * 0.82 - - highMountain * 0.92 - - coastalTraversePenalty * 0.32 - - river[i] * 0.14 - ); - railPotential[i] = clamp( - highDensity * 0.80 + - preliminaryTownInfluence[i] * 0.22 + - lowland * 0.46 + - valleyField[i] * 0.22 + - coastalLowland[i] * 0.22 - - slope[i] * 1.28 - - highMountain * 1.10 - - coastalTraversePenalty * 0.26 - - ridgeField[i] * 0.34 - ); - nationalPotential[i] = clamp( - density * 0.46 + - preliminaryTownInfluence[i] * 0.32 + - preliminaryVillageInfluence[i] * 0.20 + - agriculture[i] * 0.24 + - valleyField[i] * 0.28 + - coastalLowland[i] * 0.26 + - pass * 0.18 + - crossing * 0.18 - - slope[i] * 0.48 - - highMountain * 0.24 - - coastalTraversePenalty * 0.16 - - ridgeField[i] * 0.18 - ); - localPotential[i] = clamp( - preliminaryVillageInfluence[i] * 0.52 + - agriculture[i] * 0.38 + - coastalSettlement[i] * 0.30 + - valleySettlement[i] * 0.30 + - developable[i] * 0.18 - - slope[i] * 0.34 - - coastalTraversePenalty * 0.08 - - ridgeField[i] * 0.10 - ); - - expressway[i] = Math.max(0.18, - 1.62 - expresswayPotential[i] * 0.96 + - denseCorePenalty * 1.30 + - slope[i] * 5.4 + - highMountain * 5.8 + - extremeMountain * 4.2 + - boundaryRidgePenalty * 4.2 + - waterCrossingPenalty * 2.1 + - coastalTraversePenalty * 2.65 + - seaNear * 2.35 + - seaWide * 1.10 + - openPlainParallelPenalty * 0.12 + - hash2(x, y, seed + 13301) * 0.04 - ); - rail[i] = Math.max(0.16, - 1.48 - railPotential[i] * 1.02 + - slope[i] * 7.2 + - highMountain * 7.0 + - extremeMountain * 4.8 + - boundaryRidgePenalty * 2.4 + - waterCrossingPenalty * 1.7 + - coastalTraversePenalty * 1.75 + - seaNear * 1.50 + - seaWide * 0.70 + - hash2(x, y, seed + 13302) * 0.03 - ); - national[i] = Math.max(0.16, - 1.28 - nationalPotential[i] * 0.84 + - slope[i] * 2.8 + - ridgeField[i] * 1.18 + - Math.max(0, elevation[i] - 0.62) * 3.0 + - highMountain * 2.9 + - boundaryRidgePenalty * 1.8 + - waterCrossingPenalty * 1.25 - - valleyField[i] * 0.18 - - coastalLowland[i] * 0.08 + - coastalTraversePenalty * 1.55 + - seaNear * 0.84 + - seaWide * 0.48 - - pass * 0.42 + - hash2(x, y, seed + 13303) * 0.05 - ); - local[i] = Math.max(0.14, - 1.12 - localPotential[i] * 0.86 + - slope[i] * 1.72 + - ridgeField[i] * 0.82 + - Math.max(0, elevation[i] - 0.68) * 1.9 + - highMountain * 1.24 + - boundaryRidgePenalty * 0.72 + - waterCrossingPenalty * 0.65 - - valleyField[i] * 0.22 - - coastalLowland[i] * 0.10 + - coastalTraversePenalty * 0.82 + - seaNear * 0.48 + - seaWide * 0.26 + - hash2(x, y, seed + 13304) * 0.07 - ); - } - } - return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential }; - } - - const transportFields = buildTransportCostFields(); + const { + preliminaryUrbanInfluence, + preliminaryTownInfluence, + preliminaryVillageInfluence, + settlementDemand, + urbanEdge, + logisticsPreSuitability, + } = buildSettlementDemandFields({ + sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField, + modernCities, markets, commercialPorts, villages, + }); + const transportFields = buildFeatureTransportCostFields({ + seed, + sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, + portSuitability, passSuitability, crossingSuitability, naturalBarrierScore, + settlementDemand, urbanEdge, logisticsPreSuitability, + preliminaryTownInfluence, preliminaryVillageInfluence, + valleySettlement, coastalSettlement, developable, + }); const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths); const componentCityInfluence = influenceFromPoints([...modernCities, ...markets], 11, (p) => clamp((p.population || 8000) / 50000, 0.18, 8.0)); @@ -1333,11 +895,11 @@ export function generateMapFeatures(seed, terrain) { const water = pathWaterCrossingStats(path); const tunnel = pathTunnelStats(path); const bridgeLimit = overrides.bridgeLimit ?? (mode === "expressway" ? 20 : 10); - const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : 0); + const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : mode === "national" ? 10 : 0); const maxSeaRun = overrides.maxSeaRun ?? bridgeLimit; const maxTunnelRun = overrides.maxTunnelRun ?? tunnelLimit; const maxSeaShare = overrides.maxSeaShare ?? (mode === "expressway" ? 0.22 : mode === "rail" ? 0.030 : mode === "national" ? 0.10 : 0.05); - const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : 0); + const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : mode === "national" ? 0.12 : 0); if (water.maxSeaRun > maxSeaRun || water.seaShare > maxSeaShare) return false; if (tunnel.maxTunnelRun > maxTunnelRun || tunnel.tunnelShare > maxTunnelShare) return false; if (water.seaCells > 0) { @@ -2265,7 +1827,6 @@ const premodernRoads = []; const cityInfluence = new Float32Array(SIZE); const coreInfluence = new Float32Array(SIZE); const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9); - const populationDensity = new Float32Array(SIZE); function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") { const r = Math.ceil(radius); @@ -2730,204 +2291,14 @@ const premodernRoads = []; transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(100000); transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 }); transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs(); - var landuse = new Uint8Array(SIZE); - - // Re-run land-use classification after landuse allocation. The loop above is - // intentionally inside a helper to keep all thresholds in one place. - function classifyLanduse() { - landuse.fill(LANDUSE.RURAL); - let maxDensity = 0; - const baseNoiseSeed = seed + 15000; - const urbanCapacity = new Float32Array(SIZE); - const ruralDensityFloor = new Float32Array(SIZE); - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); - const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); - const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10; - const core = coreInfluence[i]; - const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38; - const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30; - const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10); - urbanCapacity[i] = clamp( - developable[i] * 0.66 + - plain[i] * 0.16 + - basinField[i] * 0.16 + - valleyField[i] * 0.16 + - coastalLowland[i] * 0.12 + - roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 + - riverUrban * 0.14 - - slope[i] * 0.18 - - ridgeField[i] * 0.12 - - floodplain[i] * 0.08 - ); - const highPenaltyDensity = Math.max(0, elevation[i] - 0.58); - const agrarianDensity = clamp( - agriculture[i] * 0.045 + - ruralSuitability[i] * 0.035 + - developable[i] * 0.028 + - plain[i] * 0.018 + - basinField[i] * 0.014 + - valleySettlement[i] * 0.014 + - coastalSettlement[i] * 0.012 + - villageInfluence[i] * 0.040 + - townInfluence[i] * 0.022 + - roadDensityInfluence[i] * 0.038 + - stationDensityInfluence[i] * 0.020 + - railInfluence2[i] * 0.012 - - slope[i] * 0.030 - - ridgeField[i] * 0.020 - - highPenaltyDensity * 0.058 - ); - const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025; - ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105); - populationDensity[i] = clamp( - urban * 0.66 + - core * 0.46 + - oldTown * 0.28 + - townInfluence[i] * 0.16 + - villageInfluence[i] * 0.14 + - roadDensityInfluence[i] * 0.42 + - stationDensityInfluence[i] * 0.34 + - railInfluence2[i] * 0.12 + - transport * 0.05 + - agrarianDensity * 0.34 - ); - maxDensity = Math.max(maxDensity[i]); - - if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { - landuse[i] = LANDUSE.FOREST; - continue; - } - if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.INDUSTRIAL; - continue; - } - if (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) { - landuse[i] = LANDUSE.LOGISTICS; - continue; - } - if (core > 0.38 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.CBD; - continue; - } - if (oldTown > 0.18 && urbanCapacity[i] > 0.09) { - landuse[i] = LANDUSE.OLD_URBAN; - continue; - } - - const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08; - const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28); - const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42); - const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12; - if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.SUBURB; - } else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) { - landuse[i] = LANDUSE.SUBURB; - } else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) { - landuse[i] = LANDUSE.FARMLAND; - } else { - const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18); - landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : LANDUSE.RURAL; - } - } - } - - const baseLanduse = landuse.slice(); - const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE; - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue; - const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); - const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); - let urbanNeighbors = 0; - let cbdNeighbors = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (!dx && !dy) continue; - const lu = baseLanduse[indexOf(x + dx, y + dy)]; - if (isBuilt(lu)) urbanNeighbors++; - if (lu === LANDUSE.CBD) cbdNeighbors++; - } - } - if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) { - landuse[i] = LANDUSE.CBD; - continue; - } - if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) { - const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08; - const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24; - if (fringeChance > 0.34 + noise) { - landuse[i] = LANDUSE.SUBURB; - } - } - if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) { - landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB; - } - } - } - - for (const park of logisticsParks) { - const r = 3; - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = park.x + dx; - const y = park.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue; - if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) { - landuse[i] = LANDUSE.LOGISTICS; - } - } - } - } - - if (maxDensity > 0) { - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - const lu = landuse[i]; - let floor = ruralDensityFloor[i]; - if (lu === LANDUSE.FARMLAND) { - floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110)); - } else if (lu === LANDUSE.LOGISTICS) { - floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060)); - } else if (lu === LANDUSE.RURAL) { - floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070)); - } else if (lu === LANDUSE.FOREST) { - floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0); - } - const normalized = populationDensity[i] / maxDensity; - populationDensity[i] = clamp(Math.max(normalized, floor)); - if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0; - } - } - } - classifyLanduse(); - - for (const city of modernCities) { - let urbanFootprintCells = 0; - let coreFootprintCells = 0; - const r = Math.ceil((city.urbanRadius || 8) * 1.3); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = city.x + dx; - const y = city.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - if (Math.hypot(dx, dy) > r) continue; - if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++; - if (landuse[i] === LANDUSE.CBD) coreFootprintCells++; - } - } - city.urbanFootprintCells = urbanFootprintCells; - city.coreFootprintCells = coreFootprintCells; - } - + const { landuse, populationDensity } = buildFeatureLanduse({ + seed, + elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, + developable, ruralSuitability, valleySettlement, coastalSettlement, + modernCities, logisticsParks, + roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence, + cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence, + }); const transportDebug = { humanStageVersion: "v2-sparse-raster", aStarRoutes: 0, diff --git a/mapPostAdminTransport.js b/mapPostAdminTransport.js index ca18d4d..67d1166 100644 --- a/mapPostAdminTransport.js +++ b/mapPostAdminTransport.js @@ -155,7 +155,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge ...(features.villages || []).filter((p) => (p.population || 0) >= 5000), ...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"), ]; - const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0 }; + const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0, expresswayMajorCityLinksAdded: 0, railCityChainsAdded: 0, railCityChainCitiesCovered: 0 }; // Local roads after admin: every municipal office cell should lie on a road. const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])]; @@ -230,23 +230,23 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge const parts = []; const before = nearestTrunkOrHub(chain[0], 80); if (before) { - const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } for (let i = 1; i < chain.length; i++) { const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y); - const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null; if (after) { - const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 }); + const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } let path = concatPaths(parts); if (path.length < 2) { const target = nearestTrunkOrHub(chain[0], 90); - path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 0 }) : []; + path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : []; } if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) { nationalRoads.push(path); @@ -261,6 +261,106 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge debug.nationalTownChainTownsCovered = chainDebug.townsCovered; debug.nationalTownSpursAdded = chainDebug.chainsAdded; + function majorCityKey(city) { return city?.name || `${Math.round(city.x)},${Math.round(city.y)}`; } + function expresswayCityComponents(cities, radius = 8.0) { + const parent = new Map(); + function find(k) { + const p = parent.get(k); + if (p === k) return k; + const r = find(p); + parent.set(k, r); + return r; + } + function unite(a, b) { + const ra = find(a), rb = find(b); + if (ra !== rb) parent.set(ra, rb); + } + for (const city of cities) parent.set(majorCityKey(city), majorCityKey(city)); + for (const path of [...expressways, ...externalExpressways]) { + const near = cities.filter((city) => pathTouchesCell(path, city.x, city.y, radius)); + if (near.length >= 2) { + const first = majorCityKey(near[0]); + for (const city of near.slice(1)) unite(first, majorCityKey(city)); + } + } + return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))])); + } + + function ensureMajorCityExpresswayLinks(minPopulation = 100000) { + const cities = (features.modernCities || []) + .filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y)) + .sort((a, b) => (b.population || 0) - (a.population || 0)); + const result = { minPopulation, checked: cities.length, added: 0 }; + if (cities.length < 2) return result; + for (let iter = 0; iter < cities.length * 2; iter++) { + const comps = expresswayCityComponents(cities); + if (new Set(comps.values()).size <= 1) break; + let best = null; + for (const a of cities) { + for (const b of cities) { + if (a === b || comps.get(majorCityKey(a)) === comps.get(majorCityKey(b))) continue; + const d = Math.hypot(a.x - b.x, a.y - b.y); + const score = d / Math.max(1, Math.log2(Math.sqrt((a.population || 1) * (b.population || 1)))); + if (!best || score < best.score) best = { a, b, d, score }; + } + } + if (!best) break; + let path = directPath(best.a, best.b, { maxLength: best.d * 1.18 + 12, terrain, maxSeaRun: 20 }); + if (!path.length) path = directPath(best.a, best.b, { maxLength: best.d * 1.38 + 28, terrain }); + if (!path.length) break; + path = smoothPath(path, 2); + expressways.push(path); + result.added++; + } + return result; + } + + function cityRailChain(minPopulation = 50000) { + const cities = (features.modernCities || []) + .filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y)) + .sort((a, b) => a.x - b.x || a.y - b.y); + const result = { minPopulation, checked: cities.length, chainsAdded: 0, citiesCovered: 0 }; + if (cities.length < 2) return result; + const existingRail = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])]; + const uncovered = cities.filter((city) => !anyPathTouches(existingRail, city, 1.2)); + if (!uncovered.length) return result; + const remaining = uncovered.slice(); + let chain = [remaining.shift()]; + while (remaining.length) { + const cur = chain[chain.length - 1]; + let bestIndex = 0; + let bestD = Infinity; + for (let i = 0; i < remaining.length; i++) { + const d = Math.hypot(cur.x - remaining[i].x, cur.y - remaining[i].y); + if (d < bestD) { bestD = d; bestIndex = i; } + } + chain.push(remaining.splice(bestIndex, 1)[0]); + } + const parts = []; + for (let i = 1; i < chain.length; i++) { + const a = chain[i - 1], b = chain[i]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); + if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain }); + if (p.length) parts.push(p); + } + const path = concatPaths(parts); + if (path.length >= 2) { + features.railways = features.railways || []; + features.railways.push(smoothPath(path, 1)); + result.chainsAdded = 1; + result.citiesCovered = chain.filter((city) => pathTouchesCell(path, city.x, city.y, 1.2)).length; + } + return result; + } + + const railDebug = cityRailChain(50000); + debug.railCityChainsAdded = railDebug.chainsAdded; + debug.railCityChainCitiesCovered = railDebug.citiesCovered; + + const expressDebug = ensureMajorCityExpresswayLinks(100000); + debug.expresswayMajorCityLinksAdded = expressDebug.added; + // Expressway finalization after administration: smooth and ensure both endpoints are ICs. for (let i = 0; i < expressways.length; i++) { const smoothed = smoothPath(expressways[i], 2); @@ -282,6 +382,7 @@ export function finalizeAdminAwareTransport({ seed, terrain, features, admin, ge features.externalRoads = dedupePaths(externalRoads, 1); features.expressways = dedupePaths(expressways, 2); features.externalExpressways = dedupePaths(externalExpressways, 2); + features.railways = dedupePaths(features.railways || [], 2); features.interchanges = interchanges; // Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it. diff --git a/mapPrefectureStage.js b/mapPrefectureStage.js index ce1c31e..5a14081 100644 --- a/mapPrefectureStage.js +++ b/mapPrefectureStage.js @@ -748,6 +748,35 @@ export function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCo return changed; } +export function lockPrefectureCapitalNeighborMunicipalities(owner, nodes, seeds = [], maxNeighbors = 6) { + let changed = 0; + const seedIds = new Set((seeds || []).map((node) => node?.id).filter((id) => id !== undefined)); + for (const seedNode of seeds || []) { + if (!seedNode || !nodes.has(seedNode.id)) continue; + const prefId = owner.get(seedNode.id); + if (prefId === undefined || prefId < 0) continue; + const neighbors = [...(nodes.get(seedNode.id)?.adjacent || [])] + .map(([id, edge]) => ({ node: nodes.get(id), id, edge })) + .filter((row) => row.node && owner.get(row.id) !== prefId && !seedIds.has(row.id)) + .sort((a, b) => (a.edge.crossingCost ?? 1) - (b.edge.crossingCost ?? 1) || Math.hypot(a.node.x - seedNode.x, a.node.y - seedNode.y) - Math.hypot(b.node.x - seedNode.x, b.node.y - seedNode.y)); + let taken = 0; + for (const row of neighbors) { + if (taken >= maxNeighbors) break; + const donorPref = owner.get(row.id); + if (donorPref === undefined || donorPref < 0 || donorPref === prefId) continue; + if (!wouldRemainConnectedAfterRemoval(nodes, owner, row.id, donorPref)) continue; + owner.set(row.id, prefId); + changed++; + taken++; + } + } + if (changed) { + repairPrefectureMunicipalityConnectivity(nodes, owner); + repairPrefectureMunicipalityEnclaves(nodes, owner, 6); + } + return changed; +} + function averageRegionalBorderField(adminId, municipalityToPrefectureId, prefectureMask, sea, field) { if (!field) return 0; let sum = 0; @@ -803,6 +832,10 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) { changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10); changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + const changedForCapitalNeighborLock = lockPrefectureCapitalNeighborMunicipalities(owner, graph.nodes, seeds, 7); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); + changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12); + changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]); const municipalityToPrefectureId = new Int16Array(maxAdminId + 1); municipalityToPrefectureId.fill(-1); @@ -846,6 +879,7 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) { prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0), prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0, prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0, + prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0, finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()), finalRegionalMunicipalityCountCap: 88, finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()), diff --git a/mapTransport.js b/mapTransport.js index 068224d..b1da100 100644 --- a/mapTransport.js +++ b/mapTransport.js @@ -5,11 +5,16 @@ import { assessMountainRoute, countReason, fieldBackbonePolicy, + markPathInfluence as markPathInfluenceBase, makeSpatialIndex, makeUnionFind, + meanFieldAround as meanFieldAroundBase, packDebugField, + pathCumulativeLengths, pathAverageField, pathLengthCells, + pointAtPathDistance, + sampledNetworkCells as sampledNetworkCellsBase, squaredDistance, TRANSPORT_ROUTE_POLICIES, } from "./mapTransportUtils.js"; @@ -147,6 +152,83 @@ export function buildDensityFlowRoadTransportSystem(ctx) { } const routePolicies = TRANSPORT_ROUTE_POLICIES; + const ROAD_MODE = { + expressway: { + debugBucket: "expresswayCorridors", + costField: () => expresswayCorridorCost, + potentialField: () => transportFields.expresswayPotential, + routeOptions: { + curvePenalty: 0.125, + penaltyStrength: 7.20, + terrainFlowBias: 0.08, + surfaceGrain: 0.006, + relaxRadius: 1, + relaxLineWeight: 0.19, + snapRadius: 3.5, + heuristicWeight: 0.72, + }, + backbone: { minDistance: 38, maxDistance: 190, maxDegree: 2, maxExtra: 0, parallelRadius: 84, penaltyStrengthMark: 16.50 }, + fieldPolicyMode: "expressway", + mountainMode: "expresswayMountainOnly", + connector: { curvePenalty: 0.095, terrainFlowBias: 0.12 }, + }, + national: { + debugBucket: "nationalCorridors", + costField: () => transportFields.national, + potentialField: () => transportFields.nationalPotential, + routeOptions: { + curvePenalty: 0.065, + penaltyStrength: 1.05, + terrainFlowBias: 0.24, + surfaceGrain: 0.030, + relaxRadius: 2, + relaxLineWeight: 0.28, + snapRadius: 2.5, + heuristicWeight: 0.50, + }, + backbone: { minDistance: 12, maxDistance: 130, maxDegree: 4, maxExtra: 8, parallelRadius: 6, penaltyStrengthMark: 0.32 }, + fieldPolicyMode: "national", + mountainMode: "national", + connector: { curvePenalty: 0.045, terrainFlowBias: 0.22 }, + }, + local: { + debugBucket: "localCorridors", + costField: () => transportFields.local, + potentialField: () => transportFields.localPotential, + routeOptions: { + curvePenalty: 0.035, + penaltyStrength: 0.90, + terrainFlowBias: 0.26, + surfaceGrain: 0.042, + relaxRadius: 2, + relaxLineWeight: 0.30, + snapRadius: 0.5, + heuristicWeight: 0.30, + }, + connector: { curvePenalty: 0.035, terrainFlowBias: 0.28 }, + }, + }; + + function roadMode(mode) { + return ROAD_MODE[mode] || ROAD_MODE.national; + } + + function recordSkipReason(stats, bucket, reason) { + stats[bucket]++; + countReason(stats, `${bucket}Reasons`, reason); + } + + function recordCorridor(debug, mode, from, to, path, extra = {}) { + const bucket = roadMode(mode).debugBucket; + if (!debug[bucket]) debug[bucket] = []; + debug[bucket].push({ + from, + to, + length: Math.round(pathLengthCells(path)), + ...extra, + path, + }); + } function mountainRouteAssessment(path, mode = "road") { return assessMountainRoute(path, mode, pathTerrainRisk, routePolicies); @@ -255,463 +337,6 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return dedupePointCandidates(points.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), minDistance); } - function buildExpresswayODCorridors(debug) { - const majorSuburbs = dedupeAnchors( - modernCities - .filter((c) => (c.population || 0) >= 100000) - .map(majorCitySuburbanAnchor), - 10 - ); - debug.majorCitySuburbanAnchors = majorSuburbs.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population })); - - const majorCityRefs = majorSuburbs.filter(Boolean); - const externalRefs = externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 90000 })); - const portRefs = commercialPorts - .filter((p) => p.portClass === "major" || (p.population || 0) >= 18000) - .map((p) => ({ ...p, score: 0.78 + (p.portClass === "major" ? 0.30 : 0), role: "port-logistics", population: p.population || 45000 })); - const remoteRefs = modernCities - .filter((c) => (c.population || 0) >= 65000 && !majorSuburbs.some((m) => m.city === c)) - .map((c) => { - const nearestMajor = majorSuburbs.reduce((best, m) => { - const d = Math.hypot(m.x - c.x, m.y - c.y); - return !best || d < best.d ? { m, d } : best; - }, null); - const anchor = majorCitySuburbanAnchor(c) || { x: c.x, y: c.y, score: 0.3, city: c, population: c.population }; - return { ...anchor, role: "remote-city", score: (anchor.score || 0.3) + Math.min(1.0, (nearestMajor?.d || 0) / 95) * 0.55, remoteDistance: nearestMajor?.d || 0, population: c.population || 0 }; - }) - .filter((p) => p.remoteDistance >= 52) - .sort((a, b) => b.score - a.score) - .slice(0, 8); - - const nodes = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 9); - const pairs = []; - for (let a = 0; a < nodes.length; a++) { - for (let b = a + 1; b < nodes.length; b++) { - const A = nodes[a]; - const B = nodes[b]; - const d = Math.hypot(A.x - B.x, A.y - B.y); - if (d < 48 || d > 176) continue; - const lineCost = approximateLineCost(A, B, expresswayCorridorCost); - if (!Number.isFinite(lineCost) || lineCost >= INF) continue; - const demand = Math.sqrt(Math.max(25000, A.population || 50000) * Math.max(25000, B.population || 50000)) / 100000; - const longDistanceNeed = clamp((d - 48) / 70); - const externalNeed = A.role === "external-gateway" || B.role === "external-gateway" ? 0.55 : 0; - const logisticsNeed = A.role === "port-logistics" || B.role === "port-logistics" ? 0.38 : 0; - const remoteNeed = A.role === "remote-city" || B.role === "remote-city" ? 0.42 : 0; - const score = (demand * 0.70 + longDistanceNeed * 0.90 + externalNeed + logisticsNeed + remoteNeed) / Math.max(0.9, lineCost) + hash2(A.x + B.x, A.y + B.y, seed + 18131) * 0.025; - pairs.push({ a: A, b: B, d, score }); - } - } - pairs.sort((x, y) => y.score - x.score); - - const penalty = new Float32Array(SIZE); - const degree = new Map(); - const maxCorridors = Math.min(6, Math.max(3, Math.ceil(majorSuburbs.length / 2.4))); - for (const pair of pairs) { - if (expressways.length >= maxCorridors) break; - const aid = `${pair.a.x},${pair.a.y}`; - const bid = `${pair.b.x},${pair.b.y}`; - if ((degree.get(aid) || 0) >= 2 || (degree.get(bid) || 0) >= 2) continue; - const path = routeBetweenTrafficCandidates(pair.a, pair.b, "expressway", expresswayCorridorCost, penalty, { - curvePenalty: 0.11, - penaltyStrength: 2.2, - terrainFlowBias: 0.10, - surfaceGrain: 0.006, - relaxRadius: 1, - relaxLineWeight: 0.24, - maxPathLength: pair.d * 2.25 + 42, - }); - const len = pathLengthCells(path); - if (len < 34 || len > pair.d * 2.25 + 48) continue; - if (routeTooStraightMountainOnly(path)) continue; - if (!expresswayRouteAcceptable(path)) continue; - expressways.push(path); - addCorridorInfluencePenalty(penalty, path, 24, 1.80); - degree.set(aid, (degree.get(aid) || 0) + 1); - degree.set(bid, (degree.get(bid) || 0) + 1); - debug.expresswayCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path }); - } - - // Guarantee at least one expressway approach for every very large city. The - // path still uses suburban anchors and expresswayCorridorCost, so it should - // bypass the CBD and village cores instead of cutting through them. - const expressInfluence = cachedInfluenceFromPaths(expressways, 18, "expressway:major-city-coverage"); - const allCandidateTargets = dedupeAnchors([...majorCityRefs, ...portRefs, ...externalRefs, ...remoteRefs], 8); - for (const anchor of majorCityRefs.sort((a, b) => (b.population || 0) - (a.population || 0))) { - if ((anchor.population || 0) < 100000) continue; - const ai = indexOf(anchor.x, anchor.y); - if ((expressInfluence[ai] || 0) > 0.20) continue; - const options = allCandidateTargets - .filter((q) => q !== anchor) - .map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) })) - .filter((e) => e.d >= 38 && e.d <= 170 && Number.isFinite(e.c) && e.c < INF) - .sort((a, b) => (a.d * a.c) - (b.d * b.c)); - for (const opt of options.slice(0, 8)) { - const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, { - curvePenalty: 0.11, - penaltyStrength: 2.6, - terrainFlowBias: 0.10, - surfaceGrain: 0.006, - relaxRadius: 1, - relaxLineWeight: 0.22, - maxPathLength: opt.d * 2.75 + 78, - }); - const len = pathLengthCells(path); - if (len >= 24 && len <= opt.d * 2.95 + 96 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) { - expressways.push(path); - addCorridorInfluencePenalty(penalty, path, 58, 8.80); - debug.expresswayCorridors.push({ from: "major-city-guarantee", city: anchor.city?.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path }); - break; - } - } - const refreshed = cachedInfluenceFromPaths(expressways, 9, `expressway:coverage:${anchor.x},${anchor.y}`); - if ((refreshed[ai] || 0) <= 0.20) { - // Last resort: create a short suburban approach to the nearest low-cost - // through corridor cell, still outside the urban core. This avoids the - // pathological case where a large isolated city receives no motorway at all. - const fallbackTargets = []; - const searchR = 56; - for (let dy = -searchR; dy <= searchR; dy += 3) { - for (let dx = -searchR; dx <= searchR; dx += 3) { - const x = anchor.x + dx; - const y = anchor.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - const d = Math.hypot(dx, dy); - if (d < 20 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue; - if (settlementDemand[i] > 0.44 || preliminaryTownInfluence[i] > 0.34 || preliminaryVillageInfluence[i] > 0.30) continue; - fallbackTargets.push({ x, y, d, role: "suburban-fallback", population: anchor.population, score: expresswayCorridorCost[i] + d * 0.018 }); - } - } - fallbackTargets.sort((a, b) => a.score - b.score); - const target = fallbackTargets[0]; - if (target) { - const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, { - curvePenalty: 0.11, - penaltyStrength: 2.2, - terrainFlowBias: 0.10, - surfaceGrain: 0.006, - relaxRadius: 1, - relaxLineWeight: 0.16, - maxPathLength: target.d * 2.8 + 34, - }); - if (pathLengthCells(path) >= 12 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) { - expressways.push(path); - addCorridorInfluencePenalty(penalty, path, 10, 0.74); - debug.expresswayCorridors.push({ from: "major-city-fallback-approach", city: anchor.city?.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path }); - } - } - } - } - - // Final city-level coverage pass. The anchor-level influence check can miss - // paired urban centers whose suburban anchors were deduplicated into the - // neighboring city. Check distance from each major city center to the - // motorway layer, then connect its own suburban anchor to the nearest - // existing motorway cell or create a short outward suburban approach. - function expresswayCells() { - const cells = []; - for (const path of expressways) { - for (const [x, y] of path) if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, role: "existing-expressway", population: 0 }); - } - return cells; - } - function minDistanceToExpressways(x, y) { - let best = Infinity; - for (const c of expresswayCells()) best = Math.min(best, Math.hypot(c.x - x, c.y - y)); - return best; - } - for (const city of modernCities.filter((c) => (c.population || 0) >= 100000).sort((a, b) => (b.population || 0) - (a.population || 0))) { - const coverLimit = Math.max(25, (city.urbanRadius || 13) * 1.75); - if (minDistanceToExpressways(city.x, city.y) <= coverLimit) continue; - const anchor = majorCitySuburbanAnchor(city); - if (!anchor) continue; - let addedForCity = false; - const cells = expresswayCells() - .map((q) => ({ q, d: Math.hypot(q.x - anchor.x, q.y - anchor.y), c: approximateLineCost(anchor, q, expresswayCorridorCost) })) - .filter((e) => e.d >= 8 && e.d <= 105 && Number.isFinite(e.c) && e.c < INF) - .sort((a, b) => (a.d * a.c) - (b.d * b.c)); - for (const opt of cells.slice(0, 8)) { - const path = routeBetweenTrafficCandidates(anchor, opt.q, "expressway", expresswayCorridorCost, penalty, { - curvePenalty: 0.11, - penaltyStrength: 2.6, - terrainFlowBias: 0.10, - surfaceGrain: 0.006, - relaxRadius: 1, - relaxLineWeight: 0.18, - maxPathLength: opt.d * 2.85 + 42, - }); - const len = pathLengthCells(path); - if (len >= 8 && len <= opt.d * 3.0 + 58 && !routeTooStraightMountainOnly(path) && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) { - expressways.push(path); - addCorridorInfluencePenalty(penalty, path, 10, 0.72); - debug.expresswayCorridors.push({ from: "major-city-center-coverage", city: city.name, to: opt.q.role, distance: Math.round(opt.d), length: Math.round(len), score: 0, path }); - addedForCity = true; - break; - } - } - if (!addedForCity) { - const searchR = 48; - const fallbackTargets = []; - for (let dy = -searchR; dy <= searchR; dy += 3) { - for (let dx = -searchR; dx <= searchR; dx += 3) { - const x = anchor.x + dx; - const y = anchor.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - const d = Math.hypot(dx, dy); - if (d < 16 || d > searchR || sea[i] || expresswayCorridorCost[i] >= INF) continue; - if (settlementDemand[i] > 0.46 || preliminaryTownInfluence[i] > 0.36 || preliminaryVillageInfluence[i] > 0.31) continue; - fallbackTargets.push({ x, y, d, role: "city-coverage-fallback", population: city.population, score: expresswayCorridorCost[i] + d * 0.016 }); - } - } - fallbackTargets.sort((a, b) => a.score - b.score); - for (const target of fallbackTargets.slice(0, 4)) { - const path = routeBetweenTrafficCandidates(anchor, target, "expressway", expresswayCorridorCost, penalty, { - curvePenalty: 0.11, - penaltyStrength: 2.2, - terrainFlowBias: 0.10, - surfaceGrain: 0.006, - relaxRadius: 1, - relaxLineWeight: 0.16, - maxPathLength: target.d * 2.9 + 36, - }); - if (pathLengthCells(path) >= 10 && expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) { - expressways.push(path); - addCorridorInfluencePenalty(penalty, path, 10, 0.64); - debug.expresswayCorridors.push({ from: "major-city-center-fallback", city: city.name, to: target.role, distance: Math.round(target.d), length: Math.round(pathLengthCells(path)), score: 0, path }); - break; - } - } - } - } - - // Inter-city backbone pass. The previous city-coverage fallback produced - // short suburban motorway approaches, but did not necessarily connect those - // approaches into a through network. Treat high-capacity roads as OD - // corridors: connect major-city suburb anchors, ports and external gates by - // a small Kruskal-style backbone over low-cost terrain. - const backboneAnchors = dedupeAnchors([...majorSuburbs, ...portRefs, ...externalRefs], 10) - .filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]); - const keyOf = (p) => `${p.x},${p.y}`; - const { find, unite } = makeUnionFind(backboneAnchors, keyOf); - const backbonePairs = []; - for (let a = 0; a < backboneAnchors.length; a++) { - for (let b = a + 1; b < backboneAnchors.length; b++) { - const A = backboneAnchors[a]; - const B = backboneAnchors[b]; - const d = Math.hypot(A.x - B.x, A.y - B.y); - if (d < 44 || d > 190) continue; - const c = approximateLineCost(A, B, expresswayCorridorCost); - if (!Number.isFinite(c) || c >= INF) continue; - const demand = Math.sqrt(Math.max(50000, A.population || 70000) * Math.max(50000, B.population || 70000)) / 120000; - const gatewayBonus = A.role === "external-gateway" || B.role === "external-gateway" ? 0.28 : 0; - const portBonus = A.role === "port-logistics" || B.role === "port-logistics" ? 0.18 : 0; - backbonePairs.push({ A, B, d, score: d * c / Math.max(0.55, demand + gatewayBonus + portBonus) }); - } - } - backbonePairs.sort((a, b) => a.score - b.score); - let backboneAdded = 0; - for (const pair of backbonePairs) { - const ak = keyOf(pair.A); - const bk = keyOf(pair.B); - if (find(ak) === find(bk)) continue; - if (backboneAdded >= Math.min(9, Math.max(3, backboneAnchors.length - 1))) break; - const path = routeBetweenTrafficCandidates(pair.A, pair.B, "expressway", expresswayCorridorCost, penalty, { - curvePenalty: 0.105, - penaltyStrength: 2.10, - terrainFlowBias: 0.12, - surfaceGrain: 0.007, - relaxRadius: 1, - relaxLineWeight: 0.20, - maxPathLength: pair.d * 3.05 + 96, - snapRadius: 5, - }); - const len = pathLengthCells(path); - if (len < 34 || len > pair.d * 3.15 + 116) continue; - if (routeTooStraightMountainOnly(path)) continue; - if (!expresswayRouteAcceptable(path, { allowFallback: true, allowApproach: true })) continue; - expressways.push(path); - addCorridorInfluencePenalty(penalty, path, 24, 1.70); - unite(ak, bk); - backboneAdded++; - debug.expresswayCorridors.push({ from: "expressway-backbone", to: `${pair.A.role}-${pair.B.role}`, distance: Math.round(pair.d), length: Math.round(len), score: Math.round(pair.score * 100) / 100, path }); - } - } - - - function buildNationalCorridorNetwork(debug) { - const baseNodes = [ - ...modernCities.map((c) => ({ ...c, score: 1.2 + Math.sqrt(c.population || 50000) / 430 + ((c.population || 0) >= 100000 ? 0.55 : 0) + ((c.population || 0) >= 500000 ? 1.10 : 0), role: "city", population: c.population || 0 })), - ...markets.filter((m) => (m.population || 0) >= 3000).map((m) => ({ ...m, score: 0.72 + (m.population || 6000) / 42000, role: "market", population: m.population || 0 })), - ...ports.map((p) => ({ ...p, score: 0.76 + (p.portClass === "major" ? 0.45 : 0), role: "port", population: p.population || 12000 })), - ...externalGateways.map((g) => ({ ...g, score: 0.92, role: "external-gateway", population: 42000 })), - ...villages.filter((v) => (v.population || 0) >= 2200).map((v) => ({ ...v, score: 0.38 + (v.population || 0) / 18000, role: "large-village", population: v.population || 0 })), - ]; - const nodes = dedupeAnchors(baseNodes.sort((a, b) => b.score - a.score), 6).slice(0, 48); - if (nodes.length < 2) return; - const penalty = cachedInfluenceFromPaths([...expressways, ...externalRoads], 8, "national-corridor:base"); - const connected = [nodes[0]]; - const remaining = nodes.slice(1); - const maxMain = Math.min(24, Math.max(14, Math.ceil(nodes.length * 0.42))); - - while (remaining.length && nationalRoads.length < maxMain) { - let best = null; - for (const node of remaining) { - const candidates = connected - .map((q) => { - const d = Math.hypot(node.x - q.x, node.y - q.y); - if (d < 10 || d > 112) return null; - const lineCost = approximateLineCost(node, q, transportFields.national); - if (!Number.isFinite(lineCost) || lineCost >= INF) return null; - const demand = Math.sqrt(Math.max(3000, node.population || 6000) * Math.max(3000, q.population || 6000)) / 65000; - const score = d * lineCost / Math.max(0.35, demand + node.score * 0.25 + q.score * 0.25); - return { q, d, score }; - }) - .filter(Boolean) - .sort((a, b) => a.score - b.score); - if (!candidates.length) continue; - const cand = candidates[0]; - if (!best || cand.score < best.score) best = { node, target: cand.q, d: cand.d, score: cand.score }; - } - if (!best) break; - const path = routeBetweenTrafficCandidates(best.node, best.target, "national", transportFields.national, penalty, { - curvePenalty: 0.050, - penaltyStrength: 0.92, - terrainFlowBias: 0.26, - surfaceGrain: 0.034, - relaxRadius: 2, - relaxLineWeight: 0.32, - maxPathLength: best.d * 2.55 + 38, - }); - const len = pathLengthCells(path); - if (len >= 6 && len <= best.d * 2.65 + 42 && !routeTooStraightAcrossMountains(path, "national") && transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 6, maxLength: best.d * 2.65 + 42, maxHighElevationShare: 0.34, maxSteepShare: 0.46 })) { - nationalRoads.push(path); - debug.nationalCorridors.push({ from: best.node.role, to: best.target.role, length: Math.round(len), path }); - addCorridorInfluencePenalty(penalty, path, 6, 0.30); - } - connected.push(best.node); - remaining.splice(remaining.indexOf(best.node), 1); - } - - const extraPairs = []; - for (let a = 0; a < Math.min(nodes.length, 32); a++) { - for (let b = a + 1; b < Math.min(nodes.length, 32); b++) { - const A = nodes[a]; - const B = nodes[b]; - const d = Math.hypot(A.x - B.x, A.y - B.y); - if (d < 22 || d > 86) continue; - const regional = A.regionId !== B.regionId ? 0.22 : 0; - const need = (A.score + B.score) * 0.5 + regional; - extraPairs.push({ A, B, d, score: d / Math.max(0.5, need) + hash2(A.x + B.x, A.y + B.y, seed + 18161) * 0.06 }); - } - } - extraPairs.sort((a, b) => a.score - b.score); - let addedExtra = 0; - for (const pair of extraPairs) { - if (addedExtra >= 5 || nationalRoads.length >= 29) break; - const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, { - curvePenalty: 0.050, - penaltyStrength: 1.05, - terrainFlowBias: 0.25, - surfaceGrain: 0.034, - relaxRadius: 2, - relaxLineWeight: 0.32, - maxPathLength: pair.d * 2.35 + 32, - }); - const len = pathLengthCells(path); - if (len < 8 || len > pair.d * 2.35 + 32 || routeTooStraightAcrossMountains(path, "national")) continue; - if (pathAverageField(path, penalty) > 0.42 && pathAverageField(path, transportFields.nationalPotential) < 0.37) continue; - nationalRoads.push(path); - addedExtra++; - debug.nationalCorridors.push({ from: `${pair.A.role}-extra`, to: `${pair.B.role}-extra`, length: Math.round(len), path }); - addCorridorInfluencePenalty(penalty, path, 6, 0.32); - } - - const nationalInfluence = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 7, "national:major-city-coverage"); - const importantCities = modernCities - .filter((c) => (c.population || 0) >= 100000) - .sort((a, b) => (b.population || 0) - (a.population || 0)); - const nationalTargets = dedupeAnchors([...nodes, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5); - for (const city of importantCities) { - const ci = indexOf(city.x, city.y); - if ((nationalInfluence[ci] || 0) > 0.20) continue; - const target = nationalTargets - .filter((q) => Math.hypot(q.x - city.x, q.y - city.y) > 4) - .map((q) => ({ q, d: Math.hypot(q.x - city.x, q.y - city.y), c: approximateLineCost(city, q, transportFields.national) })) - .filter((e) => e.d <= 86 && Number.isFinite(e.c) && e.c < INF) - .sort((a, b) => (a.d * a.c) - (b.d * b.c))[0]; - if (!target) continue; - const path = routeBetweenTrafficCandidates(city, target.q, "national", transportFields.national, penalty, { - curvePenalty: 0.052, - penaltyStrength: 0.86, - terrainFlowBias: 0.27, - surfaceGrain: 0.034, - relaxRadius: 2, - relaxLineWeight: 0.28, - maxPathLength: target.d * 2.5 + 34, - }); - const len = pathLengthCells(path); - if (len >= 4 && len <= target.d * 2.55 + 38 && !routeTooStraightAcrossMountains(path, "national")) { - nationalRoads.push(path); - debug.nationalCorridors.push({ from: "major-city-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path }); - addCorridorInfluencePenalty(penalty, path, 6, 0.30); - } - } - - // National roads should form regional corridors, not a set of short roads - // terminating around each town. Add a sparse backbone over cities, ports - // and external gates after the initial MST/extra pass, using the same - // terrain cost field but a larger distance envelope. - const backboneNodes = dedupeAnchors([ - ...modernCities.filter((c) => (c.population || 0) >= 65000).map((c) => ({ ...c, role: "city-backbone", score: 1.0 + Math.sqrt(c.population || 70000) / 420, population: c.population || 0 })), - ...ports.filter((p) => p.portClass === "major" || (p.population || 0) >= 9000).map((p) => ({ ...p, role: "port-backbone", score: 1.05, population: p.population || 20000 })), - ...externalGateways.map((g) => ({ ...g, role: "external-backbone", score: 1.0, population: 42000 })), - ].sort((a, b) => b.score - a.score), 7).slice(0, 34); - const keyOf = (p) => `${p.x},${p.y}`; - const { find, unite } = makeUnionFind(backboneNodes, keyOf); - const pairs = []; - for (let a = 0; a < backboneNodes.length; a++) { - for (let b = a + 1; b < backboneNodes.length; b++) { - const A = backboneNodes[a]; - const B = backboneNodes[b]; - const d = Math.hypot(A.x - B.x, A.y - B.y); - if (d < 16 || d > 132) continue; - const c = approximateLineCost(A, B, transportFields.national); - if (!Number.isFinite(c) || c >= INF) continue; - const demand = Math.sqrt(Math.max(9000, A.population || 12000) * Math.max(9000, B.population || 12000)) / 82000; - const regional = A.regionId !== B.regionId ? 0.28 : 0; - pairs.push({ A, B, d, score: d * c / Math.max(0.42, demand + regional + (A.score + B.score) * 0.18) }); - } - } - pairs.sort((a, b) => a.score - b.score); - let backboneAdded = 0; - for (const pair of pairs) { - if (backboneAdded >= Math.min(22, Math.max(8, backboneNodes.length - 1))) break; - const ak = keyOf(pair.A); - const bk = keyOf(pair.B); - if (find(ak) === find(bk)) continue; - const path = routeBetweenTrafficCandidates(pair.A, pair.B, "national", transportFields.national, penalty, { - curvePenalty: 0.052, - penaltyStrength: 0.78, - terrainFlowBias: 0.28, - surfaceGrain: 0.036, - relaxRadius: 2, - relaxLineWeight: 0.28, - maxPathLength: pair.d * 3.05 + 62, - snapRadius: 4, - }); - const len = pathLengthCells(path); - if (len < 6 || len > pair.d * 3.15 + 78) continue; - if (routeTooStraightAcrossMountains(path, "national")) continue; - nationalRoads.push(path); - addCorridorInfluencePenalty(penalty, path, 6, 0.27); - unite(ak, bk); - backboneAdded++; - debug.nationalCorridors.push({ from: "national-backbone", to: `${pair.A.role}-${pair.B.role}`, length: Math.round(len), path }); - } - } - // ------------------------------------------------------------------------- // Density-flow transport system // ------------------------------------------------------------------------- @@ -747,23 +372,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { ); } - function markPathInfluence(field, path, radius = 5, strength = 1) { - for (const [px, py] of path || []) { - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - if (dx * dx + dy * dy > radius * radius) continue; - const x = px + dx; - const y = py + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - const d = Math.hypot(dx, dy); - const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35); - if (v > field[i]) field[i] = v; - } - } - } - } + const markPathInfluence = (field, path, radius = 5, strength = 1) => markPathInfluenceBase(field, path, radius, strength, sea); function fieldAdjustedCost(baseCost, mode, flowField = null) { const out = new Float32Array(SIZE); @@ -1159,14 +768,102 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned }; } + function routeScoredPairs({ + pairs, + mode, + outPaths, + costField, + potentialField, + penalty, + accepted, + debug, + options = {}, + skip, + keyOf, + degree, + find, + unite, + }) { + const config = roadMode(mode); + const policy = fieldBackbonePolicy(config.fieldPolicyMode || mode); + let connectedAdds = 0; + let extraAdds = 0; + const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil((options.nodeCount || 1) / 4))) : Math.min(34, Math.max(16, Math.ceil((options.nodeCount || 1) * 0.46)))); + const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0; + const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3; + for (const pair of pairs) { + if (outPaths.length >= maxAdded) break; + const ak = keyOf(pair.A); + const bk = keyOf(pair.B); + const connects = find(ak) !== find(bk); + if (!connects && extraAdds >= maxExtra) { skip.degree++; continue; } + if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; } + const routeOptions = { + ...config.routeOptions, + maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42), + ...options.routeOptions, + }; + const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, routeOptions); + const len = pathLengthCells(path); + if (!path.length) { skip.noPath++; continue; } + if (len < policy.minLength || len > pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd) { skip.length++; continue; } + if (mode === "expressway") { + const mountainCheck = mountainRouteAssessment(path, config.mountainMode); + if (!mountainCheck.ok) { + recordSkipReason(skip, "mountain", mountainCheck.reason); + continue; + } + const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true }); + if (!acceptCheck.ok) { + recordSkipReason(skip, "acceptable", acceptCheck.reason); + continue; + } + } else { + const mountainCheck = mountainRouteAssessment(path, config.mountainMode); + if (!mountainCheck.ok) { + recordSkipReason(skip, "mountain", mountainCheck.reason); + continue; + } + const acceptCheck = routeAcceptableForMode(path, "national", potentialField, penalty, { + minLength: policy.minLength, + maxLength: pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd, + maxHighElevationShare: policy.maxHighElevationShare, + maxSteepShare: policy.maxSteepShare, + }); + if (!acceptCheck.ok) { + recordSkipReason(skip, "acceptable", acceptCheck.reason); + continue; + } + } + const parallel = existingParallelShare(path, accepted, mode === "expressway" ? 0.010 : 0.18); + if (parallel > (connects ? policy.parallelConnected : policy.parallelExtra)) { skip.parallel++; continue; } + outPaths.push(path); + markPathInfluence(accepted, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 6, 1); + addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 6, options.penaltyStrengthMark ?? config.backbone?.penaltyStrengthMark ?? 0.30); + degree.set(ak, (degree.get(ak) || 0) + 1); + degree.set(bk, (degree.get(bk) || 0) + 1); + if (connects) { + unite(ak, bk); + connectedAdds++; + } else { + extraAdds++; + } + skip.added++; + recordCorridor(debug, mode, pair.A.role, pair.B.role, path, { + distance: Math.round(pair.d), + score: Math.round(pair.score * 100) / 100, + }); + } + return { connectedAdds, extraAdds }; + } + function buildFieldBackbone(debug, mode, outPaths, anchors, costField, potentialField, options = {}) { + const config = roadMode(mode); const nodes = anchors.slice(0, options.maxNodes ?? (mode === "expressway" ? 38 : 76)); if (nodes.length < 2) return; - const policy = fieldBackbonePolicy(mode); - const label = mode === "expressway" ? "expresswayCorridors" : "nationalCorridors"; const penalty = new Float32Array(SIZE); const accepted = new Float32Array(SIZE); - for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1); + for (const path of outPaths) markPathInfluence(accepted, path, options.parallelRadius ?? config.backbone?.parallelRadius ?? 6, 1); if (mode === "national") { for (const path of expressways) markPathInfluence(penalty, path, 8, 0.16); for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55); @@ -1198,80 +895,22 @@ export function buildDensityFlowRoadTransportSystem(ctx) { pairs.sort((a, b) => a.score - b.score); const skip = { pairs: pairs.length, degree: 0, noPath: 0, length: 0, mountain: 0, mountainReasons: {}, acceptable: 0, acceptableReasons: {}, parallel: 0, added: 0 }; - let connectedAdds = 0; - let extraAdds = 0; - const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(10, Math.max(4, Math.ceil(nodes.length / 4))) : Math.min(34, Math.max(16, Math.ceil(nodes.length * 0.46)))); - const maxExtra = options.maxExtra ?? (mode === "expressway" ? 2 : 7); - const maxDegree = options.maxDegree ?? (mode === "expressway" ? 2 : 4); - for (const pair of pairs) { - if (outPaths.length >= maxAdded) break; - const ak = keyOf(pair.A); - const bk = keyOf(pair.B); - const connects = find(ak) !== find(bk); - if (!connects && extraAdds >= maxExtra) { skip.degree++; continue; } - if ((degree.get(ak) || 0) >= maxDegree || (degree.get(bk) || 0) >= maxDegree) { skip.degree++; continue; } - const path = routeBetweenTrafficCandidates(pair.A, pair.B, mode, costField, penalty, { - curvePenalty: mode === "expressway" ? 0.125 : 0.065, - penaltyStrength: mode === "expressway" ? 7.20 : 1.05, - terrainFlowBias: mode === "expressway" ? 0.08 : 0.24, - surfaceGrain: mode === "expressway" ? 0.006 : 0.030, - relaxRadius: mode === "expressway" ? 1 : 2, - relaxLineWeight: mode === "expressway" ? 0.19 : 0.28, - maxPathLength: pair.d * (mode === "expressway" ? 2.75 : 2.55) + (mode === "expressway" ? 84 : 42), - snapRadius: mode === "expressway" ? 3.5 : 2.5, - heuristicWeight: mode === "expressway" ? 0.72 : 0.50, - }); - const len = pathLengthCells(path); - if (!path.length) { skip.noPath++; continue; } - if (len < policy.minLength || len > pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd) { skip.length++; continue; } - if (mode === "expressway") { - const mountainCheck = mountainRouteAssessment(path, "expresswayMountainOnly"); - if (!mountainCheck.ok) { - skip.mountain++; - countReason(skip, "mountainReasons", mountainCheck.reason); - continue; - } - const acceptCheck = routeAcceptableForMode(path, mode, potentialField, penalty, {}, { allowFallback: true, allowApproach: true }); - if (!acceptCheck.ok) { - skip.acceptable++; - countReason(skip, "acceptableReasons", acceptCheck.reason); - continue; - } - } else { - const mountainCheck = mountainRouteAssessment(path, "national"); - if (!mountainCheck.ok) { - skip.mountain++; - countReason(skip, "mountainReasons", mountainCheck.reason); - continue; - } - const acceptCheck = routeAcceptableForMode(path, "national", potentialField, penalty, { - minLength: policy.minLength, - maxLength: pair.d * policy.maxLengthMultiplier + policy.maxLengthAdd, - maxHighElevationShare: policy.maxHighElevationShare, - maxSteepShare: policy.maxSteepShare, - }); - if (!acceptCheck.ok) { - skip.acceptable++; - countReason(skip, "acceptableReasons", acceptCheck.reason); - continue; - } - } - const parallel = existingParallelShare(path, accepted, mode === "expressway" ? 0.010 : 0.18); - if (parallel > (connects ? policy.parallelConnected : policy.parallelExtra)) { skip.parallel++; continue; } - outPaths.push(path); - markPathInfluence(accepted, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), 1); - addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? (mode === "expressway" ? 44 : 6), options.penaltyStrengthMark ?? (mode === "expressway" ? 2.10 : 0.30)); - degree.set(ak, (degree.get(ak) || 0) + 1); - degree.set(bk, (degree.get(bk) || 0) + 1); - if (connects) { - unite(ak, bk); - connectedAdds++; - } else { - extraAdds++; - } - skip.added++; - debug[label].push({ from: pair.A.role, to: pair.B.role, length: Math.round(len), distance: Math.round(pair.d), score: Math.round(pair.score * 100) / 100, path }); - } + const { connectedAdds, extraAdds } = routeScoredPairs({ + pairs, + mode, + outPaths, + costField, + potentialField, + penalty, + accepted, + debug, + options: { ...options, nodeCount: nodes.length }, + skip, + keyOf, + degree, + find, + unite, + }); debug[`${mode}AnchorCount`] = nodes.length; debug[`${mode}ConnectedAdds`] = connectedAdds; debug[`${mode}ExtraAdds`] = extraAdds; @@ -1313,6 +952,63 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return false; } + function ensureCityOutboundConnections({ + debug, + mode, + cities, + startForCity, + targetAnchors, + costField, + penalty, + paths, + acceptedPaths = [], + maxTargets = 8, + minDistance = 14, + maxDistance = 126, + routeOptions = {}, + acceptable, + recordFrom, + counterKey, + }) { + let added = 0; + const config = roadMode(mode); + const accepted = new Float32Array(SIZE); + for (const path of paths) markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, 1.0); + for (const path of acceptedPaths) markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, mode === "national" ? 0.55 : 1.0); + for (const city of cities) { + const start = startForCity(city); + if (!start) continue; + if (paths.some((path) => pathServesCityCenter(path, city, mode))) continue; + const candidates = targetAnchors + .filter((q) => q !== start && q.city !== city && q.source !== city) + .map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, costField) })) + .filter((e) => e.d >= minDistance && e.d <= maxDistance && Number.isFinite(e.c) && e.c < INF) + .sort((a, b) => { + const aCity = a.q.role === (mode === "expressway" ? "urban-fringe-ic" : "urban-portal") ? -10 : 0; + const bCity = b.q.role === (mode === "expressway" ? "urban-fringe-ic" : "urban-portal") ? -10 : 0; + return (a.d * a.c + aCity) - (b.d * b.c + bCity); + }); + for (const opt of candidates.slice(0, maxTargets)) { + const path = routeBetweenTrafficCandidates(start, opt.q, mode, costField, penalty, { + ...config.routeOptions, + ...routeOptions, + maxPathLength: typeof routeOptions.maxPathLength === "function" ? routeOptions.maxPathLength(opt.d) : routeOptions.maxPathLength ?? opt.d * 3.05 + 74, + searchPad: typeof routeOptions.searchPad === "function" ? routeOptions.searchPad(opt.d) : routeOptions.searchPad, + }); + const len = pathLengthCells(path); + if (!acceptable(path, len, opt, city, start, accepted)) continue; + paths.push(path); + markPathInfluence(accepted, path, config.backbone?.parallelRadius ?? 6, 1.0); + addCorridorInfluencePenalty(penalty, path, config.backbone?.parallelRadius ?? 6, mode === "expressway" ? 1.10 : 0.38); + recordCorridor(debug, mode, recordFrom, opt.q.city?.name || opt.q.source?.name || opt.q.role, path, { city: city.name }); + added++; + break; + } + } + if (counterKey) debug[counterKey] = (debug[counterKey] || 0) + added; + return added; + } + function ensureExpresswayCityIntercity(debug, expressAnchors, expressCost) { const forceCost = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { @@ -1424,7 +1120,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { expressways.push(path); markPathInfluence(accepted, path, 86, 1.0); addCorridorInfluencePenalty(penalty, path, 82, 12.40); - debug.expresswayCorridors.push({ from: "forced-city-intercity", city: city.name, to: opt.q.city?.name || opt.q.role, distance: Math.round(opt.d), length: Math.round(len), path }); + recordCorridor(debug, "expressway", "forced-city-intercity", opt.q.city?.name || opt.q.role, path, { city: city.name, distance: Math.round(opt.d) }); added++; break; } @@ -1438,58 +1134,49 @@ export function buildDensityFlowRoadTransportSystem(ctx) { .filter((p) => p && ["urban-portal", "market-portal", "port", "external-gateway", "large-village"].includes(p.role)) .sort((a, b) => (b.score || 0) - (a.score || 0)); const penalty = new Float32Array(SIZE); - const accepted = new Float32Array(SIZE); for (const path of nationalRoads) { markPathInfluence(penalty, path, 6, 0.42); - markPathInfluence(accepted, path, 6, 1.0); } - for (const path of externalRoads) markPathInfluence(accepted, path, 5, 0.55); - let added = 0; const cities = modernCities .filter((c) => (c.population || 0) >= 52000 || c.isRegionalCapital || c.isPrefecturalCapital) .sort((a, b) => (b.population || 0) - (a.population || 0)); - for (const city of cities) { - const start = cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal"); - if (!start) continue; - if (nationalRoads.some((path) => pathServesCityCenter(path, city, "national"))) continue; - const candidates = targetAnchors - .filter((q) => q !== start && q.city !== city && q.source !== city) - .map((q) => ({ q, d: Math.hypot(q.x - start.x, q.y - start.y), c: approximateLineCost(start, q, nationalCost) })) - .filter((e) => e.d >= 14 && e.d <= 126 && Number.isFinite(e.c) && e.c < INF) - .sort((a, b) => { - const aCity = a.q.role === "urban-portal" ? -10 : 0; - const bCity = b.q.role === "urban-portal" ? -10 : 0; - return (a.d * a.c + aCity) - (b.d * b.c + bCity); - }); - for (const opt of candidates.slice(0, 8)) { - const path = routeBetweenTrafficCandidates(start, opt.q, "national", nationalCost, penalty, { + ensureCityOutboundConnections({ + debug, + mode: "national", + cities, + startForCity: (city) => cityPortalAnchors(city, "national")[0] || portalSearchAroundPoint(city, "national", "urban-portal"), + targetAnchors, + costField: nationalCost, + penalty, + paths: nationalRoads, + acceptedPaths: externalRoads, + minDistance: 14, + maxDistance: 126, + maxTargets: 8, + routeOptions: { curvePenalty: 0.060, penaltyStrength: 1.04, terrainFlowBias: 0.26, surfaceGrain: 0.030, relaxRadius: 2, relaxLineWeight: 0.28, - maxPathLength: opt.d * 3.05 + 74, + maxPathLength: (d) => d * 3.05 + 74, snapRadius: 2.0, heuristicWeight: 0.60, - searchPad: Math.ceil(Math.max(42, Math.min(110, opt.d * 0.62))), - }); - const len = pathLengthCells(path); - if (len < 5 || len > opt.d * 3.15 + 82) continue; - if (!pathComesOutOfCity(path, city, start, "national")) continue; - if (routeTooStraightAcrossMountains(path, "national")) continue; - if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) continue; + searchPad: (d) => Math.ceil(Math.max(42, Math.min(110, d * 0.62))), + }, + acceptable: (path, len, opt, city, start, accepted) => { + if (len < 5 || len > opt.d * 3.15 + 82) return false; + if (!pathComesOutOfCity(path, city, start, "national")) return false; + if (routeTooStraightAcrossMountains(path, "national")) return false; + if (!transportRouteAcceptable(path, "national", transportFields.nationalPotential, penalty, { minLength: 5, maxLength: opt.d * 2.85 + 58, maxHighElevationShare: 0.36, maxSteepShare: 0.50 })) return false; const parallel = existingParallelShare(path, accepted, 0.18); - if (parallel > 0.58) continue; - nationalRoads.push(path); - markPathInfluence(accepted, path, 6, 1.0); - addCorridorInfluencePenalty(penalty, path, 6, 0.38); - debug.nationalCorridors.push({ from: "forced-city-outbound", city: city.name, to: opt.q.city?.name || opt.q.source?.name || opt.q.role, length: Math.round(len), path }); - added++; - break; - } - } - debug.forcedNationalCityConnections = (debug.forcedNationalCityConnections || 0) + added; + if (parallel > 0.58) return false; + return true; + }, + recordFrom: "forced-city-outbound", + counterKey: "forcedNationalCityConnections", + }); } function buildDensityFlowRoadSystem(debug) { @@ -1502,14 +1189,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) { const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, 14); const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow); buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, { + ...roadMode("expressway").backbone, maxNodes: 48, maxAdded: Math.min(4, Math.max(2, Math.ceil(expressAnchors.length / 12))), - maxExtra: 0, - minDistance: 38, - maxDistance: 190, - maxDegree: 2, - parallelRadius: 84, - penaltyStrengthMark: 16.50, }); ensureExpresswayCityIntercity(debug, expressAnchors, expressCost); @@ -1519,14 +1201,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) { const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, 50); const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow); buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, { + ...roadMode("national").backbone, maxNodes: 78, maxAdded: Math.min(38, Math.max(18, Math.ceil(nationalAnchors.length * 0.45))), - maxExtra: 8, - minDistance: 12, - maxDistance: 130, - maxDegree: 4, - parallelRadius: 6, - penaltyStrengthMark: 0.32, }); // Guarantee light national access to large urban areas whose portals were @@ -1558,7 +1235,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { const len = pathLengthCells(path); if (len >= 5 && len <= target.d * 2.7 + 44 && !routeTooStraightAcrossMountains(path, "national")) { nationalRoads.push(path); - debug.nationalCorridors.push({ from: "city-portal-guarantee", city: city.name, to: target.q.role, length: Math.round(len), path }); + recordCorridor(debug, "national", "city-portal-guarantee", target.q.role, path, { city: city.name }); addCorridorInfluencePenalty(nationalPenalty, path, 6, 0.28); } } @@ -1598,13 +1275,14 @@ export function buildDensityFlowRoadTransportSystem(ctx) { function addShortConnector(outPaths, a, b, mode = "local") { const d = Math.hypot(a.x - b.x, a.y - b.y); - const costField = mode === "expressway" ? expresswayCorridorCost : mode === "national" ? transportFields.national : transportFields.local; + const config = roadMode(mode); + const costField = config.costField(); let path = []; if (d > 2.2) { - path = routeBetweenTrafficCandidates(a, b, mode === "expressway" ? "expressway" : mode === "national" ? "national" : "local", costField, null, { - curvePenalty: mode === "expressway" ? 0.095 : mode === "national" ? 0.045 : 0.035, + path = routeBetweenTrafficCandidates(a, b, mode, costField, null, { + curvePenalty: config.connector.curvePenalty, penaltyStrength: 0.0, - terrainFlowBias: mode === "expressway" ? 0.12 : mode === "national" ? 0.22 : 0.28, + terrainFlowBias: config.connector.terrainFlowBias, surfaceGrain: 0.020, relaxRadius: 1, relaxLineWeight: 0.22, @@ -2045,17 +1723,8 @@ export function buildDensityFlowRoadTransportSystem(ctx) { }); transportDebugLayers.parallelPruning.push(localParallelPruning); transportDebugLayers.localSanitizationInitial = sanitizeLocalRoads(); - function sampledNetworkCells(paths, step = 2) { - const cells = []; - for (let pathId = 0; pathId < (paths || []).length; pathId++) { - const path = paths[pathId]; - for (let k = 0; k < (path?.length || 0); k += step) { - const [x, y] = path[k]; - if (inside(x, y) && !sea[indexOf(x, y)]) cells.push({ x, y, pathId }); - } - } - return cells; - } + const sampledNetworkCells = (paths, step = 2) => sampledNetworkCellsBase(paths, step, sea); + const nationalForIc = sampledNetworkCells([...nationalRoads, ...externalRoads], 2).map((q) => ({ ...q, roadClass: "national" })); const generalRoadForIc = sampledNetworkCells([...nationalRoads, ...externalRoads, ...minorRoads], 2).map((q, idx) => ({ ...q, roadClass: idx < nationalForIc.length ? "national" : "local" })); const nationalRoadIcIndex = makeSpatialIndex(nationalForIc, 16); @@ -2077,46 +1746,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { return best ? { ...best, d: bestD } : null; } - function pathCumulativeLengths(path) { - const cum = [0]; - for (let k = 1; k < (path?.length || 0); k++) cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1])); - return cum; - } - - function pointAtPathDistance(path, cum, dist) { - if (!path?.length) return null; - if (dist <= 0) return { x: path[0][0], y: path[0][1], s: 0 }; - const total = cum[cum.length - 1] || 0; - if (dist >= total) { - const p = path[path.length - 1]; - return { x: p[0], y: p[1], s: total }; - } - let k = 1; - while (k < cum.length && cum[k] < dist) k++; - const a = path[k - 1]; - const b = path[k]; - const seg = Math.max(0.0001, cum[k] - cum[k - 1]); - const t = clamp((dist - cum[k - 1]) / seg); - return { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t), s: dist }; - } - - function meanFieldAround(field, x, y, radius = 8) { - let sum = 0, n = 0; - const r = Math.ceil(radius); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - if (dx * dx + dy * dy > radius * radius) continue; - const nx = x + dx, ny = y + dy; - if (!inside(nx, ny)) continue; - const i = indexOf(nx, ny); - if (sea[i]) continue; - const w = 1 - Math.hypot(dx, dy) / Math.max(1, radius); - sum += (field?.[i] || 0) * (0.35 + w); - n += 0.35 + w; - } - } - return n ? sum / n : 0; - } + const meanFieldAround = (field, x, y, radius = 8) => meanFieldAroundBase(field, x, y, radius, sea); const icDemandCache = new Map(); function icDemandAt(p) { diff --git a/mapTransportUtils.js b/mapTransportUtils.js index 870beaf..b2cb7e3 100644 --- a/mapTransportUtils.js +++ b/mapTransportUtils.js @@ -52,6 +52,81 @@ export function pathAverageField(path, field) { return n ? sum / n : 0; } +export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null) { + for (const [px, py] of path || []) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (dx * dx + dy * dy > radius * radius) continue; + const x = px + dx; + const y = py + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea?.[i]) continue; + const d = Math.hypot(dx, dy); + const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35); + if (v > field[i]) field[i] = v; + } + } + } +} + +export function sampledNetworkCells(paths, step = 2, sea = null) { + const cells = []; + for (let pathId = 0; pathId < (paths || []).length; pathId++) { + const path = paths[pathId]; + for (let k = 0; k < (path?.length || 0); k += step) { + const [x, y] = path[k]; + if (inside(x, y) && !sea?.[indexOf(x, y)]) cells.push({ x, y, pathId }); + } + } + return cells; +} + +export function pathCumulativeLengths(path) { + const cum = [0]; + for (let k = 1; k < (path?.length || 0); k++) { + cum.push(cum[cum.length - 1] + Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1])); + } + return cum; +} + +export function pointAtPathDistance(path, cum, dist) { + if (!path?.length) return null; + if (dist <= 0) return { x: path[0][0], y: path[0][1], s: 0 }; + const total = cum[cum.length - 1] || 0; + if (dist >= total) { + const p = path[path.length - 1]; + return { x: p[0], y: p[1], s: total }; + } + let k = 1; + while (k < cum.length && cum[k] < dist) k++; + const a = path[k - 1]; + const b = path[k]; + const seg = Math.max(0.0001, cum[k] - cum[k - 1]); + const t = clamp((dist - cum[k - 1]) / seg); + return { x: Math.round(a[0] + (b[0] - a[0]) * t), y: Math.round(a[1] + (b[1] - a[1]) * t), s: dist }; +} + +export function meanFieldAround(field, x, y, radius = 8, sea = null) { + let sum = 0; + let n = 0; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (dx * dx + dy * dy > radius * radius) continue; + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const i = indexOf(nx, ny); + if (sea?.[i]) continue; + const w = 1 - Math.hypot(dx, dy) / Math.max(1, radius); + sum += (field?.[i] || 0) * (0.35 + w); + n += 0.35 + w; + } + } + return n ? sum / n : 0; +} + export function routeQualityStats(path, fields = {}) { if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 }; const length = pathLengthCells(path); diff --git a/names.js b/names.js index 2d8967e..3582bed 100644 --- a/names.js +++ b/names.js @@ -15,13 +15,13 @@ export const NAME_KANJI_POOLS = { "霞", "朝", "日", "天", "土", "砂", "石", "岩", "卯", "辰", - "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" + "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" ], inlandTerrain: [ "山", "野", "野", "沢", "森", "林", "岡", "丘", "坂", - "峰", "峠", "嶺", "尾", "平", "坪", "延", + "峰", "嶺", "尾", "平", "坪", "延", "窪", "久", "迫", "久保", "玖保", "佐古", "作古", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生", "郷", "里", @@ -85,7 +85,7 @@ export const NAME_KANJI_POOLS = { ], archaicSuffixes: [ - "井", "羽", "江", "恵", "尾", + "伊", "衣", "井", "羽", "江", "恵", "尾", "賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総", "多", "太", "知", "津", "豆", "土", "登", @@ -107,7 +107,7 @@ export const NAME_KANJI_POOLS = { settlementWords: [ "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", - "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", + "庄", "ノ庄", "之庄", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", "市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋", ] diff --git a/renderer.js b/renderer.js index 276fcae..bf72d40 100644 --- a/renderer.js +++ b/renderer.js @@ -655,17 +655,19 @@ function drawBridgeOverlay(ctx, map, path, width, mode = "road") { } function drawTunnelOverlay(ctx, map, path, width, mode = "road") { - if (mode !== "expressway") return; + if (mode !== "expressway" && mode !== "national") return; + const vectorMode = mode === "expressway" ? "expressway" : "default"; + const limit = mode === "expressway" ? 10 : 10; const tunnelChunks = specialTransportSubpaths( map, path, (i) => !map.sea?.[i] && (((map.elevation?.[i] || 0) >= 0.74 && (map.ridgeField?.[i] || 0) >= 0.46) || (map.naturalBarrierScore?.[i] || 0) >= 0.82), 2, true, - 10 + limit ); for (const chunk of tunnelChunks) { - drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", 1.15, Math.max(1.9, width * 0.82), "expressway"); + drawDottedOutlinePath(ctx, chunk, "rgba(42, 42, 42, 0.96)", mode === "expressway" ? 1.15 : 1.0, Math.max(1.75, width * 0.82), vectorMode); } } @@ -1135,8 +1137,8 @@ export function drawMap(canvas, map, options) { } if (showRoads) { for (const path of generalRoadPaths) { drawBridgeOverlay(ctx, map, path, 1.55, "road"); } - for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); } - for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); } + for (const path of map.nationalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); drawTunnelOverlay(ctx, map, path, 1.9, "national"); } + for (const path of map.externalRoads) { drawBridgeOverlay(ctx, map, path, 2.0, "road"); drawTunnelOverlay(ctx, map, path, 1.9, "national"); } for (const path of map.expressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); } for (const path of map.externalExpressways) { drawBridgeOverlay(ctx, map, path, 2.45, "expressway"); drawTunnelOverlay(ctx, map, path, 2.25, "expressway"); } }