From 3fe9c3145393d2cff797cf0bb375155c45d51ae4 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 21 May 2026 22:03:14 +0900 Subject: [PATCH] tweak --- adminRegions.js | 127 +++++++++++ app.js | 1 + mapAdminStage.js | 89 ++++++-- mapFeatures.js | 21 +- mapGeneratorHelpers.js | 94 ++++++-- mapOutput.js | 71 ++++++ mapPipeline.js | 16 +- mapTerrain.js | 483 +++++++++++++++++++++++++++++++++++------ mapUtils.js | 8 + names.js | 29 +-- renderer.js | 24 +- test.js | 127 ++++++++++- 12 files changed, 963 insertions(+), 127 deletions(-) diff --git a/adminRegions.js b/adminRegions.js index 5dc1894..bf7aa0e 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -507,6 +507,9 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, const score = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i]) continue; + const [x, y] = xyOf(i); + let coastEdge = 0; + for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1; const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0; const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82); const ridgeDivide = clamp(ridgeField[i] * 1.65 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.05); @@ -520,6 +523,7 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, majorRiver * 0.86 + basinRim * 0.54 + foothillBreak * 0.48 + + coastEdge * 0.34 + terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 - livingCorridor * 0.50 - urbanContinuity * 0.72 @@ -739,6 +743,125 @@ function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierS return count ? sum / count : 0; } +function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) { + const owner = new Int16Array(compartments.length); + owner.fill(-1); + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const compIndex = compartmentId[indexOf(center.x, center.y)]; + if (compIndex >= 0 && compartments[compIndex]?.area > 0) { + const unit = compartments[compIndex]; + unit.centerIds.push(id); + owner[compIndex] = id; + } + } + + for (let pass = 0; pass < compartments.length + 8; pass++) { + let changed = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestOwner = -1; + let bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent) { + const neighborOwner = owner[neighborId]; + if (neighborOwner < 0) continue; + const neighbor = compartments[neighborId]; + if (!neighbor || neighbor.area === 0) continue; + const center = adminCenters[neighborOwner]; + const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0; + const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020); + if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } + } + const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70; + if (bestOwner >= 0 && accept) { + owner[unit.id] = bestOwner; + changed++; + } + } + if (changed === 0) break; + } + + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestId = -1; + let bestScore = -INF; + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]]; + const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.3 : 0; + const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0; + const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0; + const d = Math.hypot(unit.x - center.x, unit.y - center.y); + const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16; + if (score > bestScore) { bestScore = score; bestId = id; } + } + owner[unit.id] = bestId >= 0 ? bestId : 0; + } + return owner; +} + +export function extractCompartmentBorders(compartmentId, prefectureMask, sea) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || compartmentId[i] < 0) continue; + const a = compartmentId[i]; + if (x + 1 < MAP_W) { + const ni = indexOf(x + 1, y); + if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H) { + const ni = indexOf(x, y + 1); + if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { + const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); + const adminId = new Int16Array(SIZE); + adminId.fill(-1); + const owner = assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea); + for (const unit of compartments) { + const assigned = owner[unit.id]; + if (assigned < 0) continue; + for (const i of unit.cells) adminId[i] = assigned; + } + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue; + const comp = compartments[compartmentId[i]]; + adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0; + } + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const i = indexOf(center.x, center.y); + if (prefectureMask[i] && !sea[i]) adminId[i] = id; + } + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + const activeCompartments = compartments.filter((unit) => unit.area > 0); + return { + adminId, + compartmentId, + compartments, + naturalBarrierScore, + debug: { + naturalCompartmentCount: activeCompartments.length, + compartmentCount: activeCompartments.length, + compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea), + averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, + finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), + voronoiLikeRateBefore: 0, + voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), + }, + }; +} + function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) { let weak = 0; let total = 0; @@ -973,3 +1096,7 @@ export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; return { changedCells, splitMunicipalities }; } + +export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { + return splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters, settlements); +} diff --git a/app.js b/app.js index 4fb719e..aeeb5c9 100644 --- a/app.js +++ b/app.js @@ -11,6 +11,7 @@ const modes = [ ["development", "Development"], ["landuse", "Land Use"], ["admin", "Municipal Borders"], + ["terrain-debug", "Terrain Debug"], ["admin-debug", "Admin Debug"], ["borders-debug", "Borders Debug"], ]; diff --git a/mapAdminStage.js b/mapAdminStage.js index f861e81..6f4e844 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -1,11 +1,11 @@ import { applyLandscapeUnitAdminPartition, - generateAdminRegions, + assignAdminRegionsFromNaturalCompartments, lockSmallUrbanComponentsToMunicipality, mergeTinyMunicipalities, removeMunicipalExclaves, smoothAdminRegionsTerrainAware, - splitOversizedRuralMunicipalities, + splitOversizedLowlandMunicipalities, snapAdminBoundariesToTerrain, } from "./adminRegions.js"; import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js"; @@ -26,6 +26,34 @@ function municipalityAreaById(adminId, prefectureMask, sea) { return area; } +function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) { + let landCells = 0; + let habitableCells = 0; + let coastlineComplexity = 0; + let mountainCells = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + landCells++; + if (slope[i] < 0.42 && ridgeField[i] < 0.55) habitableCells++; + if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++; + for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { + const ni = indexOf(nx, ny); + if (sea[ni]) { + coastlineComplexity += 1 + coastalLowland[i] * 0.8; + break; + } + } + } + } + const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length; + const settlementNodes = modernCities.length * 1.25 + markets.length * 0.9 + ports.length * 0.7 + independentSatellites * 0.8 + villages.length * 0.35; + const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); + const mountainRatio = landCells ? mountainCells / landCells : 0; + return clamp(Math.round(habitableCells / 260 + settlementNodes * 0.45 + coastlineComplexity * 0.04 + basinBonus + mountainRatio * 4), 18, 48); +} + function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { if (!city || !inside(city.x, city.y)) return 0; const start = indexOf(city.x, city.y); @@ -193,6 +221,7 @@ export function generateAdminLayout({ slope, river, ridgeField, + naturalBarrierScore, valleyField, basinField, coastalLowland, @@ -216,7 +245,9 @@ export function generateAdminLayout({ industrialZones, logisticsParks, }) { - const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0); + const boundaryRidgeField = naturalBarrierScore + ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) + : ridgeField; const municipalityCandidates = []; for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { @@ -235,7 +266,8 @@ export function generateAdminLayout({ const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); return !nearMajor && !nearSmallUrban; }); - const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum); + const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); + const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); const satelliteMunicipalSeeds = (satelliteCities || []) .filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality") .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); @@ -243,24 +275,27 @@ export function generateAdminLayout({ ...majorMunicipalSeeds, ...satelliteMunicipalSeeds, ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { - max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))), - minDistance: 9 + Math.floor(rand(seed, 1302) * 3), - threshold: 0.40, + max: Math.max(0, targetMunicipalityCount - majorMunicipalSeeds.length - satelliteMunicipalSeeds.length), + minDistance: 6 + Math.floor(rand(seed, 1302) * 3), + threshold: 0.34, seed: seed + 1300, jitter: 0.025, }), ]; - if (adminCentersRaw.length < 12) { - const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...newTowns, ...stations, ...villages] + if (adminCentersRaw.length < Math.min(targetMunicipalityCount, 18)) { + const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...ports, ...newTowns, ...stations, ...villages] .filter((p) => prefectureMask[indexOf(p.x, p.y)]) - .map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 })); - adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 }); + .map((p) => ({ x: p.x, y: p.y, score: (p.score || 0.5) + (p.population || 0) / 900000 })); + const extraFallback = pickEntities(fallback, { max: targetMunicipalityCount, minDistance: 5, threshold: 0, seed: seed + 1303 }); + for (const p of extraFallback) if (adminCentersRaw.length < targetMunicipalityCount && adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) adminCentersRaw.push(p); } - if (adminCentersRaw.length < 10) { - const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); - adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); + if (adminCentersRaw.length < targetMunicipalityCount) { + const extra = pickEntities(municipalityCandidates, { max: targetMunicipalityCount - adminCentersRaw.length, minDistance: 5, threshold: 0.26, seed: seed + 1304 }); + adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4))); } - const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); + if (adminCentersRaw.length > targetMunicipalityCount) adminCentersRaw = adminCentersRaw.slice(0, targetMunicipalityCount); + const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + const adminId = compartmentAssignment.adminId; let previousSnapshot = new Int16Array(adminId); const adminDebug = { changedAfterSmooth: 0, @@ -273,6 +308,10 @@ export function generateAdminLayout({ changedAfterOversizedRuralSplit: 0, changedAfterFinalExclaveRemoval: 0, changedAfterFinalMerge: 0, + targetMunicipalityCount, + actualMunicipalityCount: 0, + changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0, + oversizedRuralSplits: 0, satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length, satelliteMunicipalitiesMerged: 0, satelliteMunicipalitiesExpanded: 0, @@ -282,12 +321,14 @@ export function generateAdminLayout({ satelliteMunicipalityAreaByNameOrIndex: {}, independentSatelliteMunicipalities: satelliteClassificationDebug.independent, attachedSatelliteDistricts: satelliteClassificationDebug.attached, + satelliteMunicipalityStats: satelliteClassificationDebug, + ...compartmentAssignment.debug, }; function markChanged(field) { adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea); previousSnapshot = new Int16Array(adminId); } - smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); + smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2); markChanged("changedAfterSmooth"); function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { @@ -335,7 +376,7 @@ export function generateAdminLayout({ if (bestAdmin < 0) continue; sat.parentAdminHint = bestAdmin; const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, { - prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, }); if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++; @@ -348,22 +389,23 @@ export function generateAdminLayout({ markChanged("changedAfterInitialMerge"); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180); markChanged("changedAfterInitialExclaveRemoval"); - applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); for (const sat of satelliteCities || []) { if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue; const targetAdmin = adminId[indexOf(sat.x, sat.y)]; if (targetAdmin < 0) continue; expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, { - prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, }); } markChanged("changedAfterLandscapePartition"); - const oversizedSplitDebug = splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]); + const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]); adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells; adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities; + adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities; previousSnapshot = new Int16Array(adminId); - snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); + snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2); markChanged("changedAfterSnap"); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 360); markChanged("changedAfterFinalExclaveRemoval"); @@ -375,7 +417,7 @@ export function generateAdminLayout({ const targetAdmin = adminId[indexOf(sat.x, sat.y)]; if (targetAdmin < 0) continue; expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, { - prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, }); } @@ -401,8 +443,11 @@ export function generateAdminLayout({ }); adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0; adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0; + adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length; const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {}; Object.assign(adminDebug, landscapeDebug); + adminDebug.naturalCompartmentCount = adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0; + adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size; const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); diff --git a/mapFeatures.js b/mapFeatures.js index b4f8edb..e5567e0 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -34,6 +34,11 @@ export function generateMapFeatures(seed, terrain) { basinField, coastalLowland, flowAccum, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, portSuitability, crossingSuitability, passSuitability, @@ -108,9 +113,11 @@ export function generateMapFeatures(seed, terrain) { 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); - const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1); + 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)); @@ -124,10 +131,12 @@ export function generateMapFeatures(seed, terrain) { 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 riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16); + 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] - 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 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - floodplain[i] * 0.06 - remoteMountainPenalty; + 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); } } @@ -159,7 +168,7 @@ export function generateMapFeatures(seed, terrain) { 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 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 + nearbyVillages * 0.012); + 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); } } diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 8b6d967..918db7d 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -418,29 +418,52 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore); const owner = new Int16Array(compartments.length); owner.fill(-1); + for (let id = 0; id < seeded.centers.length; id++) { + const center = seeded.centers[id]; + if (!center || !inside(center.x, center.y)) continue; + const ci = compartmentId[indexOf(center.x, center.y)]; + if (ci >= 0) owner[ci] = id; + } for (const unit of compartments) { if (!unit || unit.area === 0) continue; - const counts = new Map(); - let anchorCells = 0; - for (const i of unit.cells) { - if (anchorMask[i]) anchorCells++; - const id = beforeRegionId[i]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + if (unit.cells.some((i) => anchorMask[i])) owner[unit.id] = 0; + } + for (let pass = 0; pass < compartments.length + 6; pass++) { + let changedThisPass = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestOwner = -1; + let bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent || []) { + const neighborOwner = owner[neighborId]; + if (neighborOwner < 0) continue; + const neighbor = compartments[neighborId]; + const center = seeded.centers[neighborOwner]; + const barrier = edge.target / Math.max(1, edge.count); + const sameClass = neighbor?.classId === unit.classId ? 1.0 : 0; + const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0; + const score = edge.count * 0.45 + sameClass + neighbor.coastalExposure * 0.08 + neighbor.ridgeExposure * 0.05 - barrier * 2.6 - d * 0.008; + if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } + } + if (bestOwner >= 0) { + owner[unit.id] = bestOwner; + changedThisPass++; + } } - if (anchorCells > 0) { - owner[unit.id] = 0; - continue; - } - let bestId = -1; - let best = -1; - for (const [id, count] of counts) { + if (changedThisPass === 0) break; + } + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestId = 0; + let best = -INF; + for (let id = 0; id < seeded.centers.length; id++) { const center = seeded.centers[id]; - const centerFit = center ? -Math.hypot(center.x - unit.x, center.y - unit.y) * 0.012 : 0; - const terrainFit = unit.ridgeExposure * 0.10 + unit.riverExposure * 0.04 + unit.coastalExposure * 0.08; - const score = count + centerFit + terrainFit; + if (!center) continue; + const d = Math.hypot(unit.x - center.x, unit.y - center.y); + const score = -d + (id === 0 ? (unit.cells.some((i) => anchorMask[i]) ? 1000 : -12) : 0); if (score > best) { best = score; bestId = id; } } - owner[unit.id] = bestId >= 0 ? bestId : 0; + owner[unit.id] = bestId; } const regionId = new Int16Array(beforeRegionId); @@ -472,6 +495,9 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, regionalNaturalBarrierAverageBefore: beforeNaturalAverage, regionalNaturalBarrierAverageAfter: afterNaturalAverage, regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length, + compartmentCount: compartments.filter((unit) => unit.area > 0).length, + changedAfterCompartmentAssignment: changed, + borderNaturalBarrierAverage: afterNaturalAverage, }, }; } @@ -635,8 +661,33 @@ function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeFie ridgeExposure: ridgeExposure / Math.max(1, area), riverExposure: riverExposure / Math.max(1, area), coastalExposure: coastalExposure / Math.max(1, area), + adjacent: new Map(), }); } + 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 a = compartmentId[i]; + if (a < 0 || !compartments[a]) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const b = compartmentId[ni]; + if (b < 0 || a === b || !compartments[b]) continue; + const v = (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5; + const edgeA = compartments[a].adjacent.get(b) || { count: 0, target: 0 }; + edgeA.count++; + edgeA.target += v; + compartments[a].adjacent.set(b, edgeA); + const edgeB = compartments[b].adjacent.get(a) || { count: 0, target: 0 }; + edgeB.count++; + edgeB.target += v; + compartments[b].adjacent.set(a, edgeB); + } + } + } return { compartmentId, compartments }; } @@ -845,6 +896,15 @@ export function applyOutputOptions(map, options = {}) { delete slim.flowAccum; delete slim.erosionField; delete slim.depositionField; + delete slim.terrainTemplate; + delete slim.ocean; + delete slim.lake; + delete slim.arcSpineField; + delete slim.branchRidgeField; + delete slim.depositionalLowland; + delete slim.alluvialFanField; + delete slim.deltaField; + delete slim.naturalBarrierScore; return slim; } diff --git a/mapOutput.js b/mapOutput.js index 5517111..be7b84c 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -5,6 +5,7 @@ import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLandus export function finishMapOutput({ seed, options, + terrainTemplate, cityPopulationCap, stationInfluence, roadInfluence, @@ -13,6 +14,8 @@ export function finishMapOutput({ moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -25,6 +28,12 @@ export function finishMapOutput({ flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, villages, ports, crossings, @@ -132,6 +141,58 @@ export function finishMapOutput({ castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); + const representativeFeatures = [ + ...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })), + ...markets.map((p) => ({ ...p, representativeWeight: 3.2 })), + ...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })), + ...villages.map((p) => ({ ...p, representativeWeight: 1.6 })), + ].filter((p) => p.insidePrefecture && p.name); + for (const center of adminCenters) { + const centerAdmin = adminId?.[indexOf(center.x, center.y)]; + let best = null; + let bestScore = -INF; + for (const sameAdminOnly of [true, false]) { + for (const feature of representativeFeatures) { + const featureAdmin = adminId?.[indexOf(feature.x, feature.y)]; + if (sameAdminOnly && centerAdmin >= 0 && featureAdmin >= 0 && featureAdmin !== centerAdmin) continue; + const d = Math.hypot(center.x - feature.x, center.y - feature.y); + const score = feature.representativeWeight - d * 0.11 - (sameAdminOnly ? 0 : 1.2); + if (score > bestScore) { + bestScore = score; + best = feature; + } + } + if (best) break; + } + if (best) { + center.representativeFeatureId = best.id; + center.representativeFeatureName = best.name; + center.generatedMunicipalityName = center.name; + center.name = best.name; + } + } + const adminNamePrefixes = ["\u6771", "\u897F", "\u5357", "\u5317", "\u4E0A", "\u4E0B", "\u65B0", "\u65E7", "\u4E2D", "\u5916"]; + const adminNameCounts = new Map(); + for (const center of adminCenters) adminNameCounts.set(center.name, (adminNameCounts.get(center.name) || 0) + 1); + const duplicateOrdinal = new Map(); + for (const center of adminCenters) { + if ((adminNameCounts.get(center.name) || 0) <= 1) continue; + const n = duplicateOrdinal.get(center.name) || 0; + duplicateOrdinal.set(center.name, n + 1); + const prefix = adminNamePrefixes[(Math.floor(center.x / Math.max(1, MAP_W / 3)) + Math.floor(center.y / Math.max(1, MAP_H / 3)) * 3 + n) % adminNamePrefixes.length]; + if (!String(center.name).startsWith(prefix)) center.name = `${prefix}${center.name}`; + } + const usedAdminNames = new Set(); + for (const center of adminCenters) { + let candidate = center.name; + let guard = 0; + while (usedAdminNames.has(candidate) && guard < adminNamePrefixes.length) { + candidate = `${adminNamePrefixes[(guard + Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNamePrefixes.length]}${center.name}`; + guard++; + } + center.name = candidate; + usedAdminNames.add(center.name); + } const entitiesForNames = [ ...modernCities, @@ -146,6 +207,7 @@ export function finishMapOutput({ ...newTowns, ...passes, ...crossings, + ...adminCenters, ...externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); @@ -153,6 +215,7 @@ export function finishMapOutput({ width: MAP_W, height: MAP_H, cellSize: CELL_SIZE, + terrainTemplate, prefectureMask, prefectureBorder, prefectureRegionId, @@ -162,6 +225,8 @@ export function finishMapOutput({ moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -174,6 +239,12 @@ export function finishMapOutput({ flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, villages, ports, crossings, diff --git a/mapPipeline.js b/mapPipeline.js index c96f18f..0ec01f0 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -11,10 +11,13 @@ export function generateMap(seedInput = 114514, options = {}) { const terrain = generateTerrainAndRivers(seed); const { + terrainTemplate, elevation, moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -26,6 +29,12 @@ export function generateMap(seedInput = 114514, options = {}) { flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, @@ -48,13 +57,14 @@ export function generateMap(seedInput = 114514, options = {}) { } = features; const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ - seed, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, }); return finishMapOutput({ - seed, options, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, - elevation, moisture, slope, sea, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, + seed, options, terrainTemplate, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, + elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, + arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, villages, ports, crossings, passes, 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, adminCentersRaw, adminId, adminBorders, adminDebug, diff --git a/mapTerrain.js b/mapTerrain.js index a30164b..31d47c8 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -8,6 +8,145 @@ import { neighbors8, } from "./mapGeneratorHelpers.js"; +export function buildTerrainTemplate(seed) { + const deposition = 0.18 + rand(seed, 41) * 0.72; + const erosion = 0.24 + rand(seed, 42) * 0.68; + const roughness = 0.34 + rand(seed, 43) * 0.62; + const coastAxisPick = Math.floor(rand(seed, 10) * 3); + const coastAngle = coastAxisPick === 0 + ? Math.PI / 2 + : coastAxisPick === 1 + ? 0 + : (rand(seed, 11) > 0.5 ? Math.PI / 4 : -Math.PI / 4) + (rand(seed, 14) - 0.5) * 0.28; + const ridgeJaggedness = 0.20 + rand(seed, 44) * 0.70; + const spineCount = rand(seed, 45) > 0.64 ? 2 : 1; + const sideAPlain = 0.035 + rand(seed, 56) * 0.115 + deposition * 0.085; + const sideBPlain = 0.035 + rand(seed, 57) * 0.115 + deposition * 0.085; + + return { + seed, + spineCount, + spineAngle: coastAngle + Math.PI * (0.28 + rand(seed, 46) * 0.44), + spineCurve: (rand(seed, 47) - 0.5) * 0.28, + spinePosition: (rand(seed, 48) - 0.5) * 0.56, + spineStrength: 0.66 + rand(seed, 49) * 0.44, + spineWidth: 0.060 + rand(seed, 50) * 0.050, + secondaryMountainCount: 3 + Math.floor(rand(seed, 51) * 5), + secondaryMountainSize: 0.060 + rand(seed, 52) * 0.085, + secondaryMountainStrength: 0.55 + rand(seed, 53) * 0.55, + coastAxis: coastAxisPick === 0 ? "east-west" : coastAxisPick === 1 ? "north-south" : "diagonal", + coastAngle, + coastBias: 0.18 + rand(seed, 12) * 0.24, + coastRoughness: 0.34 + rand(seed, 54) * 0.58, + coastSides: [ + { + penetration: 0.24 + rand(seed, 58) * 0.24, + inletStrength: 0.18 + rand(seed, 59) * 0.56, + plainWidth: sideAPlain, + }, + { + penetration: 0.24 + rand(seed, 60) * 0.24, + inletStrength: 0.18 + rand(seed, 61) * 0.56, + plainWidth: sideBPlain, + }, + ], + deposition, + erosion, + roughness, + ridgeJaggedness, + ridgeBranchiness: 0.28 + rand(seed, 55) * 0.62, + }; +} + +function jaggedRidgeContribution(x, y, ridge, seed) { + const dx = x - ridge.x; + const dy = y - ridge.y; + const ca = Math.cos(ridge.angle); + const sa = Math.sin(ridge.angle); + const along = dx * ca + dy * sa; + const perp = -dx * sa + dy * ca; + const nAlong = along / Math.max(0.001, ridge.length); + const lengthFade = smoothstep(1 - Math.abs(nAlong)); + if (lengthFade <= 0) return 0; + + // Bend the centerline itself with coherent long/mid waves, then apply ridge falloff. + const low = (valueNoise(along * 0.85 + ridge.seedOffset, ridge.seedOffset * 0.37, seed + 6100, 28) - 0.5) * 2; + const mid = (valueNoise(along * 1.7 - ridge.seedOffset, ridge.seedOffset * 0.23, seed + 6200, 13) - 0.5) * 2; + const sine = Math.sin(along * ridge.kinkFrequency + ridge.kinkPhase); + const curve = (ridge.curve || 0) * along * along * (along >= 0 ? 1 : -1); + const axisOffset = low * ridge.axisWobble + mid * ridge.axisWobble * 0.55 + sine * ridge.axisWobble * 0.25 + curve; + const widthNoise = 0.78 + valueNoise(along * 1.2 + ridge.seedOffset, ridge.seedOffset * 0.19, seed + 6300, 21) * ridge.widthVariation; + const localWidth = Math.max(0.006, ridge.width * widthNoise); + const jaggedPerp = perp - axisOffset; + const serration = 0.76 + valueNoise(x * 1.1 + along * 0.18, y * 1.1 + perp * 0.18, seed + ridge.seedOffset, 7) * 0.48; + return Math.exp(-(jaggedPerp * jaggedPerp) / (localWidth * localWidth)) * lengthFade * ridge.h * serration; +} + +function spineFieldAt(x, y, template, spineIndex) { + const seed = template.seed || 0; + const spacing = spineIndex === 0 ? 0 : (spineIndex % 2 ? 0.18 : -0.18); + const angle = template.spineAngle + (spineIndex - 0.5) * 0.17 + (rand(seed, 700 + spineIndex) - 0.5) * 0.18; + const ridge = { + x: 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45, + y: 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45, + angle, + width: template.spineWidth * (0.82 + rand(seed, 710 + spineIndex) * 0.38), + length: 0.78 + rand(seed, 720 + spineIndex) * 0.28, + h: template.spineStrength * (0.18 + rand(seed, 730 + spineIndex) * 0.08), + curve: template.spineCurve, + axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 1.15), + kinkFrequency: 10 + rand(seed, 740 + spineIndex) * 18, + kinkPhase: rand(seed, 750 + spineIndex) * Math.PI * 2, + seedOffset: 7600 + spineIndex * 211, + widthVariation: 0.18 + template.ridgeJaggedness * 0.34, + }; + return jaggedRidgeContribution(x, y, ridge, seed); +} + +function buildSpineRidges(seed, template) { + const spines = []; + const branches = []; + for (let i = 0; i < template.spineCount; i++) { + const angle = template.spineAngle + (i - 0.5) * 0.17 + (rand(seed, 700 + i) - 0.5) * 0.18; + const spacing = i === 0 ? 0 : (i % 2 ? 0.18 : -0.18); + const x = 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45; + const y = 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45; + spines.push({ + x, y, angle, + width: template.spineWidth * (0.82 + rand(seed, 710 + i) * 0.38), + length: 0.78 + rand(seed, 720 + i) * 0.28, + h: template.spineStrength * (0.18 + rand(seed, 730 + i) * 0.08), + curve: template.spineCurve, + axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 1.15), + kinkFrequency: 10 + rand(seed, 740 + i) * 18, + kinkPhase: rand(seed, 750 + i) * Math.PI * 2, + seedOffset: 7600 + i * 211, + widthVariation: 0.18 + template.ridgeJaggedness * 0.34, + }); + const branchCount = 3 + Math.floor(template.ridgeBranchiness * 5); + for (let b = 0; b < branchCount; b++) { + const along = (rand(seed, 810 + i * 31 + b) - 0.5) * 0.62; + const side = rand(seed, 820 + i * 31 + b) > 0.5 ? 1 : -1; + const branchAngle = angle + side * (0.55 + rand(seed, 830 + i * 31 + b) * 0.72); + branches.push({ + x: x + Math.cos(angle) * along, + y: y + Math.sin(angle) * along, + angle: branchAngle, + width: template.spineWidth * (0.42 + rand(seed, 840 + i * 31 + b) * 0.36), + length: 0.16 + rand(seed, 850 + i * 31 + b) * 0.28, + h: template.spineStrength * (0.055 + template.ridgeBranchiness * 0.085 + rand(seed, 860 + i * 31 + b) * 0.055), + curve: template.spineCurve * 0.45, + axisWobble: template.spineWidth * (0.32 + template.ridgeJaggedness * 0.72), + kinkFrequency: 14 + rand(seed, 870 + i * 31 + b) * 20, + kinkPhase: rand(seed, 880 + i * 31 + b) * Math.PI * 2, + seedOffset: 8800 + i * 311 + b * 37, + widthVariation: 0.22 + template.ridgeJaggedness * 0.30, + }); + } + } + return { spines, branches }; +} + export function generateTerrainAndRivers(seed) { let prefectureMask; let prefectureBorder; @@ -17,6 +156,8 @@ export function generateTerrainAndRivers(seed) { moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -28,35 +169,70 @@ export function generateTerrainAndRivers(seed) { flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, flowTo, portSuitability, crossingSuitability, passSuitability, } = createMapFields(); - const coastAngle = rand(seed, 11) * Math.PI * 2; + const terrainTemplate = buildTerrainTemplate(seed); + const coastAngle = terrainTemplate.coastAngle; const coastX = Math.cos(coastAngle); const coastY = Math.sin(coastAngle); - const coastThreshold = 0.22 + rand(seed, 12) * 0.22; - const coastStrength = 0.15 + rand(seed, 13) * 0.23; + const coastThreshold = terrainTemplate.coastBias; + const coastStrength = 0.13 + (1 - terrainTemplate.deposition) * 0.16 + rand(seed, 13) * 0.13; + const { spines, branches } = buildSpineRidges(seed, terrainTemplate); + + function coastPressureAt(x, y, wx = x, wy = y) { + const nx = x / (MAP_W - 1) - 0.5; + const ny = y / (MAP_H - 1) - 0.5; + const axis = nx * coastX + ny * coastY; + const waveA = (fbm(wx * 0.72 + 31, wy * 0.72 - 17, seed + 2222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[0].inletStrength * 0.18) + + (valueNoise(wx + 19, wy - 23, seed + 2233, 18) - 0.5) * (0.03 + terrainTemplate.coastSides[0].inletStrength * 0.10); + const waveB = (fbm(wx * 0.68 - 41, wy * 0.68 + 29, seed + 3222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[1].inletStrength * 0.18) + + (valueNoise(wx - 13, wy + 37, seed + 3233, 16) - 0.5) * (0.03 + terrainTemplate.coastSides[1].inletStrength * 0.10); + const sideA = smoothstep((axis + waveA - (0.50 - terrainTemplate.coastSides[0].penetration)) / Math.max(0.08, terrainTemplate.coastSides[0].plainWidth * 2.4)); + const sideB = smoothstep((-axis + waveB - (0.50 - terrainTemplate.coastSides[1].penetration)) / Math.max(0.08, terrainTemplate.coastSides[1].plainWidth * 2.4)); + return { sideA, sideB, pressure: Math.max(sideA, sideB), signedAxis: axis }; + } const seaLevel = 0.285; - const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({ - x: rand(seed, 100 + i) * MAP_W, - y: rand(seed, 200 + i) * MAP_H, - r: 10 + rand(seed, 300 + i) * 24, - h: 0.08 + rand(seed, 400 + i) * 0.16, - })); - - const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({ - x: rand(seed, 1500 + i) * MAP_W, - y: rand(seed, 1600 + i) * MAP_H, - angle: rand(seed, 1700 + i) * Math.PI * 2, - width: 3 + rand(seed, 1800 + i) * 7, - length: 42 + rand(seed, 1900 + i) * 92, - h: 0.11 + rand(seed, 2000 + i) * 0.22, - })); + const mountainBlobs = Array.from({ length: terrainTemplate.secondaryMountainCount }, (_, i) => { + const spine = spines[i % spines.length]; + const nearSpine = rand(seed, 98 + i) < 0.72; + const edgeBias = rand(seed, 99 + i) < 0.28; + const along = (rand(seed, 100 + i) - 0.5) * spine.length * 0.95; + const side = rand(seed, 101 + i) > 0.5 ? 1 : -1; + const offset = (0.055 + rand(seed, 102 + i) * 0.22) * side; + let x = nearSpine ? spine.x + Math.cos(spine.angle) * along + Math.cos(spine.angle + Math.PI / 2) * offset : rand(seed, 103 + i); + let y = nearSpine ? spine.y + Math.sin(spine.angle) * along + Math.sin(spine.angle + Math.PI / 2) * offset : rand(seed, 104 + i); + if (edgeBias) { + const edgeSide = Math.floor(rand(seed, 105 + i) * 4); + if (edgeSide === 0) x = Math.min(x, 0.08 + rand(seed, 106 + i) * 0.10); + if (edgeSide === 1) x = Math.max(x, 0.92 - rand(seed, 107 + i) * 0.10); + if (edgeSide === 2) y = Math.min(y, 0.08 + rand(seed, 108 + i) * 0.10); + if (edgeSide === 3) y = Math.max(y, 0.92 - rand(seed, 109 + i) * 0.10); + } + const coastSide = (x - 0.5) * coastX + (y - 0.5) * coastY; + const mountainSide = coastSide >= 0 ? 1 : -1; + if (rand(seed, 110 + i) < 0.46 && Math.abs(coastSide) > 0.28 - coastThreshold * 0.35) { + x -= coastX * mountainSide * (0.05 + rand(seed, 111 + i) * 0.11); + y -= coastY * mountainSide * (0.05 + rand(seed, 112 + i) * 0.11); + } + return { + x: clamp(x) * MAP_W, + y: clamp(y) * MAP_H, + r: (terrainTemplate.secondaryMountainSize * (0.72 + rand(seed, 300 + i) * 0.72)) * Math.min(MAP_W, MAP_H), + h: terrainTemplate.secondaryMountainStrength * (0.08 + rand(seed, 400 + i) * 0.17), + }; + }); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { @@ -75,26 +251,22 @@ export function generateTerrainAndRivers(seed) { mountains += Math.exp(-d * d * 2.35) * blob.h; } - let ridges = 0; - for (const ridge of ridgeBands) { - const dx = wx - ridge.x; - const dy = wy - ridge.y; - const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle); - const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle); - const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length); - const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56; - ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration; - } + const px = wx / (MAP_W - 1); + const py = wy / (MAP_H - 1); + let spineRidges = 0; + for (let si = 0; si < spines.length; si++) spineRidges += jaggedRidgeContribution(px, py, spines[si], seed); + let branchRidges = 0; + for (const ridge of branches) branchRidges += jaggedRidgeContribution(px, py, ridge, seed); + const ridges = spineRidges + branchRidges; - const directionalCoast = nx * coastX + ny * coastY; - const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08; - const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26); + const coast = coastPressureAt(x, y, wx, wy); + const coastLower = coast.pressure; // Four terrain-noise bands from continental structure to fine surface roughness. const terrainLarge = fbm(wx * 0.36 + 40, wy * 0.36 - 60, seed + 710); const terrainRegional = fbm(wx * 0.95 + 80, wy * 0.95 - 20, seed + 777); const terrainLocal = fbm(wx * 2.05 + 17, wy * 2.05 - 31, seed + 1777); const terrainFine = valueNoise(wx * 2.9 + 11, wy * 2.9 - 19, seed + 2444, 4.5); - const fineDissection = Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035; + const fineDissection = (Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035) * (0.68 + terrainTemplate.roughness * 0.74); const basin = 0.1 * Math.sin((nx * 3.1 + ny * 1.7 + rand(seed, 15)) * Math.PI) - 0.045 * Math.cos((nx * 5.2 - ny * 3.6 + rand(seed, 16)) * Math.PI); const rawElevation = 0.30 * terrainLarge + @@ -102,15 +274,18 @@ export function generateTerrainAndRivers(seed) { 0.105 * terrainLocal + 0.055 * terrainFine + mountains * 0.54 + - ridges * 1.22 + + spineRidges * 0.78 + + branchRidges * 0.92 + basin + fineDissection - - coastLower * (coastStrength + 0.19) + + coastLower * (coastStrength + 0.10 + terrainTemplate.deposition * 0.10) + 0.055; elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26); - ridgeField[i] = clamp(ridges * 4.8 + Math.max(0, mountains - 0.10) * 0.95 + fineDissection * 2.0); - basinField[i] = clamp(Math.max(0, -basin) * 3.0 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * 0.7); + arcSpineField[i] = clamp(spineRidges * 3.7); + branchRidgeField[i] = clamp(branchRidges * 3.9); + ridgeField[i] = clamp(arcSpineField[i] * 0.86 + branchRidgeField[i] * 0.72 + Math.max(0, mountains - 0.10) * 0.95 + fineDissection * 2.0); + basinField[i] = clamp(Math.max(0, -basin) * 3.0 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * (0.48 + terrainTemplate.deposition * 0.42)); moisture[i] = clamp(0.44 * fbm(wx + 400, wy - 200, seed + 333) + 0.18 * valueNoise(wx, wy, seed + 343, 11) + 0.22 * (1 - Math.abs(ny * 1.7)) + 0.28 * coastLower - Math.max(0, elevation[i] - 0.62) * 0.22); } } @@ -118,16 +293,106 @@ export function generateTerrainAndRivers(seed) { for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); - const nx = x / (MAP_W - 1) - 0.5; - const ny = y / (MAP_H - 1) - 0.5; - const directionalCoast = nx * coastX + ny * coastY; - const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08; - const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055; + const coast = coastPressureAt(x, y); + const mountainToSea = ridgeField[i] * (1 - terrainTemplate.deposition) * 0.035; + const oceanSide = coast.pressure + mountainToSea > 0.56 + terrainTemplate.deposition * 0.035; if (elevation[i] < seaLevel || oceanSide) sea[i] = 1; if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.018 + hash2(x, y, seed + 2311) * 0.012); } } + // Edge-connected water is ocean. Isolated water is only kept when it reads as + // a small mountain/valley lake or lagoon; oversized round basins become wet lowland. + const waterSeen = new Uint8Array(SIZE); + const oceanQueue = []; + for (let x = 0; x < MAP_W; x++) { + for (const y of [0, MAP_H - 1]) { + const i = indexOf(x, y); + if (sea[i] && !waterSeen[i]) { + waterSeen[i] = 1; + ocean[i] = 1; + oceanQueue.push(i); + } + } + } + for (let y = 0; y < MAP_H; y++) { + for (const x of [0, MAP_W - 1]) { + const i = indexOf(x, y); + if (sea[i] && !waterSeen[i]) { + waterSeen[i] = 1; + ocean[i] = 1; + oceanQueue.push(i); + } + } + } + for (let q = 0; q < oceanQueue.length; q++) { + const cur = oceanQueue[q]; + const [x, y] = [cur % MAP_W, Math.floor(cur / MAP_W)]; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!sea[ni] || waterSeen[ni]) continue; + waterSeen[ni] = 1; + ocean[ni] = 1; + oceanQueue.push(ni); + } + } + for (let i = 0; i < SIZE; i++) { + if (!sea[i] || waterSeen[i]) continue; + const queue = [i]; + const component = [i]; + waterSeen[i] = 1; + let sx = 0, sy = 0, perimeter = 0, ridgeSum = 0, valleySum = 0, coastTouch = 0; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + sx += x; + sy += y; + ridgeSum += ridgeField[cur]; + valleySum += valleyField[cur]; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!sea[ni]) { + perimeter++; + if (coastalLowland[ni] > 0.12 || coastPressureAt(nx, ny).pressure > 0.42) coastTouch++; + continue; + } + if (waterSeen[ni]) continue; + waterSeen[ni] = 1; + queue.push(ni); + component.push(ni); + } + } + const area = component.length; + const cx = sx / area; + const cy = sy / area; + let radiusSum = 0; + for (const ci of component) { + const x = ci % MAP_W; + const y = Math.floor(ci / MAP_W); + radiusSum += Math.hypot(x - cx, y - cy); + } + const meanRadius = radiusSum / Math.max(1, area); + const circularity = perimeter > 0 ? (4 * Math.PI * area) / (perimeter * perimeter) : 1; + const mountainLake = area <= 38 && ridgeSum / area > 0.28; + const valleyLake = area <= 70 && valleySum / area > 0.24 && circularity < 0.58; + const lagoon = area <= 110 && coastTouch / Math.max(1, perimeter) > 0.18 && circularity < 0.70; + const rareSpecial = area <= 145 && circularity < 0.52 && hash2(Math.round(cx), Math.round(cy), seed + 2401) > 0.88; + const keepLake = mountainLake || valleyLake || lagoon || rareSpecial; + for (const ci of component) { + if (keepLake) { + lake[ci] = 1; + continue; + } + sea[ci] = 0; + elevation[ci] = Math.max(seaLevel + 0.012, seaLevel + Math.min(0.055, meanRadius * 0.004) + hash2(ci, area, seed + 2402) * 0.012); + basinField[ci] = clamp(basinField[ci] + 0.42); + valleyField[ci] = clamp(valleyField[ci] + 0.18); + depositionalLowland[ci] = clamp(depositionalLowland[ci] + 0.28); + depositionField[ci] = clamp(depositionField[ci] + 0.035); + } + } + // Align coastal elevation with the sea mask. This prevents artificial one-cell cliffs // when the directional coastline cuts through a high terrain cell. for (let y = 0; y < MAP_H; y++) { @@ -135,18 +400,25 @@ export function generateTerrainAndRivers(seed) { const i = indexOf(x, y); if (sea[i]) continue; let nearestSea = INF; + let nearestOcean = INF; for (let dy = -7; dy <= 7; dy++) { for (let dx = -7; dx <= 7; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny) || !sea[indexOf(nx, ny)]) continue; nearestSea = Math.min(nearestSea, Math.hypot(dx, dy)); + if (ocean[indexOf(nx, ny)]) nearestOcean = Math.min(nearestOcean, Math.hypot(dx, dy)); } } if (nearestSea <= 7) { - const coastalCap = seaLevel + 0.018 + nearestSea * 0.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022; + const coastalCap = seaLevel + 0.018 + nearestSea * (0.022 + terrainTemplate.deposition * 0.012) + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * (0.014 + terrainTemplate.coastRoughness * 0.018); elevation[i] = Math.min(elevation[i], coastalCap); - coastalLowland[i] = clamp(1 - nearestSea / 7); + if (nearestOcean <= 7) { + const coast = coastPressureAt(x, y); + const side = coast.sideA >= coast.sideB ? terrainTemplate.coastSides[0] : terrainTemplate.coastSides[1]; + const plainReach = clamp(4.5 + side.plainWidth * 34, 5, 9); + coastalLowland[i] = clamp((1 - nearestOcean / plainReach) * (0.62 + terrainTemplate.deposition * 0.48 + side.plainWidth * 1.9) * (1 - ridgeField[i] * 0.35)); + } } } } @@ -217,11 +489,12 @@ export function generateTerrainAndRivers(seed) { if (sea[i]) continue; const flow = Math.pow(flowAccum[i], 0.46); const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36; - const steepValley = clamp(flow * (0.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise); - const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078); - const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82)); + const steepValley = clamp(flow * (0.036 + terrainTemplate.erosion * 0.050 + slope[i] * (0.14 + terrainTemplate.erosion * 0.13) + ridgeField[i] * (0.022 + terrainTemplate.erosion * 0.044)) * incisionNoise); + const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * (0.044 + terrainTemplate.erosion * 0.064)); + const lowSettling = clamp(flow * (coastalLowland[i] * (0.018 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.010 + terrainTemplate.deposition * 0.028) + (elevation[i] < 0.40 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - slope[i] * 0.82) * (1 - ridgeField[i] * 0.45)); erosionField[i] = steepValley + lateralCut; depositionField[i] = lowSettling; + depositionalLowland[i] = clamp(lowSettling * 6.5 + basinField[i] * terrainTemplate.deposition * 0.28 + coastalLowland[i] * terrainTemplate.deposition * 0.34); shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1); } } @@ -244,8 +517,8 @@ export function generateTerrainAndRivers(seed) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i]) continue; - const score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; - if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score }); + const score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + arcSpineField[i] * 0.07 + branchRidgeField[i] * 0.04 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; + if (elevation[i] > 0.40 && elevation[i] < 0.84 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.95) sourceCandidates.push({ x, y, score }); } } @@ -595,11 +868,12 @@ export function generateTerrainAndRivers(seed) { const i = indexOf(x, y); if (sea[i] || river[i] <= 0.02) continue; const r = clamp(river[i] / 3.4); - const channelCut = clamp(Math.pow(r, 0.55) * (0.060 + slope[i] * 0.145 + ridgeField[i] * 0.038)); - const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040)); - const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0))); + const channelCut = clamp(Math.pow(r, 0.55) * (0.034 + terrainTemplate.erosion * 0.052 + slope[i] * (0.075 + terrainTemplate.erosion * 0.120) + ridgeField[i] * (0.018 + terrainTemplate.erosion * 0.048))); + const valleyWiden = clamp(Math.pow(r, 0.72) * (0.012 + terrainTemplate.erosion * 0.026 + Math.max(0, elevation[i] - seaLevel) * (0.030 + terrainTemplate.erosion * 0.050) + valleyField[i] * (0.020 + terrainTemplate.erosion * 0.045))); + const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * (0.014 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.010 + terrainTemplate.deposition * 0.028) + (slope[i] < 0.10 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - ridgeField[i] * 0.45)); erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); depositionField[i] = clamp(depositionField[i] + alluvium); + depositionalLowland[i] = clamp(depositionalLowland[i] + alluvium * 5.5); fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1); valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4); basinField[i] = clamp(basinField[i] + alluvium * 3.2); @@ -622,7 +896,7 @@ export function generateTerrainAndRivers(seed) { const d = Math.hypot(dx, dy); if (d > radius || d === 0) continue; const weight = (radius + 0.35 - d) / (radius + 0.35); - const carve = Math.max(0, weight) * (0.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22); + const carve = Math.max(0, weight) * (0.005 + terrainTemplate.erosion * 0.007 + r * (0.014 + terrainTemplate.erosion * 0.022)) * 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); @@ -631,6 +905,57 @@ export function generateTerrainAndRivers(seed) { } } + // Template-driven deposition is limited to plausible low-energy places: + // river mouths, basin floors, coastal plains, and slope breaks below ridges. + const depositionElevation = new Float32Array(fluvialElevation); + 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 nearSea = 0; + let localRiver = river[i]; + let highSide = 0; + let lowSide = 1; + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + const d = Math.hypot(dx, dy); + if (d > 4.25) continue; + if (sea[ni]) nearSea = Math.max(nearSea, 1 - d / 4.25); + localRiver = Math.max(localRiver, river[ni] / (1 + d * 0.5)); + highSide = Math.max(highSide, fluvialElevation[ni]); + lowSide = Math.min(lowSide, fluvialElevation[ni]); + } + } + const reliefDrop = clamp((highSide - lowSide - 0.075) * 4.5); + const lowlandPotential = clamp( + basinField[i] * 0.44 + + coastalLowland[i] * 0.52 + + Math.pow(flowAccum[i], 0.56) * 0.32 + + plain[i] * 0.18 + + localRiver * 0.16 - + ridgeField[i] * 0.48 - + slope[i] * 0.52 - + Math.max(0, fluvialElevation[i] - 0.55) * 1.35 + ); + const delta = clamp(nearSea * localRiver * coastalLowland[i] * (0.32 + terrainTemplate.deposition * 1.25) * (1 - ridgeField[i] * 0.55)); + const fan = clamp(reliefDrop * localRiver * valleyField[i] * (0.20 + terrainTemplate.deposition * 0.95) * (1 - coastalLowland[i] * 0.45)); + const lowland = clamp(lowlandPotential * terrainTemplate.deposition + delta * 0.72 + fan * 0.42); + if (lowland <= 0.01) continue; + deltaField[i] = clamp(deltaField[i] + delta); + alluvialFanField[i] = clamp(alluvialFanField[i] + fan); + depositionalLowland[i] = clamp(depositionalLowland[i] + lowland); + depositionField[i] = clamp(depositionField[i] + lowland * 0.050); + erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.018); + const floor = seaLevel + 0.008 + basinField[i] * 0.012 + coastalLowland[i] * 0.010; + depositionElevation[i] = clamp(lerp(fluvialElevation[i], Math.max(floor, fluvialElevation[i] - 0.032), lowland * 0.55), seaLevel + 0.005, 1); + } + } + fluvialElevation.set(depositionElevation); + // Restore rugged summit relief after strong river incision. This prevents highlands // from becoming unnaturally flat or visually concave while keeping valleys cut. for (let y = 1; y < MAP_H - 1; y++) { @@ -654,7 +979,7 @@ export function generateTerrainAndRivers(seed) { // 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++) { + for (let pass = 0; pass < 3 + Math.round(terrainTemplate.deposition * 2); pass++) { const nextElevation = new Float32Array(elevation); for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { @@ -663,6 +988,9 @@ export function generateTerrainAndRivers(seed) { const lowland = clamp( coastalLowland[i] * 0.72 + basinField[i] * 0.54 + + depositionalLowland[i] * 0.52 + + deltaField[i] * 0.34 + + alluvialFanField[i] * 0.22 + valleyField[i] * 0.34 + Math.pow(flowAccum[i], 0.58) * 0.24 - ridgeField[i] * 0.62 - @@ -690,9 +1018,9 @@ export function generateTerrainAndRivers(seed) { 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); + nextElevation[i] = clamp(lerp(elevation[i], target, lowland * (0.30 + terrainTemplate.deposition * 0.26)), seaLevel + 0.006, 1); if (lowland > 0.55) { - depositionField[i] = clamp(depositionField[i] + lowland * 0.018); + depositionField[i] = clamp(depositionField[i] + lowland * (0.010 + terrainTemplate.deposition * 0.018)); erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012); } } @@ -785,7 +1113,7 @@ export function generateTerrainAndRivers(seed) { if (sea[i]) continue; const low = 1 - clamp((elevation[i] - 0.28) / 0.4); const flat = 1 - slope[i]; - const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55; + const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55 + depositionalLowland[i] * 0.34 + deltaField[i] * 0.28 + alluvialFanField[i] * 0.20; plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0)); let nearRiver = 0; @@ -798,9 +1126,9 @@ export function generateTerrainAndRivers(seed) { } } - const fan = clamp(valleyField[i] * (1 - coastalLowland[i]) * (elevation[i] > 0.34 && elevation[i] < 0.58 ? 0.9 : 0.35) * (1 - slope[i] * 0.55)); - floodplain[i] = clamp(nearRiver * plain[i] * 0.92 + coastalLowland[i] * nearRiver * 0.22); - agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06); + const fan = clamp(Math.max(alluvialFanField[i], valleyField[i] * (1 - coastalLowland[i]) * (elevation[i] > 0.34 && elevation[i] < 0.58 ? 0.9 : 0.35)) * (1 - slope[i] * 0.55)); + floodplain[i] = clamp(nearRiver * plain[i] * 0.92 + coastalLowland[i] * nearRiver * 0.22 + deltaField[i] * 0.18); + agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.30 + basinField[i] * 0.2 + depositionalLowland[i] * 0.24 + deltaField[i] * 0.18 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06); } } @@ -831,9 +1159,9 @@ export function generateTerrainAndRivers(seed) { } } - const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18; + const isDelta = (riverNear > 0.22 && coastalLowland[i] > 0.18) || deltaField[i] > 0.16; const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16; - portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16); + portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + deltaField[i] * 0.18 + depositionalLowland[i] * 0.08 + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16); } } @@ -867,12 +1195,39 @@ export function generateTerrainAndRivers(seed) { } } + 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]) continue; + const gx = Math.abs(elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]); + const gy = Math.abs(elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]); + const slopeBreak = clamp((gx + gy) * 3.2 + Math.max(0, slope[i] - 0.28) * 0.72); + const majorRiver = clamp(Math.max(0, river[i] - 0.34) * 1.45 + Math.max(0, flowAccum[i] - 0.42) * 0.58); + const basinRim = clamp(basinField[i] * Math.max(0, slope[i] - 0.16) * 1.25 + ridgeField[i] * basinField[i] * 0.32); + naturalBarrierScore[i] = clamp( + arcSpineField[i] * 0.80 + + branchRidgeField[i] * 0.62 + + ridgeField[i] * 0.54 + + majorRiver * 0.62 + + slopeBreak * 0.34 + + basinRim * 0.36 - + valleyField[i] * 0.30 - + depositionalLowland[i] * 0.42 - + coastalLowland[i] * 0.20 - + plain[i] * 0.18 + ); + } + } + return { + terrainTemplate, elevation, moisture, slope, sea, + ocean, + lake, river, floodplain, plain, @@ -884,6 +1239,12 @@ export function generateTerrainAndRivers(seed) { flowAccum, erosionField, depositionField, + arcSpineField, + branchRidgeField, + depositionalLowland, + alluvialFanField, + deltaField, + naturalBarrierScore, portSuitability, crossingSuitability, passSuitability, diff --git a/mapUtils.js b/mapUtils.js index cc5b8ef..e0dff22 100644 --- a/mapUtils.js +++ b/mapUtils.js @@ -100,6 +100,8 @@ export function createMapFields() { moisture: new Float32Array(SIZE), slope: new Float32Array(SIZE), sea: new Uint8Array(SIZE), + ocean: new Uint8Array(SIZE), + lake: new Uint8Array(SIZE), river: new Float32Array(SIZE), floodplain: new Float32Array(SIZE), plain: new Float32Array(SIZE), @@ -111,6 +113,12 @@ export function createMapFields() { flowAccum: new Float32Array(SIZE), erosionField: new Float32Array(SIZE), depositionField: new Float32Array(SIZE), + arcSpineField: new Float32Array(SIZE), + branchRidgeField: new Float32Array(SIZE), + depositionalLowland: new Float32Array(SIZE), + alluvialFanField: new Float32Array(SIZE), + deltaField: new Float32Array(SIZE), + naturalBarrierScore: new Float32Array(SIZE), flowTo, portSuitability: new Float32Array(SIZE), crossingSuitability: new Float32Array(SIZE), diff --git a/names.js b/names.js index ae9ab85..150cb9d 100644 --- a/names.js +++ b/names.js @@ -8,19 +8,21 @@ export const NAME_KANJI_POOLS = { "高", "長", "広", "深", "浅", "白", "黒", "青", "赤", "奥", "前", "後", "内", "外", - "早", "安", "真", "丸", "平", "美", "吉", "福", "幸", "徳", - "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", - ], + "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", + "霧", "霞", "朝", "日", "天", "雨", "晴", + "早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", + ], inlandTerrain: [ - "山", "谷", "ヶ谷", "沢", "原", "野", + "山", "谷", "ヶ谷", "沢", "原", "野", "荒", "森", "林", "岡", "丘", "坂", - "峰", "峠", "嶺", "尾", "平", "坪", "延", - "窪", "久", "洞", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", + "峰", "峠", "嶺", "尾", "平", "坪", "延", "燧", + "窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "聡", "郷", "里", - "馬", "鹿", "亀", "鷲", "鷹" + "馬", "鹿", "亀", "鷲", "鷹", + "妙見", ], waterTerrain: [ @@ -45,7 +47,8 @@ export const NAME_KANJI_POOLS = { "竹", "楠", "藤", "萩", "葦", "菅", "榎", "椿", "桐", "柳", "橘", "柏", "槙", "柿", "桃", - "梨", "桑", "麻", "芦", "茅", + "梨", "桑", "麻", "芦", "茅", + "粟", "稲", "麦", "稗", "米", "飯", "糠", "榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑" ], @@ -57,7 +60,7 @@ export const NAME_KANJI_POOLS = { "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", "本", "内", "窪", "平", "塚", - "畑", "牧", "前", "見", "中", "羽", "生", "塚" + "畑", "牧", "前", "見", "中", "羽", "生", "塚", "部", ], archaicPrefixes: [ @@ -65,9 +68,9 @@ export const NAME_KANJI_POOLS = { "土", "出", "丹", "播", "但", "因", "伯", "筑", "肥", "豊", "日", "紀", "志", "尾", "駿", - "甲", "信", "越", "備", "讃", + "甲", "信", "越", "備", "能", "薩", "隠", "美", "三", "若", - "遠", "近", "能", "加", "賀", + "遠", "近", "能", "加", "賀", "度", "越", "淡", "壱", "阿", "衣", "古", "彦", "多", "志", "布", "治" ], @@ -77,14 +80,14 @@ export const NAME_KANJI_POOLS = { "張", "江", "河", "斐", "濃", "岐", "防", "門", "隅", "向", "居", "前", "中", "後", "波", - "勢", "渡", "城", "紫", "野", + "勢", "渡", "城", "紫", "野", "度", "津", "島", "信", "登", "賀", "志", "良", "美", "智", "茂", "代", "古", "摩", "磨", "麻", "彦", "比古", "子" ], settlementWords: [ "里", "郷", "村", "町", "宿", "邑", "垣", "坪", - "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "妙見", + "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", "市", "場", "府", "関", "地蔵", "辻", "角", "堰", ] diff --git a/renderer.js b/renderer.js index 78d2844..0794714 100644 --- a/renderer.js +++ b/renderer.js @@ -118,6 +118,16 @@ function discreteColor(map, x, y, mode) { ]; const a = map.adminId[i]; color = a >= 0 ? palette[a % palette.length] : [220, 225, 220]; + } else if (mode === "terrain-debug") { + const ridge = clamp(map.ridgeField[i] * 0.68 + (map.arcSpineField?.[i] || 0) * 0.42 + (map.branchRidgeField?.[i] || 0) * 0.34); + const deposit = clamp((map.depositionField?.[i] || 0) * 5.0 + (map.depositionalLowland?.[i] || 0) * 0.48 + (map.alluvialFanField?.[i] || 0) * 0.34 + (map.deltaField?.[i] || 0) * 0.46); + const valley = clamp(map.valleyField[i] * 0.72 + map.river[i] * 0.22); + const barrier = clamp(map.naturalBarrierScore?.[i] || ridge); + color = [ + Math.round(220 - deposit * 70 + ridge * 48), + Math.round(226 + deposit * 38 + valley * 28 - barrier * 52), + Math.round(214 + valley * 58 + barrier * 34 - ridge * 42), + ]; } else if (mode === "admin-debug" || mode === "borders-debug") { const barrier = clamp( map.ridgeField[i] * 0.88 + @@ -449,7 +459,7 @@ function drawLabels(ctx, points, limit = Infinity) { const occupied = []; const prioritized = points .filter((p) => p?.name) - .map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) })) + .map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) + (p.kind === "Municipal Center" ? 34 : 0) })) .sort((a, b) => b.labelPriority - a.labelPriority); for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); } @@ -490,7 +500,11 @@ export function drawMap(canvas, map, options) { const showRoads = ["roads", "all", "development", "landuse"].includes(mode); const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); - if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.90)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 1.5 : mode === "all" ? 0.9 : 1.3); + if (debugBorders && map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(70,70,70,0.32)", 0.75); + if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.96)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 2.1 : mode === "all" ? 0.9 : 1.3); + if (showAdmin && mode !== "all") { + for (const p of map.adminCenters || []) dot(ctx, p, 3.0, "rgba(255,255,255,0.96)", "rgba(70,90,120,0.85)"); + } if (showHistory) { for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true); @@ -548,6 +562,9 @@ export function drawMap(canvas, map, options) { } if (showLabels) { + const adminLabels = (map.adminCenters || []) + .map((p) => ({ ...p, labelPriorityBase: mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? 90 : 8 })) + .filter((p) => mode !== "all" || p.representativeFeatureName); const important = [ ...map.modernCities, ...map.ports, @@ -555,9 +572,10 @@ export function drawMap(canvas, map, options) { ...map.castles.slice(0, mode === "all" ? 5 : 8), ...(map.satelliteCities || []), ...map.newTowns, + ...(mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? adminLabels : adminLabels.slice(0, 8)), ...map.externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - drawLabels(ctx, important, mode === "all" ? 28 : Infinity); + drawLabels(ctx, important, mode === "all" ? 34 : Infinity); } } diff --git a/test.js b/test.js index 62b6a94..147505c 100644 --- a/test.js +++ b/test.js @@ -219,6 +219,71 @@ function regionalComponentMetrics(map) { return { regionCount: ids.size, maxComponents }; } +function meanField(map, fieldName, predicate) { + let sum = 0; + let count = 0; + const field = map[fieldName]; + for (let i = 0; i < field.length; i++) { + if (!predicate(i)) continue; + sum += field[i]; + count++; + } + return count ? sum / count : 0; +} + +function ridgeSinuosityMetric(map) { + const centers = []; + for (let y = 1; y < MAP_H - 1; y++) { + let sum = 0; + let weight = 0; + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (map.sea[i]) continue; + const r = Math.max(0, map.ridgeField[i] - 0.36); + sum += x * r; + weight += r; + } + if (weight > 1.2) centers.push(sum / weight); + } + if (centers.length < 8) return 0; + let turn = 0; + let total = 0; + for (let i = 2; i < centers.length; i++) { + const a = centers[i - 1] - centers[i - 2]; + const b = centers[i] - centers[i - 1]; + turn += Math.abs(b - a); + total += Math.abs(b) + Math.abs(a) + 0.01; + } + return turn / total; +} + +function terrainCoreMetrics(map) { + const land = [...map.elevation].map((_, i) => i).filter((i) => !map.sea[i]); + const mountainCells = land.filter((i) => map.elevation[i] > 0.58 || map.ridgeField[i] > 0.42).length; + const lowlandCells = land.filter((i) => map.plain[i] > 0.38 || map.depositionalLowland?.[i] > 0.24).length; + const ridgeValues = land.map((i) => map.ridgeField[i]); + const ridgeMean = ridgeValues.reduce((sum, value) => sum + value, 0) / Math.max(1, ridgeValues.length); + const ridgeVariance = ridgeValues.reduce((sum, value) => sum + (value - ridgeMean) ** 2, 0) / Math.max(1, ridgeValues.length); + const depositionTargetMean = meanField(map, "depositionField", (i) => !map.sea[i] && (map.coastalLowland[i] > 0.18 || map.basinField[i] > 0.22 || map.river[i] > 0.18 || map.flowAccum[i] > 0.24)); + const depositionOtherMean = meanField(map, "depositionField", (i) => !map.sea[i] && map.coastalLowland[i] < 0.08 && map.basinField[i] < 0.12 && map.river[i] < 0.06 && map.flowAccum[i] < 0.12 && map.ridgeField[i] < 0.28); + const riverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] > 0.20); + const nonRiverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] <= 0.02); + return { + landCount: land.length, + mountainRatio: mountainCells / Math.max(1, land.length), + lowlandRatio: lowlandCells / Math.max(1, land.length), + ridgeVariance, + ridgeSinuosity: ridgeSinuosityMetric(map), + depositionTargetMean, + depositionOtherMean, + riverValleyMean, + nonRiverValleyMean, + depositionSum: [...map.depositionField].reduce((sum, value) => sum + value, 0), + alluvialMax: Math.max(...(map.alluvialFanField || [0])), + deltaMax: Math.max(...(map.deltaField || [0])), + }; +} + try { const map = generateMap(12345); const other = generateMap(54321); @@ -335,6 +400,7 @@ try { const cityCoreIntegrity = majorCityCoreIntegrity(map); const satelliteMetrics = satelliteMunicipalityMetrics(map); const regionalMetrics = regionalComponentMetrics(map); + const terrainMetrics = terrainCoreMetrics(map); assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists"); assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists"); @@ -352,6 +418,7 @@ try { assert(map.elevation.length === size, "elevation length matches map size"); assert(map.sea.length === size, "sea length matches map size"); + assert(map.ocean.length === size && map.lake.length === size, "ocean and lake masks match map size"); assert(map.river.length === size, "river length matches map size"); assert(map.landuse.length === size, "land-use length matches map size"); assert(map.adminId.length === size, "municipal id length matches map size"); @@ -359,6 +426,11 @@ try { assert(map.populationDensity.length === size, "population density length matches map size"); assert(map.ridgeField.length === size && map.valleyField.length === size && map.flowAccum.length === size, "causal terrain fields match map size"); assert(map.erosionField.length === size && map.depositionField.length === size, "erosion and deposition fields match map size"); + assert(map.arcSpineField.length === size && map.branchRidgeField.length === size, "spine and branch ridge fields match map size"); + assert(map.depositionalLowland.length === size && map.alluvialFanField.length === size && map.deltaField.length === size, "depositional debug fields match map size"); + assert(map.naturalBarrierScore.length === size, "natural barrier score field matches map size"); + assert(map.terrainTemplate && Number.isFinite(map.terrainTemplate.deposition) && Number.isFinite(map.terrainTemplate.erosion), "terrain template parameters are exposed"); + assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed"); assert(map.settlementCluster.length === size, "settlement cluster field matches map size"); assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist"); assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist"); @@ -404,10 +476,21 @@ try { } assert(prefectureComponents === 1, "prefecture area is a single connected component"); assert(map.prefectureBorder.length > 0, "prefecture border exists"); + assert([...map.ocean].some((value) => value === 1), "edge-connected ocean mask exists"); + assert([...map.lake].every((value, i) => !value || (map.sea[i] && !map.ocean[i])), "lake mask only marks isolated non-ocean water"); + assert([...map.sea].every((value, i) => !value || map.ocean[i] || map.lake[i]), "water cells are classified as ocean or lake"); assert(map.adminBorders.length > 0, "municipal borders exist"); assert(map.mainRivers.length > 0, "at least one major river exists"); assert(map.tributaryRivers.length > 0, "tributary river network exists"); assert(map.smallStreams.length > 0, "small stream network exists"); + assert(terrainMetrics.mountainRatio > 0.10 && terrainMetrics.mountainRatio < 0.72, "mountain and ridge area is meaningful but not total"); + assert(terrainMetrics.lowlandRatio > 0.08 && terrainMetrics.lowlandRatio < 0.72, "lowlands exist without dominating every map"); + assert(terrainMetrics.ridgeVariance > 0.004, "ridge field has nontrivial spatial variance"); + assert(terrainMetrics.ridgeSinuosity > 0.015, "ridge centerlines are not perfectly straight bands"); + assert(terrainMetrics.depositionSum > 0.2, "deposition field has nonzero values"); + assert(terrainMetrics.depositionTargetMean >= terrainMetrics.depositionOtherMean * 0.85, "deposition favors rivers, basins, and coastal lowlands"); + assert(terrainMetrics.riverValleyMean > terrainMetrics.nonRiverValleyMean * 1.08, "river cells overlap valley fields more than random non-river cells"); + assert(terrainMetrics.alluvialMax > 0 || terrainMetrics.deltaMax > 0, "alluvial fan or delta fields are active"); assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports"); assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified"); assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes"); @@ -422,7 +505,7 @@ try { assert(map.adminDebug && map.adminDebug.compartmentCount > 0, "natural compartment debug is available"); assert(map.adminDebug.averageCompartmentArea > 0, "natural compartments have positive average area"); assert(Number.isFinite(map.adminDebug.changedAfterLandscapePartition) && Number.isFinite(map.adminDebug.changedAfterSnap), "municipal changed-cell diagnostics exist"); - assert(map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal terrain partition or snap changes admin cells"); + assert(map.adminDebug.changedAfterCompartmentAssignment > 0 || map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal compartment or terrain passes change admin cells"); assert(map.adminDebug.changedAfterFinalExclaveRemoval + map.adminDebug.changedAfterFinalMerge < Math.max(2800, (map.adminDebug.changedAfterLandscapePartition + map.adminDebug.changedAfterSnap + map.adminDebug.changedAfterUrbanLock) * 1.35), "final municipal repair does not erase most terrain and urban changes"); assert(map.adminDebug.finalBorderNaturalBarrierAverage >= 0, "natural barrier score is tracked along final borders"); assert(map.adminDebug.voronoiLikeRateAfter <= Math.max(0.72, map.adminDebug.voronoiLikeRateBefore + 0.20), "natural compartment pass does not increase weak bisectors excessively"); @@ -453,6 +536,17 @@ try { assert(capitalInside, "prefectural capital is inside the prefecture"); assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized"); assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names"); + assert(map.adminCenters.every((item) => item.id && item.name), "municipal centers have ids and names"); + assert(map.entitiesForNames.some((item) => item.kind === "Municipal Center"), "municipal centers are included in label/name candidates"); + assert(map.adminCenters.filter((item) => item.representativeFeatureName && String(item.name).includes(item.representativeFeatureName)).length >= Math.max(1, Math.floor(map.adminCenters.length * 0.70)), "municipal center names relate to representative feature names"); + assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels"); + assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback"); + const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0; + assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low"); + assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities"); + assert(map.adminDebug.targetMunicipalityCount >= 18 && map.adminDebug.actualMunicipalityCount >= 16, "municipality target and actual counts are dense enough"); + assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active"); + assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering"); assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings"); assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters"); assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented"); @@ -487,12 +581,35 @@ try { assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed"); assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed"); assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed"); + assert(JSON.stringify([...againA.elevation]) === JSON.stringify([...againB.elevation]), "elevation is deterministic for the same seed"); + assert(JSON.stringify([...againA.ridgeField]) === JSON.stringify([...againB.ridgeField]), "ridge field is deterministic for the same seed"); + assert(JSON.stringify([...againA.river]) === JSON.stringify([...againB.river]), "river field is deterministic for the same seed"); + assert(JSON.stringify(terrainCoreMetrics(againA)) === JSON.stringify(terrainCoreMetrics(againB)), "terrain debug metrics are deterministic for the same seed"); const blockedCapitalName = "\u52A0\u8302"; const capitalNameMaps = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue)); const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean); assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds"); assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name"); + for (const [n, seeded] of capitalNameMaps.entries()) { + const seedValue = [114514, 12345, 54321, 777, 999][n]; + const metrics = terrainCoreMetrics(seeded); + assert(seeded.mainRivers.length > 0 && seeded.tributaryRivers.length > 0 && seeded.smallStreams.length > 0, `seed ${seedValue}: river hierarchy exists`); + assert(metrics.mountainRatio > 0.08 && metrics.lowlandRatio > 0.06, `seed ${seedValue}: mountain and lowland terrain both exist`); + assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`); + assert(metrics.depositionSum > 0.1 && metrics.depositionTargetMean >= metrics.depositionOtherMean * 0.75, `seed ${seedValue}: deposition is active in plausible lowlands`); + assert(metrics.riverValleyMean > metrics.nonRiverValleyMean, `seed ${seedValue}: rivers follow valley fields`); + assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`); + assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`); + assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`); + assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`); + assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`); + assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`); + } + const byDeposition = capitalNameMaps + .map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio })) + .sort((a, b) => a.deposition - b.deposition); + assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area"); CUSTOM_NAMES["city-0"] = "C1"; const customSameA = generateMap(321); @@ -532,7 +649,13 @@ try { assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`); assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`); assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); - assert(seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal terrain passes change cells`); + assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`); + assert(seeded.adminDebug.targetMunicipalityCount >= 18 && seeded.adminDebug.actualMunicipalityCount >= 16, `seed ${seed}: municipality count is dense enough`); + assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`); + assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`); + assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`); + assert(seeded.regionalDebug?.borderNaturalBarrierAverage > 0.12, `seed ${seed}: regional borders have natural barrier affinity`); + assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`); assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`); assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`); assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`);