From 0359bb24458d3b80e386d76469d31b81c2fa42ed Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 27 May 2026 00:13:13 +0900 Subject: [PATCH] road tweak --- mapFeatures.js | 1161 +++++++++++++++++++++++++++++++++++++++++------ mapOutput.js | 166 +++++-- mapTransport.js | 99 ++++ names.js | 23 +- renderer.js | 21 +- 5 files changed, 1268 insertions(+), 202 deletions(-) create mode 100644 mapTransport.js diff --git a/mapFeatures.js b/mapFeatures.js index 3dd04ed..1ec5e62 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -1,6 +1,7 @@ import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; import { LANDUSE } from "./landuseCodes.js"; +import { createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js"; // Lightweight Human Geography V2 // -------------------------------- @@ -629,6 +630,22 @@ export function generateMapFeatures(seed, terrain) { 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; + } + for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); @@ -636,22 +653,32 @@ export function generateMapFeatures(seed, terrain) { expressway[i] = rail[i] = national[i] = local[i] = INF; continue; } - if (elevation[i] > 0.72) { - expressway[i] = rail[i] = national[i] = INF; - local[i] = Math.max(2.8, 1.4 + slope[i] * 2.2 + ridgeField[i] * 1.4); - 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.60 + river[i] * 1.1) : 0; - const highMountain = clamp((elevation[i] - 0.58) * 2.6 + ridgeField[i] * 0.65); + 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, 2); + const coastalTraversePenalty = clamp(seaBroad * 1.35 - coastalLowland[i] * 0.58 - (portSuitability?.[i] || 0) * 0.38); + 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); + if (extremeMountain > 0.92 && pass < 0.34) { + expressway[i] = rail[i] = INF; + national[i] = elevation[i] > 0.70 ? INF : 2.6 + extremeMountain * 2.4 + waterCrossingPenalty; + local[i] = 1.8 + extremeMountain * 1.8 + waterCrossingPenalty * 0.45; + 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 + @@ -659,8 +686,9 @@ export function generateMapFeatures(seed, terrain) { lowland * 0.36 + agriculture[i] * 0.16 - denseCorePenalty * 0.54 - - slope[i] * 0.64 - - highMountain * 0.58 - + slope[i] * 0.82 - + highMountain * 0.92 - + coastalTraversePenalty * 0.32 - river[i] * 0.14 ); railPotential[i] = clamp( @@ -669,8 +697,9 @@ export function generateMapFeatures(seed, terrain) { lowland * 0.46 + valleyField[i] * 0.22 + coastalLowland[i] * 0.22 - - slope[i] * 1.05 - - highMountain * 0.82 - + slope[i] * 1.28 - + highMountain * 1.10 - + coastalTraversePenalty * 0.26 - ridgeField[i] * 0.34 ); nationalPotential[i] = clamp( @@ -682,7 +711,9 @@ export function generateMapFeatures(seed, terrain) { coastalLowland[i] * 0.26 + pass * 0.18 + crossing * 0.18 - - slope[i] * 0.36 - + slope[i] * 0.48 - + highMountain * 0.24 - + coastalTraversePenalty * 0.16 - ridgeField[i] * 0.18 ); localPotential[i] = clamp( @@ -691,28 +722,93 @@ export function generateMapFeatures(seed, terrain) { coastalSettlement[i] * 0.30 + valleySettlement[i] * 0.30 + developable[i] * 0.18 - - slope[i] * 0.26 - + slope[i] * 0.34 - + coastalTraversePenalty * 0.08 - ridgeField[i] * 0.10 ); - expressway[i] = Math.max(0.18, 1.45 - expresswayPotential[i] * 1.06 + denseCorePenalty * 1.25 + slope[i] * 3.5 + highMountain * 2.9 + waterCrossingPenalty * 1.4 + openPlainParallelPenalty * 0.10 + hash2(x, y, seed + 13301) * 0.05); - rail[i] = Math.max(0.16, 1.38 - railPotential[i] * 1.08 + slope[i] * 5.6 + highMountain * 4.2 + waterCrossingPenalty * 1.1 + hash2(x, y, seed + 13302) * 0.04); - national[i] = Math.max(0.16, 1.22 - nationalPotential[i] * 0.88 + slope[i] * 1.65 + ridgeField[i] * 0.72 + Math.max(0, elevation[i] - 0.68) * 1.2 - pass * 0.30 + waterCrossingPenalty * 0.72 + hash2(x, y, seed + 13303) * 0.06); - local[i] = Math.max(0.14, 1.10 - localPotential[i] * 0.88 + slope[i] * 1.05 + ridgeField[i] * 0.42 + Math.max(0, elevation[i] - 0.72) * 0.90 + waterCrossingPenalty * 0.45 + hash2(x, y, seed + 13304) * 0.08); + 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 + + waterCrossingPenalty * 2.1 + + coastalTraversePenalty * 1.8 + + seaNear * 1.7 + + 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 + + waterCrossingPenalty * 1.7 + + coastalTraversePenalty * 1.2 + + seaNear * 1.1 + + 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 + + waterCrossingPenalty * 1.25 - + valleyField[i] * 0.18 - + coastalLowland[i] * 0.08 + + coastalTraversePenalty * 0.95 - + 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 + + waterCrossingPenalty * 0.65 - + valleyField[i] * 0.22 - + coastalLowland[i] * 0.10 + + coastalTraversePenalty * 0.38 + + hash2(x, y, seed + 13304) * 0.07 + ); } } return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential }; } const transportFields = buildTransportCostFields(); + const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths); - function chooseCorridorSeeds(potentialField, spacing, maxCount, threshold, predicate = () => true, seedOffset = 0) { + const componentCityInfluence = influenceFromPoints([...modernCities, ...markets], 11, (p) => clamp((p.population || 8000) / 50000, 0.18, 8.0)); + const componentCapitalInfluence = influenceFromPoints(modernCities.filter((p) => p.isPrefecturalCapital), 16, () => 5.0); + + const corridorSkeleton = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + corridorSkeleton[i] = Math.round(clamp( + valleyField[i] * 0.34 + + coastalLowland[i] * 0.24 + + plain[i] * 0.16 + + basinField[i] * 0.16 + + logisticsPreSuitability[i] * 0.18 + + (passSuitability?.[i] || 0) * 0.18 + + (crossingSuitability?.[i] || 0) * 0.10 - + ridgeField[i] * 0.22 - + slope[i] * 0.28 + ) * 255); + } + + function chooseCorridorSeeds(potentialField, spacing, maxCount, threshold, predicate = () => true, seedOffset = 0, mode = "national") { const candidates = []; for (let y = 3; y < MAP_H - 3; y += 2) { for (let x = 3; x < MAP_W - 3; x += 2) { const i = indexOf(x, y); if (sea[i] || !predicate(x, y, i)) continue; - const score = potentialField[i] + hash2(x, y, seed + seedOffset) * 0.055; + const skeletonWeight = mode === "rail" ? 0.24 : mode === "expressway" ? 0.18 : 0.28; + const score = potentialField[i] + (corridorSkeleton[i] / 255) * skeletonWeight + hash2(x, y, seed + seedOffset) * 0.055; if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); } } @@ -723,6 +819,35 @@ export function generateMapFeatures(seed, terrain) { return clamp(settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36 - plain[i] * 0.18 - agriculture[i] * 0.14); } + function endpointSupport(i) { + if (i < 0 || i >= SIZE || sea[i]) return 0; + return clamp( + settlementDemand[i] * 0.66 + + preliminaryTownInfluence[i] * 0.36 + + preliminaryVillageInfluence[i] * 0.24 + + valleySettlement[i] * 0.22 + + coastalSettlement[i] * 0.18 + + agriculture[i] * 0.10 + + (passSuitability?.[i] || 0) * 0.24 + + (crossingSuitability?.[i] || 0) * 0.16 - + slope[i] * 0.22 - + ridgeField[i] * 0.14 + ); + } + + function nearMapEdgePoint(x, y, margin = 4) { + return x <= margin || y <= margin || x >= MAP_W - 1 - margin || y >= MAP_H - 1 - margin; + } + + function naturalEndpoint(x, y, i, mode = "national") { + if (nearMapEdgePoint(x, y, 4)) return true; + const support = endpointSupport(i); + if (mode === "expressway") return support > 0.24 && settlementDemand[i] > 0.06; + if (mode === "rail") return support > 0.18 && settlementDemand[i] > 0.07; + if (mode === "local") return support > 0.12 || valleySettlement[i] > 0.14 || coastalSettlement[i] > 0.14; + return support > 0.14 || transportFields.nationalPotential[i] > 0.37; + } + function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) { if (!start || !inside(start.x, start.y)) return []; const startIndex = indexOf(start.x, start.y); @@ -740,6 +865,9 @@ export function generateMapFeatures(seed, terrain) { const sameRegion = options.regionId ?? regionIdAt(start.x, start.y); const minGoalDistance = options.minGoalDistance ?? 18; const maxExpanded = options.maxExpanded ?? SIZE * 2; + const bounds = options.bounds || null; + const goalHint = options.goalHint || null; + const heuristicWeight = options.heuristicWeight ?? 0; let goalIndex = -1; let expanded = 0; @@ -758,6 +886,7 @@ export function generateMapFeatures(seed, terrain) { const nx = cx + dx; const ny = cy + dy; if (!inside(nx, ny)) continue; + if (bounds && (nx < bounds.minX || nx > bounds.maxX || ny < bounds.minY || ny > bounds.maxY)) continue; const ni = indexOf(nx, ny); if (closed[ni] || sea[ni] || costField[ni] >= INF) continue; if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue; @@ -771,11 +900,21 @@ export function generateMapFeatures(seed, terrain) { } const existing = penaltyField?.[ni] || 0; const antiConcentration = existing * penaltyStrength * (1 - corridorAllowance(ni) * 0.72); - const nd = score[current.i] + (costField[ni] + antiConcentration + turn) * Math.hypot(dx, dy); + const terrainFlowBias = (options.terrainFlowBias ?? 0) * clamp( + valleyField[ni] * 0.54 + + coastalLowland[ni] * 0.28 + + plain[ni] * 0.16 + + (passSuitability?.[ni] || 0) * 0.34 - + ridgeField[ni] * 0.24 - + slope[ni] * 0.22 + ); + const surfaceGrain = (options.surfaceGrain ?? 0) * valueNoise(nx, ny, seed + 13941, 18); + const nd = score[current.i] + Math.max(0.08, costField[ni] + antiConcentration + turn - terrainFlowBias + surfaceGrain) * Math.hypot(dx, dy); if (nd < score[ni]) { score[ni] = nd; cameFrom[ni] = current.i; - heap.push({ i: ni, f: nd }); + const h = goalHint ? Math.hypot(nx - goalHint.x, ny - goalHint.y) * heuristicWeight : 0; + heap.push({ i: ni, f: nd + h }); } } } @@ -808,27 +947,111 @@ export function generateMapFeatures(seed, terrain) { } } + function relaxRouteToTerrain(path, costField, options = {}) { + if (!path || path.length < 5) return path || []; + const radius = options.radius ?? 2; + const iterations = options.iterations ?? 1; + const lineWeight = options.lineWeight ?? 0.42; + const costWeight = options.costWeight ?? 1.0; + const grain = options.grain ?? 0.05; + let out = path.map((p) => [p[0], p[1]]); + for (let iter = 0; iter < iterations; iter++) { + const src = out.map((p) => [p[0], p[1]]); + for (let k = 1; k < src.length - 1; k++) { + const prev = out[k - 1]; + const cur = src[k]; + const next = src[k + 1]; + let best = cur; + let bestScore = INF; + const vx = next[0] - prev[0]; + const vy = next[1] - prev[1]; + const vLen2 = Math.max(1e-6, vx * vx + vy * vy); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (dx * dx + dy * dy > radius * radius) continue; + const x = cur[0] + dx; + const y = cur[1] + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || costField[i] >= INF) continue; + const t = clamp(((x - prev[0]) * vx + (y - prev[1]) * vy) / vLen2); + const projX = prev[0] + vx * t; + const projY = prev[1] + vy * t; + const lineDist = Math.hypot(x - projX, y - projY); + const neighborDist = Math.abs(Math.hypot(x - prev[0], y - prev[1]) - Math.hypot(cur[0] - prev[0], cur[1] - prev[1])) * 0.08; + const terrainBonus = valleyField[i] * 0.34 + coastalLowland[i] * 0.16 + plain[i] * 0.08 + (passSuitability?.[i] || 0) * 0.18; + const terrainPenalty = slope[i] * 0.42 + ridgeField[i] * 0.32 + Math.max(0, elevation[i] - 0.62) * 0.45; + const localGrain = grain * valueNoise(x, y, seed + 15123 + k * 17, 14); + const score = costField[i] * costWeight + lineDist * lineWeight + neighborDist + terrainPenalty - terrainBonus + localGrain; + if (score < bestScore) { + bestScore = score; + best = [x, y]; + } + } + } + out[k] = best; + } + } + const deduped = []; + let last = ""; + for (const p of out) { + const key = `${p[0]},${p[1]}`; + if (key !== last) { + deduped.push(p); + last = key; + } + } + return deduped.length >= 2 ? deduped : path; + } + function endpointFromPath(path) { const p = path?.[path.length - 1]; return p ? { x: p[0], y: p[1], regionId: regionIdAt(p[0], p[1]) } : null; } - function generateCorridorsFromField({ potentialField, costField, spacing, maxCount, threshold, minLength, penaltyRadius, penaltyStrength, curvePenalty, seedOffset, startPredicate, goalPredicate }) { + function qualityLimitsForMode(mode, overrides = {}) { + const base = mode === "rail" + ? { maxCompactness: 2.45, maxSteepShare: 0.18, maxHighElevationShare: 0, minAvgPotential: 0.12 } + : mode === "expressway" + ? { maxCompactness: 2.75, maxSteepShare: 0.24, maxHighElevationShare: 0, minAvgPotential: 0.10 } + : mode === "local" + ? { maxCompactness: 3.6, maxSteepShare: 0.46, maxHighElevationShare: 0.18, minAvgPotential: 0.02 } + : { maxCompactness: 3.3, maxSteepShare: 0.38, maxHighElevationShare: 0.04, minAvgPotential: 0.04 }; + return { ...base, ...overrides }; + } + + function transportRouteAcceptable(path, mode, potentialField, penaltyField = null, overrides = {}) { + return routeQualityAcceptable(path, { + sea, + elevation, + slope, + potential: potentialField, + penalty: penaltyField, + highElevationThreshold: mode === "local" ? 0.78 : 0.72, + steepThreshold: mode === "rail" ? 0.34 : mode === "expressway" ? 0.40 : 0.48, + }, qualityLimitsForMode(mode, overrides)); + } + + function generateCorridorsFromField({ mode = "national", potentialField, costField, spacing, maxCount, threshold, minLength, penaltyRadius, penaltyStrength, curvePenalty, terrainFlowBias = 0, surfaceGrain = 0, relaxRadius = 2, relaxLineWeight = 0.40, seedOffset, startPredicate, goalPredicate }) { const paths = []; const penaltyField = new Float32Array(SIZE); - const seeds = chooseCorridorSeeds(potentialField, spacing, maxCount * 2, threshold, startPredicate, seedOffset); + const seeds = chooseCorridorSeeds(potentialField, spacing, maxCount * 2, threshold, startPredicate, seedOffset, mode); const usedEndpoints = []; for (const start of seeds) { if (paths.length >= maxCount) break; if (distanceToNearest(usedEndpoints, start.x, start.y) < spacing * 0.55) continue; - const path = traceCorridorByCost( + let path = traceCorridorByCost( start, (x, y, i) => goalPredicate(start, x, y, i, usedEndpoints), costField, penaltyField, - { curvePenalty, penaltyStrength: penaltyStrength * 2.2, minGoalDistance: minLength, regionId: start.regionId } + { curvePenalty, penaltyStrength: penaltyStrength * 2.2, minGoalDistance: minLength, regionId: start.regionId, terrainFlowBias, surfaceGrain } ); if (path.length < minLength) continue; + const rawPath = path; + path = relaxRouteToTerrain(rawPath, costField, { radius: relaxRadius, lineWeight: relaxLineWeight, grain: surfaceGrain, iterations: 1 }); + if (path.length < Math.max(2, rawPath.length * 0.55)) path = rawPath; + if (!transportRouteAcceptable(path, mode, potentialField, penaltyField, { minLength, maxLength: mode === "expressway" ? 130 : mode === "rail" ? 112 : 150 })) continue; paths.push(path); usedEndpoints.push(start); const end = endpointFromPath(path); @@ -885,23 +1108,20 @@ export function generateMapFeatures(seed, terrain) { let townScore = 0; let logisticsScore = 0; let capitalScore = 0; + let sx = 0; + let sy = 0; const sampleStride = Math.max(1, Math.floor(cells.length / 80)); for (let c = 0; c < cells.length; c += sampleStride) { const ci = cells[c]; const x = ci % MAP_W; const y = Math.floor(ci / MAP_W); lengthScore += sampleStride; + sx += x * sampleStride; + sy += y * sampleStride; densityScore += settlementDemand[ci] * sampleStride; logisticsScore += logisticsPreSuitability[ci] * sampleStride; - for (const p of [...modernCities, ...markets]) { - const d = Math.hypot(p.x - x, p.y - y); - if (d <= 9) townScore += ((p.population || 8000) / 50000) * (1 - d / 9); - } - for (const cty of modernCities) { - if (!cty.isPrefecturalCapital) continue; - const d = Math.hypot(cty.x - x, cty.y - y); - if (d <= 14) capitalScore += 5.0 * (1 - d / 14); - } + townScore += componentCityInfluence[ci] * sampleStride; + capitalScore += componentCapitalInfluence[ci] * sampleStride; } const importance = Math.sqrt(lengthScore) * 1.20 + @@ -909,7 +1129,18 @@ export function generateMapFeatures(seed, terrain) { townScore * 0.55 + logisticsScore * 0.24 + capitalScore; - components.push({ id, mode, cells, boundary, importance, length: cells.length, repairCount: 0, potential: cells.reduce((sum, ci) => sum + (potentialField?.[ci] || 0), 0) / Math.max(1, cells.length) }); + components.push({ + id, + mode, + cells, + boundary, + cx: sx / Math.max(1, lengthScore), + cy: sy / Math.max(1, lengthScore), + importance, + length: cells.length, + repairCount: 0, + potential: cells.reduce((sum, ci) => sum + (potentialField?.[ci] || 0), 0) / Math.max(1, cells.length), + }); } return { occupied, componentId, components }; } @@ -933,16 +1164,167 @@ export function generateMapFeatures(seed, terrain) { return best; } + function componentBounds(aComp, bComp, pad = 18) { + let minX = MAP_W - 1; + let minY = MAP_H - 1; + let maxX = 0; + let maxY = 0; + for (const comp of [aComp, bComp]) { + for (const p of comp.boundary || []) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + } + return { + minX: Math.max(0, minX - pad), + minY: Math.max(0, minY - pad), + maxX: Math.min(MAP_W - 1, maxX + pad), + maxY: Math.min(MAP_H - 1, maxY + pad), + }; + } + + function traceCoarseRepair(start, targetComp, raster, costField, penaltyField, options = {}) { + const scale = options.coarseScale ?? 3; + if (scale <= 1) return null; + const cw = Math.ceil(MAP_W / scale); + const ch = Math.ceil(MAP_H / scale); + const cSize = cw * ch; + const bounds = options.bounds || { minX: 0, minY: 0, maxX: MAP_W - 1, maxY: MAP_H - 1 }; + const cbounds = { + minX: Math.max(0, Math.floor(bounds.minX / scale) - 1), + minY: Math.max(0, Math.floor(bounds.minY / scale) - 1), + maxX: Math.min(cw - 1, Math.ceil(bounds.maxX / scale) + 1), + maxY: Math.min(ch - 1, Math.ceil(bounds.maxY / scale) + 1), + }; + const cIndex = (x, y) => y * cw + x; + const cCost = new Float32Array(cSize); + const cTarget = new Uint8Array(cSize); + cCost.fill(INF); + for (let cy = cbounds.minY; cy <= cbounds.maxY; cy++) { + for (let cx = cbounds.minX; cx <= cbounds.maxX; cx++) { + let best = INF; + let target = 0; + for (let dy = 0; dy < scale; dy++) { + for (let dx = 0; dx < scale; dx++) { + const x = cx * scale + dx; + const y = cy * scale + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i] || costField[i] >= INF) continue; + best = Math.min(best, costField[i] + (penaltyField?.[i] || 0) * (options.penaltyStrength ?? 1)); + if (raster.componentId[i] === targetComp.id) target = 1; + } + } + const ci = cIndex(cx, cy); + cCost[ci] = best; + cTarget[ci] = target; + } + } + const sx = Math.floor(start.x / scale); + const sy = Math.floor(start.y / scale); + if (sx < cbounds.minX || sx > cbounds.maxX || sy < cbounds.minY || sy > cbounds.maxY) return null; + const startCi = cIndex(sx, sy); + if (cCost[startCi] >= INF) return null; + + const score = new Float32Array(cSize); + const cameFrom = new Int32Array(cSize); + const closed = new Uint8Array(cSize); + score.fill(INF); + cameFrom.fill(-1); + const heap = new MinHeap(); + score[startCi] = 0; + heap.push({ i: startCi, f: 0 }); + let goal = -1; + let guard = 0; + while (heap.length && guard++ < cSize * 2) { + const cur = heap.pop(); + if (!cur || closed[cur.i]) continue; + closed[cur.i] = 1; + if (cur.i !== startCi && cTarget[cur.i]) { + goal = cur.i; + break; + } + const cx = cur.i % cw; + const cy = Math.floor(cur.i / cw); + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const nx = cx + dx; + const ny = cy + dy; + if (nx < cbounds.minX || nx > cbounds.maxX || ny < cbounds.minY || ny > cbounds.maxY) continue; + const ni = cIndex(nx, ny); + if (closed[ni] || cCost[ni] >= INF) continue; + const nd = score[cur.i] + cCost[ni] * Math.hypot(dx, dy); + if (nd < score[ni]) { + score[ni] = nd; + cameFrom[ni] = cur.i; + const h = Math.hypot(nx - targetComp.cx / scale, ny - targetComp.cy / scale) * (options.heuristicWeight ?? 0.18); + heap.push({ i: ni, f: nd + h }); + } + } + } + } + if (goal < 0) return null; + const coarse = []; + for (let p = goal; p >= 0; p = cameFrom[p]) { + const cx = p % cw; + const cy = Math.floor(p / cw); + coarse.push([Math.min(MAP_W - 1, Math.round(cx * scale + scale * 0.5)), Math.min(MAP_H - 1, Math.round(cy * scale + scale * 0.5))]); + if (p === startCi) break; + } + coarse.reverse(); + if (coarse.length < 2) return null; + const full = [[start.x, start.y]]; + for (let k = 1; k < coarse.length; k++) { + const a = full[full.length - 1]; + const b = coarse[k]; + const steps = Math.max(1, Math.ceil(Math.hypot(a[0] - b[0], a[1] - b[1]))); + for (let s = 1; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a[0] + (b[0] - a[0]) * t); + const y = Math.round(a[1] + (b[1] - a[1]) * t); + if (inside(x, y) && !sea[indexOf(x, y)] && costField[indexOf(x, y)] < INF) full.push([x, y]); + } + } + const tail = full[full.length - 1]; + let nearest = null; + let nearestD = INF; + const stride = Math.max(1, Math.floor((targetComp.boundary?.length || 1) / 80)); + for (let k = 0; k < (targetComp.boundary?.length || 0); k += stride) { + const p = targetComp.boundary[k]; + const d = Math.hypot(p.x - tail[0], p.y - tail[1]); + if (d < nearestD) { + nearestD = d; + nearest = p; + } + } + if (nearest && nearestD <= scale * 3 + 4) { + const a = full[full.length - 1]; + const steps = Math.max(1, Math.ceil(nearestD)); + for (let s = 1; s <= steps; s++) { + const t = s / steps; + const x = Math.round(a[0] + (nearest.x - a[0]) * t); + const y = Math.round(a[1] + (nearest.y - a[1]) * t); + if (inside(x, y) && !sea[indexOf(x, y)] && costField[indexOf(x, y)] < INF) full.push([x, y]); + } + } + return full; + } + function repairTransportConnectivity(paths, mode, costField, potentialField, options = {}) { const debug = { mode, components: [], repairs: [] }; const raster = rasterizeNetworkComponents(paths, mode, potentialField); debug.components = raster.components.map((c) => ({ id: c.id, mode, - importance: c.importance, + importance: Math.round(c.importance * 10) / 10, length: c.length, - potential: c.potential, - cells: c.cells.filter((_, k) => k % Math.max(1, Math.floor(c.cells.length / 140)) === 0).map((i) => xyOf(i)), + potential: Math.round(c.potential * 100) / 100, + cx: Math.round(c.cx), + cy: Math.round(c.cy), + cells: c.cells.filter((_, k) => k % Math.max(1, Math.floor(c.cells.length / 40)) === 0).slice(0, 40).map((i) => xyOf(i)), })); const important = raster.components .filter((c) => c.length >= (options.minComponentCells ?? 18) && c.importance >= (options.minImportance ?? 8)) @@ -950,7 +1332,7 @@ export function generateMapFeatures(seed, terrain) { .slice(0, options.maxComponents ?? 8); if (important.length < 2) return debug; - const networkPenalty = influenceFromPaths(paths, options.penaltyRadius ?? 8); + const networkPenalty = cachedInfluenceFromPaths(paths, options.penaltyRadius ?? 8, `${mode}:repair`); const usedAnchors = []; const maxRepairs = options.maxRepairs ?? 4; for (let r = 0; r < maxRepairs; r++) { @@ -961,9 +1343,7 @@ export function generateMapFeatures(seed, terrain) { const ca = important[a]; const cb = important[b]; if (ca.repairCount >= 2 || cb.repairCount >= 2) continue; - const centerA = ca.boundary[Math.floor(ca.boundary.length / 2)] || { x: 0, y: 0 }; - const centerB = cb.boundary[Math.floor(cb.boundary.length / 2)] || { x: 0, y: 0 }; - const d = Math.hypot(centerA.x - centerB.x, centerA.y - centerB.y); + const d = Math.hypot(ca.cx - cb.cx, ca.cy - cb.cy); if (d < (options.minRepairDistance ?? 14) || d > (options.maxRepairDistance ?? 120)) continue; const score = d / Math.sqrt(ca.importance + cb.importance) + (ca.repairCount + cb.repairCount) * 18; if (score < bestScore) { @@ -978,23 +1358,47 @@ export function generateMapFeatures(seed, terrain) { const start = componentAnchor(aComp, roughTarget, costField, usedAnchors); const goalTarget = start ? componentAnchor(bComp, start, costField, usedAnchors) : null; if (!start || !goalTarget) break; - const path = traceCorridorByCost( - start, - (x, y, i) => raster.componentId[i] === bComp.id || (potentialField[i] > (options.highPotentialThreshold ?? 0.42) && Math.hypot(x - goalTarget.x, y - goalTarget.y) < 5), - costField, - networkPenalty, - { - curvePenalty: options.curvePenalty ?? 0.14, - penaltyStrength: options.penaltyStrength ?? 1.8, - minGoalDistance: Math.min(12, Math.max(5, Math.hypot(start.x - goalTarget.x, start.y - goalTarget.y) * 0.35)), - keepRegion: false, - maxExpanded: SIZE, - } - ); + const bounds = componentBounds(aComp, bComp, options.searchPad ?? 20); + let path = traceCoarseRepair(start, bComp, raster, costField, networkPenalty, { + ...options, + bounds, + heuristicWeight: 0.22, + }); + if (!path) { + path = traceCorridorByCost( + start, + (x, y, i) => raster.componentId[i] === bComp.id || (potentialField[i] > (options.highPotentialThreshold ?? 0.42) && Math.hypot(x - goalTarget.x, y - goalTarget.y) < 5), + costField, + networkPenalty, + { + curvePenalty: options.curvePenalty ?? 0.14, + penaltyStrength: options.penaltyStrength ?? 1.8, + terrainFlowBias: options.terrainFlowBias ?? 0.12, + surfaceGrain: options.surfaceGrain ?? 0.012, + minGoalDistance: Math.min(12, Math.max(5, Math.hypot(start.x - goalTarget.x, start.y - goalTarget.y) * 0.35)), + keepRegion: false, + maxExpanded: Math.floor(SIZE * 0.45), + bounds, + goalHint: goalTarget, + heuristicWeight: 0.18, + } + ); + } if (path.length < (options.minAddedLength ?? 6) || path.length > (options.maxAddedLength ?? 120)) { aComp.repairCount++; continue; } + const rawRepairPath = path; + path = relaxRouteToTerrain(rawRepairPath, costField, { radius: options.relaxRadius ?? 2, lineWeight: options.relaxLineWeight ?? 0.36, grain: options.surfaceGrain ?? 0.012, iterations: 1 }); + if (path.length < Math.max(2, rawRepairPath.length * 0.55)) path = rawRepairPath; + if (path.length < (options.minAddedLength ?? 6) || path.length > (options.maxAddedLength ?? 120)) { + aComp.repairCount++; + continue; + } + if (!transportRouteAcceptable(path, mode, potentialField, networkPenalty, { minLength: options.minAddedLength ?? 6, maxLength: options.maxAddedLength ?? 120 })) { + aComp.repairCount++; + continue; + } paths.push(path); debug.repairs.push({ mode, path, from: aComp.id, to: bComp.id }); usedAnchors.push(start, goalTarget); @@ -1007,16 +1411,44 @@ export function generateMapFeatures(seed, terrain) { function routeLight(a, b, snapRadius = 3, costField = transportFields.local) { if (!a || !b) return []; - const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15)); + const start = { + x: Math.round(a.x), + y: Math.round(a.y), + regionId: regionIdAt(Math.round(a.x), Math.round(a.y)), + }; + const target = { x: Math.round(b.x), y: Math.round(b.y) }; + if (!inside(start.x, start.y) || !inside(target.x, target.y)) return []; + const dist = Math.hypot(start.x - target.x, start.y - target.y); + const routed = traceCorridorByCost( + start, + (x, y) => Math.hypot(x - target.x, y - target.y) <= snapRadius, + costField, + null, + { + curvePenalty: 0.025, + penaltyStrength: 0, + minGoalDistance: Math.min(5, Math.max(2, dist * 0.10)), + keepRegion: false, + maxExpanded: Math.min(SIZE, Math.max(1800, Math.floor(dist * dist * 5.5))), + terrainFlowBias: 0.16, + surfaceGrain: 0.025, + } + ); + if (routed.length >= 2) return relaxRouteToTerrain(routed, costField, { radius: 2, lineWeight: 0.34, grain: 0.035, iterations: 1 }); + + // Fallback for rare isolated cells: still use a snapped line, but keep the + // radius small and terrain-weighted so it does not become a long artificial + // chord across mountains. + const steps = Math.max(2, Math.ceil(dist * 1.35)); const out = []; let lastKey = ""; for (let s = 0; s <= steps; s++) { const t = s / steps; - const fx = a.x + (b.x - a.x) * t; - const fy = a.y + (b.y - a.y) * t; + const fx = start.x + (target.x - start.x) * t; + const fy = start.y + (target.y - start.y) * t; let best = null; let bestCost = INF; - const radius = snapRadius + (s > 0 && s < steps ? 1 : 0); + const radius = Math.max(1, Math.min(snapRadius, 2)); for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const x = Math.round(fx + dx); @@ -1025,7 +1457,7 @@ export function generateMapFeatures(seed, terrain) { const i = indexOf(x, y); if (sea[i] || costField[i] >= INF) continue; const lineDist = Math.hypot(x - fx, y - fy); - const cost = lineDist * 0.72 + costField[i] * 0.74 - valleySettlement[i] * 0.20 - developable[i] * 0.12 + hash2(x, y, seed + 13000 + s) * 0.05; + const cost = lineDist * 0.92 + costField[i] * 1.10 - valleyField[i] * 0.22 - coastalLowland[i] * 0.10 + ridgeField[i] * 0.22 + slope[i] * 0.18; if (cost < bestCost) { bestCost = cost; best = [x, y]; @@ -1039,7 +1471,7 @@ export function generateMapFeatures(seed, terrain) { lastKey = key; } } - return out; + return relaxRouteToTerrain(out, costField, { radius: 2, lineWeight: 0.36, grain: 0.030, iterations: 1 }); } function importantNodesForRegion(regionId) { @@ -1052,6 +1484,335 @@ export function generateMapFeatures(seed, terrain) { ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 16 : 10); } + function dedupePointCandidates(points, minDistance = 5) { + const out = []; + for (const raw of points) { + if (!raw) continue; + const x = Math.round(raw.x); + const y = Math.round(raw.y); + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + if (out.some((p) => Math.hypot(p.x - x, p.y - y) < minDistance)) continue; + out.push({ ...raw, x, y, regionId: regionIdAt(x, y), score: raw.score ?? raw.nodeWeight ?? 0.5 }); + } + return out; + } + + function modeSettlementAnchors(mode, potentialField, max = 48) { + const anchors = []; + const add = (p, baseScore, role) => { + if (!p || !inside(p.x, p.y)) return; + const x = Math.round(p.x); + const y = Math.round(p.y); + const i = indexOf(x, y); + if (sea[i] || regionIdAt(x, y) < 0) return; + anchors.push({ + x, y, role, regionId: regionIdAt(x, y), + score: baseScore + (potentialField?.[i] || 0) * 1.4 + settlementDemand[i] * 0.75 + valleyField[i] * 0.18 + coastalLowland[i] * 0.12, + }); + }; + + for (const c of modernCities) add(c, mode === "expressway" ? 4.2 : mode === "rail" ? 4.0 : 3.5, "city"); + for (const pnt of commercialPorts) add(pnt, mode === "rail" ? 3.4 : mode === "expressway" ? 3.2 : 2.9, "port"); + if (mode !== "expressway") { + for (const m of markets) add(m, mode === "rail" ? 2.1 : 2.4, "market"); + for (const pss of passes) add(pss, mode === "rail" ? 0.4 : 1.5, "pass"); + } + if (mode === "national" || mode === "local") { + for (const v of villages) add(v, mode === "local" ? 1.7 : 0.9, "village"); + } + return dedupePointCandidates(anchors.sort((a, b) => b.score - a.score), 4.5).slice(0, max); + } + + function preferenceCellAnchors(mode, potentialField, max = 36, minDistance = 9) { + const step = mode === "expressway" ? 5 : mode === "rail" ? 4 : 4; + const candidates = []; + for (let y = 2; y < MAP_H - 2; y += step) { + for (let x = 2; x < MAP_W - 2; x += step) { + const i = indexOf(x, y); + if (sea[i] || regionIdAt(x, y) < 0) continue; + const pass = passSuitability?.[i] || 0; + const base = potentialField[i] || 0; + let score = base * 2.2 + settlementDemand[i] * (mode === "expressway" ? 0.35 : 0.65) + valleyField[i] * 0.42 + coastalLowland[i] * 0.20 + plain[i] * 0.12 + pass * (mode === "rail" ? 0.08 : 0.22); + if (mode === "expressway") score += logisticsPreSuitability[i] * 0.65 - settlementDemand[i] * 0.10; + if (mode === "rail") score += preliminaryTownInfluence[i] * 0.42 - slope[i] * 0.55; + if (mode === "national") score += preliminaryVillageInfluence[i] * 0.24 + crossingSuitability[i] * 0.20; + score -= ridgeField[i] * (mode === "expressway" ? 0.50 : mode === "rail" ? 0.65 : 0.30); + score -= Math.max(0, elevation[i] - 0.62) * (mode === "expressway" ? 1.4 : mode === "rail" ? 1.7 : 0.85); + score += hash2(x, y, seed + 17100 + (mode === "rail" ? 17 : mode === "expressway" ? 31 : 0)) * 0.055; + if (score > (mode === "expressway" ? 0.64 : mode === "rail" ? 0.58 : 0.52)) candidates.push({ x, y, score, regionId: regionIdAt(x, y), role: "preference-cell" }); + } + } + return pickEntities(candidates, { max, minDistance, seed: seed + 17200 + (mode === "rail" ? 23 : mode === "expressway" ? 41 : 0) }); + } + + function transportCandidatePoints(mode, potentialField, options = {}) { + const maxCells = options.maxCells ?? (mode === "expressway" ? 18 : mode === "rail" ? 26 : 40); + const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 20 : mode === "rail" ? 34 : 56); + const minDistance = options.minDistance ?? (mode === "expressway" ? 13 : mode === "rail" ? 10 : 8); + const cells = preferenceCellAnchors(mode, potentialField, maxCells, minDistance); + const settlements = modeSettlementAnchors(mode, potentialField, maxSettlements); + return dedupePointCandidates([...settlements, ...cells].sort((a, b) => b.score - a.score), Math.max(4, minDistance * 0.55)) + .slice(0, options.maxTotal ?? (mode === "expressway" ? 32 : mode === "rail" ? 48 : 74)); + } + + function routeBetweenTrafficCandidates(a, b, mode, costField, penaltyField, options = {}) { + if (!a || !b) return []; + const start = { x: Math.round(a.x), y: Math.round(a.y), regionId: regionIdAt(Math.round(a.x), Math.round(a.y)) }; + const target = { x: Math.round(b.x), y: Math.round(b.y) }; + if (!inside(start.x, start.y) || !inside(target.x, target.y)) return []; + if (sea[indexOf(start.x, start.y)] || sea[indexOf(target.x, target.y)]) return []; + const d = Math.hypot(start.x - target.x, start.y - target.y); + const snap = options.snapRadius ?? (mode === "expressway" ? 4 : mode === "rail" ? 3 : 3); + const path = traceCorridorByCost( + start, + (x, y) => Math.hypot(x - target.x, y - target.y) <= snap, + costField, + penaltyField || null, + { + curvePenalty: options.curvePenalty ?? (mode === "expressway" ? 0.10 : mode === "rail" ? 0.13 : 0.055), + penaltyStrength: options.penaltyStrength ?? (mode === "expressway" ? 1.4 : mode === "rail" ? 1.35 : 0.86), + minGoalDistance: Math.min(8, Math.max(3, d * 0.10)), + keepRegion: false, + maxExpanded: Math.min(SIZE, Math.max(2200, Math.floor(d * d * (mode === "expressway" ? 4.6 : 5.8)))), + terrainFlowBias: options.terrainFlowBias ?? (mode === "expressway" ? 0.12 : mode === "rail" ? 0.12 : 0.24), + surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.035 : 0.012), + } + ); + if (path.length < 4) return []; + const relaxed = relaxRouteToTerrain(path, costField, { + radius: options.relaxRadius ?? (mode === "national" ? 2 : 1), + lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.34 : 0.52), + grain: options.surfaceGrain ?? 0.020, + iterations: 1, + }); + return relaxed.length >= Math.max(3, path.length * 0.55) ? relaxed : path; + } + + function addCandidateKnnNetwork(paths, mode, costField, potentialField, options = {}) { + const debug = { mode, candidates: 0, added: [], skipped: 0 }; + const candidates = transportCandidatePoints(mode, potentialField, options); + debug.candidates = candidates.length; + if (candidates.length < 2) return debug; + const penalty = cachedInfluenceFromPaths(paths, options.parallelRadius ?? (mode === "expressway" ? 10 : mode === "rail" ? 7 : 6), `${mode}:knn`); + const degree = new Map(); + const edgeUsed = new Set(); + const ordered = candidates.slice().sort((a, b) => b.score - a.score); + const maxAdded = options.maxAdded ?? (mode === "expressway" ? 4 : mode === "rail" ? 8 : 18); + const k = options.k ?? (mode === "expressway" ? 3 : mode === "rail" ? 4 : 5); + const maxDegree = options.maxDegree ?? (mode === "expressway" ? 2 : 3); + for (const a of ordered) { + if (debug.added.length >= maxAdded) break; + const aid = `${a.x},${a.y}`; + if ((degree.get(aid) || 0) >= maxDegree) continue; + const near = candidates + .filter((b) => b !== a && (!options.sameRegionOnly || b.regionId === a.regionId)) + .map((b) => { + const d = Math.hypot(a.x - b.x, a.y - b.y); + const bearingJitter = hash2(a.x + b.x, a.y + b.y, seed + 17333) * 0.04; + const desire = Math.sqrt(Math.max(0.2, a.score) * Math.max(0.2, b.score)); + return { b, d, sortScore: d / desire + bearingJitter }; + }) + .filter((e) => e.d >= (options.minDistance ?? (mode === "expressway" ? 26 : mode === "rail" ? 18 : 12)) && e.d <= (options.maxDistance ?? (mode === "expressway" ? 112 : mode === "rail" ? 92 : 78))) + .sort((x, y) => x.sortScore - y.sortScore) + .slice(0, k); + for (const { b, d } of near) { + if (debug.added.length >= maxAdded) break; + const bid = `${b.x},${b.y}`; + if ((degree.get(bid) || 0) >= maxDegree) continue; + const key = aid < bid ? `${aid}|${bid}` : `${bid}|${aid}`; + if (edgeUsed.has(key)) continue; + edgeUsed.add(key); + const path = routeBetweenTrafficCandidates(a, b, mode, costField, penalty, options); + const len = pathLengthCells(path); + if (len < (options.minPathLength ?? Math.max(8, d * 0.45)) || len > (options.maxPathLength ?? d * 2.75 + 34)) { debug.skipped++; continue; } + const avgPenalty = pathAverageField(path, penalty); + const avgPotential = pathAverageField(path, potentialField); + if (avgPenalty > (options.maxParallelInfluence ?? (mode === "expressway" ? 0.39 : mode === "rail" ? 0.42 : 0.47)) && avgPotential < (options.minPotentialIfParallel ?? 0.38)) { debug.skipped++; continue; } + if (!transportRouteAcceptable(path, mode, potentialField, penalty, { minLength: options.minPathLength ?? 6, maxLength: options.maxPathLength ?? d * 2.75 + 34, maxAvgPenalty: options.maxParallelInfluence ?? Infinity })) { debug.skipped++; continue; } + paths.push(path); + debug.added.push({ mode: `${mode}-knn`, path, from: a.role || "candidate", to: b.role || "candidate" }); + degree.set(aid, (degree.get(aid) || 0) + 1); + degree.set(bid, (degree.get(bid) || 0) + 1); + addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? 6, options.addedPenalty ?? 0.30); + if ((degree.get(aid) || 0) >= maxDegree) break; + } + } + return debug; + } + + function pruneParallelSameMode(paths, mode, potentialField, options = {}) { + if (!paths?.length) return { mode, pruned: 0, kept: 0 }; + const scored = paths.map((path, originalIndex) => { + const len = pathLengthCells(path); + return { path, originalIndex, len, score: pathAverageField(path, potentialField) * 12 + Math.log1p(len) + (len > 80 ? 0.30 : 0) }; + }).sort((a, b) => b.score - a.score); + const accepted = new Uint8Array(SIZE); + const kept = []; + let pruned = 0; + const radius = options.radius ?? (mode === "expressway" ? 3 : mode === "rail" ? 2 : 2); + const threshold = options.threshold ?? (mode === "expressway" ? 0.54 : mode === "rail" ? 0.58 : 0.62); + function mark(path) { + 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, y = py + dy; + if (inside(x, y)) accepted[indexOf(x, y)] = 1; + } + } + } + } + function overlap(path) { + let hit = 0, n = 0; + for (const [x, y] of path) { + if (!inside(x, y)) continue; + n++; + if (accepted[indexOf(x, y)]) hit++; + } + return n ? hit / n : 0; + } + for (const item of scored) { + const ov = overlap(item.path); + const shortStub = item.len < (options.shortLength ?? (mode === "expressway" ? 40 : mode === "rail" ? 30 : 22)); + if (kept.length >= (options.minKeep ?? 2) && ov > threshold && (shortStub || ov > threshold + 0.13)) { + pruned++; + continue; + } + kept.push(item); + mark(item.path); + } + kept.sort((a, b) => a.originalIndex - b.originalIndex); + paths.length = 0; + paths.push(...kept.map((item) => item.path)); + return { mode, pruned, kept: kept.length }; + } + + function endpointList(paths) { + const out = []; + paths.forEach((path, pathIndex) => { + if (!path || path.length < 2) return; + const a = path[0]; + const b = path[path.length - 1]; + out.push({ x: a[0], y: a[1], pathIndex, atStart: true }); + out.push({ x: b[0], y: b[1], pathIndex, atStart: false }); + }); + return out; + } + + function validTransportEndpoint(p, mode, baseInfluence, civicPoints = []) { + if (!p || !inside(p.x, p.y)) return true; + const i = indexOf(p.x, p.y); + if (sea[i]) return true; + if (p.x < 4 || p.y < 4 || p.x > MAP_W - 5 || p.y > MAP_H - 5) return true; + const nearNetwork = (baseInfluence?.[i] || 0) > (mode === "expressway" ? 0.22 : 0.16); + const civicRadius = mode === "expressway" ? 9 : mode === "rail" ? 7 : 6; + const nearCivic = civicPoints.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < civicRadius); + const settlementLike = settlementDemand[i] > (mode === "expressway" ? 0.22 : 0.14) || preliminaryTownInfluence[i] > 0.16 || preliminaryVillageInfluence[i] > 0.22; + const passTerminus = mode !== "expressway" && (passSuitability?.[i] || 0) > 0.58 && settlementDemand[i] > 0.08; + return nearNetwork || nearCivic || (settlementLike && naturalEndpoint(p.x, p.y, i, mode)) || passTerminus; + } + + function repairDanglingTransportEndpoints(paths, mode, costField, targetPaths, potentialField, options = {}) { + const debug = { mode, added: [], checked: 0 }; + if (!paths?.length) return debug; + const endpoints = endpointList(paths); + const civic = dedupePointCandidates([ + ...modernCities, ...markets, ...commercialPorts, ...(mode === "national" || mode === "local" ? villages : []), + ], 4); + const baseInfluence = cachedInfluenceFromPaths(targetPaths || [], options.targetRadius ?? (mode === "expressway" ? 7 : 5), `${mode}:endpoint-base`); + const candidates = transportCandidatePoints(mode === "local" ? "national" : mode, potentialField, { + maxCells: mode === "expressway" ? 10 : 20, + maxSettlements: mode === "expressway" ? 12 : 36, + maxTotal: mode === "expressway" ? 20 : 52, + minDistance: mode === "expressway" ? 14 : 7, + }); + const maxAdded = options.maxAdded ?? (mode === "expressway" ? 3 : mode === "rail" ? 5 : 16); + const penalty = cachedInfluenceFromPaths(paths, options.penaltyRadius ?? 5, `${mode}:endpoint-penalty`); + for (const ep of endpoints) { + if (debug.added.length >= maxAdded) break; + debug.checked++; + if (validTransportEndpoint(ep, mode, baseInfluence, civic)) continue; + const target = [...civic, ...candidates] + .filter((q) => Math.hypot(q.x - ep.x, q.y - ep.y) >= (options.minTargetDistance ?? 7)) + .map((q) => { + const d = Math.hypot(q.x - ep.x, q.y - ep.y); + return { q, d, score: d / Math.sqrt(Math.max(0.35, q.score || 0.7)) }; + }) + .filter((e) => e.d <= (options.maxTargetDistance ?? (mode === "expressway" ? 58 : mode === "rail" ? 48 : 42))) + .sort((a, b) => a.score - b.score)[0]?.q; + if (!target) continue; + const path = routeBetweenTrafficCandidates(ep, target, mode, costField, penalty, { + ...options, + snapRadius: options.snapRadius ?? 3, + maxPathLength: options.maxPathLength ?? (mode === "expressway" ? 76 : 58), + }); + if (path.length < 4) continue; + if (!transportRouteAcceptable(path, mode, potentialField, penalty, { minLength: 4, maxLength: options.maxPathLength ?? (mode === "expressway" ? 76 : 58) })) continue; + paths.push(path); + debug.added.push({ mode: `${mode}-endpoint-repair`, path, from: "dangling-end", to: target.role || target.kind || "candidate" }); + addCorridorInfluencePenalty(penalty, path, options.penaltyRadius ?? 5, options.addedPenalty ?? 0.20); + } + return debug; + } + + function pruneDanglingTerminalSegments(paths, mode, targetPaths, options = {}) { + const debug = { mode, pruned: 0, kept: 0 }; + if (!paths?.length) return debug; + const civic = dedupePointCandidates([ + ...modernCities, ...markets, ...commercialPorts, ...(mode === "national" || mode === "local" ? villages : []), + ], 4); + const baseInfluence = cachedInfluenceFromPaths(targetPaths || [], options.targetRadius ?? (mode === "expressway" ? 7 : 5), `${mode}:terminal-base`); + const oneInvalidMax = options.oneInvalidMax ?? (mode === "expressway" ? 34 : mode === "rail" ? 24 : mode === "local" ? 10 : 18); + const bothInvalidMax = options.bothInvalidMax ?? (mode === "expressway" ? 54 : mode === "rail" ? 42 : mode === "local" ? 18 : 34); + const minKeep = options.minKeep ?? 1; + const kept = []; + for (const path of paths) { + if (!path || path.length < 2) continue; + const first = path[0]; + const last = path[path.length - 1]; + const a = { x: first[0], y: first[1] }; + const b = { x: last[0], y: last[1] }; + const len = pathLengthCells(path); + const invalidA = !validTransportEndpoint(a, mode, baseInfluence, civic); + const invalidB = !validTransportEndpoint(b, mode, baseInfluence, civic); + const prune = paths.length - debug.pruned > minKeep && ((invalidA && invalidB && len < bothInvalidMax) || ((invalidA || invalidB) && len < oneInvalidMax)); + if (prune) { + debug.pruned++; + } else { + kept.push(path); + } + } + paths.length = 0; + paths.push(...kept); + debug.kept = paths.length; + return debug; + } + + function rebalanceTransportNetworks() { + const debug = { candidateGraphs: [], parallelPruning: [], endpointRepairs: [], prunedDanglingSegments: [] }; + debug.candidateGraphs.push(addCandidateKnnNetwork(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, { + maxCells: 30, maxSettlements: 46, maxTotal: 60, k: 4, maxDegree: 3, maxAdded: 13, minDistance: 12, maxDistance: 82, sameRegionOnly: false, parallelRadius: 6, maxParallelInfluence: 0.48, curvePenalty: 0.050, terrainFlowBias: 0.24, surfaceGrain: 0.038, relaxRadius: 2, relaxLineWeight: 0.33, + })); + debug.candidateGraphs.push(addCandidateKnnNetwork(railways, "rail", transportFields.rail, transportFields.railPotential, { + maxCells: 16, maxSettlements: 24, maxTotal: 34, k: 3, maxDegree: 2, maxAdded: 4, minDistance: 22, maxDistance: 86, sameRegionOnly: false, parallelRadius: 7, maxParallelInfluence: 0.40, curvePenalty: 0.16, terrainFlowBias: 0.12, surfaceGrain: 0.010, relaxRadius: 1, relaxLineWeight: 0.52, + })); + debug.candidateGraphs.push(addCandidateKnnNetwork(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, { + maxCells: 10, maxSettlements: 12, maxTotal: 20, k: 2, maxDegree: 2, maxAdded: 2, minDistance: 36, maxDistance: 112, sameRegionOnly: false, parallelRadius: 10, maxParallelInfluence: 0.34, curvePenalty: 0.10, terrainFlowBias: 0.11, surfaceGrain: 0.008, relaxRadius: 1, relaxLineWeight: 0.58, + })); + debug.endpointRepairs.push(repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...railways], transportFields.nationalPotential, { maxAdded: 12, maxTargetDistance: 44, curvePenalty: 0.055, terrainFlowBias: 0.24, surfaceGrain: 0.038, relaxRadius: 2, relaxLineWeight: 0.33 })); + debug.endpointRepairs.push(repairDanglingTransportEndpoints(railways, "rail", transportFields.rail, [...nationalRoads, ...externalRailways], transportFields.railPotential, { maxAdded: 3, maxTargetDistance: 44, curvePenalty: 0.16, terrainFlowBias: 0.12, surfaceGrain: 0.010 })); + debug.endpointRepairs.push(repairDanglingTransportEndpoints(expressways, "expressway", transportFields.expressway, [...nationalRoads, ...externalExpressways], transportFields.expresswayPotential, { maxAdded: 1, maxTargetDistance: 60, curvePenalty: 0.10, terrainFlowBias: 0.11, surfaceGrain: 0.008 })); + debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(nationalRoads, "national", [...externalRoads, ...railways, ...minorRoads], { oneInvalidMax: 20, bothInvalidMax: 36, minKeep: 10 })); + debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(railways, "rail", [...nationalRoads, ...externalRailways], { oneInvalidMax: 26, bothInvalidMax: 42, minKeep: 3 })); + debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(expressways, "expressway", [...nationalRoads, ...externalExpressways], { oneInvalidMax: 38, bothInvalidMax: 58, minKeep: 1 })); + debug.parallelPruning.push(pruneParallelSameMode(nationalRoads, "national", transportFields.nationalPotential, { threshold: 0.64, radius: 2, minKeep: 8, shortLength: 20 })); + debug.parallelPruning.push(pruneParallelSameMode(railways, "rail", transportFields.railPotential, { threshold: 0.58, radius: 2, minKeep: 3, shortLength: 28 })); + debug.parallelPruning.push(pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.50, radius: 3, minKeep: 1, shortLength: 42 })); + return debug; + } + const premodernRoads = []; const nationalRoads = []; const minorRoads = []; @@ -1078,6 +1839,7 @@ export function generateMapFeatures(seed, terrain) { } nationalRoads.push(...generateCorridorsFromField({ + mode: "national", potentialField: transportFields.nationalPotential, costField: transportFields.national, spacing: 17, @@ -1086,12 +1848,17 @@ export function generateMapFeatures(seed, terrain) { minLength: 20, penaltyRadius: 6, penaltyStrength: 0.34, - curvePenalty: 0.10, + curvePenalty: 0.045, + terrainFlowBias: 0.22, + surfaceGrain: 0.040, + relaxRadius: 2, + relaxLineWeight: 0.32, seedOffset: 13400, startPredicate: (x, y, i) => transportFields.nationalPotential[i] > 0.27 && regionIdAt(x, y) >= 0, goalPredicate: (start, x, y, i, used) => { if (regionIdAt(x, y) !== start.regionId) return false; if (transportFields.nationalPotential[i] < 0.34) return false; + if (!naturalEndpoint(x, y, i, "national")) return false; if (distanceToNearest(used, x, y) < 11) return false; const d = Math.hypot(x - start.x, y - start.y); return d > 22 && d < 76; @@ -1099,6 +1866,7 @@ export function generateMapFeatures(seed, terrain) { })); railways.push(...generateCorridorsFromField({ + mode: "rail", potentialField: transportFields.railPotential, costField: transportFields.rail, spacing: 24, @@ -1107,12 +1875,17 @@ export function generateMapFeatures(seed, terrain) { minLength: 24, penaltyRadius: 7, penaltyStrength: 0.42, - curvePenalty: 0.30, + curvePenalty: 0.16, + terrainFlowBias: 0.15, + surfaceGrain: 0.012, + relaxRadius: 1, + relaxLineWeight: 0.52, seedOffset: 13500, startPredicate: (x, y, i) => transportFields.railPotential[i] > 0.26 && settlementDemand[i] > 0.16 && regionIdAt(x, y) >= 0, goalPredicate: (start, x, y, i, used) => { if (regionIdAt(x, y) !== start.regionId) return false; if (transportFields.railPotential[i] < 0.30 || settlementDemand[i] < 0.18) return false; + if (!naturalEndpoint(x, y, i, "rail")) return false; if (distanceToNearest(used, x, y) < 15) return false; const d = Math.hypot(x - start.x, y - start.y); return d > 28 && d < 88; @@ -1120,23 +1893,29 @@ export function generateMapFeatures(seed, terrain) { })); expressways.push(...generateCorridorsFromField({ + mode: "expressway", potentialField: transportFields.expresswayPotential, costField: transportFields.expressway, - spacing: 29, - maxCount: 10, - threshold: 0.30, - minLength: 30, - penaltyRadius: 9, - penaltyStrength: 0.55, - curvePenalty: 0.16, + spacing: 36, + maxCount: 5, + threshold: 0.35, + minLength: 34, + penaltyRadius: 10, + penaltyStrength: 0.72, + curvePenalty: 0.08, + terrainFlowBias: 0.12, + surfaceGrain: 0.010, + relaxRadius: 1, + relaxLineWeight: 0.58, seedOffset: 13600, - startPredicate: (x, y, i) => transportFields.expresswayPotential[i] > 0.27 && regionIdAt(x, y) >= 0, + startPredicate: (x, y, i) => transportFields.expresswayPotential[i] > 0.33 && settlementDemand[i] > 0.12 && regionIdAt(x, y) >= 0, goalPredicate: (start, x, y, i, used) => { if (regionIdAt(x, y) !== start.regionId) return false; - if (transportFields.expresswayPotential[i] < 0.30) return false; - if (distanceToNearest(used, x, y) < 18) return false; + if (transportFields.expresswayPotential[i] < 0.35 || settlementDemand[i] < 0.10) return false; + if (!naturalEndpoint(x, y, i, "expressway")) return false; + if (distanceToNearest(used, x, y) < 22) return false; const d = Math.hypot(x - start.x, y - start.y); - return d > 34 && d < 104; + return d > 42 && d < 112; }, })); @@ -1173,13 +1952,14 @@ export function generateMapFeatures(seed, terrain) { } const transportDebugLayers = { - expresswayPotential: transportFields.expresswayPotential, - railPotential: transportFields.railPotential, - nationalRoadPotential: transportFields.nationalPotential, + packedHeatmaps: true, + expresswayPotential: packDebugField(transportFields.expresswayPotential), + railPotential: packDebugField(transportFields.railPotential), + nationalRoadPotential: packDebugField(transportFields.nationalPotential), slopeSeaPenalty: (() => { - const out = new Float32Array(SIZE); - for (let i = 0; i < SIZE; i++) out[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42); - return out; + const src = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) src[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42); + return packDebugField(src); })(), components: [], repairedSegments: [], @@ -1187,45 +1967,72 @@ export function generateMapFeatures(seed, terrain) { }; for (const repair of [ repairTransportConnectivity(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, { - minImportance: 8.5, - minComponentCells: 18, - maxComponents: 7, - maxRepairs: 3, - maxRepairDistance: 115, - penaltyRadius: 9, - penaltyStrength: 2.3, - curvePenalty: 0.16, - highPotentialThreshold: 0.38, + minImportance: 10.0, + minComponentCells: 22, + maxComponents: 5, + maxRepairs: 2, + maxRepairDistance: 116, + penaltyRadius: 10, + penaltyStrength: 2.35, + curvePenalty: 0.18, + highPotentialThreshold: 0.40, + maxAddedLength: 112, }), repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, { - minImportance: 9.5, - minComponentCells: 16, - maxComponents: 8, - maxRepairs: 4, + minImportance: 10.5, + minComponentCells: 18, + maxComponents: 6, + maxRepairs: 3, maxRepairDistance: 105, penaltyRadius: 7, penaltyStrength: 2.0, - curvePenalty: 0.34, + curvePenalty: 0.16, + terrainFlowBias: 0.13, + surfaceGrain: 0.008, + relaxRadius: 1, + relaxLineWeight: 0.50, highPotentialThreshold: 0.36, }), repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, { - minImportance: 7.5, - minComponentCells: 12, - maxComponents: 10, - maxRepairs: 6, - maxRepairDistance: 95, + minImportance: 6.4, + minComponentCells: 10, + maxComponents: 16, + maxRepairs: 10, + maxRepairDistance: 138, penaltyRadius: 6, - penaltyStrength: 1.7, - curvePenalty: 0.11, - highPotentialThreshold: 0.34, + penaltyStrength: 1.18, + curvePenalty: 0.10, + highPotentialThreshold: 0.28, + maxAddedLength: 138, }), ]) { transportDebugLayers.components.push(...repair.components); transportDebugLayers.repairedSegments.push(...repair.repairs); } + const graphNetworkDebug = rebalanceTransportNetworks(); + transportDebugLayers.graphCandidateNetworks = graphNetworkDebug.candidateGraphs.map((item) => ({ + mode: item.mode, + candidates: item.candidates, + skipped: item.skipped, + addedCount: item.added?.length || 0, + })); + transportDebugLayers.parallelPruning = graphNetworkDebug.parallelPruning; + transportDebugLayers.endpointRepairs = graphNetworkDebug.endpointRepairs.map((item) => ({ + mode: item.mode, + checked: item.checked, + addedCount: item.added?.length || 0, + })); + transportDebugLayers.prunedDanglingSegments = graphNetworkDebug.prunedDanglingSegments; + for (const item of graphNetworkDebug.candidateGraphs || []) { + transportDebugLayers.repairedSegments.push(...(item.added || [])); + } + for (const item of graphNetworkDebug.endpointRepairs || []) { + transportDebugLayers.repairedSegments.push(...(item.added || [])); + } + function generateLocalRoadsForUnservedSettlements() { - const trunkInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8); + const trunkInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8, "local:trunk"); const candidates = [...villages, ...markets, ...ports] .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]) .map((p) => { @@ -1233,23 +2040,24 @@ export function generateMapFeatures(seed, terrain) { const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45; return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 }; }) - .filter((p) => p.score > 0.18 && trunkInfluence[indexOf(p.x, p.y)] < 0.26) + .filter((p) => p.score > 0.06 && trunkInfluence[indexOf(p.x, p.y)] < 0.34) .sort((a, b) => b.score - a.score) - .slice(0, 90); + .slice(0, 150); const localPenalty = new Float32Array(SIZE); const paths = []; const served = []; for (const start of candidates) { - if (paths.length >= 75) break; + if (paths.length >= 115) break; if (distanceToNearest(served, start.x, start.y) < 4.5) continue; - const path = traceCorridorByCost( + let path = traceCorridorByCost( start, - (x, y, i) => trunkInfluence[i] > 0.20 || (paths.length > 8 && localPenalty[i] > 0.05), + (x, y, i) => trunkInfluence[i] > 0.18 || (paths.length > 6 && localPenalty[i] > 0.045), transportFields.local, localPenalty, - { curvePenalty: 0.08, penaltyStrength: 1.15, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE } + { curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE } ); - if (path.length < 4 || path.length > 70) continue; + if (path.length < 4 || path.length > 86) continue; + if (!transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: 86 })) continue; paths.push(path); served.push(start); addCorridorInfluencePenalty(localPenalty, path, 4, 0.22); @@ -1257,7 +2065,70 @@ export function generateMapFeatures(seed, terrain) { return paths; } + function runLocalAccessPass({ + candidates, + accessInfluence, + localPenalty, + maxAdded = 80, + minSpacing = 3.5, + maxLength = 82, + debugMode = "local-access", + from = "unserved", + to = "network", + targetPredicate = null, + }) { + const served = []; + let added = 0; + for (const start of candidates) { + if (added >= maxAdded) break; + if (distanceToNearest(served, start.x, start.y) < minSpacing) continue; + let path = traceCorridorByCost( + start, + targetPredicate || ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075), + transportFields.local, + localPenalty, + { curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.72), terrainFlowBias: 0.26, surfaceGrain: 0.042 } + ); + path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 }); + const ok = path.length >= 4 && path.length <= maxLength && transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength }); + transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: debugMode, repaired: ok }); + if (!ok) continue; + minorRoads.push(path); + served.push(start); + added++; + transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to }); + addCorridorInfluencePenalty(localPenalty, path, 4, 0.18); + if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22); + } + return added; + } + minorRoads.push(...generateLocalRoadsForUnservedSettlements()); + const localEndpointRepair = repairDanglingTransportEndpoints(minorRoads, "local", transportFields.local, [...nationalRoads, ...externalRoads, ...railways], transportFields.localPotential, { + maxAdded: 36, + maxTargetDistance: 34, + curvePenalty: 0.055, + terrainFlowBias: 0.26, + surfaceGrain: 0.048, + relaxRadius: 2, + relaxLineWeight: 0.30, + targetRadius: 5, + }); + transportDebugLayers.endpointRepairs.push(localEndpointRepair); + transportDebugLayers.repairedSegments.push(...localEndpointRepair.added); + const localDanglingPrune = pruneDanglingTerminalSegments(minorRoads, "local", [...nationalRoads, ...externalRoads, ...railways], { + oneInvalidMax: 11, + bothInvalidMax: 18, + minKeep: 24, + }); + transportDebugLayers.prunedDanglingSegments.push(localDanglingPrune); + const localParallelPruning = pruneParallelSameMode(minorRoads, "local", transportFields.localPotential, { + threshold: 0.70, + radius: 1, + minKeep: 28, + shortLength: 13, + }); + transportDebugLayers.parallelPruning.push(localParallelPruning); for (const path of expressways) { for (const p of samplePath(path, 24)) { const i = indexOf(p.x, p.y); @@ -1272,10 +2143,10 @@ export function generateMapFeatures(seed, terrain) { // are through-corridors here, not automatic suburbanization generators. // A narrow field controls land-use attachment, while a broader field raises // population density around trunk roads without painting a wide suburb band. - const roadLanduseInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25); - const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0); - const roadDensityInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0); - const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4); + const roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse"); + const roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence"); + const roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density"); + const railInfluence2 = cachedInfluenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4, "rail:influence"); const stations = []; const usedStationKeys = new Set(); @@ -1405,34 +2276,54 @@ export function generateMapFeatures(seed, terrain) { const logisticsInfluence = influenceFromPoints(logisticsParks, 4.8, () => 1.0); function addFinalLocalAccessForUnservedSettlements() { - const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7); + const accessInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 8, "final-local:access"); const candidates = [ - ...markets.filter((p) => (p.population || 0) >= 5000), + ...markets.filter((p) => (p.population || 0) >= 1800), ...ports, ...logisticsParks, - ...villages.filter((p) => (elevation[indexOf(p.x, p.y)] > 0.48 || slope[indexOf(p.x, p.y)] > 0.30 || p.kind === "Valley Village") && (p.population || 0) >= 1200), + ...villages.filter((p) => (p.population || 0) >= 450), ] - .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.18) - .map((p) => ({ ...p, score: (p.population || 7000) / 18000 + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[indexOf(p.x, p.y)] })) + .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.30) + .map((p) => { + const i = indexOf(p.x, p.y); + const remoteness = Math.max(0, 0.32 - accessInfluence[i]); + const ruralValue = agriculture[i] * 0.42 + valleySettlement[i] * 0.34 + coastalSettlement[i] * 0.24 + ruralSuitability[i] * 0.34; + return { ...p, score: (p.population || 1200) / 16000 + remoteness * 3.2 + ruralValue + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[i] }; + }) .sort((a, b) => b.score - a.score) - .slice(0, 55); - const localPenalty = influenceFromPaths(minorRoads, 4); - for (const start of candidates) { - const path = traceCorridorByCost( - start, - (x, y, i) => accessInfluence[i] > 0.20 || localPenalty[i] > 0.10, - transportFields.local, - localPenalty, - { curvePenalty: 0.08, penaltyStrength: 1.0, minGoalDistance: 4, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.55) } - ); - transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: "local-access", repaired: path.length >= 4 && path.length <= 64 }); - if (path.length < 4 || path.length > 64) continue; - minorRoads.push(path); - transportDebugLayers.repairedSegments.push({ mode: "local", path, from: "unserved", to: "network" }); - addCorridorInfluencePenalty(localPenalty, path, 4, 0.20); - } + .slice(0, 130); + const localPenalty = cachedInfluenceFromPaths(minorRoads, 4, "final-local:minor"); + runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 90, maxLength: 92, debugMode: "local-access", from: "unserved", to: "network" }); + } + + function addRuralRoadMeshConnectors() { + const roadInfluenceNow = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads, ...railways, ...minorRoads], 7, "municipal-local:access"); + const localPenalty = cachedInfluenceFromPaths(minorRoads, 3, "municipal-local:minor"); + const candidates = villages + .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && roadInfluenceNow[indexOf(p.x, p.y)] < 0.46) + .map((p) => { + const i = indexOf(p.x, p.y); + const ruralScore = agriculture[i] * 0.55 + valleySettlement[i] * 0.42 + coastalSettlement[i] * 0.25 + ruralSuitability[i] * 0.42 + Math.max(0, 0.46 - roadInfluenceNow[i]) * 2.4; + return { ...p, score: ruralScore + (p.population || 700) / 24000 + transportFields.localPotential[i] * 0.55 }; + }) + .filter((p) => p.score > 0.30) + .sort((a, b) => b.score - a.score) + .slice(0, 170); + runLocalAccessPass({ + candidates, + accessInfluence: roadInfluenceNow, + localPenalty, + maxAdded: 70, + minSpacing: 3.0, + maxLength: 76, + debugMode: "rural-mesh", + from: "rural-settlement", + to: "local-network", + targetPredicate: (x, y, i) => roadInfluenceNow[i] > 0.18 || localPenalty[i] > 0.07, + }); } addFinalLocalAccessForUnservedSettlements(); + addRuralRoadMeshConnectors(); var landuse = new Uint8Array(SIZE); // Re-run land-use classification after landuse allocation. The loop above is diff --git a/mapOutput.js b/mapOutput.js index c5da6b8..d384c6f 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -1,6 +1,7 @@ import { createNameDebug } from "./names.js"; -import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js"; +import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js"; import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; +import { routeQualityAcceptable } from "./mapTransport.js"; const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u; @@ -475,65 +476,133 @@ export function finishMapOutput({ ]); function addMunicipalCenterLocalAccess() { - if (!transportDebug) return; - const debugLayers = transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] }); + const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] })) : { repairedSegments: [], unservedSettlements: [] }; debugLayers.repairedSegments ||= []; debugLayers.unservedSettlements ||= []; - const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7); - const localPenalty = influenceFromPaths(minorRoads, 4); + const influenceCache = new Map(); + const signature = (paths) => `${paths?.length || 0}:${(paths || []).reduce((sum, path) => sum + (path?.length || 0), 0)}`; + const cachedInfluence = (paths, radius, label) => { + const key = `${label}:${radius}:${signature(paths)}`; + let grid = influenceCache.get(key); + if (!grid) { + grid = influenceFromPaths(paths, radius); + influenceCache.set(key, grid); + } + return grid; + }; + const accessInfluence = cachedInfluence([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 8, "municipal-access"); + const localPenalty = cachedInfluence(minorRoads, 4, "municipal-minor"); + const perPrefectureQuota = new Map(); const candidates = adminCenters - .filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.16) - .sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0)) - .slice(0, 45); + .filter((p) => { + if (!inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) return false; + const i = indexOf(p.x, p.y); + const meaningful = (p.municipalityPopulation || 0) >= 4500 || (populationDensity?.[i] || 0) > 0.08 || (p.representativeFeatureName && accessInfluence[i] < 0.22); + return meaningful && accessInfluence[i] < 0.42; + }) + .sort((a, b) => { + const ai = indexOf(a.x, a.y); + const bi = indexOf(b.x, b.y); + const as = (a.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[ai]) * 145000; + const bs = (b.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[bi]) * 145000; + return bs - as; + }) + .filter((p) => { + const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1; + const used = perPrefectureQuota.get(prefId) || 0; + if (used >= 18) return false; + perPrefectureQuota.set(prefId, used + 1); + return true; + }) + .slice(0, 95); - function routeAccess(start) { - const maxSteps = 72; - const out = []; - const visited = new Set([`${start.x},${start.y}`]); - let x = start.x; - let y = start.y; - let lastKey = ""; - for (let step = 0; step < maxSteps; step++) { - const i = indexOf(x, y); - if (step > 3 && (accessInfluence[i] > 0.20 || localPenalty[i] > 0.10)) return out; - let best = null; - let bestScore = INF; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (!dx && !dy) continue; + function addLocalPenalty(path, radius = 4, strength = 0.20) { + for (const [x, y] of path || []) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const i = indexOf(nx, ny); + if (sea[i]) continue; + localPenalty[i] = Math.max(localPenalty[i], strength * (1 - d / radius)); + } + } + } + } + + function routeAccess(start) { + const startIndex = indexOf(start.x, start.y); + if (sea[startIndex]) return []; + const score = new Float32Array(MAP_W * MAP_H); + const cameFrom = new Int32Array(MAP_W * MAP_H); + const closed = new Uint8Array(MAP_W * MAP_H); + score.fill(INF); + cameFrom.fill(-1); + const heap = new MinHeap(); + score[startIndex] = 0; + heap.push({ i: startIndex, f: 0 }); + const maxExpanded = Math.min(MAP_W * MAP_H, 24000); + let goal = -1; + let expanded = 0; + while (heap.length && expanded++ < maxExpanded) { + const current = heap.pop(); + if (!current || closed[current.i]) continue; + closed[current.i] = 1; + const [cx, cy] = xyOf(current.i); + const straightDistance = Math.hypot(cx - start.x, cy - start.y); + if (straightDistance > 3 && (accessInfluence[current.i] > 0.11 || localPenalty[current.i] > 0.06)) { + goal = current.i; + break; + } + if (straightDistance > 150) continue; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const nx = cx + dx; + const ny = cy + dy; + if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); - if (sea[ni]) continue; - if (visited.has(`${nx},${ny}`)) continue; - const cost = - 1.1 + - slope[ni] * 1.4 + - ridgeField[ni] * 0.55 + - Math.max(0, elevation[ni] - 0.70) * 1.2 - - Math.max(roadInfluence[ni], railInfluence2[ni]) * 2.8 - - plain[ni] * 0.28 - - valleyField[ni] * 0.18 - - coastalLowland[ni] * 0.16 + - localPenalty[ni] * 0.55; - if (cost < bestScore) { - bestScore = cost; - best = [nx, ny]; + if (closed[ni] || sea[ni]) continue; + const step = Math.hypot(dx, dy); + const highPenalty = Math.max(0, elevation[ni] - 0.64); + const targetAttraction = Math.max(accessInfluence[ni] * 2.4, localPenalty[ni] * 1.25, roadInfluence[ni] * 1.8, railInfluence2[ni] * 1.2); + const terrainCost = + 1.0 + + slope[ni] * 1.20 + + ridgeField[ni] * 0.52 + + highPenalty * 1.55 - + plain[ni] * 0.38 - + valleyField[ni] * 0.42 - + coastalLowland[ni] * 0.18 + + Math.max(0, localPenalty[ni] - 0.18) * 0.38 - + targetAttraction; + const nd = score[current.i] + Math.max(0.16, terrainCost) * step; + if (nd < score[ni]) { + score[ni] = nd; + cameFrom[ni] = current.i; + heap.push({ i: ni, f: nd }); } } } - if (!best) break; - x = best[0]; - y = best[1]; - const key = `${x},${y}`; - visited.add(key); - if (key !== lastKey) { - out.push([x, y]); - lastKey = key; - } } - return []; + if (goal < 0) return []; + const path = []; + for (let p = goal; p >= 0; p = cameFrom[p]) { + path.push(xyOf(p)); + if (p === startIndex) break; + } + path.reverse(); + if (path.length < 4 || path.length > 112) return []; + return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, penalty: localPenalty, highElevationThreshold: 0.78, steepThreshold: 0.52 }, { + minLength: 4, + maxLength: 112, + maxCompactness: 4.0, + maxHighElevationShare: 0.22, + maxSteepShare: 0.50, + }) ? path : []; } for (const center of candidates) { @@ -541,6 +610,7 @@ export function finishMapOutput({ debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 }); if (path.length < 4) continue; minorRoads.push(path); + addLocalPenalty(path); debugLayers.repairedSegments.push({ mode: "municipal-local", path, from: "municipal-center", to: "network" }); } } diff --git a/mapTransport.js b/mapTransport.js new file mode 100644 index 0000000..b218cb4 --- /dev/null +++ b/mapTransport.js @@ -0,0 +1,99 @@ +import { SIZE, clamp, indexOf, inside } from "./mapUtils.js"; + +export function pathSetSignature(paths) { + let cells = 0; + let endpoints = 0; + for (const path of paths || []) { + cells += path?.length || 0; + const a = path?.[0]; + const b = path?.[path.length - 1]; + if (a) endpoints = (endpoints + a[0] * 17 + a[1] * 31) | 0; + if (b) endpoints = (endpoints + b[0] * 47 + b[1] * 73) | 0; + } + return `${paths?.length || 0}:${cells}:${endpoints >>> 0}`; +} + +export function createPathInfluenceCache(influenceFromPaths) { + const cache = new Map(); + return (paths, radius, label = "paths") => { + const key = `${label}:${radius}:${pathSetSignature(paths)}`; + let grid = cache.get(key); + if (!grid) { + grid = influenceFromPaths(paths, radius); + cache.set(key, grid); + } + return grid; + }; +} + +export function packDebugField(field) { + const out = new Uint8Array(SIZE); + for (let i = 0; i < SIZE; i++) out[i] = Math.round(clamp(field?.[i] || 0) * 255); + return out; +} + +export function pathLengthCells(path) { + let total = 0; + for (let i = 1; i < (path?.length || 0); i++) { + total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); + } + return total; +} + +export function pathAverageField(path, field) { + if (!path?.length || !field) return 0; + let sum = 0; + let n = 0; + for (const [x, y] of path) { + if (!inside(x, y)) continue; + sum += field[indexOf(x, y)] || 0; + n++; + } + 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); + const first = path[0]; + const last = path[path.length - 1]; + const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0; + let high = 0; + let steep = 0; + let water = 0; + let potential = 0; + let penalty = 0; + let n = 0; + for (const [x, y] of path) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (fields.sea?.[i]) water++; + if ((fields.elevation?.[i] || 0) > (fields.highElevationThreshold ?? 0.72)) high++; + if ((fields.slope?.[i] || 0) > (fields.steepThreshold ?? 0.44)) steep++; + potential += fields.potential?.[i] || 0; + penalty += fields.penalty?.[i] || 0; + n++; + } + return { + length, + compactness: direct > 0.001 ? length / direct : Infinity, + highElevationShare: high / Math.max(1, n), + steepShare: steep / Math.max(1, n), + waterShare: water / Math.max(1, n), + avgPotential: potential / Math.max(1, n), + avgPenalty: penalty / Math.max(1, n), + }; +} + +export function routeQualityAcceptable(path, fields = {}, limits = {}) { + const q = routeQualityStats(path, fields); + if (q.length < (limits.minLength ?? 2)) return false; + if (q.length > (limits.maxLength ?? Infinity)) return false; + if (q.compactness > (limits.maxCompactness ?? 3.2)) return false; + if (q.highElevationShare > (limits.maxHighElevationShare ?? 0.0)) return false; + if (q.steepShare > (limits.maxSteepShare ?? 0.38)) return false; + if (q.waterShare > (limits.maxWaterShare ?? 0)) return false; + if (q.avgPenalty > (limits.maxAvgPenalty ?? Infinity) && q.avgPotential < (limits.minPotentialIfPenalty ?? 0.35)) return false; + if (q.avgPotential < (limits.minAvgPotential ?? 0)) return false; + return true; +} diff --git a/names.js b/names.js index bc59096..4a089d2 100644 --- a/names.js +++ b/names.js @@ -1,6 +1,6 @@ import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; -export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山"]; +export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山", "八幡", "相生",]; function nameCharCount(value) { return Array.from(String(value || "")).length; @@ -18,7 +18,7 @@ export const NAME_KANJI_POOLS = { "大", "小", "上", "下", "中", "奥", "脇", "東", "西", "南", "北", "新", "古", "本", - "高", "長", "広", "深", "浅", + "高", "長", "広", "深", "浅", "明", "重", "荒", "白", "黒", "青", "赤", "藍", "奥", "前", "後", "内", "外", "美", "吉", "福", "幸", "徳", @@ -26,7 +26,7 @@ export const NAME_KANJI_POOLS = { "霞", "朝", "日", "天", "土", "砂", "石", "岩", "卯", "辰", - "荒", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" + "駒", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" ], inlandTerrain: [ @@ -37,7 +37,7 @@ export const NAME_KANJI_POOLS = { "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生", "郷", "里", "馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥", - "湯", + "湯", "宍", ], waterTerrain: [ @@ -54,16 +54,15 @@ export const NAME_KANJI_POOLS = { "州", "洲", "瀬", "砂", "潮", "塩", "浜", "泊", "江", "浦", "灘", "入", "戸", "門", - "鯵", "鰐", "漁", "魚", "鮫", "鮎", ], plants: [ "松", "杉", "桜", "梅", "栗", "竹", "楠", "藤", "萩", "葦", "菅", "榎", "椿", "桐", "柳", - "橘", "柏", "槙", "柿", "桃", - "梨", "桑", "麻", "芦", "茅", - "粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", + "橘", "柏", "槙", "柿", "桃", "稲", "花", "草", "菊", + "梨", "桑", "麻", "芦", "茅", "根", + "粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", "篠", "榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜" ], @@ -72,9 +71,9 @@ export const NAME_KANJI_POOLS = { "島", "江", "瀬", "井", "戸", "口", "辺", "里", "郷", "村", "町", - "宿", "庄", "台", "坂", "橋", - "本", "内", "窪", "平", "塚", - "畑", "牧", "前", "見", "中", "羽", "生", "塚", "部", "栄", "永", + "宿", "庄", "台", "坂", "橋", "明", + "本", "内", "窪", "平", "塚", "根", + "畑", "牧", "前", "見", "中", "羽", "生", "駒", "塚", "部", "栄", "永", "平", ], archaicPrefixes: [ @@ -118,7 +117,7 @@ export const NAME_KANJI_POOLS = { ], settlementWords: [ - "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", + "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", "條", "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", "市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋", diff --git a/renderer.js b/renderer.js index d7fd419..3cd3e60 100644 --- a/renderer.js +++ b/renderer.js @@ -208,10 +208,13 @@ function vectorPath(path) { if (cached) return cached; const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]); - const simplified = simplifyRdp(points, CELL_SIZE * 0.34); - const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false); - pathVectorCache.set(path, smoothed); - return smoothed; + // Transport routes are already cost-routed on the raster grid. A large RDP + // tolerance erases those small valley/contour bends and makes roads look like + // ruler-straight overlays, so smooth first and simplify only lightly. + const smoothedBase = chaikin(points, path.length > 8 ? 1 : 0, false); + const simplified = simplifyRdp(smoothedBase, CELL_SIZE * 0.14); + pathVectorCache.set(path, simplified); + return simplified; } function drawPolylinePoints(ctx, points) { @@ -636,7 +639,8 @@ function drawDebugCells(ctx, map, field, color) { const i = indexOf(x, y); const debugMask = map.humanRegionMask || map.prefectureMask; if (!debugMask[i] || map.sea[i]) continue; - const v = clamp(field[i] || 0, 0, 1); + const raw = field[i] || 0; + const v = clamp(raw > 1 ? raw / 255 : raw, 0, 1); if (v <= 0.12) continue; ctx.fillStyle = color(v); ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); @@ -917,7 +921,10 @@ export function drawMap(canvas, map, options) { for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5); } if (showMinorRoads) { - for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35); + // Local roads need a visible casing on pale green lowland/farmland tiles. + // Keep the fill light, but use a warmer grey outline rather than a nearly + // invisible white-on-green stroke. + for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(132, 126, 112, 0.72)", 2.75); } if (showRoads) { for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); @@ -939,7 +946,7 @@ export function drawMap(canvas, map, options) { for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false); } if (showMinorRoads) { - for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false); + for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 253, 244, 0.98)", 1.25, false); } if (showRoads) { for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);