2406 lines
120 KiB
JavaScript
2406 lines
120 KiB
JavaScript
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
|
|
import {
|
|
aStar,
|
|
averagePathField,
|
|
compactPathArray,
|
|
distanceToNearest,
|
|
getDegree,
|
|
incrementDegree,
|
|
influenceFromPaths,
|
|
influenceFromPoints,
|
|
makeTransportCost,
|
|
nearestConnectable,
|
|
neighbors8,
|
|
pathCompactness,
|
|
pathEndpointDistance,
|
|
pathLength,
|
|
pathOverlapRatio,
|
|
samplePath,
|
|
smoothPathByLineOfSight,
|
|
} from "./mapGeneratorHelpers.js";
|
|
|
|
export function generateMapFeatures(seed, terrain) {
|
|
const {
|
|
elevation,
|
|
moisture,
|
|
slope,
|
|
sea,
|
|
river,
|
|
floodplain,
|
|
plain,
|
|
agriculture,
|
|
ridgeField,
|
|
valleyField,
|
|
basinField,
|
|
coastalLowland,
|
|
flowAccum,
|
|
arcSpineField,
|
|
branchRidgeField,
|
|
depositionalLowland,
|
|
alluvialFanField,
|
|
deltaField,
|
|
portSuitability,
|
|
crossingSuitability,
|
|
passSuitability,
|
|
prefectureMask,
|
|
} = terrain;
|
|
|
|
function pickPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true }) {
|
|
const candidates = [];
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!predicate(x, y, i)) continue;
|
|
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.08;
|
|
if (score >= threshold) candidates.push({ x, y, score });
|
|
}
|
|
}
|
|
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
|
|
}
|
|
|
|
let ports = pickPoints(portSuitability, {
|
|
threshold: 0.3 + rand(seed, 1001) * 0.08,
|
|
max: 3 + Math.floor(rand(seed, 1002) * 7),
|
|
minDistance: 10,
|
|
seedOffset: 1000,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => {
|
|
const i = indexOf(p.x, p.y);
|
|
let seaEdge = 0;
|
|
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
|
|
const nx = p.x + dx;
|
|
const ny = p.y + dy;
|
|
if (inside(nx, ny) && sea[indexOf(nx, ny)]) seaEdge += 1 / (1 + Math.hypot(dx, dy));
|
|
}
|
|
const harborPotential = p.score + coastalLowland[i] * 0.28 + river[i] * 0.08 + seaEdge * 0.025 - slope[i] * 0.2;
|
|
return { ...p, harborPotential, seaEdge, portClass: "fishing", kind: "Fishing Port" };
|
|
}).sort((a, b) => b.harborPotential - a.harborPotential)
|
|
.map((p, n) => {
|
|
const isLakeLike = p.seaEdge < 0.25 && river[indexOf(p.x, p.y)] > 0.32;
|
|
const portClass = isLakeLike ? "lake" : n === 0 ? "major" : n < 3 && p.harborPotential > 0.34 ? "regional" : "fishing";
|
|
const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port";
|
|
return { ...p, portClass, kind, score: p.harborPotential };
|
|
});
|
|
if (!ports.some((p) => p.portClass === "major")) {
|
|
const fallbackMajor = ports.find((p) => p.portClass !== "lake") || ports[0];
|
|
if (fallbackMajor) {
|
|
fallbackMajor.portClass = "major";
|
|
fallbackMajor.kind = "Major Port";
|
|
fallbackMajor.score += 0.16;
|
|
}
|
|
}
|
|
const majorPorts = ports.filter((p) => p.portClass === "major");
|
|
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
|
|
|
|
let crossings = pickPoints(crossingSuitability, {
|
|
threshold: 0.28 + rand(seed, 1011) * 0.08,
|
|
max: 8 + Math.floor(rand(seed, 1012) * 15),
|
|
minDistance: 8,
|
|
seedOffset: 1010,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "River Crossing" }));
|
|
|
|
let passes = pickPoints(passSuitability, {
|
|
threshold: 0.16 + rand(seed, 1021) * 0.08,
|
|
max: 4 + Math.floor(rand(seed, 1022) * 10),
|
|
minDistance: 9,
|
|
seedOffset: 1020,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Pass" }));
|
|
|
|
const settlementCluster = new Float32Array(SIZE);
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.65 + (deltaField?.[i] || 0) * 0.85;
|
|
const spineBarrier = (arcSpineField?.[i] || 0) * 0.62 + (branchRidgeField?.[i] || 0) * 0.42;
|
|
const valleyCorridor = clamp(valleyField[i] * 0.62 + river[i] * 0.16);
|
|
const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18 + depositional * 0.22);
|
|
const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - spineBarrier * 0.34 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1);
|
|
const localPatch = valueNoise(x * 0.7, y * 0.7, seed + 1037, 10);
|
|
const broadPatch = fbm(x * 0.32 + 71, y * 0.32 - 19, seed + 1038);
|
|
settlementCluster[i] = clamp((valleyCorridor + lowlandCorridor) * terrainGate * (0.72 + broadPatch * 0.42 + localPatch * 0.18));
|
|
}
|
|
}
|
|
|
|
const settlementScore = new Float32Array(SIZE);
|
|
for (let y = 2; y < MAP_H - 2; y++) {
|
|
for (let x = 2; x < MAP_W - 2; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
let nearFeature = 0;
|
|
for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4));
|
|
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.95;
|
|
const spineBarrier = (arcSpineField?.[i] || 0) * 0.48 + (branchRidgeField?.[i] || 0) * 0.34;
|
|
const riverPull = Math.min(0.36, river[i] * 0.14 + valleyField[i] * 0.16 + depositional * 0.08);
|
|
const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52;
|
|
const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] + spineBarrier - 0.22) * (1 - valleyField[i]) * 0.75;
|
|
const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + depositional * 0.13 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - spineBarrier * 0.12 - floodplain[i] * 0.06 - remoteMountainPenalty;
|
|
settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13);
|
|
}
|
|
}
|
|
|
|
let villages = pickPoints(settlementScore, {
|
|
threshold: 0.32 + rand(seed, 1031) * 0.1,
|
|
max: 28 + Math.floor(rand(seed, 1032) * 44),
|
|
minDistance: 3 + Math.floor(rand(seed, 1033) * 3),
|
|
seedOffset: 1030,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Village" }));
|
|
|
|
const marketScore = new Float32Array(SIZE);
|
|
for (let y = 4; y < MAP_H - 4; y++) {
|
|
for (let x = 4; x < MAP_W - 4; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
|
|
let villagePull = 0;
|
|
let nearbyVillages = 0;
|
|
for (const v of villages) {
|
|
const d = Math.hypot(x - v.x, y - v.y);
|
|
if (d < 24) {
|
|
villagePull += 1 / (1 + d);
|
|
nearbyVillages++;
|
|
}
|
|
}
|
|
|
|
let featurePull = 0;
|
|
for (const p of [...ports, ...crossings]) featurePull = Math.max(featurePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 3));
|
|
const confluence = river[i] > 0.36 && valleyField[i] > 0.24 ? 0.12 : 0;
|
|
marketScore[i] = clamp(villagePull * 1.25 + featurePull * 0.34 + plain[i] * 0.2 + basinField[i] * 0.16 + (depositionalLowland?.[i] || 0) * 0.10 + (deltaField?.[i] || 0) * 0.08 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 - (arcSpineField?.[i] || 0) * 0.08 + nearbyVillages * 0.012);
|
|
}
|
|
}
|
|
|
|
let markets = pickPoints(marketScore, {
|
|
threshold: 0.2 + rand(seed, 1041) * 0.08,
|
|
max: 6 + Math.floor(rand(seed, 1042) * 12),
|
|
minDistance: 11,
|
|
seedOffset: 1040,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Market Town" }));
|
|
|
|
const defenseScore = new Float32Array(SIZE);
|
|
for (let y = 3; y < MAP_H - 3; y++) {
|
|
for (let x = 3; x < MAP_W - 3; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const hillShoulder = clamp(1 - Math.abs(elevation[i] - 0.50) / 0.24);
|
|
let riverArms = 0;
|
|
for (const [nx, ny] of neighbors8(x, y)) if (river[indexOf(nx, ny)] > 0.32) riverArms++;
|
|
const confluence = riverArms >= 3 ? 0.38 : riverArms === 2 ? 0.18 : 0;
|
|
const roadJunctionProxy = (
|
|
(distanceToNearest(markets, x, y) < 7 ? 1 : 0) +
|
|
(distanceToNearest(crossings, x, y) < 6 ? 1 : 0) +
|
|
(distanceToNearest(passes, x, y) < 7 ? 1 : 0) +
|
|
(distanceToNearest(commercialPorts, x, y) < 8 ? 1 : 0)
|
|
) >= 2 ? 0.32 : 0;
|
|
const hillEdge = plain[i] > 0.2 && elevation[i] > 0.36 && elevation[i] < 0.62 && (slope[i] > 0.12 || ridgeField[i] > 0.12) ? 0.3 : 0;
|
|
const mountainRidgeCastle = elevation[i] > 0.56 && ridgeField[i] > 0.3 && valleyField[i] > 0.1 ? 0.28 : 0;
|
|
const validCastleSite = confluence > 0 || roadJunctionProxy > 0 || hillEdge > 0 || mountainRidgeCastle > 0;
|
|
defenseScore[i] = validCastleSite
|
|
? clamp(hillShoulder * 0.28 + confluence + roadJunctionProxy + hillEdge + mountainRidgeCastle + slope[i] * 0.05 - floodplain[i] * 0.42 - coastalLowland[i] * 0.12)
|
|
: 0;
|
|
}
|
|
}
|
|
|
|
let castles = pickPoints(defenseScore, {
|
|
threshold: 0.34 + rand(seed, 1051) * 0.08,
|
|
max: 2 + Math.floor(rand(seed, 1052) * 4),
|
|
minDistance: 15,
|
|
seedOffset: 1050,
|
|
predicate: (x, y, i) => !sea[i] && defenseScore[i] > 0,
|
|
}).map((p) => ({
|
|
...p,
|
|
kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle",
|
|
}));
|
|
|
|
function normalEdgePenalty(x, y) {
|
|
if (nearMapEdge(x, y, 1)) return INF;
|
|
if (nearMapEdge(x, y, 2)) return 7;
|
|
if (nearMapEdge(x, y, 4)) return 2.8;
|
|
return 0;
|
|
}
|
|
|
|
function premodernCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const crossingBonus = distanceToNearest(crossings, x, y) < 4 ? 0.65 : 0;
|
|
const passBonus = distanceToNearest(passes, x, y) < 4 ? 0.45 : 0;
|
|
const riverPenalty = river[i] > 0.28 ? (crossingBonus ? 0.45 : 2.4) : 0;
|
|
const highMountain = elevation[i] > 0.72 ? 4.2 : elevation[i] > 0.58 ? 1.4 : 0;
|
|
return Math.max(0.35, 1 + slope[i] * 5.8 + riverPenalty + highMountain + floodplain[i] * 0.62 - plain[i] * 0.32 - valleyField[i] * 0.42 - coastalLowland[i] * 0.12 - passBonus + normalEdgePenalty(x, y) + hash2(x, y, seed + 111) * 0.16);
|
|
}
|
|
|
|
const premodernRoads = [];
|
|
function addPremodernRoad(a, b) {
|
|
const path = aStar(a, b, premodernCost);
|
|
if (path.length > 3) premodernRoads.push(path);
|
|
}
|
|
|
|
for (const castle of castles) {
|
|
const near = pickEntities([...markets, ...ports, ...crossings, ...passes].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - castle.x, p.y - castle.y)) })), { max: 2 + Math.floor(rand(seed, castle.x + castle.y) * 3), minDistance: 1, threshold: 0 });
|
|
for (const p of near) addPremodernRoad(castle, p);
|
|
}
|
|
for (const market of markets) {
|
|
const near = pickEntities([...markets.filter((p) => p !== market), ...ports, ...crossings].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - market.x, p.y - market.y)) })), { max: 1 + Math.floor(rand(seed, market.x + market.y + 20) * 3), minDistance: 1, threshold: 0 });
|
|
for (const p of near) addPremodernRoad(market, p);
|
|
}
|
|
|
|
function urbanSiteSuitability(p) {
|
|
const i = indexOf(p.x, p.y);
|
|
if (sea[i]) return 0;
|
|
const portBonus = p.kind === "Port Town" || p.portClass === "major" || p.portClass === "regional" ? 0.18 : 0;
|
|
const historicalBonus = p.kind === "Market City" || p.kind === "Castle Town" ? 0.05 : 0;
|
|
return clamp(
|
|
plain[i] * 0.46 +
|
|
agriculture[i] * 0.18 +
|
|
basinField[i] * 0.20 +
|
|
coastalLowland[i] * 0.20 +
|
|
valleyField[i] * 0.12 +
|
|
portBonus + historicalBonus -
|
|
slope[i] * 0.58 -
|
|
ridgeField[i] * 0.34 -
|
|
Math.max(0, elevation[i] - 0.55) * 1.35
|
|
);
|
|
}
|
|
|
|
function cityPopulationCap(p) {
|
|
const i = indexOf(p.x, p.y);
|
|
const suitability = urbanSiteSuitability(p);
|
|
if (suitability < 0.18 || elevation[i] > 0.66 || slope[i] > 0.82 || ridgeField[i] > 0.72) return 85000;
|
|
if (suitability < 0.28 || elevation[i] > 0.60 || slope[i] > 0.62) return 180000;
|
|
if (suitability < 0.38) return 420000;
|
|
return INF;
|
|
}
|
|
|
|
let castleTowns = castles.map((c) => ({ x: c.x, y: c.y, score: c.score + 0.45, kind: "Castle Town" }));
|
|
const cityCandidates = [
|
|
...castleTowns.map((p) => ({ ...p, score: p.score + 0.4 })),
|
|
...ports.map((p) => ({ ...p, kind: "Port Town", score: p.score + 0.28 })),
|
|
...markets.map((p) => ({ ...p, kind: "Market City", score: p.score + 0.12 })),
|
|
].map((p) => {
|
|
const i = indexOf(p.x, p.y);
|
|
const suitability = urbanSiteSuitability(p);
|
|
return {
|
|
...p,
|
|
urbanSuitability: suitability,
|
|
score: p.score + suitability * 0.72 - slope[i] * 0.20 - ridgeField[i] * 0.16 - Math.max(0, elevation[i] - 0.58) * 0.78,
|
|
};
|
|
}).filter((p) => p.urbanSuitability >= 0.10 || p.kind === "Castle Town");
|
|
|
|
let modernCities = pickEntities(cityCandidates, {
|
|
max: 7 + Math.floor(rand(seed, 1061) * 10),
|
|
minDistance: 9,
|
|
threshold: 0.33 + rand(seed, 1062) * 0.12,
|
|
seed: seed + 1060,
|
|
}).map((p, n) => {
|
|
const rank = n === 0 ? "Prefectural Capital" : n < 4 ? "Regional Center" : "Small City";
|
|
const r = rand(seed, 1600 + n * 13 + p.x * 3 + p.y);
|
|
const rawScale = Math.pow(1 - n / Math.max(1, cityCandidates.length + 1), 1.55) * 0.58 + Math.pow(r, 3.4) * 0.42;
|
|
const rankBase = rank === "Prefectural Capital" ? 420000 : rank === "Regional Center" ? 115000 : 26000;
|
|
const rankSpread = rank === "Prefectural Capital" ? 1450000 : rank === "Regional Center" ? 520000 : 185000;
|
|
const pi = indexOf(p.x, p.y);
|
|
const suitability = p.urbanSuitability ?? urbanSiteSuitability(p);
|
|
const geographyBoost = clamp(plain[pi] * 0.34 + agriculture[pi] * 0.18 + basinField[pi] * 0.2 + coastalLowland[pi] * 0.18 + valleyField[pi] * 0.12 + suitability * 0.24 + (p.kind === "Port Town" ? 0.22 : 0));
|
|
const rawPopulation = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000;
|
|
const population = Math.min(rawPopulation, cityPopulationCap(p));
|
|
const urbanRadius = clamp(7.5 + Math.sqrt(population) / 80 + (rank === "Prefectural Capital" ? 3.0 : rank === "Regional Center" ? 1.5 : 0), 8, 32);
|
|
const coreRadius = clamp(2.6 + Math.sqrt(population) / 320, 3, 9);
|
|
const urbanWeight = clamp(0.74 + Math.log10(Math.max(10000, population)) * 0.36, 1.15, 3.05);
|
|
return { ...p, population, urbanRadius, coreRadius, urbanWeight, rank, kind: p.kind || "City" };
|
|
});
|
|
|
|
function fallbackCapitalCandidate() {
|
|
const pools = [...markets, ...ports, ...villages].filter((p) => p && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
|
let best = null;
|
|
let bestScore = -INF;
|
|
for (const p of pools) {
|
|
const i = indexOf(p.x, p.y);
|
|
const score = urbanSiteSuitability(p) * 1.6 + plain[i] * 0.32 + populationDensityProxyForCapital(i) + (p.kind?.includes("Port") ? 0.18 : 0) + (p.score || 0);
|
|
if (score > bestScore) { bestScore = score; best = p; }
|
|
}
|
|
if (best) return { ...best, kind: "Market City", population: 360000, urbanRadius: 15, coreRadius: 4.6, urbanWeight: 1.9, score: bestScore };
|
|
|
|
for (let y = 4; y < MAP_H - 4; y++) {
|
|
for (let x = 4; x < MAP_W - 4; x++) {
|
|
const i = indexOf(x, y);
|
|
if (!prefectureMask[i] || sea[i]) continue;
|
|
const score = plain[i] * 0.72 + agriculture[i] * 0.24 + basinField[i] * 0.18 + coastalLowland[i] * 0.14 - slope[i] * 0.72 - ridgeField[i] * 0.32;
|
|
if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Market City" }; }
|
|
}
|
|
}
|
|
return best ? { ...best, population: 320000, urbanRadius: 14, coreRadius: 4.2, urbanWeight: 1.7 } : null;
|
|
}
|
|
|
|
function populationDensityProxyForCapital(i) {
|
|
return settlementScore[i] * 0.18 + marketScore[i] * 0.12;
|
|
}
|
|
|
|
if (modernCities.length === 0 || !modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) {
|
|
const fallbackCapital = fallbackCapitalCandidate();
|
|
if (fallbackCapital) modernCities.unshift(fallbackCapital);
|
|
}
|
|
|
|
if (modernCities.length > 0) {
|
|
modernCities.sort((a, b) => (b.population || 0) + b.score * 90000 - ((a.population || 0) + a.score * 90000));
|
|
let capitalIndex = -1;
|
|
let capitalScore = -INF;
|
|
for (let i = 0; i < modernCities.length; i++) {
|
|
const city = modernCities[i];
|
|
const ci = indexOf(city.x, city.y);
|
|
if (!prefectureMask[ci] || sea[ci]) continue;
|
|
const suitability = urbanSiteSuitability(city);
|
|
const score = suitability * 900000 + (city.population || 0) * 0.55 + (city.score || 0) * 120000 - slope[ci] * 180000 - Math.max(0, elevation[ci] - 0.58) * 360000;
|
|
if (score > capitalScore) { capitalScore = score; capitalIndex = i; }
|
|
}
|
|
if (capitalIndex > 0) modernCities.unshift(modernCities.splice(capitalIndex, 1)[0]);
|
|
const capCell = indexOf(modernCities[0].x, modernCities[0].y);
|
|
const capPopulation = prefectureMask[capCell]
|
|
? Math.max(modernCities[0].population || 0, 620000)
|
|
: Math.min(modernCities[0].population || 0, 180000);
|
|
modernCities[0] = {
|
|
...modernCities[0],
|
|
rank: prefectureMask[capCell] ? "Prefectural Capital" : "Regional Center",
|
|
kind: prefectureMask[capCell] ? "Prefectural Capital" : (modernCities[0].kind || "City"),
|
|
isPrefecturalCapital: Boolean(prefectureMask[capCell]),
|
|
population: capPopulation,
|
|
urbanRadius: prefectureMask[capCell] ? Math.max(modernCities[0].urbanRadius || 0, 18) : modernCities[0].urbanRadius,
|
|
coreRadius: prefectureMask[capCell] ? Math.max(modernCities[0].coreRadius || 0, 5.5) : modernCities[0].coreRadius,
|
|
urbanWeight: prefectureMask[capCell] ? Math.max(modernCities[0].urbanWeight || 0, 2.15) : modernCities[0].urbanWeight,
|
|
};
|
|
for (let i = 1; i < modernCities.length; i++) modernCities[i] = { ...modernCities[i], isPrefecturalCapital: false };
|
|
}
|
|
|
|
const capital = modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || modernCities.find((city) => prefectureMask[indexOf(city.x, city.y)]) || markets.find((p) => prefectureMask[indexOf(p.x, p.y)]) || ports.find((p) => prefectureMask[indexOf(p.x, p.y)]) || { x: Math.floor(MAP_W / 2), y: Math.floor(MAP_H / 2), score: 1, population: 0, urbanRadius: 12, coreRadius: 4, urbanWeight: 1, isPrefecturalCapital: true };
|
|
|
|
const populationDensity = new Float32Array(SIZE);
|
|
let maxPopulationDensity = 0;
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
let density = 0;
|
|
for (const city of modernCities) {
|
|
const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.55);
|
|
const d = Math.hypot(city.x - x, city.y - y);
|
|
const urbanR = Math.max(5, city.urbanRadius || 11);
|
|
const coreR = Math.max(2.4, city.coreRadius || 4);
|
|
density += populationScale * 1.55 / (1 + Math.pow(d / urbanR, 2.35));
|
|
density += populationScale * 1.05 * Math.exp(-(d * d) / (coreR * coreR * 2.2));
|
|
}
|
|
for (const market of markets) {
|
|
const d = Math.hypot(market.x - x, market.y - y);
|
|
density += 0.22 / (1 + Math.pow(d / 7.5, 2.2));
|
|
}
|
|
for (const village of villages) {
|
|
const d = Math.hypot(village.x - x, village.y - y);
|
|
density += 0.055 / (1 + Math.pow(d / 4.2, 2));
|
|
}
|
|
density *= clamp(0.48 + plain[i] * 0.62 + agriculture[i] * 0.14 + basinField[i] * 0.22 + coastalLowland[i] * 0.18 + valleyField[i] * 0.1 - slope[i] * 1.05 - ridgeField[i] * 0.48 - Math.max(0, elevation[i] - 0.58) * 1.05, 0.018, 1.22);
|
|
populationDensity[i] = density;
|
|
if (density > maxPopulationDensity) maxPopulationDensity = density;
|
|
}
|
|
}
|
|
if (maxPopulationDensity > 0) {
|
|
for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxPopulationDensity);
|
|
}
|
|
|
|
function densityValue(x, y) {
|
|
return populationDensity[indexOf(x, y)] || 0;
|
|
}
|
|
|
|
function midDensityAffinity(x, y) {
|
|
const d = densityValue(x, y);
|
|
return clamp(1 - Math.abs(d - 0.38) / 0.38);
|
|
}
|
|
|
|
function transportTier(p) {
|
|
const pop = p?.population || 0;
|
|
if (p?.isPrefecturalCapital || p?.rank === "Prefectural Capital") return 0;
|
|
if (pop >= 900000) return 1;
|
|
if (pop >= 360000) return 2;
|
|
if (pop >= 180000) return 3;
|
|
if (pop >= 90000) return 4;
|
|
if (p?.portClass === "major") return 2;
|
|
if (p?.portClass === "regional") return 3;
|
|
if (p?.kind === "Market Town") return 4;
|
|
if (p?.kind?.includes("Castle")) return 5;
|
|
return 6;
|
|
}
|
|
|
|
function nodeKey(p) {
|
|
return `${p.x},${p.y}`;
|
|
}
|
|
|
|
function addUniqueNode(list, node) {
|
|
if (!node) return;
|
|
const key = nodeKey(node);
|
|
if (!list.some((p) => nodeKey(p) === key)) list.push(node);
|
|
}
|
|
|
|
function pointLineDistanceXY(x, y, a, b) {
|
|
const vx = b.x - a.x;
|
|
const vy = b.y - a.y;
|
|
const len2 = vx * vx + vy * vy;
|
|
if (len2 <= 0.0001) return Math.hypot(x - a.x, y - a.y);
|
|
const t = clamp(((x - a.x) * vx + (y - a.y) * vy) / len2, 0, 1);
|
|
return Math.hypot(x - (a.x + vx * t), y - (a.y + vy * t));
|
|
}
|
|
|
|
function segmentProgressXY(x, y, a, b) {
|
|
const vx = b.x - a.x;
|
|
const vy = b.y - a.y;
|
|
const len2 = vx * vx + vy * vy;
|
|
if (len2 <= 0.0001) return 0;
|
|
return clamp(((x - a.x) * vx + (y - a.y) * vy) / len2, 0, 1);
|
|
}
|
|
|
|
function cellTransportCorridorScore(x, y, mode = "road") {
|
|
if (!inside(x, y)) return -INF;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return -INF;
|
|
const barrier = mountainBarrierPenalty(x, y, mode === "express" ? "express" : mode === "rail" ? "rail" : "road");
|
|
if (barrier >= INF) return -INF;
|
|
const density = densityValue(x, y);
|
|
const midDensity = midDensityAffinity(x, y);
|
|
const lowland = plain[i] * 0.42 + basinField[i] * 0.24 + coastalLowland[i] * 0.22 + valleyField[i] * 0.34 + agriculture[i] * 0.08;
|
|
const terrainCost = slope[i] * (mode === "rail" ? 1.35 : mode === "express" ? 1.18 : 1.0)
|
|
+ ridgeField[i] * 0.62
|
|
+ Math.max(0, elevation[i] - (mode === "rail" ? 0.50 : 0.56)) * 1.18
|
|
+ barrier * (mode === "rail" ? 0.0048 : mode === "express" ? 0.0036 : 0.0038);
|
|
if (mode === "rail") return density * 1.95 + lowland + coastalLowland[i] * 0.22 + valleyField[i] * 0.24 - terrainCost;
|
|
if (mode === "express") return density * 0.82 + midDensity * 1.10 + lowland * 0.62 - Math.max(0, density - 0.88) * 1.35 - terrainCost;
|
|
return density * 1.28 + midDensity * 0.28 + lowland + valleyField[i] * 0.18 - terrainCost;
|
|
}
|
|
|
|
function sampleCorridorValue(a, b, mode = "road", samples = 14) {
|
|
let total = 0;
|
|
let count = 0;
|
|
for (let k = 1; k < samples; k++) {
|
|
const t = k / samples;
|
|
const x = Math.round(a.x + (b.x - a.x) * t);
|
|
const y = Math.round(a.y + (b.y - a.y) * t);
|
|
if (!inside(x, y)) continue;
|
|
const score = cellTransportCorridorScore(x, y, mode);
|
|
if (score <= -INF / 2) continue;
|
|
total += score;
|
|
count++;
|
|
}
|
|
return count ? total / count : -3;
|
|
}
|
|
|
|
function pairTransportScore(a, b, mode = "road") {
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < 4) return -INF;
|
|
const demand = Math.sqrt(Math.max(0.01, transportDemand(a)) * Math.max(0.01, transportDemand(b)));
|
|
const hierarchyDelta = Math.max(0, transportTier(b) - transportTier(a));
|
|
const corridor = sampleCorridorValue(a, b, mode);
|
|
const sameCorridor = sameCorridorAffinity(a, b);
|
|
const distancePenalty = mode === "express" ? d / 58 : mode === "rail" ? d / 48 : d / 42;
|
|
const hierarchyBonus = hierarchyDelta * (mode === "express" ? 0.16 : 0.10);
|
|
const portBonus = (a.portClass || b.portClass) ? (mode === "rail" ? 0.34 : mode === "express" ? 0.18 : 0.26) : 0;
|
|
return demand * (mode === "express" ? 1.08 : mode === "rail" ? 1.18 : 1.0) + corridor * 0.72 + sameCorridor + hierarchyBonus + portBonus - distancePenalty;
|
|
}
|
|
|
|
function buildHierarchicalLinks(nodes, { mode = "road", maxLinks = 10, extraLinks = 3, minDistance = 10, maxDistance = 70, maxDegree = 3, seedOffset = 0 } = {}) {
|
|
const unique = [];
|
|
const seen = new Set();
|
|
for (const node of nodes.filter(Boolean)) {
|
|
const i = indexOf(node.x, node.y);
|
|
if (!inside(node.x, node.y) || sea[i]) continue;
|
|
const key = nodeKey(node);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
unique.push({ ...node, transportTier: transportTier(node), demand: transportDemand(node) });
|
|
}
|
|
const ranked = unique.sort((a, b) => a.transportTier - b.transportTier || b.demand - a.demand || b.score - a.score);
|
|
const degree = new Map();
|
|
const usedPairs = new Set();
|
|
const links = [];
|
|
|
|
function pairKey(a, b) {
|
|
const ak = nodeKey(a);
|
|
const bk = nodeKey(b);
|
|
return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`;
|
|
}
|
|
function tryAdd(a, b, force = false) {
|
|
if (!a || !b || nodeKey(a) === nodeKey(b)) return false;
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < minDistance || d > maxDistance) return false;
|
|
const key = pairKey(a, b);
|
|
if (usedPairs.has(key)) return false;
|
|
if (!force && (getDegree(degree, a) >= maxDegree || getDegree(degree, b) >= maxDegree)) return false;
|
|
usedPairs.add(key);
|
|
incrementDegree(degree, a);
|
|
incrementDegree(degree, b);
|
|
links.push({ a, b, score: pairTransportScore(a, b, mode), distance: d });
|
|
return true;
|
|
}
|
|
|
|
for (let i = 1; i < ranked.length && links.length < maxLinks; i++) {
|
|
const child = ranked[i];
|
|
const parentCandidates = ranked.slice(0, i)
|
|
.filter((parent) => transportTier(parent) <= transportTier(child) && Math.hypot(parent.x - child.x, parent.y - child.y) <= maxDistance)
|
|
.map((parent) => ({ parent, score: pairTransportScore(parent, child, mode) - getDegree(degree, parent) * 0.16 - Math.max(0, getDegree(degree, child) - 1) * 0.22 }))
|
|
.sort((a, b) => b.score - a.score);
|
|
if (parentCandidates[0]) tryAdd(parentCandidates[0].parent, child, true);
|
|
}
|
|
|
|
const candidates = [];
|
|
for (let i = 0; i < ranked.length; i++) {
|
|
for (let j = i + 1; j < ranked.length; j++) {
|
|
const a = ranked[i];
|
|
const b = ranked[j];
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < minDistance || d > maxDistance) continue;
|
|
candidates.push({ a, b, score: pairTransportScore(a, b, mode) + hash2(a.x + b.x, a.y + b.y, seed + seedOffset + i * 31 + j * 37) * 0.05 });
|
|
}
|
|
}
|
|
candidates.sort((a, b) => b.score - a.score);
|
|
let addedExtra = 0;
|
|
for (const c of candidates) {
|
|
if (links.length >= maxLinks || addedExtra >= extraLinks) break;
|
|
if (tryAdd(c.a, c.b)) addedExtra++;
|
|
}
|
|
return links;
|
|
}
|
|
|
|
function pickCorridorWaypoints(a, b, mode = "road", maxCount = 2) {
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < 20) return [];
|
|
const width = mode === "express" ? 10.5 : mode === "rail" ? 8.5 : 9.5;
|
|
const minProgress = 0.18;
|
|
const maxProgress = 0.82;
|
|
const candidates = [];
|
|
const minX = Math.max(1, Math.floor(Math.min(a.x, b.x) - width - 3));
|
|
const maxX = Math.min(MAP_W - 2, Math.ceil(Math.max(a.x, b.x) + width + 3));
|
|
const minY = Math.max(1, Math.floor(Math.min(a.y, b.y) - width - 3));
|
|
const maxY = Math.min(MAP_H - 2, Math.ceil(Math.max(a.y, b.y) + width + 3));
|
|
for (let y = minY; y <= maxY; y++) {
|
|
for (let x = minX; x <= maxX; x++) {
|
|
const progress = segmentProgressXY(x, y, a, b);
|
|
if (progress < minProgress || progress > maxProgress) continue;
|
|
const lineD = pointLineDistanceXY(x, y, a, b);
|
|
if (lineD > width) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const cellScore = cellTransportCorridorScore(x, y, mode);
|
|
if (cellScore <= -INF / 2) continue;
|
|
const centerBias = -Math.abs(progress - 0.5) * 0.15;
|
|
const linePenalty = lineD / width * (mode === "express" ? 0.42 : 0.32);
|
|
const density = densityValue(x, y);
|
|
const densityGate = mode === "express" ? midDensityAffinity(x, y) * 0.22 : density * 0.20;
|
|
const score = cellScore + densityGate + centerBias - linePenalty + hash2(x, y, seed + 6400 + mode.length * 101) * 0.05;
|
|
candidates.push({ x, y, score, progress, kind: `${mode} corridor waypoint` });
|
|
}
|
|
}
|
|
if (!candidates.length) return [];
|
|
const count = Math.min(maxCount, d > 62 ? 2 : 1);
|
|
return pickEntities(candidates, {
|
|
max: count,
|
|
minDistance: Math.max(7, Math.floor(d / 4.2)),
|
|
threshold: mode === "express" ? -0.42 : -0.30,
|
|
seed: seed + 6500 + Math.round(a.x * 13 + a.y * 17 + b.x * 19 + b.y * 23),
|
|
}).sort((p, q) => p.progress - q.progress);
|
|
}
|
|
|
|
function makeDensityAwareTransportCost(baseCost, mode, guidePoints = []) {
|
|
return (x, y, cx, cy) => {
|
|
const base = baseCost(x, y, cx, cy);
|
|
if (base >= INF) return base;
|
|
const density = densityValue(x, y);
|
|
const midDensity = midDensityAffinity(x, y);
|
|
const cityDistance = distanceToNearest(modernCities, x, y);
|
|
let guidePull = 0;
|
|
for (const p of guidePoints) guidePull = Math.max(guidePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 5.8));
|
|
if (mode === "rail") {
|
|
const lowDemandPenalty = Math.max(0, 0.08 - density) * 2.4;
|
|
return Math.max(0.30, base + lowDemandPenalty - density * 0.72 - guidePull * 0.34);
|
|
}
|
|
if (mode === "express") {
|
|
const coreAvoid = cityDistance < 2.6 ? 5.0 : cityDistance < 5.8 ? 1.4 : 0;
|
|
const lowDemandPenalty = Math.max(0, 0.10 - density) * 3.2;
|
|
return Math.max(0.42, base + lowDemandPenalty + coreAvoid - midDensity * 0.36 - Math.min(density, 0.72) * 0.18 - guidePull * 0.20);
|
|
}
|
|
const lowDemandPenalty = Math.max(0, 0.06 - density) * 1.5;
|
|
return Math.max(0.28, base + lowDemandPenalty - density * 0.44 - midDensity * 0.12 - guidePull * 0.26);
|
|
};
|
|
}
|
|
|
|
function routeThroughTransportCorridor(a, b, mode, baseCost, existingPaths, hubs, avoidPoints, options = {}) {
|
|
const start = routePoint(a, mode, a.x * 31 + a.y * 37 + (options.salt || 0));
|
|
const goal = routePoint(b, mode, b.x * 31 + b.y * 37 + 17 + (options.salt || 0));
|
|
const via = pickCorridorWaypoints(start, goal, mode, options.maxWaypoints ?? (mode === "express" ? 1 : 2));
|
|
const terminals = [start, ...via, goal];
|
|
const endpointSet = [start, goal, ...via];
|
|
const guidedBaseCost = makeDensityAwareTransportCost(baseCost, mode, via);
|
|
const path = [];
|
|
for (let i = 0; i < terminals.length - 1; i++) {
|
|
const from = terminals[i];
|
|
const to = terminals[i + 1];
|
|
const cost = makeTransportCost(
|
|
guidedBaseCost,
|
|
[...existingPaths, path],
|
|
hubs,
|
|
endpointSet,
|
|
options.corridorRadius ?? (mode === "express" ? 5 : mode === "rail" ? 5 : 3),
|
|
options.corridorStrength ?? (mode === "express" ? 9.4 : mode === "rail" ? 8.8 : 5.8),
|
|
avoidPoints,
|
|
options.avoidRadius ?? (mode === "express" ? 8.0 : mode === "rail" ? 2.5 : 3.2),
|
|
options.avoidStrength ?? (mode === "express" ? 12.0 : mode === "rail" ? 4.2 : 5.4),
|
|
);
|
|
const segment = aStar(from, to, cost);
|
|
if (segment.length < 2) return { path: [], via, start, goal };
|
|
if (path.length) path.push(...segment.slice(1));
|
|
else path.push(...segment);
|
|
}
|
|
return { path, via, start, goal };
|
|
}
|
|
|
|
function nearPassPoint(x, y, radius = 5) {
|
|
return distanceToNearest(passes, x, y) <= radius;
|
|
}
|
|
|
|
function mountainBarrierPenalty(x, y, type = "rail") {
|
|
const i = indexOf(x, y);
|
|
const e = elevation[i];
|
|
const s = slope[i];
|
|
const pass = nearPassPoint(x, y, type === "express" ? 7 : type === "rail" ? 6 : 5);
|
|
if (e > 0.84) return INF;
|
|
if (pass && e > 0.80 && s > 0.16) return INF;
|
|
if (!pass && e > 0.78) return INF;
|
|
if (!pass && e > 0.70 && s > 0.16) return INF;
|
|
if (!pass && e > 0.66 && s > 0.28) return INF;
|
|
if (!pass && e > 0.72) return type === "express" ? 260 : type === "rail" ? 330 : type === "minor" ? 80 : 155;
|
|
if (!pass && e > 0.64 && s > 0.20) return type === "express" ? 145 : type === "rail" ? 180 : type === "minor" ? 54 : 96;
|
|
const passDiscount = pass ? (type === "minor" ? 0.35 : 0.22) : 1;
|
|
const mountain = Math.max(0, e - 0.48);
|
|
const steep = Math.max(0, s - 0.15);
|
|
const typeFactor = type === "express" ? 360 : type === "rail" ? 430 : type === "minor" ? 115 : 210;
|
|
return (mountain * mountain * typeFactor + steep * steep * 150 + ridgeField[i] * 9.5) * passDiscount;
|
|
}
|
|
|
|
function transportAccessPoint(node, mode = "road", salt = 0) {
|
|
if (!node || nearMapEdge(node.x, node.y, 1) || node.kind === "External Gateway") return node;
|
|
const minR = mode === "express" ? 6 : mode === "rail" ? 2 : 3;
|
|
const maxR = mode === "express" ? 16 : mode === "rail" ? 6 : 8;
|
|
let best = null;
|
|
let bestScore = -INF;
|
|
for (let dy = -maxR; dy <= maxR; dy++) {
|
|
for (let dx = -maxR; dx <= maxR; dx++) {
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < minR || d > maxR) continue;
|
|
const x = node.x + dx;
|
|
const y = node.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const barrier = mode === "express" || mode === "rail" ? mountainBarrierPenalty(x, y, mode) : mountainBarrierPenalty(x, y, "road");
|
|
if (barrier >= INF) continue;
|
|
const targetD = (minR + maxR) * 0.5;
|
|
const flatness = plain[i] * 1.0 + agriculture[i] * 0.2 + valleyField[i] * 0.26 + coastalLowland[i] * 0.16 - slope[i] * 1.22 - ridgeField[i] * 0.72 - Math.max(0, elevation[i] - 0.58) * 2.35;
|
|
const ring = -Math.abs(d - targetD) * 0.08;
|
|
const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12;
|
|
const density = densityValue(x, y);
|
|
const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? density * 0.70 + midDensityAffinity(x, y) * 0.18 - Math.max(0, density - 0.92) * 0.35 : density * 0.24;
|
|
const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12;
|
|
const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = { x, y, score: node.score || 0.5, kind: `${mode} Access`, parent: node };
|
|
}
|
|
}
|
|
}
|
|
return best || node;
|
|
}
|
|
|
|
function routePoint(node, mode, salt = 0) {
|
|
return transportAccessPoint(node, mode, salt);
|
|
}
|
|
|
|
const townAvoidNodes = [...modernCities, ...markets, ...ports];
|
|
|
|
const urbanCenters = modernCities.map((city, n) => {
|
|
let best = { x: city.x, y: city.y, score: city.score + 0.5 };
|
|
let bestScore = -INF;
|
|
const searchR = Math.max(2, Math.round(city.coreRadius));
|
|
for (let dy = -searchR; dy <= searchR; dy++) {
|
|
for (let dx = -searchR; dx <= searchR; dx++) {
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
const score = plain[i] * 0.54 + agriculture[i] * 0.16 - slope[i] * 0.36 - d * 0.06 + hash2(x, y, seed + 1700 + n) * 0.07;
|
|
if (score > bestScore) { bestScore = score; best = { x, y, score: city.score + 0.5, cityIndex: n, parent: city }; }
|
|
}
|
|
}
|
|
return { ...best, kind: city.rank === "Prefectural Capital" ? "Central Business District" : "Urban Center", population: Math.round(city.population * (city.rank === "Prefectural Capital" ? 0.18 : 0.12)), insidePrefecture: Boolean(prefectureMask[indexOf(best.x, best.y)]) };
|
|
});
|
|
|
|
function railCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "rail");
|
|
if (barrier >= INF) return INF;
|
|
const density = densityValue(x, y);
|
|
const highPenalty = Math.max(0, elevation[i] - 0.52) * 14 + barrier;
|
|
const riverPenalty = river[i] > 0.5 ? 1.6 : river[i] > 0.25 ? 0.7 : 0;
|
|
return Math.max(0.42, 1 + slope[i] * 22 + highPenalty + riverPenalty + floodplain[i] * 0.28 - density * 0.88 - plain[i] * 0.28 - valleyField[i] * 0.62 - coastalLowland[i] * 0.48 + ridgeField[i] * 1.4 + normalEdgePenalty(x, y) + hash2(x, y, seed + 222) * 0.08);
|
|
}
|
|
|
|
const railways = [];
|
|
const branchRailways = [];
|
|
const railDegree = new Map();
|
|
const railCore = [capital];
|
|
const railHubs = [...modernCities, ...commercialPorts];
|
|
|
|
function addRailRoute(a, b, bucket = railways) {
|
|
const existingRails = [...railways, ...branchRailways];
|
|
const { path } = routeThroughTransportCorridor(a, b, "rail", railCost, existingRails, railHubs, townAvoidNodes, {
|
|
salt: a.x * 19 + a.y * 23 + b.x * 7 + b.y * 11,
|
|
maxWaypoints: bucket === railways ? 2 : 1,
|
|
corridorRadius: 5,
|
|
corridorStrength: bucket === railways ? 10.8 : 8.4,
|
|
avoidRadius: 2.4,
|
|
avoidStrength: 4.2,
|
|
});
|
|
const length = pathLength(path);
|
|
const direct = pathEndpointDistance(path);
|
|
const overlap = pathOverlapRatio(path, existingRails, 2);
|
|
const densityPurpose = averagePathField(path, populationDensity) * 1.22 + averagePathField(path, plain) * 0.24 + averagePathField(path, valleyField) * 0.22 + averagePathField(path, coastalLowland) * 0.16;
|
|
const isMain = bucket === railways;
|
|
if (path.length > 3 && direct >= (isMain ? 18 : 12) && length >= (isMain ? 22 : 14) && pathCompactness(path) < (isMain ? 3.25 : 3.5) && overlap < (isMain ? 0.32 : 0.22) && densityPurpose > (isMain ? 0.20 : 0.13)) {
|
|
bucket.push(path);
|
|
incrementDegree(railDegree, a);
|
|
incrementDegree(railDegree, b);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const transportCities = modernCities.filter((city) => (city.population || 0) >= 120000);
|
|
const railBackboneNodes = [capital, ...transportCities, ...commercialPorts.filter((p) => p.portClass !== "fishing")];
|
|
const mainRailLinks = buildHierarchicalLinks(railBackboneNodes, {
|
|
mode: "rail",
|
|
maxLinks: 3 + Math.floor(rand(seed, 1070) * 3),
|
|
extraLinks: 1,
|
|
minDistance: 16,
|
|
maxDistance: 72,
|
|
maxDegree: 3,
|
|
seedOffset: 1070,
|
|
});
|
|
for (const link of mainRailLinks) {
|
|
if (addRailRoute(link.a, link.b, railways)) {
|
|
addUniqueNode(railCore, link.a);
|
|
addUniqueNode(railCore, link.b);
|
|
}
|
|
}
|
|
|
|
const branchRailNodes = [capital, ...railCore, ...modernCities.filter((city) => city !== capital && (city.population || 0) < 220000), ...majorPorts];
|
|
const branchRailLinks = buildHierarchicalLinks(branchRailNodes, {
|
|
mode: "rail",
|
|
maxLinks: 5 + Math.floor(rand(seed, 1071) * 4),
|
|
extraLinks: 1,
|
|
minDistance: 12,
|
|
maxDistance: 54,
|
|
maxDegree: 2,
|
|
seedOffset: 1071,
|
|
});
|
|
for (const link of branchRailLinks) {
|
|
if (railways.length && addRailRoute(link.a, link.b, branchRailways)) {
|
|
addUniqueNode(railCore, link.a);
|
|
addUniqueNode(railCore, link.b);
|
|
}
|
|
}
|
|
|
|
compactPathArray(railways, { minLength: 17, maxOverlap: 0.34, maxCount: 5 });
|
|
compactPathArray(branchRailways, { minLength: 11, maxOverlap: 0.22, maxCount: 9 });
|
|
|
|
const railInfluence = influenceFromPaths([...railways, ...branchRailways], 5);
|
|
const stationCandidates = [
|
|
...modernCities.map((p, i) => ({ ...routePoint(p, "rail", 1900 + i), score: p.score + 0.46, kind: "Major Station", population: p.population })),
|
|
...railways.flatMap((path) => samplePath(path, 18 + Math.floor(rand(seed, path.length) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.52 + agriculture[indexOf(p.x, p.y)] * 0.2 })),
|
|
...branchRailways.flatMap((path) => samplePath(path, 16 + Math.floor(rand(seed, path.length + 99) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.42 + agriculture[indexOf(p.x, p.y)] * 0.2 })),
|
|
];
|
|
|
|
let stations = pickEntities(stationCandidates, { max: 14 + Math.floor(rand(seed, 1080) * 22), minDistance: 6, threshold: 0.38, seed: seed + 1080 });
|
|
|
|
const industrialScore = new Float32Array(SIZE);
|
|
for (let y = 4; y < MAP_H - 4; y++) {
|
|
for (let x = 4; x < MAP_W - 4; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const nearPort = 1 / (1 + distanceToNearest(majorPorts.length ? majorPorts : commercialPorts, x, y) / 5);
|
|
const nearCity = distanceToNearest(modernCities, x, y);
|
|
const cityEdge = nearCity > 5 && nearCity < 20 ? 0.22 : nearCity <= 5 ? -0.25 : 0;
|
|
industrialScore[i] = clamp(plain[i] * 0.24 + coastalLowland[i] * 0.24 + railInfluence[i] * 0.38 + nearPort * 0.58 + river[i] * 0.04 + cityEdge - slope[i] * 0.36 - ridgeField[i] * 0.18 - floodplain[i] * 0.03);
|
|
}
|
|
}
|
|
|
|
let industrialZones = pickPoints(industrialScore, {
|
|
threshold: 0.31 + rand(seed, 1091) * 0.09,
|
|
max: 4 + Math.floor(rand(seed, 1092) * 13),
|
|
minDistance: 10,
|
|
seedOffset: 1090,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Industrial Zone" }));
|
|
|
|
function roadCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "road");
|
|
if (barrier >= INF) return INF;
|
|
const density = densityValue(x, y);
|
|
const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0;
|
|
return Math.max(0.35, 1 + slope[i] * 17.8 + barrier + Math.max(0, elevation[i] - 0.54) * 9.2 + nodeAvoid + (river[i] > 0.45 ? 0.85 : 0) + floodplain[i] * 0.22 - density * 0.50 - plain[i] * 0.22 - valleyField[i] * 0.28 - coastalLowland[i] * 0.20 + ridgeField[i] * 1.15 + normalEdgePenalty(x, y) + hash2(x, y, seed + 333) * 0.08);
|
|
}
|
|
|
|
function expresswayCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "express");
|
|
if (barrier >= INF) return INF;
|
|
const density = densityValue(x, y);
|
|
const midDensity = midDensityAffinity(x, y);
|
|
const cityDistance = distanceToNearest(modernCities, x, y);
|
|
const coreAvoid = cityDistance < 2.2 ? 18.0 : cityDistance < 4.5 ? 7.0 : cityDistance < 7.5 ? 2.0 : 0;
|
|
const marketAvoid = distanceToNearest(markets, x, y) < 2.5 ? 1.8 : 0;
|
|
const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.8 : 0;
|
|
const urbanCorridorBonus = density * 1.18 + midDensity * 0.28 + (cityDistance >= 4 && cityDistance <= 16 ? 0.40 : 0);
|
|
const constructionCost = 0.58 + slope[i] * 28.0 + barrier * 1.10 + Math.max(0, elevation[i] - 0.60) * 16.0 + ridgeField[i] * 1.45 + (river[i] > 0.45 ? 1.15 : river[i] * 0.45);
|
|
return Math.max(0.50, 1.18 + constructionCost + coreAvoid + marketAvoid + lowDensityPenalty - urbanCorridorBonus - plain[i] * 0.12 - valleyField[i] * 0.12 - coastalLowland[i] * 0.12 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015);
|
|
}
|
|
|
|
const nationalRoads = [];
|
|
const roadDegree = new Map();
|
|
function transportDemand(p) {
|
|
const pop = Math.sqrt(Math.max(0, p.population || 0)) / 700;
|
|
const capitalBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 2.1 : 0;
|
|
const portBoost = p.portClass === "major" ? 1.4 : p.portClass === "regional" ? 0.8 : p.portClass ? 0.35 : 0;
|
|
const historyBoost = p.kind?.includes("Castle") ? 0.55 : p.kind === "Market Town" ? 0.42 : 0;
|
|
const gatewayBoost = p.kind === "External Gateway" ? 1.1 : 0;
|
|
return 0.35 + pop + capitalBoost + portBoost + historyBoost + gatewayBoost;
|
|
}
|
|
|
|
function sameCorridorAffinity(a, b) {
|
|
const ai = indexOf(a.x, a.y);
|
|
const bi = indexOf(b.x, b.y);
|
|
return Math.min(0.6, (basinField[ai] + basinField[bi]) * 0.14 + (valleyField[ai] + valleyField[bi]) * 0.10 + (coastalLowland[ai] + coastalLowland[bi]) * 0.10);
|
|
}
|
|
|
|
const roadTargetCandidates = [...modernCities.filter((p) => (p.population || 0) >= 110000), ...ports, ...markets, ...castles]
|
|
.map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.34 + ((p.population || 0) >= 220000 ? 0.30 : 0.05) + densityValue(p.x, p.y) * 0.20 }));
|
|
const pickedRoadTargets = pickEntities(roadTargetCandidates, {
|
|
max: 8 + Math.floor(rand(seed, 1101) * 9),
|
|
minDistance: 9,
|
|
threshold: 0.1,
|
|
seed: seed + 1100,
|
|
});
|
|
const roadTargets = [
|
|
capital,
|
|
...pickedRoadTargets
|
|
.filter((p) => Math.hypot(p.x - capital.x, p.y - capital.y) > 2)
|
|
.sort((a, b) => transportDemand(b) - transportDemand(a)),
|
|
];
|
|
const roadHubs = [...modernCities, ...ports, ...markets, ...stations];
|
|
const roadCore = [capital];
|
|
|
|
function addNationalRoad(a, b) {
|
|
const existing = [...nationalRoads, ...railways, ...branchRailways];
|
|
const { path } = routeThroughTransportCorridor(a, b, "road", roadCost, existing, roadHubs, townAvoidNodes, {
|
|
salt: a.x * 31 + a.y * 37 + b.x * 13 + b.y * 17,
|
|
maxWaypoints: 2,
|
|
corridorRadius: 3,
|
|
corridorStrength: 7.0,
|
|
avoidRadius: 2.8,
|
|
avoidStrength: 4.4,
|
|
});
|
|
const direct = pathEndpointDistance(path);
|
|
const urbanPasses = modernCities.filter((city) => path.some(([x, y]) => Math.hypot(x - city.x, y - city.y) <= Math.max(6, Math.min(13, (city.urbanRadius || 8) * 0.78)))).length;
|
|
const densityPurpose = averagePathField(path, populationDensity) * 1.28 + averagePathField(path, plain) * 0.18 + averagePathField(path, valleyField) * 0.16;
|
|
const passBonusOk = urbanPasses >= 1 || densityPurpose > 0.16 || direct >= 20;
|
|
if (path.length > 3 && direct >= 12 && pathLength(path) >= 14 && pathCompactness(path) < 4.15 && pathOverlapRatio(path, existing, 2) < 0.74 && passBonusOk) {
|
|
nationalRoads.push(path);
|
|
incrementDegree(roadDegree, a);
|
|
incrementDegree(roadDegree, b);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function addNationalRoadRelaxed(a, b, bucket = nationalRoads) {
|
|
if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 5) return false;
|
|
const existing = [...nationalRoads, ...bucket, ...railways, ...branchRailways];
|
|
const { path } = routeThroughTransportCorridor(a, b, "road", roadCost, existing, roadHubs, townAvoidNodes, {
|
|
salt: a.x * 47 + a.y * 53 + b.x * 59 + b.y * 61,
|
|
maxWaypoints: 2,
|
|
corridorRadius: 4,
|
|
corridorStrength: 8.2,
|
|
avoidRadius: 2.3,
|
|
avoidStrength: 3.2,
|
|
});
|
|
const direct = pathEndpointDistance(path);
|
|
const densityPurpose = averagePathField(path, populationDensity) * 1.06 + averagePathField(path, plain) * 0.14 + averagePathField(path, valleyField) * 0.18 + averagePathField(path, coastalLowland) * 0.10;
|
|
const usefulInside = path.some(([x, y]) => prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]);
|
|
if (path.length > 3 && usefulInside && direct >= 8 && pathLength(path) >= 10 && pathCompactness(path) < 5.05 && pathOverlapRatio(path, existing, 2) < 0.86 && densityPurpose > 0.070) {
|
|
bucket.push(path);
|
|
incrementDegree(roadDegree, a);
|
|
incrementDegree(roadDegree, b);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function coverageRoadCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const rawBarrier = mountainBarrierPenalty(x, y, "road");
|
|
if (rawBarrier >= INF && elevation[i] > 0.84) return INF;
|
|
const barrier = rawBarrier >= INF ? 90 + Math.max(0, elevation[i] - 0.66) * 160 + slope[i] * 34 : rawBarrier * 0.38;
|
|
const density = densityValue(x, y);
|
|
return Math.max(0.34, 1 + slope[i] * 9.8 + barrier + Math.max(0, elevation[i] - 0.58) * 4.8 + ridgeField[i] * 0.45 + (river[i] > 0.5 ? 0.75 : 0) - density * 0.86 - plain[i] * 0.20 - valleyField[i] * 0.30 - coastalLowland[i] * 0.16 + normalEdgePenalty(x, y) * 0.4 + hash2(x, y, seed + 338) * 0.04);
|
|
}
|
|
|
|
function nationalRoadCorridorScore(path) {
|
|
if (!path || !path.length) return -1;
|
|
const density = averagePathField(path, populationDensity);
|
|
const lowland = averagePathField(path, plain);
|
|
const valley = averagePathField(path, valleyField);
|
|
const coast = averagePathField(path, coastalLowland);
|
|
const avgSlope = averagePathField(path, slope);
|
|
const avgElevation = averagePathField(path, elevation);
|
|
// Low-density valley/coastal corridors are allowed. The score is meant to
|
|
// reject truly roadless mountain/ridge alignments, not rural national roads.
|
|
return density * 0.46 + lowland * 0.24 + valley * 0.24 + coast * 0.16 - avgSlope * 0.18 - Math.max(0, avgElevation - 0.60) * 0.15;
|
|
}
|
|
|
|
function isBackcountryNationalRoad(path, a, b) {
|
|
const corridor = nationalRoadCorridorScore(path);
|
|
const endpointWeight = nationalRoadPopulationWeight(a) + nationalRoadPopulationWeight(b);
|
|
const direct = pathEndpointDistance(path);
|
|
const valley = averagePathField(path, valleyField);
|
|
const coast = averagePathField(path, coastalLowland);
|
|
const lowland = averagePathField(path, plain);
|
|
const naturalCorridor = valley * 0.8 + coast * 0.65 + lowland * 0.55;
|
|
const endpointDensity = Math.max(densityValue(a.x, a.y), densityValue(b.x, b.y));
|
|
const remoteEndpoint = endpointDensity < 0.10 && endpointWeight < 52000;
|
|
const longRemote = direct > 34 && corridor < 0.075 && naturalCorridor < 0.16;
|
|
return (corridor < 0.045 && naturalCorridor < 0.13) || (remoteEndpoint && corridor < 0.070 && naturalCorridor < 0.18) || longRemote;
|
|
}
|
|
|
|
function addNationalRoadCoverageFallback(a, b, bucket = nationalRoads) {
|
|
if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 5) return false;
|
|
const existing = [...nationalRoads, ...bucket, ...railways, ...branchRailways].filter(Boolean);
|
|
const path = aStar(a, b, makeTransportCost(coverageRoadCost, existing, roadHubs, [a, b], 4, 8.2, townAvoidNodes, 2.0, 3.0));
|
|
const direct = pathEndpointDistance(path);
|
|
const usefulInside = path.some(([x, y]) => prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]);
|
|
if (path.length > 4 && usefulInside && direct >= 7 && pathLength(path) < 150 && pathCompactness(path) < 6.9 && !isBackcountryNationalRoad(path, a, b)) {
|
|
bucket.push(path);
|
|
incrementDegree(roadDegree, a);
|
|
incrementDegree(roadDegree, b);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const roadLinks = buildHierarchicalLinks(roadTargets, {
|
|
mode: "road",
|
|
maxLinks: 7 + Math.floor(rand(seed, 1102) * 5),
|
|
extraLinks: 3,
|
|
minDistance: 13,
|
|
maxDistance: 62,
|
|
maxDegree: 3,
|
|
seedOffset: 1102,
|
|
});
|
|
for (const link of roadLinks) {
|
|
if (addNationalRoad(link.a, link.b)) {
|
|
addUniqueNode(roadCore, link.a);
|
|
addUniqueNode(roadCore, link.b);
|
|
}
|
|
}
|
|
|
|
// National roads should behave like long trunk corridors: they intentionally
|
|
// pass near as many urbanized cells/cities as possible, unlike expressways.
|
|
const trunkCities = modernCities
|
|
.filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 90000)
|
|
.slice()
|
|
.sort((a, b) => a.x - b.x || a.y - b.y);
|
|
for (let i = 0; i < trunkCities.length - 1; i++) {
|
|
const a = trunkCities[i];
|
|
const b = trunkCities[i + 1];
|
|
const d = a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
|
|
if (a && b && d >= 13 && d <= 58 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b);
|
|
}
|
|
// Add a second, sparse north-south / inland-coastal layer so towns are not
|
|
// only chained left-to-right. This helps yellow national roads pass through
|
|
// multiple towns instead of ending as isolated spurs.
|
|
const verticalTrunkCities = trunkCities.slice().sort((a, b) => a.y - b.y || a.x - b.x);
|
|
for (let i = 0; i < verticalTrunkCities.length - 2; i += 3) {
|
|
const a = verticalTrunkCities[i];
|
|
const b = verticalTrunkCities[Math.min(verticalTrunkCities.length - 1, i + 2)];
|
|
const d = a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
|
|
if (a && b && d >= 20 && d <= 62 && (a.population || 0) >= 110000 && (b.population || 0) >= 110000 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b);
|
|
}
|
|
|
|
function uniqueByCell(nodes) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const node of nodes.filter(Boolean)) {
|
|
if (!inside(node.x, node.y) || sea[indexOf(node.x, node.y)]) continue;
|
|
const key = nodeKey(node);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(node);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function internalNationalRoadCellCount(paths = nationalRoads) {
|
|
const seen = new Set();
|
|
for (const path of paths) {
|
|
for (const [x, y] of path || []) {
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (prefectureMask[i] && !sea[i]) seen.add(`${x},${y}`);
|
|
}
|
|
}
|
|
return seen.size;
|
|
}
|
|
|
|
|
|
function nationalRoadPopulationWeight(node) {
|
|
if (!node) return 0;
|
|
const pop = Math.max(0, node.population || 0);
|
|
if (pop > 0) return pop;
|
|
if (node.portClass === "major") return 180000;
|
|
if (node.portClass === "regional") return 90000;
|
|
if (node.portClass) return 35000;
|
|
if (node.kind === "Market Town" || node.kind === "Market City") return 55000;
|
|
if (node.kind?.includes("Castle")) return 45000;
|
|
return 18000;
|
|
}
|
|
|
|
function nationalRoadPopulationCoverage(paths = nationalRoads, radius = 7.0) {
|
|
const ruralNodes = villages
|
|
.filter((v) => prefectureMask[indexOf(v.x, v.y)] && !sea[indexOf(v.x, v.y)] && (transportDemand(v) > 0.24 || settlementCluster[indexOf(v.x, v.y)] > 0.33))
|
|
.sort((a, b) => transportDemand(b) - transportDemand(a))
|
|
.slice(0, 18);
|
|
const nodes = uniqueByCell([
|
|
capital,
|
|
...modernCities.filter((city) => ((city.population || 0) >= 60000 || city.isPrefecturalCapital)),
|
|
...ports.filter((p) => p.portClass !== "fishing"),
|
|
...markets,
|
|
...castleTowns,
|
|
...ruralNodes,
|
|
]);
|
|
let total = 0;
|
|
let covered = 0;
|
|
const uncovered = [];
|
|
for (const node of nodes) {
|
|
const weight = nationalRoadPopulationWeight(node);
|
|
if (weight <= 0) continue;
|
|
total += weight;
|
|
const d = nearestPathCellDistance(node, paths);
|
|
if (d <= radius) covered += weight;
|
|
else uncovered.push({ node, weight, distance: d, score: weight * (1 + Math.min(2.8, d / 12)) + transportDemand(node) * 48000 });
|
|
}
|
|
uncovered.sort((a, b) => b.score - a.score);
|
|
const uncoveredPopulation = Math.max(0, total - covered);
|
|
return { ratio: total ? covered / total : 1, total, covered, uncoveredPopulation, uncovered };
|
|
}
|
|
|
|
// Metropolitan national roads are split by role: yellow radial roads connect
|
|
// the large city to neighbouring cities/ports; white ring roads are generated
|
|
// later as ordinary urban ring roads.
|
|
const metroRoadHubs = uniqueByCell([capital, ...modernCities.filter((city) => city !== capital && (city.population || 0) >= 240000)])
|
|
.filter((city) => prefectureMask[indexOf(city.x, city.y)])
|
|
.slice(0, 4);
|
|
|
|
function addMetroRadialNationalRoads() {
|
|
let added = 0;
|
|
for (const hub of metroRoadHubs) {
|
|
const maxRadials = (hub.population || 0) >= 900000 || hub.isPrefecturalCapital ? 5 : 3;
|
|
const bySector = new Map();
|
|
const candidates = uniqueByCell([
|
|
...modernCities.filter((city) => city !== hub && (city.population || 0) >= 70000),
|
|
...ports.filter((p) => p.portClass !== "fishing"),
|
|
...markets,
|
|
]);
|
|
for (const node of candidates) {
|
|
if (!prefectureMask[indexOf(node.x, node.y)]) continue;
|
|
const d = Math.hypot(node.x - hub.x, node.y - hub.y);
|
|
if (d < 9 || d > 46) continue;
|
|
const angle = Math.atan2(node.y - hub.y, node.x - hub.x);
|
|
const sector = Math.floor(((angle + Math.PI) / (Math.PI * 2)) * 8);
|
|
const coveredPenalty = nearestPathCellDistance(node, nationalRoads) <= 6.2 ? 0.72 : 0;
|
|
const score = pairTransportScore(hub, node, "road") + transportDemand(node) * 0.44 + densityValue(node.x, node.y) * 0.22 - getDegree(roadDegree, node) * 0.16 - coveredPenalty;
|
|
const old = bySector.get(sector);
|
|
if (!old || score > old.score) bySector.set(sector, { node, score, d });
|
|
}
|
|
const sectorTargets = [...bySector.values()].sort((a, b) => b.score - a.score);
|
|
let made = 0;
|
|
for (const { node } of sectorTargets) {
|
|
if (made >= maxRadials) break;
|
|
if (getDegree(roadDegree, hub) >= 10 || getDegree(roadDegree, node) >= 7) continue;
|
|
if (addNationalRoad(hub, node) || addNationalRoadRelaxed(hub, node) || addNationalRoadCoverageFallback(hub, node)) {
|
|
made++;
|
|
added++;
|
|
}
|
|
}
|
|
}
|
|
return added;
|
|
}
|
|
|
|
function ensureInternalNationalRoadCoverage(maxAdded = 7) {
|
|
let added = 0;
|
|
const minimumCells = Math.max(72, Math.floor((MAP_W + MAP_H) * 0.58));
|
|
const targetPopulationCoverage = 0.85;
|
|
const maxUncoveredPopulation = 50000;
|
|
const minimumNetworkPaths = 10;
|
|
const hasEnoughCells = () => internalNationalRoadCellCount() >= minimumCells && nationalRoads.length >= minimumNetworkPaths;
|
|
const coverageState = () => nationalRoadPopulationCoverage(nationalRoads, 8.5);
|
|
const hasEnoughPopulationCoverage = () => {
|
|
const state = coverageState();
|
|
return state.ratio >= targetPopulationCoverage && state.uncoveredPopulation <= maxUncoveredPopulation;
|
|
};
|
|
if (hasEnoughPopulationCoverage() && nationalRoads.length >= minimumNetworkPaths) return added;
|
|
|
|
const populationNodes = uniqueByCell([
|
|
capital,
|
|
...modernCities.filter((city) => (city.population || 0) >= 85000),
|
|
...ports.filter((p) => p.portClass === "major" || p.portClass === "regional"),
|
|
...markets.filter((p) => transportDemand(p) > 0.35),
|
|
...castleTowns.filter((p) => transportDemand(p) > 0.35),
|
|
]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a));
|
|
const internalNodes = uniqueByCell([
|
|
capital,
|
|
...modernCities.filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 85000),
|
|
...ports.filter((p) => prefectureMask[indexOf(p.x, p.y)] && (p.portClass === "major" || p.portClass === "regional")),
|
|
...markets.filter((p) => prefectureMask[indexOf(p.x, p.y)] && transportDemand(p) > 0.35),
|
|
...castleTowns.filter((p) => prefectureMask[indexOf(p.x, p.y)] && transportDemand(p) > 0.35),
|
|
]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a));
|
|
if (populationNodes.length < 2 && internalNodes.length < 2) return added;
|
|
|
|
function targetIsWorthNationalRoad(node) {
|
|
if (!node || node === capital) return false;
|
|
const w = nationalRoadPopulationWeight(node);
|
|
if (w >= 135000) return true;
|
|
if (node.portClass === "major" || node.portClass === "regional") return true;
|
|
if (densityValue(node.x, node.y) >= 0.18 && w >= 75000) return true;
|
|
if (w >= 18000 && transportDemand(node) >= 0.28 && (valleyField[indexOf(node.x, node.y)] > 0.20 || coastalLowland[indexOf(node.x, node.y)] > 0.18 || plain[indexOf(node.x, node.y)] > 0.36)) return true;
|
|
return false;
|
|
}
|
|
|
|
function tryCoverageLink(a, b) {
|
|
if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 6) return false;
|
|
const before = coverageState().ratio;
|
|
const oldCount = nationalRoads.length;
|
|
if (!(addNationalRoadRelaxed(a, b) || addNationalRoadCoverageFallback(a, b))) return false;
|
|
const path = nationalRoads[nationalRoads.length - 1];
|
|
const after = coverageState().ratio;
|
|
if (isBackcountryNationalRoad(path, a, b) && after - before < 0.025) {
|
|
nationalRoads.splice(oldCount, nationalRoads.length - oldCount);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const currentCoverage = coverageState();
|
|
const uncoveredPopulationTargets = currentCoverage.uncovered.map((item) => item.node).filter(targetIsWorthNationalRoad);
|
|
const internalSpanNodes = internalNodes.length >= 2 ? internalNodes : populationNodes;
|
|
const byX = internalSpanNodes.slice().sort((a, b) => a.x - b.x);
|
|
const byY = internalSpanNodes.slice().sort((a, b) => a.y - b.y);
|
|
const edgeBackstops = uniqueByCell([byX[0], byX[byX.length - 1], byY[0], byY[byY.length - 1]])
|
|
.filter((node) => targetIsWorthNationalRoad(node) && Math.hypot(node.x - capital.x, node.y - capital.y) >= 9 && densityValue(node.x, node.y) >= 0.12)
|
|
.sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a));
|
|
const ruralBackboneTargets = villages
|
|
.filter((v) => prefectureMask[indexOf(v.x, v.y)] && !sea[indexOf(v.x, v.y)] && nearestPathCellDistance(v, nationalRoads) > 7.0)
|
|
.filter((v) => transportDemand(v) >= 0.28 && (valleyField[indexOf(v.x, v.y)] > 0.20 || coastalLowland[indexOf(v.x, v.y)] > 0.18 || plain[indexOf(v.x, v.y)] > 0.36))
|
|
.sort((a, b) => transportDemand(b) - transportDemand(a))
|
|
.slice(0, 5);
|
|
const primaryTargets = uniqueByCell([
|
|
...uncoveredPopulationTargets.slice(0, 5),
|
|
...edgeBackstops.slice(0, 2),
|
|
...ruralBackboneTargets,
|
|
...populationNodes.filter(targetIsWorthNationalRoad).slice(0, 4),
|
|
]).filter((node) => node !== capital && Math.hypot(node.x - capital.x, node.y - capital.y) >= 7);
|
|
|
|
for (const target of primaryTargets) {
|
|
if (added >= maxAdded || hasEnoughPopulationCoverage()) break;
|
|
if (nearestPathCellDistance(target, nationalRoads) <= 6.5) continue;
|
|
if (tryCoverageLink(capital, target)) added++;
|
|
}
|
|
|
|
const orderedByPopulation = populationNodes
|
|
.filter((node) => targetIsWorthNationalRoad(node) && nearestPathCellDistance(node, nationalRoads) > 7.0)
|
|
.sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a));
|
|
for (const target of orderedByPopulation) {
|
|
if (added >= maxAdded || hasEnoughPopulationCoverage()) break;
|
|
const anchor = populationNodes
|
|
.filter((node) => nodeKey(node) !== nodeKey(target) && nearestPathCellDistance(node, nationalRoads) <= 5.8)
|
|
.sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0] || capital;
|
|
const d = Math.hypot(anchor.x - target.x, anchor.y - target.y);
|
|
if (d < 7 || d > 50) continue;
|
|
if (getDegree(roadDegree, anchor) >= 7 || getDegree(roadDegree, target) >= 5) continue;
|
|
if (tryCoverageLink(anchor, target)) added++;
|
|
}
|
|
|
|
// Cell-count backstop is deliberately weak: use it only when both network
|
|
// shape and population coverage are poor. This avoids forcing yellow roads
|
|
// into sparsely inhabited mountain or peninsula tips just to hit 100% coverage.
|
|
if (!hasEnoughCells() && coverageState().ratio < 0.80) {
|
|
for (const chain of [byX, byY]) {
|
|
if (added >= maxAdded) break;
|
|
for (let i = 0; i < chain.length - 1; i += 3) {
|
|
if (added >= maxAdded || hasEnoughCells() || hasEnoughPopulationCoverage()) break;
|
|
const a = chain[i];
|
|
const b = chain[i + 1];
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < 10 || d > 42) continue;
|
|
if (!targetIsWorthNationalRoad(a) && !targetIsWorthNationalRoad(b)) continue;
|
|
if (getDegree(roadDegree, a) >= 6 || getDegree(roadDegree, b) >= 6) continue;
|
|
if (tryCoverageLink(a, b)) added++;
|
|
}
|
|
}
|
|
}
|
|
return added;
|
|
}
|
|
|
|
const metroRadialNationalRoadsAdded = addMetroRadialNationalRoads();
|
|
const internalNationalRoadFallbacks = ensureInternalNationalRoadCoverage(8);
|
|
|
|
const expressways = [];
|
|
const expressDegree = new Map();
|
|
const expressCore = [capital];
|
|
|
|
function snapPathToExistingExpressways(path, existingPaths, radius = 2.4) {
|
|
if (!path?.length || !existingPaths?.length) return path || [];
|
|
const snapped = [];
|
|
const skipEnd = Math.min(5, Math.floor(path.length / 5));
|
|
for (let pi = 0; pi < path.length; pi++) {
|
|
const [x, y] = path[pi];
|
|
let best = null;
|
|
let bestD = radius;
|
|
if (pi >= skipEnd && pi < path.length - skipEnd) {
|
|
for (const existing of existingPaths) {
|
|
for (const [ex, ey] of existing) {
|
|
const d = Math.hypot(x - ex, y - ey);
|
|
if (d < bestD) { bestD = d; best = [ex, ey]; }
|
|
}
|
|
}
|
|
}
|
|
const next = best || [x, y];
|
|
const last = snapped[snapped.length - 1];
|
|
if (!last || last[0] !== next[0] || last[1] !== next[1]) snapped.push(next);
|
|
}
|
|
return snapped;
|
|
}
|
|
|
|
|
|
function addExpressway(a, b, bucket = expressways) {
|
|
const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways];
|
|
let { path } = routeThroughTransportCorridor(a, b, "express", expresswayCost, existing, roadHubs, townAvoidNodes, {
|
|
salt: a.x * 41 + a.y * 43 + b.x * 19 + b.y * 29,
|
|
maxWaypoints: 1,
|
|
corridorRadius: 5,
|
|
corridorStrength: 10.8,
|
|
avoidRadius: 8.5,
|
|
avoidStrength: 14.0,
|
|
});
|
|
path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.54 && elevation[indexOf(x, y)] < 0.78, 12);
|
|
path = snapPathToExistingExpressways(path, expressways, 2.4);
|
|
const direct = pathEndpointDistance(path);
|
|
const densityPurpose = averagePathField(path, populationDensity) * 0.8 + averagePathField(path, plain) * 0.16 + averagePathField(path, coastalLowland) * 0.12;
|
|
const turnScore = pathTurnScore(path);
|
|
const deviation = pathLateralDeviationRatio(path);
|
|
const compact = pathCompactness(path);
|
|
if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && compact < 2.28 && turnScore < 0.64 && deviation < 0.36 && pathOverlapRatio(path, existing, 2) < 0.34 && densityPurpose > 0.11) {
|
|
bucket.push(path);
|
|
incrementDegree(expressDegree, a);
|
|
incrementDegree(expressDegree, b);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const expressNodes = [capital, ...modernCities.filter((p) => (p.population || 0) >= 220000), ...majorPorts.filter((p) => p.portClass === "major")];
|
|
const expressLinks = buildHierarchicalLinks(expressNodes, {
|
|
mode: "express",
|
|
maxLinks: 1 + Math.floor(rand(seed, 1120) * 2),
|
|
extraLinks: rand(seed, 1121) > 0.72 ? 1 : 0,
|
|
minDistance: 26,
|
|
maxDistance: 86,
|
|
maxDegree: 2,
|
|
seedOffset: 1120,
|
|
});
|
|
for (const link of expressLinks) {
|
|
if (addExpressway(link.a, link.b)) {
|
|
addUniqueNode(expressCore, link.a);
|
|
addUniqueNode(expressCore, link.b);
|
|
}
|
|
}
|
|
|
|
const ringRoads = [];
|
|
const ringExpressways = [];
|
|
const ringRailways = [];
|
|
|
|
function ringAnchorCandidates(city, mode, targetRadius, sectors = 8) {
|
|
const anchors = [];
|
|
const minR = Math.max(5, targetRadius - 5);
|
|
const maxR = targetRadius + 7;
|
|
for (let s = 0; s < sectors; s++) {
|
|
const angle0 = (s / sectors) * Math.PI * 2;
|
|
let best = null;
|
|
let bestScore = -INF;
|
|
for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) {
|
|
for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) {
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < minR || d > maxR) continue;
|
|
const angle = Math.atan2(dy, dx);
|
|
let delta = Math.abs(Math.atan2(Math.sin(angle - angle0), Math.cos(angle - angle0)));
|
|
if (delta > Math.PI / sectors * 0.95) continue;
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || !prefectureMask[i]) continue;
|
|
const barrier = mode === "road" ? mountainBarrierPenalty(x, y, "road") : mountainBarrierPenalty(x, y, mode === "express" ? "express" : "rail");
|
|
if (barrier >= INF) continue;
|
|
const density = densityValue(x, y);
|
|
const densityTerm = mode === "rail" ? density * 0.75 : mode === "express" ? midDensityAffinity(x, y) * 0.72 : density * 0.28 + midDensityAffinity(x, y) * 0.22;
|
|
const score = plain[i] * 0.72 + agriculture[i] * 0.12 + densityTerm - slope[i] * 1.25 - Math.max(0, elevation[i] - 0.58) * 1.3 - barrier * 0.01 - Math.abs(d - targetRadius) * 0.035 + hash2(x, y, seed + 4100 + s * 37 + mode.length * 101) * 0.08;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = { x, y, score, kind: `${mode} ring anchor`, parent: city };
|
|
}
|
|
}
|
|
}
|
|
if (best) anchors.push(best);
|
|
}
|
|
return anchors;
|
|
}
|
|
|
|
function ringCost(baseCost, city, targetRadius, mode) {
|
|
return (x, y, cx, cy) => {
|
|
const base = baseCost(x, y, cx, cy);
|
|
if (base >= INF) return base;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
const tooClose = Math.max(0, targetRadius * 0.46 - d);
|
|
const tooFar = Math.max(0, d - targetRadius * 1.55);
|
|
const bandPenalty = tooClose * 0.34 + tooFar * 0.16 + Math.abs(d - targetRadius) * 0.018;
|
|
const density = densityValue(x, y);
|
|
const densityBias = mode === "rail" ? -density * 0.42 : mode === "express" ? -midDensityAffinity(x, y) * 0.32 + Math.max(0, density - 0.82) * 0.8 : -density * 0.12;
|
|
return Math.max(0.36, base + bandPenalty + densityBias);
|
|
};
|
|
}
|
|
|
|
function softRingRailCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
const barrier = mountainBarrierPenalty(x, y, "rail");
|
|
if (sea[i] || barrier >= INF) return INF;
|
|
const density = densityValue(x, y);
|
|
return Math.max(0.38, 1 + slope[i] * 14 + barrier + Math.max(0, elevation[i] - 0.56) * 22 + (river[i] > 0.5 ? 1.3 : river[i] * 0.6) - density * 0.62 - plain[i] * 0.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7222) * 0.05);
|
|
}
|
|
|
|
function softRingExpressCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
const barrier = mountainBarrierPenalty(x, y, "express");
|
|
if (sea[i] || barrier >= INF) return INF;
|
|
return Math.max(0.38, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.58) * 20 + (river[i] > 0.5 ? 1.0 : river[i] * 0.5) - midDensityAffinity(x, y) * 0.42 - plain[i] * 0.14 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7444) * 0.05);
|
|
}
|
|
|
|
function addEnvironmentalRing(city, mode, bucket, baseCost, existingPaths, targetRadius) {
|
|
const anchors = ringAnchorCandidates(city, mode, targetRadius, mode === "road" ? 7 : 8);
|
|
if (anchors.length < 3) return 0;
|
|
let made = 0;
|
|
const cost = ringCost(baseCost, city, targetRadius, mode);
|
|
for (let i = 0; i < anchors.length - (anchors.length < 4 ? 1 : 0); i++) {
|
|
const a = anchors[i];
|
|
const b = anchors[(i + 1) % anchors.length];
|
|
if (Math.hypot(a.x - b.x, a.y - b.y) > targetRadius * 1.85) continue;
|
|
const path = aStar(a, b, makeTransportCost(cost, [...existingPaths, ...bucket], roadHubs, [a, b], mode === "road" ? 3 : 4, mode === "road" ? 4.8 : 7.0, townAvoidNodes, mode === "express" ? 3.8 : 2.2, mode === "express" ? 4.8 : 2.8));
|
|
if (path.length >= 5 && path.length <= targetRadius * 8.0) {
|
|
bucket.push(path);
|
|
made++;
|
|
}
|
|
}
|
|
return made;
|
|
}
|
|
|
|
function flexibleRingAnchors(city, targetRadius, maxAnchors = 6) {
|
|
const candidates = [];
|
|
const maxR = targetRadius + 11;
|
|
const minR = Math.max(5, targetRadius * 0.45);
|
|
for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) {
|
|
for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) {
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < minR || d > maxR) continue;
|
|
const x = city.x + dx;
|
|
const y = city.y + dy;
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || !prefectureMask[i] || elevation[i] > 0.82) continue;
|
|
const score = plain[i] * 0.7 + midDensityAffinity(x, y) * 0.32 + densityValue(x, y) * 0.2 - slope[i] * 1.15 - Math.max(0, elevation[i] - 0.58) * 0.88 - Math.abs(d - targetRadius) * 0.02 + hash2(x, y, seed + 7555) * 0.06;
|
|
candidates.push({ x, y, score, angle: Math.atan2(dy, dx), kind: "flexible ring anchor", parent: city });
|
|
}
|
|
}
|
|
return pickEntities(candidates, { max: maxAnchors, minDistance: 5, threshold: -1, seed: seed + city.x * 83 + city.y * 89 })
|
|
.sort((a, b) => a.angle - b.angle);
|
|
}
|
|
|
|
function addLooseEnvironmentalRing(city, bucket, baseCost, targetRadius) {
|
|
let anchors = ringAnchorCandidates(city, "road", targetRadius, 6);
|
|
if (anchors.length < 3) anchors = flexibleRingAnchors(city, targetRadius, 6);
|
|
if (anchors.length < 2) return 0;
|
|
let made = 0;
|
|
for (let i = 0; i < anchors.length; i++) {
|
|
const a = anchors[i];
|
|
const b = anchors[(i + 1) % anchors.length];
|
|
const path = aStar(a, b, (x, y, cx, cy) => {
|
|
const base = baseCost(x, y, cx, cy);
|
|
if (base >= INF) return INF;
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
const band = Math.max(0, targetRadius * 0.42 - d) * 0.22 + Math.max(0, d - targetRadius * 1.7) * 0.14 + Math.abs(d - targetRadius) * 0.012;
|
|
return Math.max(0.3, base + band);
|
|
});
|
|
if (path.length >= 4 && path.length <= targetRadius * 9.0) {
|
|
bucket.push(path);
|
|
made++;
|
|
}
|
|
}
|
|
return made;
|
|
}
|
|
|
|
const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 160000).slice(0, 5);
|
|
let metroRingRoadSegmentsAdded = 0;
|
|
for (const city of mediumRingCities) {
|
|
const radius = clamp(8 + Math.sqrt(city.population || 100000) / 175, 10, 22);
|
|
metroRingRoadSegmentsAdded += addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...railways, ...branchRailways], radius);
|
|
}
|
|
const largeRingCities = uniqueByCell([...metroRoadHubs, ...modernCities.filter((c) => (c.population || 0) >= 420000)]).slice(0, 3);
|
|
for (const city of largeRingCities) {
|
|
const roadRadius = clamp(10 + Math.sqrt(city.population || 400000) / 155, 13, 28);
|
|
const railRadius = Math.max(8, roadRadius - 4);
|
|
const roadRingSegments = addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...expressways, ...railways, ...branchRailways], roadRadius);
|
|
metroRingRoadSegmentsAdded += roadRingSegments || addLooseEnvironmentalRing(city, ringRoads, roadCost, roadRadius);
|
|
// Expressway rings are intentionally disabled; expressways stay as sparse interurban corridors.
|
|
const railRingSegments = addEnvironmentalRing(city, "rail", ringRailways, railCost, [...railways, ...branchRailways, ...nationalRoads, ...expressways], railRadius);
|
|
if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius);
|
|
}
|
|
ringExpressways.length = 0;
|
|
compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: 18 });
|
|
compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: 8 });
|
|
|
|
const gatewayCandidates = [];
|
|
for (let x = 0; x < MAP_W; x++) for (const y of [0, MAP_H - 1]) {
|
|
const i = indexOf(x, y);
|
|
if (!sea[i]) {
|
|
const density = densityValue(x, y);
|
|
gatewayCandidates.push({
|
|
x, y,
|
|
side: y === 0 ? "N" : "S",
|
|
score: plain[i] * 0.9 + agriculture[i] * 0.35 + valleyField[i] * 0.42 + coastalLowland[i] * 0.28 + density * 0.55 + (1 - slope[i]) * 0.42 - Math.max(0, elevation[i] - 0.56) * 1.8 - ridgeField[i] * 0.42
|
|
});
|
|
}
|
|
}
|
|
for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) {
|
|
const i = indexOf(x, y);
|
|
if (!sea[i]) {
|
|
const density = densityValue(x, y);
|
|
gatewayCandidates.push({
|
|
x, y,
|
|
side: x === 0 ? "W" : "E",
|
|
score: plain[i] * 0.9 + agriculture[i] * 0.35 + valleyField[i] * 0.42 + coastalLowland[i] * 0.28 + density * 0.55 + (1 - slope[i]) * 0.42 - Math.max(0, elevation[i] - 0.56) * 1.8 - ridgeField[i] * 0.42
|
|
});
|
|
}
|
|
}
|
|
|
|
const minExternalGatewayCount = Math.min(5, gatewayCandidates.length);
|
|
const targetGatewayCount = Math.min(gatewayCandidates.length, 4 + Math.floor(rand(seed, 1201) * 3));
|
|
let externalGateways = pickEntities(gatewayCandidates, {
|
|
max: targetGatewayCount,
|
|
minDistance: 20,
|
|
threshold: 0.18,
|
|
seed: seed + 1201,
|
|
}).map((p) => ({ ...p, kind: "External Gateway" }));
|
|
if (externalGateways.length < minExternalGatewayCount) {
|
|
const fallbackGateways = gatewayCandidates
|
|
.slice()
|
|
.sort((a, b) => b.score - a.score);
|
|
for (const gate of fallbackGateways) {
|
|
if (externalGateways.some((p) => Math.hypot(p.x - gate.x, p.y - gate.y) < 18)) continue;
|
|
externalGateways.push({ ...gate, kind: "External Gateway" });
|
|
if (externalGateways.length >= minExternalGatewayCount) break;
|
|
}
|
|
}
|
|
|
|
function externalRoadCost(goal) {
|
|
return (x, y) => {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "road");
|
|
if (barrier >= INF) return INF;
|
|
const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0;
|
|
const density = densityValue(x, y);
|
|
const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0;
|
|
return Math.max(0.35, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.54) * 7.5 + nodeAvoid + (river[i] > 0.45 ? 0.9 : 0) + floodplain[i] * 0.24 - density * 0.3 - plain[i] * 0.24 + borderPenalty + hash2(x, y, seed + 333) * 0.06);
|
|
};
|
|
}
|
|
function externalExpresswayCost(goal) {
|
|
return (x, y) => {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "express");
|
|
if (barrier >= INF) return INF;
|
|
const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0;
|
|
const density = densityValue(x, y);
|
|
const cityDistance = distanceToNearest(modernCities, x, y);
|
|
const coreAvoid = cityDistance < 2.2 ? 16.0 : cityDistance < 4.5 ? 6.0 : cityDistance < 7.5 ? 1.8 : 0;
|
|
const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.2 : 0;
|
|
const urbanCorridorBonus = density * 1.04 + midDensityAffinity(x, y) * 0.24 + (cityDistance >= 4 && cityDistance <= 16 ? 0.32 : 0);
|
|
const constructionCost = 0.55 + slope[i] * 23.0 + barrier * 1.06 + Math.max(0, elevation[i] - 0.60) * 12.0 + ridgeField[i] * 1.20 + (river[i] > 0.45 ? 1 : river[i] * 0.38);
|
|
return Math.max(0.50, 1.14 + constructionCost + coreAvoid + lowDensityPenalty + floodplain[i] * 0.18 + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 444) * 0.04);
|
|
};
|
|
}
|
|
function externalRailCost(goal) {
|
|
return (x, y) => {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "rail");
|
|
if (barrier >= INF) return INF;
|
|
const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 9 : nearMapEdge(x, y, 3) ? 1.8 : 0;
|
|
const density = densityValue(x, y);
|
|
return Math.max(0.42, 1 + slope[i] * 22 + barrier + Math.max(0, elevation[i] - 0.52) * 14 + (river[i] > 0.45 ? 1.2 : 0) + borderPenalty - density * 1.0 - plain[i] * 0.28 + hash2(x, y, seed + 222) * 0.05);
|
|
};
|
|
}
|
|
|
|
const externalRoads = [];
|
|
const externalExpressways = [];
|
|
const externalRailways = [];
|
|
const nationalRoadBranchRoads = [];
|
|
|
|
function selectExternalStart(pool, gate, degreeMap, maxDegree = 2) {
|
|
const sorted = pool
|
|
.filter(Boolean)
|
|
.map((p) => ({ ...p, d: Math.hypot(p.x - gate.x, p.y - gate.y), degree: getDegree(degreeMap, p) }))
|
|
.sort((a, b) => a.d + a.degree * 16 + (a.degree >= maxDegree ? 30 : 0) - (b.d + b.degree * 16 + (b.degree >= maxDegree ? 30 : 0)));
|
|
return sorted.find((p) => p.degree < maxDegree) || sorted[0] || capital;
|
|
}
|
|
|
|
externalGateways.forEach((gate, idx) => {
|
|
// Always lay a national-road class gateway link first. Expressways are
|
|
// additional sparse corridors; they should not replace the ordinary trunk
|
|
// road connection to the neighbouring prefecture.
|
|
const roadStartRaw = selectExternalStart([...roadCore, ...modernCities, ...ports, ...markets], gate, roadDegree, 4);
|
|
const roadStart = routePoint(roadStartRaw, "road", gate.x * 53 + gate.y * 59);
|
|
const roadExisting = [...nationalRoads, ...externalRoads, ...expressways, ...externalExpressways, ...railways, ...branchRailways];
|
|
let roadPath = aStar(roadStart, gate, makeTransportCost(externalRoadCost(gate), roadExisting, roadHubs, [roadStart, gate], 3, 7.0, townAvoidNodes, 3.2, 5.6));
|
|
if (roadPath.length > 6) {
|
|
externalRoads.push(roadPath);
|
|
incrementDegree(roadDegree, roadStartRaw);
|
|
incrementDegree(roadDegree, gate);
|
|
addUniqueNode(roadCore, gate);
|
|
}
|
|
|
|
const makeExpressLink = idx === 0 || idx === 1 || rand(seed, 1210 + idx) > 0.58;
|
|
if (makeExpressLink) {
|
|
const expressStartRaw = selectExternalStart([...expressCore, ...roadCore, ...modernCities, ...ports], gate, expressDegree, 3);
|
|
const expressStart = routePoint(expressStartRaw, "express", gate.x * 71 + gate.y * 73);
|
|
const expressExisting = [...expressways, ...externalExpressways, ...nationalRoads, ...externalRoads, ...railways, ...branchRailways];
|
|
let expressPath = aStar(expressStart, gate, makeTransportCost(externalExpresswayCost(gate), expressExisting, roadHubs, [expressStart, gate], 4, 8.2, townAvoidNodes, 5.4, 7.8));
|
|
expressPath = smoothPathByLineOfSight(expressPath, (x, y) => externalExpresswayCost(gate)(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.56 && elevation[indexOf(x, y)] < 0.80, 12);
|
|
expressPath = snapPathToExistingExpressways(expressPath, [...expressways, ...externalExpressways], 2.4);
|
|
const direct = pathEndpointDistance(expressPath);
|
|
const densityPurpose = averagePathField(expressPath, populationDensity) * 0.72 + averagePathField(expressPath, plain) * 0.14 + averagePathField(expressPath, coastalLowland) * 0.10;
|
|
const turnScore = pathTurnScore(expressPath);
|
|
const deviation = pathLateralDeviationRatio(expressPath);
|
|
if (expressPath.length > 6 && direct >= 18 && pathLength(expressPath) >= 20 && pathCompactness(expressPath) < 2.40 && turnScore < 0.66 && deviation < 0.42 && densityPurpose > 0.08) {
|
|
externalExpressways.push(expressPath);
|
|
incrementDegree(expressDegree, expressStartRaw);
|
|
incrementDegree(expressDegree, gate);
|
|
expressCore.push(gate);
|
|
}
|
|
}
|
|
|
|
if ((idx === 0 || rand(seed, 1220 + idx) > 0.5) && modernCities.length > 0) {
|
|
const railStartRaw = selectExternalStart([...railCore, ...modernCities, ...ports], gate, railDegree, 2);
|
|
const railStart = routePoint(railStartRaw, "rail", gate.x * 61 + gate.y * 67);
|
|
const railExisting = [...railways, ...branchRailways, ...externalRailways, ...nationalRoads, ...externalRoads, ...expressways, ...externalExpressways];
|
|
const railPath = aStar(railStart, gate, makeTransportCost(externalRailCost(gate), railExisting, railHubs, [railStart, gate], 4, 8.2, townAvoidNodes, 2.5, 4.4));
|
|
if (railPath.length > 6) {
|
|
externalRailways.push(railPath);
|
|
incrementDegree(railDegree, railStartRaw);
|
|
incrementDegree(railDegree, gate);
|
|
}
|
|
}
|
|
});
|
|
|
|
function nearestOtherTrunkCell(node, ownPath, paths, minDistance = 3.5) {
|
|
let best = null;
|
|
let bestD = INF;
|
|
for (const path of paths) {
|
|
if (!path || path === ownPath) continue;
|
|
for (let k = 0; k < path.length; k += Math.max(1, Math.floor(path.length / 46))) {
|
|
const [x, y] = path[k];
|
|
const d = Math.hypot(node.x - x, node.y - y);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = { x, y, d };
|
|
}
|
|
}
|
|
}
|
|
return best && bestD >= minDistance ? best : null;
|
|
}
|
|
|
|
function terminalIsConnectedToNationalRoad(node, ownPath = null, extraPaths = [], radius = 3.8) {
|
|
if (nearMapEdge(node.x, node.y, 4) || distanceToNearest(externalGateways, node.x, node.y) <= 4.2) return true;
|
|
const paths = [...nationalRoads, ...externalRoads, ...extraPaths];
|
|
for (const path of paths) {
|
|
if (!path || path === ownPath) continue;
|
|
const step = Math.max(1, Math.floor(path.length / 64));
|
|
for (let k = 0; k < path.length; k += step) {
|
|
const [x, y] = path[k];
|
|
if (Math.hypot(node.x - x, node.y - y) <= radius) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function terminalImportance(node) {
|
|
let best = 0;
|
|
for (const city of modernCities) {
|
|
const d = Math.hypot(node.x - city.x, node.y - city.y);
|
|
if (d > 6.5) continue;
|
|
if (city.isPrefecturalCapital || city.rank === "Prefectural Capital") best = Math.max(best, 4);
|
|
else if ((city.population || 0) >= 180000) best = Math.max(best, 3);
|
|
else if ((city.population || 0) >= 90000) best = Math.max(best, 2);
|
|
else best = Math.max(best, 1);
|
|
}
|
|
for (const port of ports) {
|
|
const d = Math.hypot(node.x - port.x, node.y - port.y);
|
|
if (d > 6.5) continue;
|
|
if (port.portClass === "major") best = Math.max(best, 3);
|
|
else if (port.portClass === "regional") best = Math.max(best, 2);
|
|
else best = Math.max(best, 1);
|
|
}
|
|
for (const market of markets) if (Math.hypot(node.x - market.x, node.y - market.y) <= 5.5) best = Math.max(best, 1);
|
|
for (const castle of castles) if (Math.hypot(node.x - castle.x, node.y - castle.y) <= 5.5) best = Math.max(best, 1);
|
|
return best;
|
|
}
|
|
|
|
function repairNationalRoadDeadEnds() {
|
|
const repairs = [];
|
|
const trunkPaths = () => [...nationalRoads, ...externalRoads, ...repairs];
|
|
let repairCount = 0;
|
|
for (let pass = 0; pass < 3; pass++) {
|
|
for (const path of nationalRoads) {
|
|
if (!path || path.length < 8) continue;
|
|
const terminals = [
|
|
{ x: path[0][0], y: path[0][1] },
|
|
{ x: path[path.length - 1][0], y: path[path.length - 1][1] },
|
|
];
|
|
for (const terminal of terminals) {
|
|
if (repairCount >= 28) break;
|
|
if (terminalIsConnectedToNationalRoad(terminal, path, repairs, 3.8)) continue;
|
|
const target = nearestOtherTrunkCell(terminal, path, trunkPaths(), 4.0);
|
|
if (!target || target.d > 38) continue;
|
|
const existing = [...nationalRoads, ...externalRoads, ...repairs];
|
|
const repairPath = aStar(terminal, target, makeTransportCost(roadCost, existing, roadHubs, [terminal, target], 2, 7.4, townAvoidNodes, 2.8, 4.6));
|
|
if (repairPath.length >= 4 && repairPath.length <= 68 && pathCompactness(repairPath) < 4.6 && pathOverlapRatio(repairPath, existing, 2) < 0.76) {
|
|
repairs.push(repairPath);
|
|
repairCount++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
nationalRoads.push(...repairs);
|
|
return repairs.length;
|
|
}
|
|
const nationalRoadDeadEndRepairs = repairNationalRoadDeadEnds();
|
|
|
|
function demoteUnresolvedNationalRoadBranches() {
|
|
let demoted = 0;
|
|
for (let i = nationalRoads.length - 1; i >= 0; i--) {
|
|
const path = nationalRoads[i];
|
|
if (!path || path.length < 8) continue;
|
|
const a = { x: path[0][0], y: path[0][1] };
|
|
const b = { x: path[path.length - 1][0], y: path[path.length - 1][1] };
|
|
const aConnected = terminalIsConnectedToNationalRoad(a, path, [], 3.8);
|
|
const bConnected = terminalIsConnectedToNationalRoad(b, path, [], 3.8);
|
|
const deadCount = (aConnected ? 0 : 1) + (bConnected ? 0 : 1);
|
|
if (!deadCount) continue;
|
|
const aImportance = terminalImportance(a);
|
|
const bImportance = terminalImportance(b);
|
|
const importantTrunk = Math.max(aImportance, bImportance) >= 3 || (aImportance >= 2 && bImportance >= 2 && pathEndpointDistance(path) >= 24);
|
|
const looksLikeBranch = deadCount >= 2 || !importantTrunk || pathLength(path) < 34;
|
|
if (!looksLikeBranch) continue;
|
|
nationalRoads.splice(i, 1);
|
|
nationalRoadBranchRoads.push(path);
|
|
demoted++;
|
|
}
|
|
return demoted;
|
|
}
|
|
const nationalRoadBranchDemotions = demoteUnresolvedNationalRoadBranches();
|
|
const postDemotionInternalNationalRoadFallbacks = ensureInternalNationalRoadCoverage(3);
|
|
|
|
function terminalIsConnectedToRail(node, ownPath = null, extraPaths = [], radius = 3.8) {
|
|
if (nearMapEdge(node.x, node.y, 4) || distanceToNearest(externalGateways, node.x, node.y) <= 4.2) return true;
|
|
const paths = [...railways, ...branchRailways, ...externalRailways, ...ringRailways, ...extraPaths];
|
|
for (const path of paths) {
|
|
if (!path || path === ownPath) continue;
|
|
const step = Math.max(1, Math.floor(path.length / 64));
|
|
for (let k = 0; k < path.length; k += step) {
|
|
const [x, y] = path[k];
|
|
if (Math.hypot(node.x - x, node.y - y) <= radius) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function railTerminalImportance(node) {
|
|
let best = 0;
|
|
for (const city of modernCities) {
|
|
const d = Math.hypot(node.x - city.x, node.y - city.y);
|
|
if (d > 6.5) continue;
|
|
if (city.isPrefecturalCapital || city.rank === "Prefectural Capital") best = Math.max(best, 4);
|
|
else if ((city.population || 0) >= 220000) best = Math.max(best, 3);
|
|
else if ((city.population || 0) >= 120000) best = Math.max(best, 2);
|
|
else best = Math.max(best, 1);
|
|
}
|
|
for (const port of ports) {
|
|
const d = Math.hypot(node.x - port.x, node.y - port.y);
|
|
if (d > 6.5) continue;
|
|
if (port.portClass === "major") best = Math.max(best, 2);
|
|
else if (port.portClass === "regional") best = Math.max(best, 1);
|
|
}
|
|
for (const station of stations) if (Math.hypot(node.x - station.x, node.y - station.y) <= 5.0) best = Math.max(best, 1);
|
|
return best;
|
|
}
|
|
|
|
function repairRailDeadEnds() {
|
|
const repairs = [];
|
|
const currentPaths = () => [...railways, ...branchRailways, ...externalRailways, ...ringRailways, ...repairs];
|
|
for (let pass = 0; pass < 3; pass++) {
|
|
for (const path of [...railways, ...branchRailways, ...externalRailways]) {
|
|
if (!path || path.length < 8) continue;
|
|
for (const terminal of [{ x: path[0][0], y: path[0][1] }, { x: path[path.length - 1][0], y: path[path.length - 1][1] }]) {
|
|
if (terminalIsConnectedToRail(terminal, path, repairs, 3.8)) continue;
|
|
let target = nearestOtherTrunkCell(terminal, path, currentPaths(), 4.0);
|
|
if ((!target || target.d > 48) && railTerminalImportance(terminal) >= 2) {
|
|
const candidates = [...modernCities, ...ports, ...stations]
|
|
.map((p) => ({ p, d: Math.hypot(terminal.x - p.x, terminal.y - p.y) }))
|
|
.filter(({ d }) => d >= 6 && d <= 42)
|
|
.sort((a, b) => a.d - b.d);
|
|
for (const { p } of candidates) {
|
|
const access = routePoint(p, "rail", 9400 + p.x * 17 + p.y * 19);
|
|
if (terminalIsConnectedToRail(access, path, repairs, 3.8)) { target = access; break; }
|
|
}
|
|
}
|
|
if (!target || (target.d && target.d > 52)) continue;
|
|
const existing = currentPaths();
|
|
const repairPath = aStar(terminal, target, makeTransportCost(railCost, existing, railHubs, [terminal, target], 4, 8.4, townAvoidNodes, 2.0, 3.8));
|
|
if (repairPath.length >= 4 && repairPath.length <= 60 && pathCompactness(repairPath) < 4.0 && pathTurnScore(repairPath) < 0.86 && pathOverlapRatio(repairPath, existing, 2) < 0.84) {
|
|
repairs.push(repairPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
branchRailways.push(...repairs);
|
|
return repairs.length;
|
|
}
|
|
const railDeadEndRepairs = repairRailDeadEnds();
|
|
|
|
let throughExpresswayAdded = false;
|
|
function throughExpresswayCost(a, b) {
|
|
return (x, y) => {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "express");
|
|
if (barrier >= INF) return INF;
|
|
const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3;
|
|
const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.2 : 0;
|
|
const density = densityValue(x, y);
|
|
const cityDistance = distanceToNearest(modernCities, x, y);
|
|
const coreAvoid = cityDistance < 2.0 ? 12.0 : cityDistance < 4.5 ? 4.8 : cityDistance < 7.5 ? 1.4 : 0;
|
|
const lowDensityPenalty = density < 0.08 ? (0.08 - density) * 4.0 : 0;
|
|
const urbanCorridorBonus = density * 1.00 + midDensityAffinity(x, y) * 0.22 + (cityDistance >= 4 && cityDistance <= 18 ? 0.34 : 0);
|
|
return Math.max(0.48, 1.16 + slope[i] * 21.0 + barrier * 1.08 + Math.max(0, elevation[i] - 0.62) * 13.0 + coreAvoid + lowDensityPenalty + (river[i] > 0.45 ? 1.0 : 0) + borderPenalty - urbanCorridorBonus - plain[i] * 0.14 - coastalLowland[i] * 0.12 + hash2(x, y, seed + 9101) * 0.03);
|
|
};
|
|
}
|
|
|
|
function permissiveThroughExpresswayCost(a, b) {
|
|
return (x, y) => {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) return 24 + nearMapEdge(x, y, 2) * 2 + hash2(x, y, seed + 9202) * 0.2;
|
|
const rawBarrier = mountainBarrierPenalty(x, y, "express");
|
|
const tunnelBarrier = rawBarrier >= INF ? 120 + Math.max(0, elevation[i] - 0.66) * 260 + slope[i] * 55 : rawBarrier;
|
|
const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3;
|
|
const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.0 : 0;
|
|
const cityDistance = distanceToNearest(modernCities, x, y);
|
|
const coreAvoid = cityDistance < 2.0 ? 9.0 : cityDistance < 4.5 ? 3.5 : 0;
|
|
const density = densityValue(x, y);
|
|
const urbanCorridorBonus = density * 0.85 + midDensityAffinity(x, y) * 0.18 + (cityDistance >= 4 && cityDistance <= 18 ? 0.24 : 0);
|
|
return Math.max(0.52, 1.18 + slope[i] * 14.0 + tunnelBarrier * 0.42 + Math.max(0, elevation[i] - 0.66) * 18.0 + coreAvoid + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 9201) * 0.035);
|
|
};
|
|
}
|
|
|
|
function pointToSegmentDistance(p, a, b) {
|
|
const vx = b.x - a.x;
|
|
const vy = b.y - a.y;
|
|
const len2 = vx * vx + vy * vy;
|
|
if (len2 <= 0.0001) return Math.hypot(p.x - a.x, p.y - a.y);
|
|
const t = clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len2, 0, 1);
|
|
return Math.hypot(p.x - (a.x + vx * t), p.y - (a.y + vy * t));
|
|
}
|
|
|
|
function pathTurnScore(path) {
|
|
if (!path || path.length < 3) return 0;
|
|
let total = 0;
|
|
let count = 0;
|
|
for (let i = 1; i < path.length - 1; i++) {
|
|
const [x0, y0] = path[i - 1];
|
|
const [x1, y1] = path[i];
|
|
const [x2, y2] = path[i + 1];
|
|
const ax = x1 - x0;
|
|
const ay = y1 - y0;
|
|
const bx = x2 - x1;
|
|
const by = y2 - y1;
|
|
const al = Math.hypot(ax, ay);
|
|
const bl = Math.hypot(bx, by);
|
|
if (al < 0.01 || bl < 0.01) continue;
|
|
const dot = clamp((ax * bx + ay * by) / (al * bl), -1, 1);
|
|
total += Math.acos(dot);
|
|
count++;
|
|
}
|
|
return count ? total / count : 0;
|
|
}
|
|
|
|
function pathLateralDeviationRatio(path) {
|
|
if (!path || path.length < 3) return 0;
|
|
const a = { x: path[0][0], y: path[0][1] };
|
|
const b = { x: path[path.length - 1][0], y: path[path.length - 1][1] };
|
|
const direct = Math.max(1, Math.hypot(b.x - a.x, b.y - a.y));
|
|
let maxDeviation = 0;
|
|
for (let i = 1; i < path.length - 1; i++) {
|
|
const p = { x: path[i][0], y: path[i][1] };
|
|
maxDeviation = Math.max(maxDeviation, pointToSegmentDistance(p, a, b));
|
|
}
|
|
return maxDeviation / direct;
|
|
}
|
|
|
|
function chooseThroughExpresswayVia(a, b) {
|
|
const candidates = [capital, ...modernCities.filter((city) => (city.population || 0) >= 90000)];
|
|
let best = null;
|
|
let bestScore = -INF;
|
|
for (const city of candidates) {
|
|
if (!city || !prefectureMask[indexOf(city.x, city.y)] || sea[indexOf(city.x, city.y)]) continue;
|
|
const access = routePoint(city, "express", city.x * 73 + city.y * 79 + 9301);
|
|
const lineD = pointToSegmentDistance(access, a, b);
|
|
const density = densityValue(access.x, access.y);
|
|
const popScore = Math.sqrt(Math.max(0, city.population || 0)) / 520;
|
|
const score = density * 2.8 + popScore + (city.isPrefecturalCapital ? 0.9 : 0) - lineD / 38 - mountainBarrierPenalty(access.x, access.y, "express") * 0.004;
|
|
if (score > bestScore) { bestScore = score; best = access; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function addThroughExpressway() {
|
|
if (externalGateways.length < 2) return false;
|
|
let bestPair = null;
|
|
let bestScore = -INF;
|
|
for (let i = 0; i < externalGateways.length; i++) {
|
|
for (let j = i + 1; j < externalGateways.length; j++) {
|
|
const a = externalGateways[i];
|
|
const b = externalGateways[j];
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
const opposite = (a.side === "N" && b.side === "S") || (a.side === "S" && b.side === "N") || (a.side === "W" && b.side === "E") || (a.side === "E" && b.side === "W");
|
|
const score = d + (opposite ? 42 : 0) - Math.abs((a.score || 0) - (b.score || 0)) * 3;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestPair = [a, b];
|
|
}
|
|
}
|
|
}
|
|
if (!bestPair) return false;
|
|
const [a, b] = bestPair;
|
|
const existing = [...externalExpressways, ...expressways, ...nationalRoads, ...railways, ...branchRailways];
|
|
const via = chooseThroughExpresswayVia(a, b);
|
|
let path = [];
|
|
let viaUsed = false;
|
|
if (via) {
|
|
let first = aStar(a, via, makeTransportCost(throughExpresswayCost(a, via), existing, roadHubs, [a, via], 5, 9.6, townAvoidNodes, 4.2, 6.0));
|
|
first = smoothPathByLineOfSight(first, (x, y) => throughExpresswayCost(a, via)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11);
|
|
let second = aStar(via, b, makeTransportCost(throughExpresswayCost(via, b), [...existing, first], roadHubs, [via, b], 5, 9.6, townAvoidNodes, 4.2, 6.0));
|
|
second = smoothPathByLineOfSight(second, (x, y) => throughExpresswayCost(via, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11);
|
|
if (first.length > 6 && second.length > 6) { path = [...first, ...second.slice(1)]; viaUsed = true; }
|
|
}
|
|
if (path.length < 12) {
|
|
path = aStar(a, b, makeTransportCost(throughExpresswayCost(a, b), existing, roadHubs, [a, b], 5, 9.4, townAvoidNodes, 6.0, 9.0));
|
|
path = smoothPathByLineOfSight(path, (x, y) => throughExpresswayCost(a, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11);
|
|
}
|
|
path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8);
|
|
if (!viaUsed && (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.45 || pathCompactness(path) > 3.25)) {
|
|
path = aStar(a, b, makeTransportCost(permissiveThroughExpresswayCost(a, b), existing, roadHubs, [a, b], 4, 7.2, townAvoidNodes, 4.0, 6.5));
|
|
path = smoothPathByLineOfSight(path, (x, y) => permissiveThroughExpresswayCost(a, b)(x, y, x, y) < INF, 12);
|
|
path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8);
|
|
}
|
|
const turnScore = pathTurnScore(path);
|
|
const deviation = pathLateralDeviationRatio(path);
|
|
const compactness = pathCompactness(path);
|
|
if (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.42 || compactness > (viaUsed ? 4.10 : 3.05) || turnScore > (viaUsed ? 0.72 : 0.60) || deviation > (viaUsed ? 0.52 : 0.38)) return false;
|
|
externalExpressways.push(path);
|
|
incrementDegree(expressDegree, a);
|
|
incrementDegree(expressDegree, b);
|
|
throughExpresswayAdded = true;
|
|
return true;
|
|
}
|
|
addThroughExpressway();
|
|
|
|
function nearestPathCellDistance(node, paths) {
|
|
let best = INF;
|
|
for (const path of paths) {
|
|
for (const [x, y] of path) best = Math.min(best, Math.hypot(node.x - x, node.y - y));
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function pruneHighMountainTransport(paths, threshold = 0.82) {
|
|
for (let i = paths.length - 1; i >= 0; i--) {
|
|
if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1);
|
|
}
|
|
}
|
|
for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways]) pruneHighMountainTransport(paths, 0.82);
|
|
|
|
const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6);
|
|
const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4);
|
|
|
|
const icCandidates = [];
|
|
for (const path of [...expressways, ...externalExpressways]) {
|
|
icCandidates.push(...samplePath(path, 11 + Math.floor(rand(seed, path.length + 333) * 5)).map((p) => ({ ...p, score: 0.62 + plain[indexOf(p.x, p.y)] * 0.24 + midDensityAffinity(p.x, p.y) * 0.16, kind: "Interchange" })));
|
|
for (const city of modernCities) {
|
|
let best = null;
|
|
let bestDistance = 999;
|
|
for (const [x, y] of path) {
|
|
const d = Math.hypot(x - city.x, y - city.y);
|
|
if (d < bestDistance) { bestDistance = d; best = { x, y }; }
|
|
}
|
|
if (best && bestDistance > 4 && bestDistance < 18) icCandidates.push({ ...best, score: 0.8 + city.score * 0.1, kind: "Urban Interchange" });
|
|
}
|
|
}
|
|
|
|
let interchanges = pickEntities(icCandidates, { max: 14 + Math.floor(rand(seed, 1130) * 18), minDistance: 7, threshold: 0.44, seed: seed + 1130 });
|
|
|
|
const icAccessRoads = [];
|
|
const nationalRoadAccessPoints = nationalRoads.flatMap((path) => samplePath(path, 8));
|
|
for (const ic of interchanges) {
|
|
const accessTargets = [
|
|
...industrialZones.map((p) => ({ ...p, score: 0.95 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 7) })),
|
|
...modernCities.map((p) => ({ ...routePoint(p, "road", 8200 + p.x * 7 + p.y), score: 0.72 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 10) })),
|
|
...nationalRoadAccessPoints.map((p) => ({ ...p, score: 0.62 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 6), kind: "National Road Access" })),
|
|
];
|
|
const target = pickEntities(accessTargets, { max: 1, minDistance: 1, threshold: 0, seed: seed + 1134 + ic.x * 3 + ic.y })[0];
|
|
if (!target || Math.hypot(target.x - ic.x, target.y - ic.y) > 22) continue;
|
|
const path = aStar(ic, target, roadCost);
|
|
if (path.length > 2 && path.length < 36) icAccessRoads.push(path);
|
|
}
|
|
|
|
const logisticsScore = new Float32Array(SIZE);
|
|
for (let y = 4; y < MAP_H - 4; y++) {
|
|
for (let x = 4; x < MAP_W - 4; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const nearIC = 1 / (1 + distanceToNearest(interchanges, x, y) / 3);
|
|
const cityPenalty = distanceToNearest(modernCities, x, y) < 5 ? 0.28 : 0;
|
|
logisticsScore[i] = clamp(nearIC * 0.56 + plain[i] * 0.24 + roadInfluence[i] * 0.22 + expressInfluence[i] * 0.16 - slope[i] * 0.32 - cityPenalty);
|
|
}
|
|
}
|
|
|
|
let logisticsParks = pickPoints(logisticsScore, {
|
|
threshold: 0.32 + rand(seed, 1141) * 0.1,
|
|
max: 3 + Math.floor(rand(seed, 1142) * 13),
|
|
minDistance: 9,
|
|
seedOffset: 1140,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "Logistics Park" }));
|
|
|
|
const cityInfluence = influenceFromPoints(modernCities, 34, (p) => p.urbanWeight || 1.2);
|
|
const cityCoreInfluence = influenceFromPoints(urbanCenters, 11, (p) => p.parent?.coreRadius ? 1.35 + p.parent.coreRadius / 5 : 1.2);
|
|
const stationInfluence = influenceFromPoints(stations, 10, () => 1);
|
|
const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...ringRailways, ...externalRailways], 6);
|
|
const satelliteScore = new Float32Array(SIZE);
|
|
const largeCitiesForSatellites = modernCities.filter((c) => (c.population || 0) >= 320000);
|
|
for (let y = 4; y < MAP_H - 4; y++) {
|
|
for (let x = 4; x < MAP_W - 4; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || !prefectureMask[i]) continue;
|
|
let ringPull = 0;
|
|
let parent = null;
|
|
for (const city of largeCitiesForSatellites) {
|
|
const d = Math.hypot(city.x - x, city.y - y);
|
|
const ideal = clamp(11 + Math.sqrt(city.population || 320000) / 150, 13, 27);
|
|
const v = clamp(1 - Math.abs(d - ideal) / 9);
|
|
if (v > ringPull) { ringPull = v; parent = city; }
|
|
}
|
|
if (!parent) continue;
|
|
const railPull = Math.max(railInfluence2[i], stationInfluence[i] * 0.84);
|
|
const separated = distanceToNearest(modernCities, x, y) > 7 ? 1 : 0;
|
|
satelliteScore[i] = clamp(ringPull * 0.42 + railPull * 0.38 + populationDensity[i] * 0.14 + plain[i] * 0.2 + basinField[i] * 0.08 + agriculture[i] * 0.05 - slope[i] * 0.86 - ridgeField[i] * 0.34 - Math.max(0, elevation[i] - 0.56) * 0.72 + separated * 0.1 + hash2(x, y, seed + 1160) * 0.035);
|
|
}
|
|
}
|
|
let satelliteCities = pickPoints(satelliteScore, {
|
|
threshold: 0.43 + rand(seed, 1161) * 0.07,
|
|
max: Math.min(14, 2 + largeCitiesForSatellites.length * 4 + Math.floor(rand(seed, 1162) * 4)),
|
|
minDistance: 8,
|
|
seedOffset: 1160,
|
|
predicate: (x, y, i) => !sea[i] && prefectureMask[i],
|
|
}).map((p, n) => {
|
|
const parent = largeCitiesForSatellites.slice().sort((a, b) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(b.x - p.x, b.y - p.y))[0];
|
|
const basePop = parent ? parent.population * (0.045 + rand(seed, 1165 + n) * 0.11) : 42000 + rand(seed, 1165 + n) * 90000;
|
|
return { ...p, kind: "Satellite City", parentCityIndex: parent ? modernCities.indexOf(parent) : -1, population: Math.round(basePop / 1000) * 1000, urbanRadius: 5 + Math.sqrt(basePop) / 135, coreRadius: 1.5 + Math.sqrt(basePop) / 420, urbanWeight: 0.55 + Math.sqrt(basePop) / 720 };
|
|
});
|
|
const satelliteInfluence = influenceFromPoints(satelliteCities, 16, (p) => p.urbanWeight || 0.8);
|
|
const oldCoreInfluence = influenceFromPoints([...castleTowns, ...markets, ...ports], 12, () => 1);
|
|
const industrialInfluence = influenceFromPoints(industrialZones, 9, () => 1);
|
|
const logisticsInfluence = influenceFromPoints(logisticsParks, 9, () => 1);
|
|
const interchangeInfluence = influenceFromPoints(interchanges, 8, () => 1);
|
|
const premodernInfluence = influenceFromPaths(premodernRoads, 4);
|
|
const villageInfluence = influenceFromPoints(villages, 7, () => 1);
|
|
|
|
const newTownScore = new Float32Array(SIZE);
|
|
for (let y = 4; y < MAP_H - 4; y++) {
|
|
for (let x = 4; x < MAP_W - 4; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const dCity = distanceToNearest(modernCities, x, y);
|
|
const ring = dCity > 8 && dCity < 22 ? 1 : 0;
|
|
const uplandTerrace = elevation[i] > 0.36 && elevation[i] < 0.58 && slope[i] < 0.34 && ridgeField[i] < 0.34 ? 0.24 : 0;
|
|
newTownScore[i] = clamp(ring * 0.34 + stationInfluence[i] * 0.24 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.1 + plain[i] * 0.14 + uplandTerrace + agriculture[i] * 0.06 - slope[i] * 0.72 - ridgeField[i] * 0.22 - floodplain[i] * 0.22 - satelliteInfluence[i] * 0.18);
|
|
}
|
|
}
|
|
|
|
let newTowns = pickPoints(newTownScore, {
|
|
threshold: 0.32 + rand(seed, 1151) * 0.1,
|
|
max: 2 + Math.floor(rand(seed, 1152) * 10),
|
|
minDistance: 11,
|
|
seedOffset: 1150,
|
|
predicate: (x, y, i) => !sea[i],
|
|
}).map((p) => ({ ...p, kind: "New Town" }));
|
|
|
|
const minorRoads = [...nationalRoadBranchRoads];
|
|
const trunkNodes = [...markets, ...modernCities, ...stations.slice(0, 24), ...crossings.slice(0, 16)];
|
|
const roadNetInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways, ...premodernRoads], 3);
|
|
|
|
function minorRoadCost(x, y) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || elevation[i] > 0.72) return INF;
|
|
const barrier = mountainBarrierPenalty(x, y, "minor");
|
|
if (barrier >= INF) return INF;
|
|
return Math.max(0.3, 1 + slope[i] * 8.4 + barrier * 0.55 + Math.max(0, elevation[i] - 0.58) * 4.4 + floodplain[i] * 0.18 + (river[i] > 0.5 ? 1.0 : 0.18 * river[i]) - plain[i] * 0.24 - valleyField[i] * 0.36 - coastalLowland[i] * 0.12 + ridgeField[i] * 0.58 - roadNetInfluence[i] * 0.35 + normalEdgePenalty(x, y) + hash2(x, y, seed + 555) * 0.15);
|
|
}
|
|
|
|
const connectedPairs = new Set();
|
|
function addMinorRoad(a, b) {
|
|
const key = `${a.x},${a.y}|${b.x},${b.y}`;
|
|
const reverseKey = `${b.x},${b.y}|${a.x},${a.y}`;
|
|
if (connectedPairs.has(key) || connectedPairs.has(reverseKey)) return;
|
|
connectedPairs.add(key);
|
|
const path = aStar(a, b, minorRoadCost);
|
|
if (path.length > 2 && path.length < 90) minorRoads.push(path);
|
|
}
|
|
|
|
function nearestRoadAccessNode(node, paths, sampleStep = 7) {
|
|
let best = null;
|
|
let bestD = INF;
|
|
for (const path of paths) {
|
|
if (!path || path.length === 0) continue;
|
|
const step = Math.max(1, Math.floor(path.length / Math.max(8, Math.ceil(path.length / sampleStep))));
|
|
for (let k = 0; k < path.length; k += step) {
|
|
const [x, y] = path[k];
|
|
const d = Math.hypot(node.x - x, node.y - y);
|
|
if (d < bestD) { bestD = d; best = { x, y, kind: "Road access", d }; }
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// Branches from the yellow national-road network are drawn as ordinary white
|
|
// roads. This keeps the national-road layer as a through-network while still
|
|
// connecting local towns, ports, castle towns, and suburban/new-town nodes.
|
|
const nationalAccessPaths = [...nationalRoads, ...externalRoads, ...ringRoads, ...premodernRoads];
|
|
const localTownNodes = [...modernCities, ...ports, ...markets, ...castleTowns, ...satelliteCities, ...newTowns]
|
|
.filter((node, idx, arr) => idx === arr.findIndex((p) => Math.hypot(p.x - node.x, p.y - node.y) < 2.5));
|
|
for (const town of localTownNodes) {
|
|
const nearestTrunk = nearestRoadAccessNode(town, nationalAccessPaths, 6);
|
|
if (nearestTrunk && nearestTrunk.d > 2.8 && nearestTrunk.d < 42) addMinorRoad(town, nearestTrunk);
|
|
}
|
|
const neighborTownNodes = localTownNodes.slice().sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
for (const town of neighborTownNodes.slice(0, 48)) {
|
|
const neighbor = pickEntities(neighborTownNodes.filter((p) => p !== town).map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - town.x, p.y - town.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
|
|
if (neighbor && Math.hypot(neighbor.x - town.x, neighbor.y - town.y) < 22) addMinorRoad(town, neighbor);
|
|
}
|
|
|
|
for (const village of villages) {
|
|
if (rand(seed, village.x * 13 + village.y * 17) < 0.90) {
|
|
const target = pickEntities(trunkNodes.map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - village.x, p.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
|
|
if (target && Math.hypot(target.x - village.x, target.y - village.y) < 34) addMinorRoad(village, target);
|
|
}
|
|
}
|
|
for (const market of markets) {
|
|
const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 4, minDistance: 1, threshold: 0 });
|
|
for (const v of localVillages) addMinorRoad(market, v);
|
|
}
|
|
for (const pass of passes.slice(0, 8)) {
|
|
const target = pickEntities([...markets, ...villages].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - pass.x, p.y - pass.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
|
|
if (target) addMinorRoad(pass, target);
|
|
}
|
|
for (const port of ports) {
|
|
const target = pickEntities([...markets, ...villages, ...stations.slice(0, 18)].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - port.x, p.y - port.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
|
|
if (target && Math.hypot(target.x - port.x, target.y - port.y) < 24) addMinorRoad(port, target);
|
|
}
|
|
for (const localCenter of [...satelliteCities, ...newTowns]) {
|
|
const target = pickEntities([...stations, ...markets, ...modernCities].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - localCenter.x, p.y - localCenter.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
|
|
if (target && Math.hypot(target.x - localCenter.x, target.y - localCenter.y) < 30) addMinorRoad(localCenter, target);
|
|
}
|
|
for (const station of stations.slice(0, 28)) {
|
|
const locals = pickEntities([...villages, ...markets, ...ports].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - station.x, p.y - station.y)) })), { max: 2, minDistance: 1, threshold: 0 });
|
|
for (const local of locals) if (Math.hypot(local.x - station.x, local.y - station.y) < 22) addMinorRoad(station, local);
|
|
}
|
|
for (const village of villages.slice(0, 42)) {
|
|
const neighbor = pickEntities(villages.filter((v) => v !== village).map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - village.x, v.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
|
|
if (neighbor && Math.hypot(neighbor.x - village.x, neighbor.y - village.y) < 14) addMinorRoad(village, neighbor);
|
|
}
|
|
|
|
const combinedModernTransport = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways];
|
|
const requiredTransportNodes = [capital, ...externalGateways, ...modernCities.filter((city) => (city.population || 0) >= 120000 || city.isPrefecturalCapital)];
|
|
const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernTransport) <= 7).length;
|
|
const allExpresswayPaths = [...expressways, ...externalExpressways];
|
|
const expresswayCells = allExpresswayPaths.flat();
|
|
const expresswayAverageDensity = expresswayCells.length ? expresswayCells.reduce((sum, [x, y]) => sum + densityValue(x, y), 0) / expresswayCells.length : 0;
|
|
const nationalRoadPopulationCoverageDebug = nationalRoadPopulationCoverage(nationalRoads, 8.5);
|
|
const transportDebug = {
|
|
requiredNodeCount: requiredTransportNodes.length,
|
|
connectedRequiredNodeCount,
|
|
throughExpresswayAdded,
|
|
nationalRoadDeadEndRepairs,
|
|
railDeadEndRepairs,
|
|
nationalRoadBranchDemotions,
|
|
metroRadialNationalRoadsAdded,
|
|
metroRingRoadSegmentsAdded,
|
|
internalNationalRoadFallbacks,
|
|
postDemotionInternalNationalRoadFallbacks,
|
|
nationalRoadPopulationCoverage: Number(nationalRoadPopulationCoverageDebug.ratio.toFixed(3)),
|
|
nationalRoadUncoveredPopulation: Math.round(nationalRoadPopulationCoverageDebug.uncoveredPopulation || 0),
|
|
internalNationalRoadCellCount: internalNationalRoadCellCount(),
|
|
externalGatewayCount: externalGateways.length,
|
|
externalNationalRoadCount: externalRoads.length,
|
|
expresswayAverageDensity: Number(expresswayAverageDensity.toFixed(3)),
|
|
expresswayPathCount: allExpresswayPaths.length,
|
|
minorRoadCount: minorRoads.length,
|
|
minorRoadTotalLength: Math.round(minorRoads.reduce((sum, path) => sum + pathLength(path), 0)),
|
|
};
|
|
|
|
const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1);
|
|
const landuse = new Uint8Array(SIZE);
|
|
for (let y = 0; y < MAP_H; y++) {
|
|
for (let x = 0; x < MAP_W; x++) {
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) continue;
|
|
const mountain = elevation[i] > 0.62 || slope[i] > 0.46 || ridgeField[i] > 0.64;
|
|
const farm = agriculture[i] > 0.26 && (plain[i] > 0.2 || valleyField[i] > 0.32 || basinField[i] > 0.25);
|
|
let nearestCity = null;
|
|
let nearestCityDistance = INF;
|
|
for (const city of modernCities) {
|
|
const d = Math.hypot(city.x - x, city.y - y);
|
|
if (d < nearestCityDistance) { nearestCityDistance = d; nearestCity = city; }
|
|
}
|
|
const dCity = nearestCityDistance;
|
|
const populationScale = nearestCity ? clamp(Math.log10(Math.max(10000, nearestCity.population)) - 4, 0.25, 2.2) : 0.5;
|
|
const normalizedUrbanDistance = nearestCity ? dCity / Math.max(6, nearestCity.urbanRadius) : 99;
|
|
const cityClusterBoost = nearestCity ? clamp(1 - normalizedUrbanDistance) * (0.18 + populationScale * 0.16) : 0;
|
|
const density = populationDensity[i];
|
|
const oldTownScore = oldCoreInfluence[i] * 0.64 + premodernInfluence[i] * 0.32 + plain[i] * 0.12 + density * 0.08;
|
|
const terrainUrbanPenalty = slope[i] * 1.02 + ridgeField[i] * 0.55 + Math.max(0, elevation[i] - 0.56) * 0.56;
|
|
const nodeCausalPull = Math.max(stationInfluence[i] * 0.18, premodernInfluence[i] * 0.13, coastalLowland[i] * river[i] * 0.12, valleyField[i] * 0.08);
|
|
const satelliteEnvelope = satelliteInfluence[i] * 0.54;
|
|
const urbanEnvelope = cityInfluence[i] * 0.58 + cityCoreInfluence[i] * 0.3 + satelliteEnvelope + density * 0.47 + stationInfluence[i] * 0.18 + oldCoreInfluence[i] * 0.14 + newTownInfluence[i] * 0.12 + cityClusterBoost + nodeCausalPull - terrainUrbanPenalty;
|
|
const coreScore = cityCoreInfluence[i] * 0.74 + urbanEnvelope * 0.3 + density * 0.36 + satelliteInfluence[i] * 0.16 + stationInfluence[i] * 0.06 + railInfluence2[i] * 0.04 - slope[i] * 0.82 - ridgeField[i] * 0.28;
|
|
const suburbScore = urbanEnvelope * 0.54 + density * 0.14 + satelliteInfluence[i] * 0.22 + stationInfluence[i] * 0.09 + roadInfluence[i] * 0.05 + railInfluence2[i] * 0.05 + plain[i] * 0.16 + valleyField[i] * 0.04 + populationScale * 0.05 + (coreScore < 0.58 ? 0.05 : 0) - slope[i] * 0.76 - ridgeField[i] * 0.22;
|
|
const roadsideScore = interchangeInfluence[i] * 0.54 + logisticsInfluence[i] * 0.18 + roadInfluence[i] * 0.1 + plain[i] * 0.1 - cityInfluence[i] * 0.02;
|
|
const isolatedCorridor = roadInfluence[i] > 0.22 && cityInfluence[i] < 0.08 && stationInfluence[i] < 0.08 && interchangeInfluence[i] < 0.18;
|
|
const ruralScore = villageInfluence[i] * 0.3 + agriculture[i] * 0.38 + plain[i] * 0.18 - slope[i] * 0.08;
|
|
|
|
if (mountain) landuse[i] = 9;
|
|
else if (industrialInfluence[i] > 0.44) landuse[i] = 5;
|
|
else if (logisticsInfluence[i] > 0.42) landuse[i] = 6;
|
|
else if (newTownInfluence[i] > 0.42 && urbanEnvelope > 0.16) landuse[i] = 7;
|
|
else if (coreScore > 0.68 && density > 0.48 && stationInfluence[i] > 0.05 && slope[i] < 0.24 && ridgeField[i] < 0.36) landuse[i] = 3;
|
|
else if (oldTownScore > 0.49) landuse[i] = 2;
|
|
else if (suburbScore > 0.235 && !isolatedCorridor && slope[i] < 0.32 && ridgeField[i] < 0.48 && (normalizedUrbanDistance < 1.42 || satelliteInfluence[i] > 0.24)) landuse[i] = 4;
|
|
else if (roadsideScore > 0.5 && plain[i] > 0.18 && slope[i] < 0.34 && ridgeField[i] < 0.5 && !isolatedCorridor && (interchangeInfluence[i] > 0.24 || logisticsInfluence[i] > 0.16 || cityInfluence[i] > 0.09)) landuse[i] = 8;
|
|
else if (farm) landuse[i] = 1;
|
|
else if (ruralScore > 0.3) landuse[i] = 0;
|
|
else landuse[i] = 0;
|
|
}
|
|
}
|
|
|
|
function hasUrbanNeighborCluster(x, y, radius = 2, minUrban = 7) {
|
|
let urban = 0;
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (!inside(nx, ny)) continue;
|
|
const lu = landuse[indexOf(nx, ny)];
|
|
if (lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8) urban++;
|
|
}
|
|
}
|
|
return urban >= minUrban;
|
|
}
|
|
|
|
function removeIsolatedUrbanPatches(maxCells = 22) {
|
|
const seen = new Uint8Array(SIZE);
|
|
const namedCenters = [...modernCities, ...(satelliteCities || []), ...markets, ...ports, ...newTowns, ...stations];
|
|
const queue = [];
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (seen[i] || !prefectureMask[i] || sea[i]) continue;
|
|
const lu0 = landuse[i];
|
|
if (!(lu0 >= 2 && lu0 <= 8)) continue;
|
|
const component = [];
|
|
let maxDensity = 0;
|
|
queue.length = 0;
|
|
queue.push(i);
|
|
seen[i] = 1;
|
|
for (let q = 0; q < queue.length; q++) {
|
|
const cur = queue[q];
|
|
component.push(cur);
|
|
maxDensity = Math.max(maxDensity, populationDensity[cur]);
|
|
const [x, y] = xyOf(cur);
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
|
|
if (!(landuse[ni] >= 2 && landuse[ni] <= 8)) continue;
|
|
seen[ni] = 1;
|
|
queue.push(ni);
|
|
}
|
|
}
|
|
if (component.length > maxCells) continue;
|
|
let hasAnchor = false;
|
|
for (const ci of component) {
|
|
const [x, y] = xyOf(ci);
|
|
if (distanceToNearest(namedCenters, x, y) <= 5.8) {
|
|
hasAnchor = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasAnchor) {
|
|
for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? 1 : 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let pass = 0; pass < 2; pass++) removeIsolatedUrbanPatches(36);
|
|
|
|
// CBD is no longer a marker. It is a DID-like contiguous high-density core:
|
|
// first remove isolated core cells, then grow connected high-density cells
|
|
// from each urban center according to population scale.
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4;
|
|
}
|
|
}
|
|
|
|
function growDidCore(center, city, salt) {
|
|
if (!center || !city) return 0;
|
|
const start = indexOf(center.x, center.y);
|
|
if (sea[start] || !prefectureMask[start]) return 0;
|
|
if ((city.population || 0) < 220000) return 0;
|
|
const targetCells = Math.round(clamp(2 + Math.sqrt(city.population || 80000) / 74, 4, 22));
|
|
const maxRadius = clamp((city.coreRadius || 3) * 2.4 + Math.sqrt(city.population || 80000) / 260, 6, 16);
|
|
const selected = new Set();
|
|
const queued = new Set([start]);
|
|
const heap = new MinHeap();
|
|
heap.push({ i: start, f: -10 });
|
|
let made = 0;
|
|
|
|
while (heap.length > 0 && made < targetCells) {
|
|
const cur = heap.pop();
|
|
if (!cur || selected.has(cur.i)) continue;
|
|
const [x, y] = xyOf(cur.i);
|
|
const i = cur.i;
|
|
const d = Math.hypot(x - center.x, y - center.y);
|
|
const support = populationDensity[i] * 1.18 + cityInfluence[i] * 0.22 + stationInfluence[i] * 0.18 + plain[i] * 0.12 - slope[i] * 1.24 - ridgeField[i] * 0.54 - Math.max(0, elevation[i] - 0.58) * 0.50 - floodplain[i] * 0.08 - d / maxRadius * 0.22;
|
|
if (d > maxRadius || support < 0.44 || sea[i] || !prefectureMask[i]) continue;
|
|
if (!(landuse[i] === 2 || landuse[i] === 3 || landuse[i] === 4 || landuse[i] === 7 || populationDensity[i] > 0.22 || stationInfluence[i] > 0.14)) continue;
|
|
|
|
selected.add(i);
|
|
landuse[i] = 3;
|
|
made++;
|
|
|
|
for (const [nx, ny] of neighbors8(x, y)) {
|
|
const ni = indexOf(nx, ny);
|
|
if (queued.has(ni) || selected.has(ni) || sea[ni] || !prefectureMask[ni]) continue;
|
|
const nd = Math.hypot(nx - center.x, ny - center.y);
|
|
if (nd > maxRadius + 1) continue;
|
|
const score = populationDensity[ni] * 1.24 + cityInfluence[ni] * 0.22 + stationInfluence[ni] * 0.18 + plain[ni] * 0.12 - slope[ni] * 1.25 - ridgeField[ni] * 0.54 - nd / maxRadius * 0.22 + hash2(nx, ny, seed + salt) * 0.03;
|
|
queued.add(ni);
|
|
heap.push({ i: ni, f: -score });
|
|
}
|
|
}
|
|
return made;
|
|
}
|
|
|
|
urbanCenters.forEach((center, n) => growDidCore(center, center.parent || modernCities[n], 9400 + n * 17));
|
|
for (let pass = 0; pass < 3; pass++) removeIsolatedUrbanPatches(42);
|
|
for (let y = 1; y < MAP_H - 1; y++) {
|
|
for (let x = 1; x < MAP_W - 1; x++) {
|
|
const i = indexOf(x, y);
|
|
if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4;
|
|
}
|
|
}
|
|
|
|
|
|
return {
|
|
ports,
|
|
crossings,
|
|
passes,
|
|
settlementCluster,
|
|
settlementScore,
|
|
villages,
|
|
markets,
|
|
castles,
|
|
premodernRoads,
|
|
minorRoads,
|
|
castleTowns,
|
|
modernCities,
|
|
populationDensity,
|
|
railways,
|
|
branchRailways,
|
|
ringRailways,
|
|
externalRailways,
|
|
stations,
|
|
industrialZones,
|
|
nationalRoads,
|
|
ringRoads,
|
|
expressways,
|
|
ringExpressways,
|
|
icAccessRoads,
|
|
externalRoads,
|
|
externalExpressways,
|
|
interchanges,
|
|
logisticsParks,
|
|
satelliteCities,
|
|
newTowns,
|
|
landuse,
|
|
stationInfluence,
|
|
roadInfluence,
|
|
railInfluence2,
|
|
villageInfluence,
|
|
externalGateways,
|
|
transportDebug,
|
|
cityPopulationCap,
|
|
};
|
|
}
|