Prefecture Map Generator v16
+Prefecture Map Generator v17
Terrain-highlighted prefecture generation with terrain-snapped municipalities, a clear prefectural capital, hidden small streams, city-seeking national roads, and hover tooltips. diff --git a/mapGenerator.js b/mapGenerator.js index caaf54e..16c2841 100644 --- a/mapGenerator.js +++ b/mapGenerator.js @@ -1,4 +1,4 @@ -import { CUSTOM_NAMES, KIND_SUFFIXES, NAME_PARTS } from "./names.js"; +import { CUSTOM_NAMES, NAME_PARTS } from "./names.js"; export const MAP_W = 172; export const MAP_H = 122; @@ -90,6 +90,16 @@ function neighbors8(x, y) { return out; } +function neighbors4(x, y) { + const out = []; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny)) out.push([nx, ny, 1]); + } + return out; +} + function distanceToNearest(points, x, y, fallback = 999) { let best = fallback; for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y)); @@ -663,7 +673,7 @@ function tagInsidePrefecture(points, prefectureMask) { return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) })); } -function defaultName(seed, id, kind) { +function defaultName(seed, id) { const prefixKey = id.split("-")[0]; const n = Number(id.split("-")[1] || 0); const prefixes = NAME_PARTS.prefixes || [""]; @@ -673,8 +683,7 @@ function defaultName(seed, id, kind) { const useInfix = hash2(n + prefixKey.length * 17, seed + n * 31, seed + 2777) >= 0.7; const infix = useInfix ? infixes[(seed * 3 + n * 11) % infixes.length] : ""; const suffixWord = suffixes[(seed * 5 + n * 13 + prefixKey.length) % suffixes.length]; - const kindSuffix = KIND_SUFFIXES[prefixKey] || kind || ""; - return `${prefix}${infix}${suffixWord}${kindSuffix}`; + return `${prefix}${infix}${suffixWord}`; } function attachIdsAndNames(points, prefix, seed, kindOverride = null) { @@ -684,7 +693,7 @@ function attachIdsAndNames(points, prefix, seed, kindOverride = null) { return { ...p, id, - name: CUSTOM_NAMES[id] || defaultName(seed + prefix.length * 1000, id, kind), + name: CUSTOM_NAMES[id] || defaultName(seed + prefix.length * 1000, id), insidePrefecture: Boolean(p.insidePrefecture), }; }); @@ -720,12 +729,15 @@ function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, ri const ridgeBarrier = Math.max(ridgeField[ni], ridgeField[curIndex]); const riverBarrier = Math.max(river[ni], river[curIndex]); - const ridgePenalty = Math.max(0, elevation[ni] - 0.40) * 9.4 + Math.abs(elevation[ni] - elevation[curIndex]) * 8.8 + ridgeBarrier * 14.2; - const slopePenalty = slope[ni] * 7.8; - const riverPenalty = riverBarrier > 0.7 ? 13.0 : riverBarrier > 0.42 ? 8.2 : riverBarrier > 0.22 ? 3.8 : 0; - const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.95 : 0; - const valleyLocalityBonus = valleyField[ni] * 0.03; - const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty - valleyLocalityBonus - urbanContinuityBonus) * step; + const highDivide = Math.max(elevation[ni], elevation[curIndex]); + const watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0); + const ridgePenalty = Math.max(0, highDivide - 0.36) * 16.0 + Math.abs(elevation[ni] - elevation[curIndex]) * 12.4 + watershedBarrier; + const slopePenalty = slope[ni] * 10.6; + const valleyBarrier = valleyField[ni] > 0.50 ? valleyField[ni] * (riverBarrier > 0.16 ? 7.2 : 2.6) : 0; + const riverPenalty = riverBarrier > 0.7 ? 22.0 : riverBarrier > 0.42 ? 14.8 : riverBarrier > 0.22 ? 7.4 : riverBarrier > 0.12 ? 2.2 : 0; + const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.65 : 0; + const valleyLocalityBonus = valleyField[ni] * 0.16; + const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty + valleyBarrier - valleyLocalityBonus - urbanContinuityBonus) * step; const nextDist = dist[curIndex] + stepCost; if (nextDist < dist[ni]) { @@ -741,11 +753,11 @@ function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, ri function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) { return clamp( - ridgeField[i] * 2.75 + - river[i] * 2.05 + - slope[i] * 1.18 + - Math.max(0, elevation[i] - 0.5) * 1.05 - - valleyField[i] * 0.10 + ridgeField[i] * 3.15 + + river[i] * 2.45 + + valleyField[i] * 0.62 + + slope[i] * 1.06 + + Math.max(0, elevation[i] - 0.5) * 1.18 ); } @@ -764,7 +776,7 @@ function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, const counts = new Map(); let ownCount = 0; - for (const [nx, ny] of neighbors8(x, y)) { + for (const [nx, ny] of neighbors4(x, y)) { const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) continue; const id = current[ni]; @@ -880,6 +892,192 @@ function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity } } + +function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxIslandCells = 220) { + const protectedByAdmin = new Map(); + for (const p of [...adminCenters, ...protectedPoints]) { + if (!p || !inside(p.x, p.y)) continue; + const id = adminId[indexOf(p.x, p.y)]; + if (id < 0) continue; + if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set()); + protectedByAdmin.get(id).add(indexOf(p.x, p.y)); + } + + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); + + const globalSeen = new Uint8Array(SIZE); + const queue = []; + for (const id of ids) { + const components = []; + for (let i = 0; i < SIZE; i++) { + if (globalSeen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; + const comp = []; + let hasProtected = protectedByAdmin.get(id)?.has(i) || false; + queue.length = 0; + queue.push(i); + globalSeen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + if (protectedByAdmin.get(id)?.has(cur)) hasProtected = true; + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (globalSeen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; + globalSeen[ni] = 1; + queue.push(ni); + } + } + components.push({ cells: comp, hasProtected }); + } + if (components.length <= 1) continue; + components.sort((a, b) => (b.hasProtected ? 1000000 : 0) + b.cells.length - ((a.hasProtected ? 1000000 : 0) + a.cells.length)); + const keep = new Set(components[0].cells); + for (const component of components.slice(1)) { + const mainSize = components[0].cells.length; + if (component.hasProtected && component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.42) continue; + if (component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.36) continue; + const counts = new Map(); + for (const ci of component.cells) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1); + } + } + let target = -1; + let best = -1; + for (const [other, count] of counts) if (count > best) { best = count; target = other; } + if (target >= 0) for (const ci of component.cells) adminId[ci] = target; + } + } + +} + +function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 6) { + let current = new Int16Array(adminId); + for (let pass = 0; pass < passes; pass++) { + const next = new Int16Array(current); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + const own = current[i]; + if (!prefectureMask[i] || sea[i] || own < 0) continue; + + const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.24; + if (urbanCell) continue; + + let isBoundary = false; + const counts = new Map([[own, 0]]); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const id = current[ni]; + if (id < 0) continue; + if (id !== own) isBoundary = true; + const terrain = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField); + const weight = terrain > 0.60 ? 0.45 : 1.0; + counts.set(id, (counts.get(id) || 0) + weight); + } + if (!isBoundary) continue; + + let localBarrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField); + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + localBarrier = Math.max(localBarrier, terrainBoundaryStrength(indexOf(nx, ny), elevation, slope, river, ridgeField, valleyField)); + } + } + if (localBarrier > 0.50) continue; + + let bestId = own; + let best = counts.get(own) || 0; + for (const [id, score] of counts) { + if (id === own) continue; + const adjusted = score + (populationDensity[i] < 0.12 ? 0.42 : 0) + (plainnessForBoundary(elevation, slope, ridgeField, i) ? 0.24 : 0); + if (adjusted > best + 0.65) { + best = adjusted; + bestId = id; + } + } + if (bestId !== own) next[i] = bestId; + } + } + current = next; + } + adminId.set(current); +} + +function plainnessForBoundary(elevation, slope, ridgeField, i) { + return elevation[i] < 0.58 && slope[i] < 0.26 && ridgeField[i] < 0.34; +} + +function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) { + populationDensity.fill(0); + const allCities = [...modernCities, ...satelliteCities]; + for (const city of allCities) { + const urbanR = Math.max(4, city.urbanRadius || 8); + const coreR = Math.max(2, city.coreRadius || 3); + const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65); + const r = Math.ceil(urbanR * 2.2); + 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] || !prefectureMask[i]) continue; + const d = Math.hypot(dx, dy); + const lu = landuse[i]; + const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10; + const radial = 1 / (1 + Math.pow(d / urbanR, 2.5)); + const core = Math.exp(-(d * d) / (coreR * coreR * 2.0)); + const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24); + populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18); + } + } + } + let maxDensity = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]); + if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); + + for (const city of allCities) { + let urbanCells = 0; + let coreCells = 0; + let densitySum = 0; + const r = Math.ceil((city.urbanRadius || 8) * 2.0); + 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 (!prefectureMask[i] || sea[i]) continue; + const d = Math.hypot(dx, dy); + if (d > r) continue; + const lu = landuse[i]; + if (lu >= 2 && lu <= 8) { + urbanCells++; + densitySum += populationDensity[i]; + if (lu === 3) coreCells++; + } + } + } + const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000; + const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200); + const coreComponent = coreCells * 3200; + const densityComponent = densitySum * 650; + city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000; + city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9); + } +} + export function generateMap(seedInput = 114514) { const seed = Number(seedInput) >>> 0; @@ -1495,8 +1693,7 @@ export function generateMap(seedInput = 114514) { const d = Math.hypot(dx, dy); if (d > radius || d === 0) continue; const weight = (radius + 0.35 - d) / (radius + 0.35); - const highRidgeGuard = clamp((elevation[ni] - 0.68) / 0.18) * clamp(ridgeField[ni] * 1.2); - const carve = Math.max(0, weight) * (0.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22) * (1 - highRidgeGuard * 0.72); + const carve = Math.max(0, weight) * (0.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22); fluvialElevation[ni] = clamp(fluvialElevation[ni] - carve, seaLevel + 0.005, 1); erosionField[ni] = clamp(erosionField[ni] + carve * 3.0); valleyField[ni] = clamp(valleyField[ni] + carve * 12.0); @@ -1523,6 +1720,57 @@ export function generateMap(seedInput = 114514) { } elevation.set(fluvialElevation); + + // Broad alluvial/coastal/basin plains. The plain score alone is not enough; + // the elevation surface must also be locally calm, otherwise every lowland + // still reads as rugged terrain. Smooth only low, wet depositional cells and + // leave ridges/headwaters untouched. + for (let pass = 0; pass < 4; pass++) { + const nextElevation = new Float32Array(elevation); + 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 lowland = clamp( + coastalLowland[i] * 0.72 + + basinField[i] * 0.54 + + valleyField[i] * 0.34 + + Math.pow(flowAccum[i], 0.58) * 0.24 - + ridgeField[i] * 0.62 - + Math.max(0, elevation[i] - 0.54) * 1.65 - + slope[i] * 0.74 + ); + if (lowland <= 0.12) continue; + let sum = 0; + let weight = 0; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const nx = x + dx; + const ny = y + dy; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const d = Math.hypot(dx, dy); + if (d > 2.25) continue; + const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11); + const w = compatible / (1 + d); + sum += elevation[ni] * w; + weight += w; + } + } + if (weight <= 0) continue; + const localMean = sum / weight; + const terrace = Math.round(localMean * 42) / 42; + const target = lerp(localMean, terrace, 0.28); + nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1); + if (lowland > 0.55) { + depositionField[i] = clamp(depositionField[i] + lowland * 0.018); + erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012); + } + } + } + elevation.set(nextElevation); + } + for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); @@ -1871,12 +2119,47 @@ export function generateMap(seedInput = 114514) { 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), @@ -1890,30 +2173,78 @@ export function generateMap(seedInput = 114514) { 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 geographyBoost = clamp(plain[pi] * 0.34 + agriculture[pi] * 0.18 + basinField[pi] * 0.2 + coastalLowland[pi] * 0.18 + valleyField[pi] * 0.12 + (p.kind === "Port Town" ? 0.22 : 0)); - const population = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000; + 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: "Prefectural Capital", - kind: "Prefectural Capital", - isPrefecturalCapital: true, - population: Math.max(modernCities[0].population || 0, 620000), - urbanRadius: Math.max(modernCities[0].urbanRadius || 0, 18), - coreRadius: Math.max(modernCities[0].coreRadius || 0, 5.5), - urbanWeight: Math.max(modernCities[0].urbanWeight || 0, 2.15), + 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[0] || markets[0] || ports[0] || { 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 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; @@ -1964,19 +2295,19 @@ export function generateMap(seedInput = 114514) { const i = indexOf(x, y); const e = elevation[i]; const s = slope[i]; - const pass = nearPassPoint(x, y, type === "express" ? 7 : 5); - if (e > 0.86) return INF; - if (pass && e > 0.82 && s > 0.16) return INF; - if (!pass && e > 0.80) return INF; - if (!pass && e > 0.72 && s > 0.18) return INF; - if (!pass && e > 0.68 && s > 0.32) return INF; - if (!pass && e > 0.74) return type === "express" ? 175 : 215; - if (!pass && e > 0.66 && s > 0.22) return type === "express" ? 105 : 135; - const passDiscount = pass ? 0.18 : 1; - const mountain = Math.max(0, e - 0.50); - const steep = Math.max(0, s - 0.18); - const typeFactor = type === "express" ? 260 : type === "rail" ? 320 : 120; - return (mountain * mountain * typeFactor + steep * steep * 120) * passDiscount; + 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) { @@ -1994,7 +2325,7 @@ export function generateMap(seedInput = 114514) { if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i]) continue; - const barrier = mode === "express" || mode === "rail" ? mountainBarrierPenalty(x, y, mode) : 0; + 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; @@ -2127,9 +2458,11 @@ export function generateMap(seedInput = 114514) { 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] * 15.8 + Math.max(0, elevation[i] - 0.56) * 2.6 + nodeAvoid + (river[i] > 0.45 ? 0.85 : 0) + floodplain[i] * 0.22 - density * 0.50 - plain[i] * 0.18 - valleyField[i] * 0.24 - coastalLowland[i] * 0.18 + ridgeField[i] * 0.90 + normalEdgePenalty(x, y) + hash2(x, y, seed + 333) * 0.08); + 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) { @@ -2255,7 +2588,7 @@ export function generateMap(seedInput = 114514) { if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i] || !prefectureMask[i]) continue; - const barrier = mode === "road" ? 0 : mountainBarrierPenalty(x, y, mode === "express" ? "express" : "rail"); + 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; @@ -2287,15 +2620,17 @@ export function generateMap(seedInput = 114514) { function softRingRailCost(x, y) { const i = indexOf(x, y); - if (sea[i] || elevation[i] > 0.82) return INF; + 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] * 12 + Math.max(0, elevation[i] - 0.58) * 16 + (river[i] > 0.5 ? 1.3 : river[i] * 0.6) - density * 0.62 - plain[i] * 0.18 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7222) * 0.05); + 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); - if (sea[i] || elevation[i] > 0.82) return INF; - return Math.max(0.38, 1 + slope[i] * 11 + Math.max(0, elevation[i] - 0.6) * 14 + (river[i] > 0.5 ? 1.0 : river[i] * 0.5) - midDensityAffinity(x, y) * 0.42 - plain[i] * 0.12 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7444) * 0.05); + 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) { @@ -2371,18 +2706,18 @@ export function generateMap(seedInput = 114514) { const expressRadius = roadRadius + 3 + rand(seed, city.x * 71 + city.y * 73) * 3; const railRadius = Math.max(8, roadRadius - 4); addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...expressways, ...railways, ...branchRailways], roadRadius); - const expressRingSegments = addEnvironmentalRing(city, "express", ringExpressways, expresswayCost, [...expressways, ...nationalRoads, ...railways, ...branchRailways], expressRadius); + // Expressway rings are intentionally disabled; expressways stay as sparse interurban corridors. const railRingSegments = addEnvironmentalRing(city, "rail", ringRailways, railCost, [...railways, ...branchRailways, ...nationalRoads, ...expressways], railRadius); // Expressway rings should be rare; do not force a fallback ring when terrain rejects it. if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius); } - compactPathArray(ringExpressways, { minLength: 10, maxOverlap: 0.22, maxCount: 2 }); + 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]) gatewayCandidates.push({ x, y, side: y === 0 ? "N" : "S", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 }); } - for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: x === 0 ? "W" : "E", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 }); } + for (let x = 0; x < MAP_W; x++) for (const y of [0, MAP_H - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: y === 0 ? "N" : "S", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 + coastalLowland[i] * 0.2 - Math.max(0, elevation[i] - 0.56) * 1.6 - ridgeField[i] * 0.35 }); } + for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: x === 0 ? "W" : "E", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 + coastalLowland[i] * 0.2 - Math.max(0, elevation[i] - 0.56) * 1.6 - ridgeField[i] * 0.35 }); } let externalGateways = pickEntities(gatewayCandidates, { max: 2 + Math.floor(rand(seed, 1201) * 3), @@ -2395,10 +2730,12 @@ export function generateMap(seedInput = 114514) { 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] * 10 + nodeAvoid + (river[i] > 0.45 ? 0.9 : 0) + floodplain[i] * 0.24 - density * 0.3 - plain[i] * 0.22 + borderPenalty + hash2(x, y, seed + 333) * 0.06); + 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) { @@ -2476,13 +2813,13 @@ export function generateMap(seedInput = 114514) { if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); } } - for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, ringExpressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82); + for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82); - const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways, ...ringExpressways], 6); - const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways], 4); + const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6); + const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4); const icCandidates = []; - for (const path of [...expressways, ...ringExpressways, ...externalExpressways]) { + 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; @@ -2599,8 +2936,10 @@ export function generateMap(seedInput = 114514) { function minorRoadCost(x, y) { const i = indexOf(x, y); - if (sea[i]) return INF; - return Math.max(0.3, 1 + slope[i] * 7.2 + floodplain[i] * 0.18 + (river[i] > 0.5 ? 1.0 : 0.18 * river[i]) - plain[i] * 0.22 - valleyField[i] * 0.34 - coastalLowland[i] * 0.12 + ridgeField[i] * 0.4 - roadNetInfluence[i] * 0.35 + normalEdgePenalty(x, y) + hash2(x, y, seed + 555) * 0.15); + 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(); @@ -2831,6 +3170,7 @@ export function generateMap(seedInput = 114514) { adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); } const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); + smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { if (!city || !prefectureMask[indexOf(city.x, city.y)]) return; @@ -2887,7 +3227,27 @@ export function generateMap(seedInput = 114514) { } lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520); lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 260); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 180); + snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 5); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 360); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 220); const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); + + // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. + // This keeps population figures proportional to the actually rendered urbanized area. + recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2); + for (const city of modernCities) { + if (city.isPrefecturalCapital) continue; + const cap = cityPopulationCap(city); + if (cap < INF && (city.population || 0) > cap) { + city.population = Math.round(cap / 1000) * 1000; + city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2); + city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0); + } + } + function makeHarborWorks(ports) { const out = []; for (const port of ports) { @@ -2984,7 +3344,7 @@ export function generateMap(seedInput = 114514) { premodernRoads, minorRoads, modernCities, - prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital) || modernCities[0] || null, + prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null, totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0), populationDensity, railways, diff --git a/names.js b/names.js index 2e82d5e..01f8cdc 100644 --- a/names.js +++ b/names.js @@ -2,30 +2,11 @@ // Edit only this file to change generated names or override individual IDs. export const NAME_PARTS = { - prefixes: ["青", "白", "黒", "赤", "東", "西", "南", "北", "中", "大", "小", "浅", "高", "川", "山", "森", "松", "原", "桜", "海", "新", "古", "上", "下"], - infixes: ["野", "原", "川", "浜", "崎", "丘", "谷", "峰", "浦", "沢", "島", "岡", "井", "瀬", "関", "橋", "口", "田", "津", "森", "台", "見"], - suffixes: ["町", "市", "郷", "台", "丘", "浦", "港", "里", "野", "沢", "原", "橋", "森", "浜", "崎", "峠"], + prefixes: ["青", "白", "黒", "赤", "東", "西", "南", "北", "中", "大", "小", "浅", "深", "高", "川", "山", "森", "松", "原", "桜", "海", "新", "古", "上", "下", "福", "岡", "宮", "伊", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "木", "林", "富", "住", "根", "葉", "由", "葦", "牟", "日", "月", "君",], + infixes: ["野", "原", "川", "ヶ", "浜", "矢", "丘", "谷", "津", "浦", "沢", "島", "尾", "井", "瀬", "関", "橋", "口", "田", "津", "森", "阿", "見"], + suffixes: ["島", "郷", "台", "丘", "浦", "吉", "里", "野", "沢", "原", "橋", "森", "浜", "呂", "峠"], }; -export const KIND_SUFFIXES = { - city: " City", - port: " Port", - market: " Town", - village: " Village", - castle: " Castle", - castleTown: " Castle Town", - castleRuin: " Castle Ruins", - station: " Station", - interchange: "IC", - industrial: " Industrial Park", - logistics: " Logistics Park", - newtown: " New Town", - satellite: " Satellite City", - pass: " Pass", - crossing: " Crossing", - gateway: " Gate", - admin: " District", -}; export const CUSTOM_NAMES = { // "city-0": "Aohara", diff --git a/renderer.js b/renderer.js index 74f3e32..a687b11 100644 --- a/renderer.js +++ b/renderer.js @@ -146,7 +146,7 @@ function drawBase(ctx, map, mode, continuousTerrain) { const eR = fieldSample(map.elevation, fx + 0.6, fy); const eU = fieldSample(map.elevation, fx, fy - 0.6); const eD = fieldSample(map.elevation, fx, fy + 0.6); - const shade = clamp(0.9 + (eL - eR) * 1.0 + (eU - eD) * 0.65, 0.72, 1.18); + const shade = clamp(0.9 + (eR - eL) * 1.0 + (eD - eU) * 0.65, 0.72, 1.18); const elevation = fieldSample(map.elevation, fx, fy); const contour = Math.abs((elevation * 16) - Math.round(elevation * 16)); const majorContour = Math.abs((elevation * 8) - Math.round(elevation * 8)); @@ -440,7 +440,6 @@ export function drawMap(canvas, map, options) { for (const path of map.icAccessRoads || []) drawPath(ctx, path, "rgba(255, 230, 150, 0.82)", 1.45); for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.0); for (const path of map.expressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.0); - for (const path of map.ringExpressways || []) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 3.3); for (const path of map.externalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.5); for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.2); } diff --git a/test.html b/test.html index 9e0c8f7..8dda95b 100644 --- a/test.html +++ b/test.html @@ -2,7 +2,7 @@
-