import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js"; import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; import { LANDUSE } from "./landuseCodes.js"; // Lightweight Human Geography V2 // -------------------------------- // This replaces the heavy iterative human stage with a sparse skeleton + raster // synthesis model: // 1. build terrain-derived human context once // 2. place villages/towns/cities by region quotas // 3. make sparse approximate transport paths without full-resolution A* // 4. synthesize population and land-use fields in one raster pass 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, prefectureRegionId, naturalBarrierScore, } = terrain; function regionIdAt(x, y) { if (!inside(x, y)) return -1; const i = indexOf(x, y); if (sea[i]) return -1; if (prefectureMask?.[i]) return 0; const id = prefectureRegionId?.[i]; return id !== undefined && id >= 0 ? id : -1; } function inFocusedPrefecture(p) { return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); } function localConfluenceScore(x, y) { let arms = 0; let strong = 0; for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; const rv = river[indexOf(nx, ny)]; if (rv > 0.18) arms++; if (rv > 0.34) strong++; } return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); } // --- 1. Human context: one full raster pass ----------------------------- const developable = new Float32Array(SIZE); const ruralSuitability = new Float32Array(SIZE); const townSuitability = new Float32Array(SIZE); const valleySettlement = new Float32Array(SIZE); const coastalSettlement = new Float32Array(SIZE); const confluenceField = new Float32Array(SIZE); const barrierCost = new Float32Array(SIZE); const corridorCost = new Float32Array(SIZE); const settlementCluster = new Float32Array(SIZE); const settlementScore = new Float32Array(SIZE); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); if (sea[i]) { barrierCost[i] = INF; corridorCost[i] = INF; continue; } const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; const highPenalty = Math.max(0, elevation[i] - 0.56); const lowSlope = clamp(1 - slope[i] * 2.3); const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; confluenceField[i] = confluence; developable[i] = clamp( plain[i] * 0.34 + agriculture[i] * 0.24 + basinField[i] * 0.24 + valleyField[i] * 0.24 + coastalLowland[i] * 0.18 + depositional * 0.22 + lowSlope * 0.10 - slope[i] * 0.82 - ridgeField[i] * 0.52 - spine * 0.24 - highPenalty * 1.14 - floodplain[i] * 0.03 ); valleySettlement[i] = clamp( valleyField[i] * 0.52 + river[i] * 0.08 + confluence * 0.38 + depositional * 0.20 + basinField[i] * 0.16 + plain[i] * 0.08 + lowSlope * 0.12 - slope[i] * 0.54 - ridgeField[i] * 0.30 - spine * 0.16 - highPenalty * 0.70 - floodplain[i] * 0.10 ); coastalSettlement[i] = clamp( coastalLowland[i] * 0.50 + (portSuitability?.[i] || 0) * 0.30 + (deltaField?.[i] || 0) * 0.20 + plain[i] * 0.10 - slope[i] * 0.52 - ridgeField[i] * 0.24 - spine * 0.12 ); const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise); ruralSuitability[i] = clamp( agriculture[i] * 0.42 + developable[i] * 0.28 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.15 + settlementCluster[i] * 0.24 - Math.max(0, elevation[i] - 0.64) * 0.56 ); townSuitability[i] = clamp( developable[i] * 0.40 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.20 + confluence * 0.34 + basinField[i] * 0.16 + plain[i] * 0.12 + settlementCluster[i] * 0.16 - slope[i] * 0.34 - ridgeField[i] * 0.17 - spine * 0.10 ); settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10); const naturalBarrier = naturalBarrierScore?.[i] || 0; barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); } } // --- region statistics --------------------------------------------------- const regionStats = new Map(); function ensureRegion(regionId) { let st = regionStats.get(regionId); if (!st) { st = { id: regionId, area: 0, developableCells: 0, developableSum: 0, valleyCells: 0, coastCells: 0, townCells: 0, plainCells: 0, minX: MAP_W, minY: MAP_H, maxX: 0, maxY: 0, }; regionStats.set(regionId, st); } return st; } for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); if (sea[i]) continue; const regionId = regionIdAt(x, y); if (regionId < 0) continue; const st = ensureRegion(regionId); st.area++; st.developableSum += developable[i]; if (developable[i] > 0.16) st.developableCells++; if (valleySettlement[i] > 0.24) st.valleyCells++; if (coastalSettlement[i] > 0.25) st.coastCells++; if (townSuitability[i] > 0.28) st.townCells++; if (plain[i] > 0.24) st.plainCells++; st.minX = Math.min(st.minX, x); st.minY = Math.min(st.minY, y); st.maxX = Math.max(st.maxX, x); st.maxY = Math.max(st.maxY, y); } } function visibilityFactor(regionId, st) { if (regionId === 0) return 1.15; if (!st || st.area <= 0) return 0; // Small map-edge slivers should not get the same municipal/human density // as full neighboring prefectures. This keeps external regions legible. return clamp(Math.sqrt(st.area / 1700), 0.28, 0.92); } function pickRegionalPoints(scoreArray, { stride = 1, threshold = 0.25, minDistance = 6, totalMax = 100, seedOffset = 0, quotaForRegion, predicate = () => true, kind = "Point", extraScore = () => 0, }) { const byRegion = new Map(); for (let y = 2; y < MAP_H - 2; y += stride) { for (let x = 2; x < MAP_W - 2; x += stride) { const i = indexOf(x, y); if (sea[i] || !predicate(x, y, i)) continue; const regionId = regionIdAt(x, y); if (regionId < 0) continue; const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055; if (score < threshold) continue; if (!byRegion.has(regionId)) byRegion.set(regionId, []); byRegion.get(regionId).push({ x, y, score, kind, regionId }); } } const out = []; for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { const st = regionStats.get(regionId); const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0; if (quota <= 0) continue; out.push(...pickEntities(candidates, { max: quota, minDistance, threshold, seed: seed + seedOffset + regionId * 1009, jitter: 0.04, })); } return out.sort((a, b) => b.score - a.score).slice(0, totalMax); } function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) { const candidates = []; for (let y = 2; y < MAP_H - 2; y += stride) { for (let x = 2; x < MAP_W - 2; x += stride) { const i = indexOf(x, y); if (sea[i] || !predicate(x, y, i)) continue; const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07; if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); } } return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); } // --- 2. Sparse points ---------------------------------------------------- let ports = pickGlobalPoints(portSuitability || coastalSettlement, { threshold: 0.30 + rand(seed, 1001) * 0.08, max: 10, minDistance: 13, seedOffset: 1000, predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25, }).map((p, n) => { const i = indexOf(p.x, p.y); const harborPotential = (portSuitability?.[i] || 0) + coastalLowland[i] * 0.22 + (deltaField?.[i] || 0) * 0.08 - slope[i] * 0.18; const portClass = n === 0 ? "major" : n < 3 && harborPotential > 0.34 ? "regional" : harborPotential > 0.24 ? "fishing" : "lake"; const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port"; return { ...p, harborPotential, portClass, kind, score: harborPotential }; }).sort((a, b) => b.harborPotential - a.harborPotential); if (ports.length && !ports.some((p) => p.portClass === "major")) { ports[0].portClass = "major"; ports[0].kind = "Major Port"; } const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); const crossings = pickGlobalPoints(crossingSuitability || confluenceField, { threshold: 0.30 + rand(seed, 1011) * 0.06, max: 18, minDistance: 9, seedOffset: 1010, predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09, }).map((p) => ({ ...p, kind: "River Crossing" })); const passes = pickGlobalPoints(passSuitability || valleySettlement, { threshold: 0.18 + rand(seed, 1021) * 0.06, max: 12, minDistance: 11, seedOffset: 1020, predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i], }).map((p) => ({ ...p, kind: "Pass" })); const villageScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08); } const villages = pickRegionalPoints(villageScore, { stride: 2, threshold: 0.25 + rand(seed, 1031) * 0.04, totalMax: 140, minDistance: 5, seedOffset: 1030, kind: "Village", quotaForRegion: (regionId, st) => { if (!st || st.developableCells < 10) return 0; const vf = visibilityFactor(regionId, st); const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf; const min = regionId === 0 ? 10 : st.area > 1100 ? 3 : st.area > 280 ? 1 : 0; const max = regionId === 0 ? 30 : st.area > 1800 ? 13 : st.area > 600 ? 7 : 3; return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max)); }, }).map((p, n) => { const i = indexOf(p.x, p.y); const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village"; const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100; return { ...p, kind, population }; }); const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); const marketScore = 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 featurePull = Math.max( distanceToNearest(ports, x, y) < 8 ? 0.10 : 0, distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0, confluenceField[i] * 0.16 ); const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0; marketScore[i] = clamp( townSuitability[i] * 0.62 + villageInfluence[i] * 0.38 + featurePull + valleyMouth + basinField[i] * 0.12 + plain[i] * 0.14 + coastalLowland[i] * 0.08 - slope[i] * 0.18 - ridgeField[i] * 0.08 ); } } const markets = pickRegionalPoints(marketScore, { stride: 2, threshold: 0.31 + rand(seed, 1041) * 0.045, totalMax: 52, minDistance: 9, seedOffset: 1040, kind: "Market Town", quotaForRegion: (regionId, st) => { if (!st || st.townCells < 8) return 0; const vf = visibilityFactor(regionId, st); const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf; const min = regionId === 0 ? 4 : st.area > 1300 ? 1 : 0; const max = regionId === 0 ? 11 : st.area > 1800 ? 5 : st.area > 650 ? 3 : 1; return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max)); }, extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08, }).map((p, n) => { const i = indexOf(p.x, p.y); const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town"; const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000; return { ...p, kind, population }; }); const defenseScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; defenseScore[i] = clamp( confluenceField[i] * 0.38 + townSuitability[i] * 0.16 + ridgeField[i] * clamp(1 - Math.abs(elevation[i] - 0.52) / 0.25) * 0.42 + plain[i] * 0.08 - floodplain[i] * 0.36 - coastalLowland[i] * 0.08 ); } const castles = pickGlobalPoints(defenseScore, { threshold: 0.34 + rand(seed, 1051) * 0.06, max: 5, minDistance: 16, seedOffset: 1050, }).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", })); const castleTowns = castles.map((c, n) => { const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0]; const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x; const y = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.y : c.y; return { x, y, score: c.score, kind: "Castle Town", population: 12000 + Math.round(rand(seed, 1060 + n) * 22000 / 1000) * 1000, regionId: regionIdAt(x, y) }; }); // --- 3. Cities by region, without detailed urban flood-fill -------------- function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) { if (!p || !inside(p.x, p.y)) return 0; const centerRegion = regionIdAt(p.x, p.y); let capacity = 0; const r = Math.ceil(radius); for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { const x = p.x + dx; const y = p.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i]) continue; if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue; const d = Math.hypot(dx, dy); if (d > radius) continue; const dev = developable[i]; if (dev < 0.04) continue; const radial = clamp(1 - d / Math.max(1, radius)); const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24); capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias; } } return Math.max(26000, Math.round(capacity / 1000) * 1000); } const urbanCandidates = [ ...markets.map((p) => ({ ...p, candidateKind: "town" })), ...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })), ...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })), ...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })), ]; const cityCandidateByRegion = new Map(); for (const p of urbanCandidates) { const i = indexOf(p.x, p.y); const regionId = regionIdAt(p.x, p.y); if (regionId < 0) continue; const capacity = estimateUrbanCapacity(p, regionId === 0 ? 30 : 24, regionId === 0 ? 1.12 : 1.0); const score = Math.log10(capacity + 1) * 0.72 + townSuitability[i] * 1.40 + developable[i] * 1.05 + confluenceField[i] * 0.22 + (p.candidateKind === "port" ? 0.48 : 0) + (p.candidateKind === "castleTown" ? 0.22 : 0) + hash2(p.x, p.y, seed + 12000) * 0.16; if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []); cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId }); } const modernCities = []; const usedCitySites = []; for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) { const st = regionStats.get(regionId); if (!st || st.developableCells < 30) continue; const vf = visibilityFactor(regionId, st); const maxCities = regionId === 0 ? clamp(Math.round(3 + st.developableCells / 520 + rand(seed, 12100) * 2), 5, 9) : clamp(Math.round((st.developableCells / 850 + 0.8) * vf), st.area > 1500 ? 1 : 0, st.area > 2600 ? 4 : st.area > 950 ? 2 : 1); const selected = pickEntities(list, { max: maxCities, minDistance: regionId === 0 ? 16 : 18, threshold: 0, seed: seed + 12110 + regionId * 313, jitter: 0.02, }); for (const p of selected) { if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue; usedCitySites.push(p); modernCities.push(p); } } if (!modernCities.some((p) => inFocusedPrefecture(p))) { const focusCandidates = [...markets, ...commercialPorts, ...villages].filter((p) => inFocusedPrefecture(p)); let fallback = focusCandidates.sort((a, b) => { const ai = indexOf(a.x, a.y); const bi = indexOf(b.x, b.y); return (townSuitability[bi] + developable[bi]) - (townSuitability[ai] + developable[ai]); })[0]; if (!fallback) { let best = null; let bestScore = -INF; for (let y = 2; y < MAP_H - 2; y += 2) { for (let x = 2; x < MAP_W - 2; x += 2) { const i = indexOf(x, y); if (!prefectureMask[i] || sea[i]) continue; const score = townSuitability[i] + developable[i] + hash2(x, y, seed + 12199) * 0.04; if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Local City", regionId: 0 }; } } } fallback = best; } if (fallback) modernCities.push({ ...fallback, candidateKind: fallback.candidateKind || "fallback", score: fallback.score || 0.5, capacity: estimateUrbanCapacity(fallback, 30, 1.15), regionId: 0, }); } modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score); for (const [rank, city] of modernCities.entries()) { const isFocused = inFocusedPrefecture(city); const isPrefecturalCapital = isFocused && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital); const isRegionalCapital = !isFocused && !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId && c.isRegionalCapital); const rawPop = isPrefecturalCapital ? 450000 + rand(seed, 12200) * 1150000 : isRegionalCapital ? 160000 + rand(seed, 12201 + city.regionId * 17) * 460000 : 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000; const capMultiplier = isPrefecturalCapital ? 1.22 : isRegionalCapital ? 1.08 : 1.0; const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000; city.population = Math.max(isPrefecturalCapital ? 260000 : isRegionalCapital ? 90000 : 24000, population); city.isPrefecturalCapital = isPrefecturalCapital; city.isRegionalCapital = isRegionalCapital; city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; city.kind = city.rank; city.urbanRadius = clamp(5.8 + Math.sqrt(city.population) / 72, 8, isPrefecturalCapital ? 40 : isRegionalCapital ? 32 : 24); city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isPrefecturalCapital ? 8.5 : 6.5); city.sprawlRadius = clamp(city.urbanRadius * (isPrefecturalCapital ? 1.65 : isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isPrefecturalCapital ? 56 : isRegionalCapital ? 42 : 30); city.urbanWeight = clamp(0.95 + Math.log10(Math.max(10000, city.population)) * 0.29, 1.08, 2.5); } function cityPopulationCap(city) { const radius = city?.isPrefecturalCapital ? 34 : city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18; const bias = city?.isPrefecturalCapital ? 1.25 : city?.isRegionalCapital ? 1.12 : 1.0; return estimateUrbanCapacity(city, radius, bias); } // --- 4. Lightweight corridors ------------------------------------------- function routeLight(a, b, snapRadius = 3) { if (!a || !b) return []; const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15)); const out = []; let lastKey = ""; for (let s = 0; s <= steps; s++) { const t = s / steps; const fx = a.x + (b.x - a.x) * t; const fy = a.y + (b.y - a.y) * t; let best = null; let bestCost = INF; const radius = snapRadius + (s > 0 && s < steps ? 1 : 0); for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const x = Math.round(fx + dx); const y = Math.round(fy + dy); if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i]) continue; const lineDist = Math.hypot(x - fx, y - fy); const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05; if (cost < bestCost) { bestCost = cost; best = [x, y]; } } } if (!best) best = [Math.round(fx), Math.round(fy)]; const key = `${best[0]},${best[1]}`; if (key !== lastKey) { out.push(best); lastKey = key; } } return out; } function importantNodesForRegion(regionId) { const inRegion = (p) => regionIdAt(p.x, p.y) === regionId; return [ ...modernCities.filter(inRegion).map((p) => ({ ...p, nodeWeight: 8 + (p.population || 0) / 120000 })), ...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })), ...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })), ...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })), ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, regionId === 0 ? 18 : 10); } const premodernRoads = []; const nationalRoads = []; const minorRoads = []; const railways = []; const branchRailways = []; const externalRoads = []; const externalRailways = []; const expressways = []; const ringRoads = []; const ringRailways = []; const ringExpressways = []; const externalExpressways = []; const icAccessRoads = []; const externalGateways = []; // Premodern roads connect castles/markets/ports sparsely. for (const c of castles) { const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2); for (const n of near) { const path = routeLight(c, n, 2); if (path.length > 2) premodernRoads.push(path); } } for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { const nodes = importantNodesForRegion(regionId); if (nodes.length < 2) continue; const connected = [nodes[0]]; const remaining = nodes.slice(1); const maxEdges = regionId === 0 ? Math.min(14, nodes.length + 3) : Math.min(7, nodes.length + 1); while (remaining.length && nationalRoads.length < 48) { let best = null; let bestScore = INF; for (const a of connected) { for (const b of remaining) { const d = Math.hypot(a.x - b.x, a.y - b.y); const score = d - (a.nodeWeight + b.nodeWeight) * 0.9; if (score < bestScore) { bestScore = score; best = { a, b }; } } } if (!best) break; const path = routeLight(best.a, best.b, 3); if (path.length > 2) nationalRoads.push(path); connected.push(best.b); remaining.splice(remaining.indexOf(best.b), 1); if (connected.length - 1 >= maxEdges) break; } // A few k-nearest shortcuts for urbanized regions. const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, regionId === 0 ? 8 : 4); for (let i = 0; i < urbanNodes.length; i++) { const a = urbanNodes[i]; const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0]; if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue; const path = routeLight(a, b, 3); if (path.length > 2) nationalRoads.push(path); } // Railways: only high-order cities/ports, as a lightweight placeholder. const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, regionId === 0 ? 7 : 4); railNodes.sort((a, b) => a.x - b.x || a.y - b.y); for (let i = 1; i < railNodes.length; i++) { const path = routeLight(railNodes[i - 1], railNodes[i], 4); if (path.length > 4) railways.push(path); } } // External gateways at land edges; used by naming/UI and later transport work. for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { const st = regionStats.get(regionId); if (!st || st.area < 140) continue; const edgeCandidates = []; for (let y = st.minY; y <= st.maxY; y += 3) { for (const x of [st.minX, st.maxX]) { if (!inside(x, y)) continue; const i = indexOf(x, y); if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] }); } } for (let x = st.minX; x <= st.maxX; x += 3) { for (const y of [st.minY, st.maxY]) { if (!inside(x, y)) continue; const i = indexOf(x, y); if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] }); } } const gateway = pickEntities(edgeCandidates, { max: regionId === 0 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0]; if (gateway) { gateway.kind = "External Gateway"; gateway.regionId = regionId; externalGateways.push(gateway); const target = importantNodesForRegion(regionId)[0]; if (target) { const path = routeLight(gateway, target, 3); if (path.length > 2) externalRoads.push(path); } } } // Approximate expressways as a very small subset of top inter-city links. const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6); for (let i = 1; i < topCities.length && expressways.length < 4; i++) { const a = topCities[i - 1]; const b = topCities[i]; if (Math.hypot(a.x - b.x, a.y - b.y) < 85) { const path = routeLight(a, b, 5); if (path.length > 5) expressways.push(path); } } const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads, ...expressways], 5); const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4); const stations = []; const usedStationKeys = new Set(); function addStation(x, y, kind = "Station", score = 1) { x = Math.round(x); y = Math.round(y); if (!inside(x, y) || sea[indexOf(x, y)]) return; const key = `${x},${y}`; if (usedStationKeys.has(key)) return; usedStationKeys.add(key); stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) }); } for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5); for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8); const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85); // --- 5. Approximate city/town influence and land-use --------------------- const cityInfluence = new Float32Array(SIZE); const coreInfluence = new Float32Array(SIZE); const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9); const populationDensity = new Float32Array(SIZE); function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") { const r = Math.ceil(radius); for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { const x = p.x + dx; const y = p.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i]) continue; const d = Math.hypot(dx, dy); if (d > radius) continue; const terrain = terrainWeighted ? clamp(0.24 + developable[i] * 1.00 + valleySettlement[i] * 0.16 + coastalSettlement[i] * 0.10 - slope[i] * 0.20 - ridgeField[i] * 0.12 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.06, 0, 1.34) : 1; const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain; if (combine === "add") grid[i] = Math.min(3.4, grid[i] + v); else if (v > grid[i]) grid[i] = v; } } } for (const city of modernCities) { addKernel(cityInfluence, city, city.sprawlRadius || Math.round((city.urbanRadius || 10) * 1.4), (city.urbanWeight || 1.0) * (city.isPrefecturalCapital ? 0.46 : city.isRegionalCapital ? 0.38 : 0.30), 2.75, true, "add"); addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.18, true, "add"); addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true, "max"); } const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25)); // Industrial/logistics/new town placeholders remain lightweight. They are // routed by land-use proximity rather than expensive search passes. const industrialZones = []; for (const p of [...commercialPorts, ...modernCities.slice(0, 5)]) { const candidates = []; for (let dy = -10; dy <= 10; dy++) { for (let dx = -10; dx <= 10; dx++) { const x = p.x + dx; const y = p.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i]) continue; const d = Math.hypot(dx, dy); if (d < 3 || d > 10) continue; const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06; if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) }); } } const z = pickEntities(candidates, { max: 1, minDistance: 6, seed: seed + 14010 + p.x * 3 + p.y })[0]; if (z && industrialZones.every((q) => Math.hypot(q.x - z.x, q.y - z.y) > 13)) industrialZones.push(z); if (industrialZones.length >= 8) break; } const industrialInfluence = influenceFromPoints(industrialZones, 5, () => 1.0); const satelliteCities = []; const newTowns = []; const logisticsParks = []; const interchanges = []; var landuse = new Uint8Array(SIZE); // Re-run land-use classification after landuse allocation. The loop above is // intentionally inside a helper to keep all thresholds in one place. function classifyLanduse() { landuse.fill(LANDUSE.RURAL); let maxDensity = 0; const baseNoiseSeed = seed + 15000; const urbanCapacity = new Float32Array(SIZE); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); if (sea[i]) continue; const transport = Math.max(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.26 + roadInfluence[i] * 0.12 + railInfluence2[i] * 0.10; const core = coreInfluence[i]; const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38; const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30; const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10); urbanCapacity[i] = clamp( developable[i] * 0.66 + plain[i] * 0.16 + basinField[i] * 0.16 + valleyField[i] * 0.16 + coastalLowland[i] * 0.12 + transport * 0.18 + riverUrban * 0.14 - slope[i] * 0.18 - ridgeField[i] * 0.12 - floodplain[i] * 0.08 ); populationDensity[i] = clamp(urban * 0.66 + core * 0.46 + oldTown * 0.28 + townInfluence[i] * 0.16 + villageInfluence[i] * 0.14 + transport * 0.12); maxDensity = Math.max(maxDensity, populationDensity[i]); if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { landuse[i] = LANDUSE.FOREST; continue; } if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) { landuse[i] = LANDUSE.INDUSTRIAL; continue; } if (core > 0.38 && urbanCapacity[i] > 0.10) { landuse[i] = LANDUSE.CBD; continue; } if (oldTown > 0.18 && urbanCapacity[i] > 0.09) { landuse[i] = LANDUSE.OLD_URBAN; continue; } const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08; const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadInfluence[i] * 0.10 + 0.28); const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42); const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12; if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) { landuse[i] = LANDUSE.SUBURB; } else if (transport > 0.18 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.04 || cityInfluence[i] > 0.09)) { landuse[i] = transport > 0.28 && stationInfluence[i] > 0.10 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE; } else if (agriculture[i] > 0.24 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.18)) { landuse[i] = LANDUSE.FARMLAND; } else { landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL; } } } const baseLanduse = landuse.slice(); const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE; for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); if (sea[i] || baseLanduse[i] === LANDUSE.FOREST) continue; const transport = Math.max(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); let urbanNeighbors = 0; let cbdNeighbors = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const lu = baseLanduse[indexOf(x + dx, y + dy)]; if (isBuilt(lu)) urbanNeighbors++; if (lu === LANDUSE.CBD) cbdNeighbors++; } } if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) { landuse[i] = LANDUSE.CBD; continue; } if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) { const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08; const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24; if (fringeChance > 0.34 + noise) { landuse[i] = urbanNeighbors >= 4 || transport > 0.28 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE; } } if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) { landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB; } if (landuse[i] === LANDUSE.SUBURB && urbanNeighbors <= 1) { const keep = clamp(cityInfluence[i] * 0.52 + transport * 0.32 + stationInfluence[i] * 0.18 + 0.08); if (hash2(x, y, seed + 15051) > keep) { landuse[i] = agriculture[i] > 0.22 ? LANDUSE.FARMLAND : LANDUSE.RURAL; } } } } if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); } classifyLanduse(); for (const city of modernCities) { let urbanFootprintCells = 0; let coreFootprintCells = 0; const r = Math.ceil((city.urbanRadius || 8) * 1.3); for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { const x = city.x + dx; const y = city.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i]) continue; if (Math.hypot(dx, dy) > r) continue; if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++; if (landuse[i] === LANDUSE.CBD) coreFootprintCells++; } } city.urbanFootprintCells = urbanFootprintCells; city.coreFootprintCells = coreFootprintCells; } const transportDebug = { humanStageVersion: "v2-sparse-raster", aStarRoutes: 0, regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0), nationalRoadPopulationCoverage: 0, nationalRoadUncoveredPopulation: 0, }; return { ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity, railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug, }; }