road update

This commit is contained in:
33333-33333 2026-05-26 21:14:37 +09:00
commit a6e66f2838
9 changed files with 2174 additions and 258 deletions

View file

@ -1,4 +1,4 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js";
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";
@ -91,6 +91,7 @@ export function generateMapFeatures(seed, terrain) {
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;
@ -132,23 +133,25 @@ export function generateMapFeatures(seed, terrain) {
spine * 0.12
);
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
settlementCluster[i] = clamp((developable[i] * 0.50 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.34 + plain[i] * 0.12) * clusterNoise);
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.24 +
coastalSettlement[i] * 0.15 +
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.24 +
coastalSettlement[i] * 0.20 +
confluence * 0.34 +
basinField[i] * 0.16 +
plain[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 -
@ -304,9 +307,9 @@ export function generateMapFeatures(seed, terrain) {
const villageScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
villageScore[i] = clamp(ruralSuitability[i] * 0.60 + agriculture[i] * 0.30 + plain[i] * 0.15 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.16 + settlementCluster[i] * 0.16);
villageScore[i] = clamp(ruralSuitability[i] * 0.50 + agriculture[i] * 0.48 + plain[i] * 0.42 + Math.max(0, plain[i] * 1.12 + agriculture[i] * 0.78 + basinField[i] * 0.30 - river[i] * 0.36 - valleyField[i] * 0.14 - flowAccum[i] * 0.10) * 0.58 + valleySettlement[i] * 0.06 + coastalSettlement[i] * 0.34 + settlementCluster[i] * 0.22 - river[i] * 0.10 - flowAccum[i] * 0.04);
}
const villages = pickRegionalPoints(villageScore, {
let villages = pickRegionalPoints(villageScore, {
stride: 2,
threshold: 0.18 + rand(seed, 1031) * 0.030,
totalMax: 280,
@ -316,18 +319,51 @@ export function generateMapFeatures(seed, terrain) {
quotaForRegion: (regionId, st) => {
if (!st || st.developableCells < 10) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 46 + 3.2) * vf;
const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 32 + 3.2) * vf;
const min = st.area > 2600 ? 20 : st.area > 1400 ? 12 : st.area > 520 ? 5 : st.area > 220 ? 2 : 0;
const max = st.area > 3600 ? 72 : st.area > 2200 ? 50 : st.area > 900 ? 25 : 10;
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
},
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village";
const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100;
const kind = coastalSettlement[i] > 0.36 ? "Coastal Village" : valleySettlement[i] > 0.46 ? "Valley Village" : "Village";
const population = Math.round((900 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.30) * 9800 + ruralSuitability[i] * 4700 + agriculture[i] * 3600) / 100) * 100;
return { ...p, kind, population };
});
// Supplemental open-plain villages: broad Japanese-style farmland should not be empty
// just because it lacks a river/confluence anchor.
const openPlainVillageScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const open = Math.max(0, plain[i] * 0.92 + agriculture[i] * 0.72 + basinField[i] * 0.26 + (depositionalLowland?.[i] || 0) * 0.20 - river[i] * 0.22 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.14);
openPlainVillageScore[i] = clamp(open + settlementCluster[i] * 0.16 + ruralSuitability[i] * 0.18);
}
const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, {
stride: 2,
threshold: 0.235 + rand(seed, 1036) * 0.020,
totalMax: 120,
minDistance: 5,
seedOffset: 1035,
kind: "Plain Village",
predicate: (x, y, i) => plain[i] > 0.20 && agriculture[i] > 0.16 && river[i] < 0.30 && valleyField[i] < 0.52 && slope[i] < 0.32,
quotaForRegion: (regionId, st) => {
if (!st || st.plainCells < 24) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.plainCells / 62 + st.developableCells / 180 + 1.4) * vf;
const min = st.plainCells > 360 ? 6 : st.plainCells > 160 ? 3 : st.plainCells > 70 ? 1 : 0;
const max = st.plainCells > 720 ? 24 : st.plainCells > 360 ? 16 : st.plainCells > 140 ? 8 : 3;
return Math.round(clamp(raw + rand(seed, 1037 + regionId * 29) * 1.4, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.34 + agriculture[i] * 0.26 - river[i] * 0.20 - valleyField[i] * 0.12),
}).filter((p) => distanceToNearest(villages, p.x, p.y) >= 4.5)
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((1100 + Math.pow(rand(seed, 18220 + n * 31 + p.x * 7 + p.y), 1.12) * 7600 + agriculture[i] * 3900 + plain[i] * 2200) / 100) * 100;
return { ...p, kind: "Plain Village", population };
});
villages = [...villages, ...supplementalPlainVillages];
const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
const marketScore = new Float32Array(SIZE);
@ -335,29 +371,34 @@ export function generateMapFeatures(seed, terrain) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const openPlainMarket = Math.max(0, plain[i] * 0.68 + agriculture[i] * 0.52 + basinField[i] * 0.24 + (depositionalLowland?.[i] || 0) * 0.18 - river[i] * 0.16 - valleyField[i] * 0.06 - slope[i] * 0.18);
const featurePull = Math.max(
distanceToNearest(ports, x, y) < 8 ? 0.10 : 0,
distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0,
confluenceField[i] * 0.16
distanceToNearest(ports, x, y) < 10 ? 0.16 : 0,
distanceToNearest(crossings, x, y) < 6 ? 0.035 : 0,
confluenceField[i] * 0.08
);
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
marketScore[i] = clamp(
townSuitability[i] * 0.58 +
agriculture[i] * 0.16 +
plain[i] * 0.14 +
villageInfluence[i] * 0.40 +
townSuitability[i] * 0.50 +
agriculture[i] * 0.30 +
plain[i] * 0.28 +
openPlainMarket * 0.74 +
coastalSettlement[i] * 0.22 +
villageInfluence[i] * 0.28 +
featurePull +
valleyMouth +
basinField[i] * 0.12 +
plain[i] * 0.14 +
basinField[i] * 0.14 +
plain[i] * 0.22 +
coastalLowland[i] * 0.08 -
slope[i] * 0.18 -
ridgeField[i] * 0.08
ridgeField[i] * 0.08 -
river[i] * 0.08 -
flowAccum[i] * 0.035
);
}
}
const markets = pickRegionalPoints(marketScore, {
let markets = pickRegionalPoints(marketScore, {
stride: 2,
threshold: 0.245 + rand(seed, 1041) * 0.035,
totalMax: 110,
@ -367,19 +408,50 @@ export function generateMapFeatures(seed, terrain) {
quotaForRegion: (regionId, st) => {
if (!st || st.townCells < 8) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 105 + st.plainCells / 125 + st.valleyCells / 105 + st.coastCells / 130 + 2.1) * vf;
const raw = (st.developableCells / 92 + st.plainCells / 108 + st.valleyCells / 92 + st.coastCells / 72 + 2.7) * vf;
const min = st.area > 2600 ? 9 : st.area > 1200 ? 5 : st.area > 520 ? 2 : 0;
const max = st.area > 3600 ? 30 : st.area > 2200 ? 22 : st.area > 800 ? 10 : 5;
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
},
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08,
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 10 ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035,
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town";
const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000;
const kind = coastalSettlement[i] > 0.38 && distanceToNearest(ports, p.x, p.y) < 11 ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
const population = Math.round((9000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.02) * 70000 + marketScore[i] * 40000 + villageInfluence[i] * 7800 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 22000 + coastalSettlement[i] * 12000) / 1000) * 1000;
return { ...p, kind, population };
});
const openPlainMarketScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const open = Math.max(0, plain[i] * 0.86 + agriculture[i] * 0.64 + basinField[i] * 0.28 + (depositionalLowland?.[i] || 0) * 0.22 - river[i] * 0.20 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.16);
openPlainMarketScore[i] = clamp(open + townSuitability[i] * 0.20 + villageInfluence[i] * 0.18 + settlementCluster[i] * 0.12);
}
const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, {
stride: 2,
threshold: 0.335 + rand(seed, 1046) * 0.025,
totalMax: 55,
minDistance: 9,
seedOffset: 1045,
kind: "Plain Market Town",
predicate: (x, y, i) => plain[i] > 0.22 && agriculture[i] > 0.18 && river[i] < 0.28 && valleyField[i] < 0.50 && slope[i] < 0.30,
quotaForRegion: (regionId, st) => {
if (!st || st.plainCells < 60) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.plainCells / 260 + st.developableCells / 520 + 0.45) * vf;
const min = st.plainCells > 520 ? 2 : st.plainCells > 220 ? 1 : 0;
const max = st.plainCells > 900 ? 8 : st.plainCells > 420 ? 5 : st.plainCells > 160 ? 2 : 1;
return Math.round(clamp(raw + rand(seed, 1047 + regionId * 31) * 0.9, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.24 + agriculture[i] * 0.18 - river[i] * 0.14 - valleyField[i] * 0.08),
}).filter((p) => distanceToNearest(markets, p.x, p.y) >= 8.5 && distanceToNearest(villages, p.x, p.y) >= 3.5)
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((10000 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.02) * 52000 + openPlainMarketScore[i] * 26000 + agriculture[i] * 9000 + plain[i] * 8000) / 1000) * 1000;
return { ...p, kind: "Plain Market Town", population };
});
markets = [...markets, ...supplementalPlainMarkets];
const defenseScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
@ -428,7 +500,7 @@ export function generateMapFeatures(seed, terrain) {
const dev = developable[i];
if (dev < 0.04) continue;
const radial = clamp(1 - d / Math.max(1, radius));
const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24);
const terrainMultiplier = clamp(0.60 + plain[i] * 0.48 + agriculture[i] * 0.22 + basinField[i] * 0.28 + coastalLowland[i] * 0.18 + valleyField[i] * 0.08 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.38);
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
}
}
@ -448,8 +520,8 @@ export function generateMapFeatures(seed, terrain) {
const regionId = regionIdAt(p.x, p.y);
if (regionId < 0) continue;
const st = regionStats.get(regionId);
const cityRadius = st && st.area > 2400 ? 28 : st && st.area > 900 ? 24 : 20;
const capacity = estimateUrbanCapacity(p, cityRadius, 1.0);
const cityRadius = st && st.area > 2400 ? 30 : st && st.area > 900 ? 26 : 22;
const capacity = estimateUrbanCapacity(p, cityRadius, p.candidateKind === "town" ? 1.14 : p.candidateKind === "port" ? 1.10 : 1.06);
const score =
Math.log10(capacity + 1) * 0.72 +
townSuitability[i] * 1.40 +
@ -469,8 +541,8 @@ export function generateMapFeatures(seed, terrain) {
if (!st || st.developableCells < 30) continue;
const vf = visibilityFactor(regionId, st);
const maxCities = clamp(
Math.round((st.developableCells / 720 + 0.9) * vf + rand(seed, 12100 + regionId * 17) * 1.2),
st.area > 1600 ? 1 : 0,
Math.round((st.developableCells / 560 + 1.15) * vf + rand(seed, 12100 + regionId * 17) * 1.4),
(st.area > 900 || st.developableCells > 180) ? 1 : 0,
st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1
);
const selected = pickEntities(list, {
@ -497,11 +569,11 @@ export function generateMapFeatures(seed, terrain) {
const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
const isRegionalCapital = isFirstInRegion;
const rawPop = isRegionalCapital
? 150000 + rand(seed, 12201 + city.regionId * 17) * 520000
: 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000;
const capMultiplier = isRegionalCapital ? 1.10 : 1.0;
? 300000 + rand(seed, 12201 + city.regionId * 17) * 740000
: 68000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.62) * 430000;
const capMultiplier = isRegionalCapital ? 1.68 : 1.28;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
city.population = Math.max(isRegionalCapital ? 90000 : 24000, population);
city.population = Math.max(isRegionalCapital ? 260000 : 52000, population);
city.isPrefecturalCapital = isPrefecturalCapital;
city.isRegionalCapital = isRegionalCapital;
city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
@ -518,8 +590,422 @@ export function generateMapFeatures(seed, terrain) {
return estimateUrbanCapacity(city, radius, bias);
}
// --- 4. Lightweight corridors -------------------------------------------
function routeLight(a, b, snapRadius = 3) {
// --- 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);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) {
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 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);
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.64 -
highMountain * 0.58 -
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.05 -
highMountain * 0.82 -
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.36 -
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.26 -
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);
}
}
return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential };
}
const transportFields = buildTransportCostFields();
function chooseCorridorSeeds(potentialField, spacing, maxCount, threshold, predicate = () => true, seedOffset = 0) {
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;
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
}
}
return pickEntities(candidates, { max: maxCount, minDistance: spacing, threshold, seed: seed + seedOffset, jitter: 0.03 });
}
function corridorAllowance(i) {
return clamp(settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36 - plain[i] * 0.18 - agriculture[i] * 0.14);
}
function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) {
if (!start || !inside(start.x, start.y)) return [];
const startIndex = indexOf(start.x, start.y);
if (sea[startIndex] || costField[startIndex] >= INF) return [];
const score = new Float32Array(SIZE);
const cameFrom = new Int32Array(SIZE);
const closed = new Uint8Array(SIZE);
score.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
score[startIndex] = 0;
heap.push({ i: startIndex, f: 0 });
const curvePenalty = options.curvePenalty ?? 0.12;
const penaltyStrength = options.penaltyStrength ?? 1.0;
const sameRegion = options.regionId ?? regionIdAt(start.x, start.y);
const minGoalDistance = options.minGoalDistance ?? 18;
const maxExpanded = options.maxExpanded ?? SIZE * 2;
let goalIndex = -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);
if (current.i !== startIndex && Math.hypot(cx - start.x, cy - start.y) >= minGoalDistance && goalRegionPredicate(cx, cy, current.i)) {
goalIndex = current.i;
break;
}
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 (closed[ni] || sea[ni] || costField[ni] >= INF) continue;
if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue;
const prev = cameFrom[current.i];
let turn = 0;
if (prev >= 0) {
const [px, py] = xyOf(prev);
const ax = cx - px;
const ay = cy - py;
turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0;
}
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);
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = current.i;
heap.push({ i: ni, f: nd });
}
}
}
}
if (goalIndex < 0) return [];
const path = [];
for (let p = goalIndex; p >= 0; p = cameFrom[p]) {
path.push(xyOf(p));
if (p === startIndex) break;
}
return path.reverse();
}
function addCorridorInfluencePenalty(penaltyField, corridor, radius = 7, strength = 0.35) {
for (const [x, y] of corridor || []) {
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;
const openPlain = clamp(plain[i] * 0.54 + agriculture[i] * 0.34 - settlementDemand[i] * 0.24 - valleyField[i] * 0.22 - coastalLowland[i] * 0.18);
const allowParallel = corridorAllowance(i);
penaltyField[i] = Math.max(penaltyField[i], strength * (1 - d / radius) * (0.48 + openPlain * 1.15 - allowParallel * 0.42));
}
}
}
}
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 }) {
const paths = [];
const penaltyField = new Float32Array(SIZE);
const seeds = chooseCorridorSeeds(potentialField, spacing, maxCount * 2, threshold, startPredicate, seedOffset);
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(
start,
(x, y, i) => goalPredicate(start, x, y, i, usedEndpoints),
costField,
penaltyField,
{ curvePenalty, penaltyStrength: penaltyStrength * 2.2, minGoalDistance: minLength, regionId: start.regionId }
);
if (path.length < minLength) continue;
paths.push(path);
usedEndpoints.push(start);
const end = endpointFromPath(path);
if (end) usedEndpoints.push(end);
addCorridorInfluencePenalty(penaltyField, path, penaltyRadius, penaltyStrength);
}
return paths;
}
function rasterizeNetworkComponents(paths, mode, potentialField) {
const occupied = new Uint8Array(SIZE);
for (const path of paths) {
for (const [x, y] of path || []) {
if (inside(x, y) && !sea[indexOf(x, y)]) occupied[indexOf(x, y)] = 1;
}
}
const componentId = new Int32Array(SIZE);
componentId.fill(-1);
const components = [];
for (let i = 0; i < SIZE; i++) {
if (!occupied[i] || componentId[i] >= 0) continue;
const id = components.length;
const queue = [i];
const cells = [];
const boundary = [];
componentId[i] = id;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
let edge = false;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) { edge = true; continue; }
const ni = indexOf(nx, ny);
if (!occupied[ni]) {
edge = true;
continue;
}
if (componentId[ni] < 0) {
componentId[ni] = id;
queue.push(ni);
}
}
}
if (edge) boundary.push({ x, y, i: cur });
}
let lengthScore = 0;
let densityScore = 0;
let townScore = 0;
let logisticsScore = 0;
let capitalScore = 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;
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);
}
}
const importance =
Math.sqrt(lengthScore) * 1.20 +
densityScore * 0.38 +
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) });
}
return { occupied, componentId, components };
}
function componentAnchor(component, target, costField, usedAnchors = []) {
if (!component?.boundary?.length) return null;
let best = null;
let bestScore = INF;
const stride = Math.max(1, Math.floor(component.boundary.length / 90));
for (let k = 0; k < component.boundary.length; k += stride) {
const p = component.boundary[k];
if (costField[p.i] >= INF) continue;
if (distanceToNearest(usedAnchors, p.x, p.y) < 8) continue;
const d = target ? Math.hypot(p.x - target.x, p.y - target.y) : 0;
const score = d + costField[p.i] * 3.5 - corridorAllowance(p.i) * 2.4 + hash2(p.x, p.y, seed + 13701) * 1.8;
if (score < bestScore) {
bestScore = score;
best = { x: p.x, y: p.y, componentId: component.id, regionId: regionIdAt(p.x, p.y) };
}
}
return best;
}
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,
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)),
}));
const important = raster.components
.filter((c) => c.length >= (options.minComponentCells ?? 18) && c.importance >= (options.minImportance ?? 8))
.sort((a, b) => b.importance - a.importance)
.slice(0, options.maxComponents ?? 8);
if (important.length < 2) return debug;
const networkPenalty = influenceFromPaths(paths, options.penaltyRadius ?? 8);
const usedAnchors = [];
const maxRepairs = options.maxRepairs ?? 4;
for (let r = 0; r < maxRepairs; r++) {
let bestPair = null;
let bestScore = INF;
for (let a = 0; a < important.length; a++) {
for (let b = a + 1; b < important.length; b++) {
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);
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) {
bestScore = score;
bestPair = [ca, cb];
}
}
}
if (!bestPair) break;
const [aComp, bComp] = bestPair;
const roughTarget = bComp.boundary[Math.floor(bComp.boundary.length / 2)];
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,
}
);
if (path.length < (options.minAddedLength ?? 6) || path.length > (options.maxAddedLength ?? 120)) {
aComp.repairCount++;
continue;
}
paths.push(path);
debug.repairs.push({ mode, path, from: aComp.id, to: bComp.id });
usedAnchors.push(start, goalTarget);
aComp.repairCount++;
bComp.repairCount++;
addCorridorInfluencePenalty(networkPenalty, path, options.penaltyRadius ?? 8, options.addedPenalty ?? 0.38);
}
return debug;
}
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 out = [];
@ -537,16 +1023,16 @@ export function generateMapFeatures(seed, terrain) {
const y = Math.round(fy + dy);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
if (sea[i] || costField[i] >= INF) continue;
const lineDist = Math.hypot(x - fx, y - fy);
const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05;
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;
if (cost < bestCost) {
bestCost = cost;
best = [x, y];
}
}
}
if (!best) best = [Math.round(fx), Math.round(fy)];
if (!best) continue;
const key = `${best[0]},${best[1]}`;
if (key !== lastKey) {
out.push(best);
@ -579,6 +1065,7 @@ export function generateMapFeatures(seed, terrain) {
const ringExpressways = [];
const externalExpressways = [];
const icAccessRoads = [];
const interchanges = [];
const externalGateways = [];
// Premodern roads connect castles/markets/ports sparsely.
@ -590,48 +1077,68 @@ export function generateMapFeatures(seed, terrain) {
}
}
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
const nodes = importantNodesForRegion(regionId);
if (nodes.length < 2) continue;
const connected = [nodes[0]];
const remaining = nodes.slice(1);
const maxEdges = (regionStats.get(regionId)?.area || 0) > 2200 ? Math.min(13, nodes.length + 3) : Math.min(7, nodes.length + 1);
while (remaining.length && nationalRoads.length < 48) {
let best = null;
let bestScore = INF;
for (const a of connected) {
for (const b of remaining) {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const score = d - (a.nodeWeight + b.nodeWeight) * 0.9;
if (score < bestScore) { bestScore = score; best = { a, b }; }
}
}
if (!best) break;
const path = routeLight(best.a, best.b, 3);
if (path.length > 2) nationalRoads.push(path);
connected.push(best.b);
remaining.splice(remaining.indexOf(best.b), 1);
if (connected.length - 1 >= maxEdges) break;
}
nationalRoads.push(...generateCorridorsFromField({
potentialField: transportFields.nationalPotential,
costField: transportFields.national,
spacing: 17,
maxCount: 42,
threshold: 0.30,
minLength: 20,
penaltyRadius: 6,
penaltyStrength: 0.34,
curvePenalty: 0.10,
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 (distanceToNearest(used, x, y) < 11) return false;
const d = Math.hypot(x - start.x, y - start.y);
return d > 22 && d < 76;
},
}));
// A few k-nearest shortcuts for urbanized regions.
const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 7 : 4);
for (let i = 0; i < urbanNodes.length; i++) {
const a = urbanNodes[i];
const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0];
if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue;
const path = routeLight(a, b, 3);
if (path.length > 2) nationalRoads.push(path);
}
railways.push(...generateCorridorsFromField({
potentialField: transportFields.railPotential,
costField: transportFields.rail,
spacing: 24,
maxCount: 18,
threshold: 0.30,
minLength: 24,
penaltyRadius: 7,
penaltyStrength: 0.42,
curvePenalty: 0.30,
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 (distanceToNearest(used, x, y) < 15) return false;
const d = Math.hypot(x - start.x, y - start.y);
return d > 28 && d < 88;
},
}));
// Railways: only high-order cities/ports, as a lightweight placeholder.
const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 6 : 4);
railNodes.sort((a, b) => a.x - b.x || a.y - b.y);
for (let i = 1; i < railNodes.length; i++) {
const path = routeLight(railNodes[i - 1], railNodes[i], 4);
if (path.length > 4) railways.push(path);
}
}
expressways.push(...generateCorridorsFromField({
potentialField: transportFields.expresswayPotential,
costField: transportFields.expressway,
spacing: 29,
maxCount: 10,
threshold: 0.30,
minLength: 30,
penaltyRadius: 9,
penaltyStrength: 0.55,
curvePenalty: 0.16,
seedOffset: 13600,
startPredicate: (x, y, i) => transportFields.expresswayPotential[i] > 0.27 && 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;
const d = Math.hypot(x - start.x, y - start.y);
return d > 34 && d < 104;
},
}));
// External gateways at land edges; used by naming/UI and later transport work.
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
@ -659,20 +1166,105 @@ export function generateMapFeatures(seed, terrain) {
externalGateways.push(gateway);
const target = importantNodesForRegion(regionId)[0];
if (target) {
const path = routeLight(gateway, target, 3);
const path = routeLight(gateway, target, 3, transportFields.national);
if (path.length > 2) externalRoads.push(path);
}
}
}
// Approximate expressways as a very small subset of top inter-city links.
const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6);
for (let i = 1; i < topCities.length && expressways.length < 4; i++) {
const a = topCities[i - 1];
const b = topCities[i];
if (Math.hypot(a.x - b.x, a.y - b.y) < 85) {
const path = routeLight(a, b, 5);
if (path.length > 5) expressways.push(path);
const transportDebugLayers = {
expresswayPotential: transportFields.expresswayPotential,
railPotential: transportFields.railPotential,
nationalRoadPotential: 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;
})(),
components: [],
repairedSegments: [],
unservedSettlements: [],
};
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,
}),
repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, {
minImportance: 9.5,
minComponentCells: 16,
maxComponents: 8,
maxRepairs: 4,
maxRepairDistance: 105,
penaltyRadius: 7,
penaltyStrength: 2.0,
curvePenalty: 0.34,
highPotentialThreshold: 0.36,
}),
repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
minImportance: 7.5,
minComponentCells: 12,
maxComponents: 10,
maxRepairs: 6,
maxRepairDistance: 95,
penaltyRadius: 6,
penaltyStrength: 1.7,
curvePenalty: 0.11,
highPotentialThreshold: 0.34,
}),
]) {
transportDebugLayers.components.push(...repair.components);
transportDebugLayers.repairedSegments.push(...repair.repairs);
}
function generateLocalRoadsForUnservedSettlements() {
const trunkInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8);
const candidates = [...villages, ...markets, ...ports]
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)])
.map((p) => {
const i = indexOf(p.x, p.y);
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)
.sort((a, b) => b.score - a.score)
.slice(0, 90);
const localPenalty = new Float32Array(SIZE);
const paths = [];
const served = [];
for (const start of candidates) {
if (paths.length >= 75) break;
if (distanceToNearest(served, start.x, start.y) < 4.5) continue;
const path = traceCorridorByCost(
start,
(x, y, i) => trunkInfluence[i] > 0.20 || (paths.length > 8 && localPenalty[i] > 0.05),
transportFields.local,
localPenalty,
{ curvePenalty: 0.08, penaltyStrength: 1.15, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE }
);
if (path.length < 4 || path.length > 70) continue;
paths.push(path);
served.push(start);
addCorridorInfluencePenalty(localPenalty, path, 4, 0.22);
}
return paths;
}
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
for (const path of expressways) {
for (const p of samplePath(path, 24)) {
const i = indexOf(p.x, p.y);
if (sea[i] || transportFields.expresswayPotential[i] < 0.24) continue;
if (interchanges.every((q) => Math.hypot(q.x - p.x, q.y - p.y) > 14)) {
interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i], regionId: regionIdAt(p.x, p.y) });
}
}
}
@ -782,7 +1374,6 @@ export function generateMapFeatures(seed, terrain) {
const satelliteCities = [];
const newTowns = [];
const interchanges = [];
const logisticsScore = new Float32Array(SIZE);
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
@ -812,6 +1403,36 @@ export function generateMapFeatures(seed, terrain) {
predicate: (x, y, i) => logisticsScore[i] > 0.30 && (roadInfluence[i] > 0.10 || railInfluence2[i] > 0.08 || stationInfluence[i] > 0.08),
}).map((p) => ({ ...p, kind: "Logistics Park", score: logisticsScore[indexOf(p.x, p.y)], population: 0 }));
const logisticsInfluence = influenceFromPoints(logisticsParks, 4.8, () => 1.0);
function addFinalLocalAccessForUnservedSettlements() {
const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7);
const candidates = [
...markets.filter((p) => (p.population || 0) >= 5000),
...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),
]
.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)] }))
.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);
}
}
addFinalLocalAccessForUnservedSettlements();
var landuse = new Uint8Array(SIZE);
// Re-run land-use classification after landuse allocation. The loop above is
@ -1014,6 +1635,12 @@ export function generateMapFeatures(seed, terrain) {
humanStageVersion: "v2-sparse-raster",
aStarRoutes: 0,
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
fieldCorridorTransport: true,
expresswayFieldCorridors: expressways.length,
railFieldCorridors: railways.length,
nationalRoadFieldCorridors: nationalRoads.length,
localRoadFieldCorridors: minorRoads.length,
layers: transportDebugLayers,
nationalRoadPopulationCoverage: 0,
nationalRoadUncoveredPopulation: 0,
};