tweak
This commit is contained in:
parent
f420a3f8f3
commit
b707dadec9
12 changed files with 4324 additions and 3065 deletions
398
adminRegions.js
398
adminRegions.js
|
|
@ -156,12 +156,31 @@ export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask,
|
|||
}
|
||||
}
|
||||
|
||||
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) {
|
||||
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) {
|
||||
const area = new Map();
|
||||
const pop = new Map();
|
||||
const adjacency = new Map();
|
||||
const cityMunicipalities = new Set();
|
||||
for (const city of modernCities || []) if (inside(city.x, city.y)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]);
|
||||
for (const city of modernCities || []) {
|
||||
if (!inside(city.x, city.y)) continue;
|
||||
const id = adminId[indexOf(city.x, city.y)];
|
||||
if (id < 0) continue;
|
||||
if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id);
|
||||
}
|
||||
for (const point of options.protectedPoints || []) {
|
||||
if (!point || !inside(point.x, point.y)) continue;
|
||||
const id = adminId[indexOf(point.x, point.y)];
|
||||
if (id >= 0) cityMunicipalities.add(id);
|
||||
}
|
||||
const satelliteByAdmin = new Map();
|
||||
for (const sat of options.satelliteCities || []) {
|
||||
if (!sat || !inside(sat.x, sat.y)) continue;
|
||||
const id = adminId[indexOf(sat.x, sat.y)];
|
||||
if (id < 0) continue;
|
||||
if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []);
|
||||
satelliteByAdmin.get(id).push(sat);
|
||||
}
|
||||
const satelliteStats = options.satelliteStats || null;
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
|
|
@ -187,16 +206,35 @@ export function mergeTinyMunicipalities(adminId, prefectureMask, sea, population
|
|||
for (const [id, cells] of area) {
|
||||
const score = cells + (pop.get(id) || 0) * 16;
|
||||
if (cells >= minArea || cityMunicipalities.has(id)) continue;
|
||||
const satellites = satelliteByAdmin.get(id) || [];
|
||||
const protectedSatellite = satellites.some((sat) => {
|
||||
const minSatelliteArea = sat.satelliteMinArea || options.satelliteMinArea || 110;
|
||||
return sat.municipalityClass === "independentSatelliteMunicipality" && (
|
||||
cells >= minSatelliteArea ||
|
||||
(sat.population || 0) >= (options.satelliteIndependentPopulationThreshold || 60000) ||
|
||||
(sat.distinctUrbanComponentArea || 0) >= 80 ||
|
||||
sat.separatedByBarrier
|
||||
);
|
||||
});
|
||||
if (protectedSatellite) continue;
|
||||
let bestNeighbor = -1;
|
||||
let bestScore = -1;
|
||||
for (const [key, border] of adjacency) {
|
||||
const [a, b] = key.split(":").map(Number);
|
||||
if (a !== id && b !== id) continue;
|
||||
const other = a === id ? b : a;
|
||||
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24;
|
||||
const parentBias = satellites.some((sat) => inside(sat.parentX ?? -1, sat.parentY ?? -1) && adminId[indexOf(sat.parentX, sat.parentY)] === other) ? 26 : 0;
|
||||
const ruralBias = satellites.some((sat) => sat.municipalityClass === "smallTownAttachedToRuralMunicipality") ? Math.min(12, (area.get(other) || 0) * 0.01) : 0;
|
||||
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24 + parentBias + ruralBias;
|
||||
if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; }
|
||||
}
|
||||
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) mergeTarget.set(id, bestNeighbor);
|
||||
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) {
|
||||
mergeTarget.set(id, bestNeighbor);
|
||||
if (satelliteStats && satellites.length) {
|
||||
satelliteStats.satelliteMunicipalitiesMerged += satellites.length;
|
||||
for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mergeTarget.size === 0) return;
|
||||
for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]);
|
||||
|
|
@ -465,6 +503,119 @@ function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyFie
|
|||
return 11;
|
||||
}
|
||||
|
||||
export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) {
|
||||
const score = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
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);
|
||||
const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.55) * ridgeField[i] * 1.4 + slope[i] * ridgeField[i] * 0.8);
|
||||
const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.32) * Math.max(0, slope[i] - 0.18) * 1.15 + Math.max(0, ridgeField[i] - 0.34) * basinField[i] * 0.62) : 0;
|
||||
const foothillBreak = clamp(Math.max(0, slope[i] - 0.30) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.48)) * 0.82);
|
||||
const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18);
|
||||
score[i] = clamp(
|
||||
ridgeDivide * 0.92 +
|
||||
crest * 0.72 +
|
||||
majorRiver * 0.86 +
|
||||
basinRim * 0.54 +
|
||||
foothillBreak * 0.48 +
|
||||
terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 -
|
||||
livingCorridor * 0.50 -
|
||||
urbanContinuity * 0.72
|
||||
);
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAccum, valleyField, populationDensity, landuse) {
|
||||
if (classA !== classB) {
|
||||
const bothUrban = classA <= 3 && classB <= 3;
|
||||
const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB);
|
||||
if (!bothUrban && !bothLivingCorridor) return false;
|
||||
}
|
||||
const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
|
||||
const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) &&
|
||||
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34);
|
||||
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.42 && !majorRiverEdge;
|
||||
const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62;
|
||||
return barrier < threshold && (!majorRiverEdge || urbanEdge);
|
||||
}
|
||||
|
||||
function naturalGroupKey(unit) {
|
||||
if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`;
|
||||
if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`;
|
||||
if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`;
|
||||
if (unit.classId === 7) return `valley:${Math.round((unit.x + unit.y) / 12)}`;
|
||||
if (unit.classId === 8 || unit.classId === 9) return `mountain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
|
||||
return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
|
||||
}
|
||||
|
||||
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) {
|
||||
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||
const compartmentId = new Int32Array(SIZE);
|
||||
compartmentId.fill(-1);
|
||||
const cellClass = new Int16Array(SIZE);
|
||||
cellClass.fill(-1);
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
|
||||
|
||||
const compartments = [];
|
||||
const queue = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (cellClass[i] < 0 || compartmentId[i] >= 0) continue;
|
||||
const id = compartments.length;
|
||||
const startClass = cellClass[i];
|
||||
const cells = [];
|
||||
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
compartmentId[i] = id;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const [x, y] = xyOf(cur);
|
||||
cells.push(cur);
|
||||
sx += x; sy += y; pop += populationDensity[cur];
|
||||
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
|
||||
ridgeExposure += ridgeField[cur];
|
||||
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
||||
coastalExposure += coastalLowland[cur];
|
||||
basinIdentity += basinField[cur];
|
||||
valleyIdentity += valleyField[cur];
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue;
|
||||
const edgeBarrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5;
|
||||
if (!canShareNaturalCompartment(cur, ni, startClass, cellClass[ni], edgeBarrier, river, flowAccum, valleyField, populationDensity, landuse)) continue;
|
||||
compartmentId[ni] = id;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
const area = cells.length;
|
||||
compartments.push({
|
||||
id,
|
||||
cells,
|
||||
area,
|
||||
x: sx / area,
|
||||
y: sy / area,
|
||||
classId: startClass,
|
||||
dominantLandscapeClass: startClass,
|
||||
population: pop,
|
||||
urbanWeight: urbanWeight / area,
|
||||
ridgeExposure: ridgeExposure / area,
|
||||
riverExposure: riverExposure / area,
|
||||
coastalExposure: coastalExposure / area,
|
||||
basinIdentity: basinIdentity / area,
|
||||
valleyIdentity: valleyIdentity / area,
|
||||
centerIds: [],
|
||||
adjacent: new Map(),
|
||||
});
|
||||
}
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
mergeTinyLandscapeUnits(compartmentId, compartments, 12);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
return { compartmentId, compartments, naturalBarrierScore };
|
||||
}
|
||||
|
||||
function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) {
|
||||
const unitId = new Int32Array(SIZE);
|
||||
unitId.fill(-1);
|
||||
|
|
@ -559,52 +710,124 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
|
|||
}
|
||||
}
|
||||
|
||||
function landscapeTransitionCost(a, b, edge) {
|
||||
function naturalOwnershipAffinity(unit, neighbor, edge) {
|
||||
const boundaryTarget = edge.target / Math.max(1, edge.count);
|
||||
const bothUrban = a.classId <= 3 && b.classId <= 3;
|
||||
const bothCorridor = (a.classId === 5 || a.classId === 7 || a.classId === 10) && (b.classId === 5 || b.classId === 7 || b.classId === 10);
|
||||
const urbanContinuity = bothUrban ? 2.1 : (a.urbanWeight + b.urbanWeight) > 0.75 && Math.abs(a.urbanWeight - b.urbanWeight) < 0.35 ? 0.9 : 0;
|
||||
return Math.max(0.18, 0.70 + boundaryTarget * 4.2 + (a.classId === b.classId ? 0 : 0.75) + ((a.classId === 8 || b.classId === 8) ? 1.2 : 0) - urbanContinuity - (bothCorridor ? 0.55 : 0));
|
||||
const sameClass = unit.classId === neighbor.classId ? 1.0 : 0;
|
||||
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1.1 : 0;
|
||||
const bothUrban = unit.classId <= 3 && neighbor.classId <= 3;
|
||||
const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId);
|
||||
const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0;
|
||||
const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8);
|
||||
return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty;
|
||||
}
|
||||
|
||||
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
|
||||
const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
|
||||
if (units.length === 0) return;
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
const center = adminCenters[id];
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const unit = units[unitId[indexOf(center.x, center.y)]];
|
||||
if (unit) unit.centerIds.push(id);
|
||||
}
|
||||
|
||||
const owner = new Int16Array(units.length);
|
||||
const dist = new Float32Array(units.length);
|
||||
owner.fill(-1); dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
for (const unit of units) {
|
||||
if (unit.area === 0 || unit.centerIds.length === 0) continue;
|
||||
const id = unit.centerIds[0];
|
||||
owner[unit.id] = id; dist[unit.id] = 0; heap.push({ i: unit.id, f: 0 });
|
||||
}
|
||||
while (heap.length) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const unit = units[cur.i];
|
||||
const currentOwner = owner[cur.i];
|
||||
if (!unit || currentOwner < 0) continue;
|
||||
for (const [nextId, edge] of unit.adjacent) {
|
||||
const next = units[nextId];
|
||||
if (!next || next.area === 0) continue;
|
||||
const nextDist = dist[cur.i] + landscapeTransitionCost(unit, next, edge) + Math.sqrt(next.area) * 0.012 + (next.urbanWeight > 0.75 && next.centerIds.length === 0 ? -0.20 : 0);
|
||||
if (nextDist < dist[nextId]) {
|
||||
dist[nextId] = nextDist; owner[nextId] = currentOwner; heap.push({ i: nextId, f: nextDist });
|
||||
function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) {
|
||||
let sum = 0;
|
||||
let count = 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] || adminId[i] < 0) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue;
|
||||
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const unit of units) {
|
||||
return count ? sum / count : 0;
|
||||
}
|
||||
|
||||
function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) {
|
||||
let weak = 0;
|
||||
let total = 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] || adminId[i] < 0) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const a = adminId[i], b = adminId[ni];
|
||||
if (!prefectureMask[ni] || sea[ni] || a < 0 || b < 0 || a === b) continue;
|
||||
total++;
|
||||
const ca = adminCenters[a], cb = adminCenters[b];
|
||||
if (!ca || !cb) continue;
|
||||
const mx = (x + nx) * 0.5, my = (y + ny) * 0.5;
|
||||
const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.0;
|
||||
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.38) weak++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return total ? weak / total : 0;
|
||||
}
|
||||
|
||||
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
|
||||
const before = new Int16Array(adminId);
|
||||
const initialNaturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
|
||||
const beforeVoronoiLikeRate = weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, initialNaturalBarrierScore);
|
||||
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
|
||||
if (compartments.length === 0) return;
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
const center = adminCenters[id];
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
const unit = compartments[compartmentId[indexOf(center.x, center.y)]];
|
||||
if (unit) unit.centerIds.push(id);
|
||||
}
|
||||
|
||||
const owner = new Int16Array(compartments.length);
|
||||
owner.fill(-1);
|
||||
for (const unit of compartments) {
|
||||
if (unit.area === 0 || unit.centerIds.length === 0) continue;
|
||||
owner[unit.id] = unit.centerIds[0];
|
||||
}
|
||||
|
||||
for (let pass = 0; pass < compartments.length + 4; 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 score = naturalOwnershipAffinity(unit, neighbor, edge) + Math.min(0.8, Math.sqrt(Math.max(1, neighbor.area)) * 0.018);
|
||||
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
|
||||
}
|
||||
const accept = unit.classId <= 3 ? bestScore > -0.15 : unit.classId === 8 || unit.classId === 9 ? bestScore > -0.80 : bestScore > -0.45;
|
||||
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, 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.4 : 0;
|
||||
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.9 : 0;
|
||||
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.2 : 0;
|
||||
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
|
||||
const score = sameGroup + sameClass + urbanFit - d * 0.018 - unit.ridgeExposure * 0.18;
|
||||
if (score > bestScore) { bestScore = score; bestId = id; }
|
||||
}
|
||||
owner[unit.id] = bestId >= 0 ? bestId : 0;
|
||||
}
|
||||
|
||||
for (const unit of compartments) {
|
||||
const assigned = owner[unit.id];
|
||||
if (assigned >= 0) 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;
|
||||
|
|
@ -616,7 +839,18 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e
|
|||
if (prefectureMask[i] && !sea[i]) adminId[i] = id;
|
||||
}
|
||||
}
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
||||
let changedCells = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
|
||||
const activeCompartments = compartments.filter((unit) => unit.area > 0);
|
||||
applyLandscapeUnitAdminPartition.lastDebug = {
|
||||
compartmentCount: activeCompartments.length,
|
||||
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
|
||||
changedAfterNaturalCompartmentPartition: changedCells,
|
||||
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
|
||||
voronoiLikeRateBefore: beforeVoronoiLikeRate,
|
||||
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
|
||||
};
|
||||
}
|
||||
|
||||
export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) {
|
||||
|
|
@ -659,3 +893,83 @@ export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, eleva
|
|||
adminId.set(current);
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
|
||||
}
|
||||
|
||||
export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
|
||||
const before = new Int16Array(adminId);
|
||||
const area = new Map();
|
||||
const lowland = new Map();
|
||||
const mountain = new Map();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
||||
const id = adminId[i];
|
||||
area.set(id, (area.get(id) || 0) + 1);
|
||||
const living = (plain[i] || 0) * 0.42 + (agriculture[i] || 0) * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12;
|
||||
const rough = ridgeField[i] * 0.54 + slope[i] * 0.36 + Math.max(0, elevation[i] - 0.58) * 0.38;
|
||||
lowland.set(id, (lowland.get(id) || 0) + living);
|
||||
mountain.set(id, (mountain.get(id) || 0) + rough);
|
||||
}
|
||||
const areas = [...area.values()].sort((a, b) => a - b);
|
||||
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
|
||||
if (!median) return { changedCells: 0, splitMunicipalities: 0 };
|
||||
|
||||
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
|
||||
const unitOwner = new Int16Array(compartments.length);
|
||||
unitOwner.fill(-1);
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
const counts = new Map();
|
||||
for (const i of unit.cells) {
|
||||
const id = adminId[i];
|
||||
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
let bestId = -1, best = -1;
|
||||
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
|
||||
unitOwner[unit.id] = bestId;
|
||||
}
|
||||
|
||||
const adminCenterIndex = new Map();
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
const c = adminCenters[id];
|
||||
if (c && inside(c.x, c.y)) adminCenterIndex.set(id, indexOf(c.x, c.y));
|
||||
}
|
||||
|
||||
let splitMunicipalities = 0;
|
||||
for (const [id, cells] of area) {
|
||||
const averageLowland = (lowland.get(id) || 0) / cells;
|
||||
const averageMountain = (mountain.get(id) || 0) / cells;
|
||||
if (cells < median * 2.25 || averageLowland < 0.28 || averageMountain > 0.44) continue;
|
||||
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
|
||||
const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000);
|
||||
if (meaningfulNodes.length < 2) continue;
|
||||
let changedHere = 0;
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue;
|
||||
const centerIndex = adminCenterIndex.get(id);
|
||||
if (centerIndex >= 0 && unit.cells.includes(centerIndex)) continue;
|
||||
if (unit.classId === 8 || unit.classId === 9) continue;
|
||||
let bestNeighbor = -1;
|
||||
let bestScore = -INF;
|
||||
for (const [neighborId, edge] of unit.adjacent) {
|
||||
const neighborOwner = unitOwner[neighborId];
|
||||
if (neighborOwner < 0 || neighborOwner === id) continue;
|
||||
const boundaryTarget = edge.target / Math.max(1, edge.count);
|
||||
const neighbor = compartments[neighborId];
|
||||
const nodePull = meaningfulNodes.reduce((best, p) => Math.max(best, 1 / (1 + Math.hypot(p.x - unit.x, p.y - unit.y) / 6)), 0);
|
||||
const score = edge.count * 0.7 + boundaryTarget * 1.4 + nodePull * 1.2 - Math.max(0, (neighbor?.ridgeExposure || 0) - unit.ridgeExposure) * 0.35;
|
||||
if (score > bestScore) { bestScore = score; bestNeighbor = neighborOwner; }
|
||||
}
|
||||
if (bestNeighbor < 0 || bestScore < 2.2) continue;
|
||||
for (const ci of unit.cells) {
|
||||
if (adminId[ci] === id) {
|
||||
adminId[ci] = bestNeighbor;
|
||||
changedHere++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changedHere > Math.max(28, cells * 0.035)) splitMunicipalities++;
|
||||
}
|
||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
||||
let changedCells = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
|
||||
return { changedCells, splitMunicipalities };
|
||||
}
|
||||
|
|
|
|||
4
app.js
4
app.js
|
|
@ -11,6 +11,8 @@ const modes = [
|
|||
["development", "Development"],
|
||||
["landuse", "Land Use"],
|
||||
["admin", "Municipal Borders"],
|
||||
["admin-debug", "Admin Debug"],
|
||||
["borders-debug", "Borders Debug"],
|
||||
];
|
||||
|
||||
const state = {
|
||||
|
|
@ -76,6 +78,8 @@ function getStats(map) {
|
|||
["Logistics Parks", countText(map.logisticsParks)],
|
||||
["New Towns", countText(map.newTowns)],
|
||||
["Municipalities", map.adminCenters.length],
|
||||
["Admin changed cells", map.adminDebug ? `${map.adminDebug.changedAfterLandscapePartition || 0} partition / ${map.adminDebug.changedAfterSnap || 0} snap` : "-"],
|
||||
["Regional changed cells", map.regionalDebug?.regionalChangedAfterNaturalPartition ?? "-"],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
410
mapAdminStage.js
Normal file
410
mapAdminStage.js
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import {
|
||||
applyLandscapeUnitAdminPartition,
|
||||
generateAdminRegions,
|
||||
lockSmallUrbanComponentsToMunicipality,
|
||||
mergeTinyMunicipalities,
|
||||
removeMunicipalExclaves,
|
||||
smoothAdminRegionsTerrainAware,
|
||||
splitOversizedRuralMunicipalities,
|
||||
snapAdminBoundariesToTerrain,
|
||||
} from "./adminRegions.js";
|
||||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
|
||||
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
|
||||
|
||||
function changedCellsSince(before, after, prefectureMask, sea) {
|
||||
let changed = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function municipalityAreaById(adminId, prefectureMask, sea) {
|
||||
const area = new Map();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
||||
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
|
||||
if (!city || !inside(city.x, city.y)) return 0;
|
||||
const start = indexOf(city.x, city.y);
|
||||
if (!prefectureMask[start] || sea[start]) return 0;
|
||||
const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7));
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const queue = [start];
|
||||
seen[start] = 1;
|
||||
let area = 0;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const [x, y] = xyOf(cur);
|
||||
const d = Math.hypot(x - city.x, y - city.y);
|
||||
if (d > radius) continue;
|
||||
const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18;
|
||||
if (!urban) continue;
|
||||
area++;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) {
|
||||
if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 };
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
|
||||
let maxBarrier = 0;
|
||||
let lowUrbanRun = 0;
|
||||
let bestLowUrbanRun = 0;
|
||||
let densitySum = 0;
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const x = Math.round(a.x + (b.x - a.x) * t);
|
||||
const y = Math.round(a.y + (b.y - a.y) * t);
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42);
|
||||
maxBarrier = Math.max(maxBarrier, barrier);
|
||||
densitySum += populationDensity[i];
|
||||
const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20;
|
||||
if (urban) lowUrbanRun = 0;
|
||||
else {
|
||||
lowUrbanRun++;
|
||||
bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun);
|
||||
}
|
||||
}
|
||||
return {
|
||||
separatedByBarrier: maxBarrier > 0.56,
|
||||
ruralGap: bestLowUrbanRun >= 4,
|
||||
averageDensity: densitySum / (steps + 1),
|
||||
maxBarrier,
|
||||
};
|
||||
}
|
||||
|
||||
function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) {
|
||||
let independent = 0;
|
||||
let attached = 0;
|
||||
for (const sat of satelliteCities || []) {
|
||||
if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue;
|
||||
const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0];
|
||||
const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99;
|
||||
const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse);
|
||||
const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity);
|
||||
const i = indexOf(sat.x, sat.y);
|
||||
const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier;
|
||||
const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000);
|
||||
let municipalityClass = "independentSatelliteMunicipality";
|
||||
if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent";
|
||||
else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict";
|
||||
else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality";
|
||||
else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality";
|
||||
|
||||
sat.municipalityClass = municipalityClass;
|
||||
sat.parentX = parent?.x;
|
||||
sat.parentY = parent?.y;
|
||||
sat.parentAdminHint = -1;
|
||||
sat.distinctUrbanComponentArea = urbanArea;
|
||||
sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap;
|
||||
sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360);
|
||||
if (municipalityClass === "independentSatelliteMunicipality") independent++;
|
||||
else attached++;
|
||||
}
|
||||
return { independent, attached };
|
||||
}
|
||||
|
||||
function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) {
|
||||
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context;
|
||||
if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0;
|
||||
const start = indexOf(satellite.x, satellite.y);
|
||||
if (!prefectureMask[start] || sea[start]) return 0;
|
||||
const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520);
|
||||
const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality"
|
||||
? Math.min(130, targetAreaBase * 0.55)
|
||||
: satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict"
|
||||
? Math.min(190, targetAreaBase * 0.62)
|
||||
: targetAreaBase;
|
||||
const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32;
|
||||
const heap = new MinHeap();
|
||||
const best = new Float32Array(SIZE);
|
||||
best.fill(INF);
|
||||
heap.push({ i: start, f: 0 });
|
||||
best[start] = 0;
|
||||
const claimed = [];
|
||||
while (heap.length > 0 && claimed.length < targetArea) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
|
||||
const [x, y] = xyOf(cur.i);
|
||||
const d = Math.hypot(x - satellite.x, y - satellite.y);
|
||||
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
|
||||
let invadesOtherCore = false;
|
||||
for (const city of modernCities || []) {
|
||||
if (!city || (city.population || 0) < 140000) continue;
|
||||
if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue;
|
||||
if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) {
|
||||
invadesOtherCore = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (invadesOtherCore) continue;
|
||||
const compatible = d <= (satellite.urbanRadius || 5) * 1.25 ||
|
||||
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
|
||||
populationDensity[cur.i] > 0.12 ||
|
||||
roadInfluence[cur.i] > 0.12 ||
|
||||
railInfluence2[cur.i] > 0.10 ||
|
||||
stationInfluence?.[cur.i] > 0.10 ||
|
||||
basinField[cur.i] > 0.22 ||
|
||||
valleyField[cur.i] > 0.24 ||
|
||||
coastalLowland[cur.i] > 0.20;
|
||||
if (!compatible && claimed.length > targetArea * 0.55) continue;
|
||||
claimed.push(cur.i);
|
||||
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni]) continue;
|
||||
const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0);
|
||||
const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32;
|
||||
const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9);
|
||||
const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step;
|
||||
if (nd < best[ni]) {
|
||||
best[ni] = nd;
|
||||
heap.push({ i: ni, f: nd });
|
||||
}
|
||||
}
|
||||
}
|
||||
let changed = 0;
|
||||
for (const i of claimed) {
|
||||
if (adminId[i] !== targetAdmin) changed++;
|
||||
adminId[i] = targetAdmin;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function generateAdminLayout({
|
||||
seed,
|
||||
prefectureMask,
|
||||
sea,
|
||||
elevation,
|
||||
slope,
|
||||
river,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
plain,
|
||||
agriculture,
|
||||
settlementScore,
|
||||
populationDensity,
|
||||
stationInfluence,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
villageInfluence,
|
||||
landuse,
|
||||
modernCities,
|
||||
satelliteCities,
|
||||
newTowns,
|
||||
markets,
|
||||
villages,
|
||||
ports,
|
||||
stations,
|
||||
industrialZones,
|
||||
logisticsParks,
|
||||
}) {
|
||||
const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0);
|
||||
const municipalityCandidates = [];
|
||||
for (let y = 2; y < MAP_H - 2; y++) {
|
||||
for (let x = 2; x < MAP_W - 2; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28;
|
||||
const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025;
|
||||
if (score > 0.40) municipalityCandidates.push({ x, y, score });
|
||||
}
|
||||
}
|
||||
const majorMunicipalSeeds = modernCities
|
||||
.filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)])
|
||||
.map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city }));
|
||||
const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => {
|
||||
const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28));
|
||||
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 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 }));
|
||||
let adminCentersRaw = [
|
||||
...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,
|
||||
seed: seed + 1300,
|
||||
jitter: 0.025,
|
||||
}),
|
||||
];
|
||||
if (adminCentersRaw.length < 12) {
|
||||
const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...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 });
|
||||
}
|
||||
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)));
|
||||
}
|
||||
const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse);
|
||||
let previousSnapshot = new Int16Array(adminId);
|
||||
const adminDebug = {
|
||||
changedAfterSmooth: 0,
|
||||
changedAfterUrbanLock: 0,
|
||||
changedAfterSmallUrbanLock: 0,
|
||||
changedAfterInitialMerge: 0,
|
||||
changedAfterInitialExclaveRemoval: 0,
|
||||
changedAfterLandscapePartition: 0,
|
||||
changedAfterSnap: 0,
|
||||
changedAfterOversizedRuralSplit: 0,
|
||||
changedAfterFinalExclaveRemoval: 0,
|
||||
changedAfterFinalMerge: 0,
|
||||
satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length,
|
||||
satelliteMunicipalitiesMerged: 0,
|
||||
satelliteMunicipalitiesExpanded: 0,
|
||||
satelliteMunicipalitiesTooSmall: 0,
|
||||
averageSatelliteMunicipalityArea: 0,
|
||||
minSatelliteMunicipalityArea: 0,
|
||||
satelliteMunicipalityAreaByNameOrIndex: {},
|
||||
independentSatelliteMunicipalities: satelliteClassificationDebug.independent,
|
||||
attachedSatelliteDistricts: satelliteClassificationDebug.attached,
|
||||
};
|
||||
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);
|
||||
markChanged("changedAfterSmooth");
|
||||
|
||||
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
|
||||
if (!city || !prefectureMask[indexOf(city.x, city.y)]) return;
|
||||
let bestAdmin = -1;
|
||||
let bestD = INF;
|
||||
adminCentersRaw.forEach((center, id) => {
|
||||
const d = Math.hypot(center.x - city.x, center.y - city.y);
|
||||
if (d < bestD) { bestD = d; bestAdmin = id; }
|
||||
});
|
||||
if (bestAdmin < 0) return;
|
||||
const r = Math.ceil(radius);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8));
|
||||
if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const city of modernCities) {
|
||||
const radius = (city.population || 0) >= 500000
|
||||
? clamp(17 + Math.sqrt(city.population) / 120, 20, 38)
|
||||
: clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11);
|
||||
lockUrbanClusterToMunicipality(city, radius, true);
|
||||
}
|
||||
for (const sat of satelliteCities || []) {
|
||||
if (!prefectureMask[indexOf(sat.x, sat.y)]) continue;
|
||||
let bestAdmin = -1;
|
||||
if (sat.municipalityClass === "independentSatelliteMunicipality") {
|
||||
let bestD = INF;
|
||||
adminCentersRaw.forEach((center, id) => {
|
||||
const d = Math.hypot(center.x - sat.x, center.y - sat.y);
|
||||
if (d < bestD) { bestD = d; bestAdmin = id; }
|
||||
});
|
||||
} else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) {
|
||||
bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)];
|
||||
}
|
||||
if (bestAdmin < 0) continue;
|
||||
sat.parentAdminHint = bestAdmin;
|
||||
const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, {
|
||||
prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
|
||||
});
|
||||
if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++;
|
||||
}
|
||||
markChanged("changedAfterUrbanLock");
|
||||
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
|
||||
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
|
||||
markChanged("changedAfterSmallUrbanLock");
|
||||
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: adminCentersRaw });
|
||||
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);
|
||||
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,
|
||||
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]);
|
||||
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
|
||||
adminDebug.oversizedRuralMunicipalitiesSplit = 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);
|
||||
markChanged("changedAfterSnap");
|
||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 360);
|
||||
markChanged("changedAfterFinalExclaveRemoval");
|
||||
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: adminCentersRaw });
|
||||
markChanged("changedAfterFinalMerge");
|
||||
|
||||
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,
|
||||
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
|
||||
});
|
||||
}
|
||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 260);
|
||||
|
||||
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
const satelliteAreas = [];
|
||||
(satelliteCities || []).forEach((sat, index) => {
|
||||
if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return;
|
||||
const id = adminId[indexOf(sat.x, sat.y)];
|
||||
const area = areaById.get(id) || 0;
|
||||
const key = sat.name || `satellite-${index}`;
|
||||
adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area;
|
||||
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < 80 || ((sat.population || 0) >= 60000 && area < 120))) {
|
||||
sat.municipalityClass = "smallTownAttachedToRuralMunicipality";
|
||||
adminDebug.satelliteMunicipalitiesTooSmall++;
|
||||
return;
|
||||
}
|
||||
if (sat.municipalityClass === "independentSatelliteMunicipality") {
|
||||
satelliteAreas.push(area);
|
||||
if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++;
|
||||
}
|
||||
});
|
||||
adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0;
|
||||
adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0;
|
||||
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
|
||||
Object.assign(adminDebug, landscapeDebug);
|
||||
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
|
||||
|
||||
|
||||
return { adminCentersRaw, adminId, adminBorders, adminDebug };
|
||||
}
|
||||
1323
mapFeatures.js
Normal file
1323
mapFeatures.js
Normal file
File diff suppressed because it is too large
Load diff
3012
mapGenerator.js
3012
mapGenerator.js
File diff suppressed because it is too large
Load diff
910
mapGeneratorHelpers.js
Normal file
910
mapGeneratorHelpers.js
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
import { generateEntityName } from "./names.js";
|
||||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, xyOf } from "./mapUtils.js";
|
||||
|
||||
|
||||
export function neighbors8(x, y) {
|
||||
const out = [];
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function neighbors4(x, y) {
|
||||
const out = [];
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (inside(nx, ny)) out.push([nx, ny, 1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function distanceToNearest(points, x, y, fallback = 999) {
|
||||
let best = fallback;
|
||||
for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y));
|
||||
return best;
|
||||
}
|
||||
|
||||
export function aStar(start, goal, costAt) {
|
||||
const startIndex = indexOf(start.x, start.y);
|
||||
const goalIndex = indexOf(goal.x, goal.y);
|
||||
if (startIndex === goalIndex) return [[start.x, start.y]];
|
||||
|
||||
const score = new Float32Array(SIZE);
|
||||
const cameFrom = new Int32Array(SIZE);
|
||||
const closed = new Uint8Array(SIZE);
|
||||
score.fill(INF);
|
||||
cameFrom.fill(-1);
|
||||
|
||||
const heap = new MinHeap();
|
||||
score[startIndex] = 0;
|
||||
heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) });
|
||||
|
||||
let guard = 0;
|
||||
while (heap.length > 0 && guard++ < SIZE * 3) {
|
||||
const current = heap.pop();
|
||||
if (!current || closed[current.i]) continue;
|
||||
closed[current.i] = 1;
|
||||
|
||||
if (current.i === goalIndex) {
|
||||
const path = [];
|
||||
let p = goalIndex;
|
||||
while (p !== -1) {
|
||||
const [x, y] = xyOf(p);
|
||||
path.push([x, y]);
|
||||
if (p === startIndex) break;
|
||||
p = cameFrom[p];
|
||||
}
|
||||
return path.reverse();
|
||||
}
|
||||
|
||||
const [cx, cy] = xyOf(current.i);
|
||||
for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) {
|
||||
const nextIndex = indexOf(nx, ny);
|
||||
if (closed[nextIndex]) continue;
|
||||
const cost = costAt(nx, ny, cx, cy);
|
||||
if (cost >= INF) continue;
|
||||
const nextScore = score[current.i] + cost * stepDistance;
|
||||
if (nextScore < score[nextIndex]) {
|
||||
score[nextIndex] = nextScore;
|
||||
cameFrom[nextIndex] = current.i;
|
||||
heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function influenceFromPaths(paths, radius) {
|
||||
const grid = new Float32Array(SIZE);
|
||||
for (const path of paths) {
|
||||
for (const [x, y] of path) {
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const i = indexOf(nx, ny);
|
||||
grid[i] = Math.max(grid[i], 1 / (1 + d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function pointKey(p) {
|
||||
return `${p.x},${p.y}`;
|
||||
}
|
||||
|
||||
export function getDegree(degreeMap, p) {
|
||||
return degreeMap.get(pointKey(p)) || 0;
|
||||
}
|
||||
|
||||
export function incrementDegree(degreeMap, p) {
|
||||
degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1);
|
||||
}
|
||||
|
||||
export function nearestConnectable(points, target, degreeMap, maxDegree = 3) {
|
||||
if (!points.length) return null;
|
||||
const sorted = points
|
||||
.map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) }))
|
||||
.sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7));
|
||||
return sorted.find((p) => p.degree < maxDegree) || sorted[0];
|
||||
}
|
||||
|
||||
export function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) {
|
||||
if (!grid) return 0;
|
||||
const value = grid[indexOf(x, y)];
|
||||
if (value <= 0.0001) return 0;
|
||||
|
||||
const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2;
|
||||
if (nearEndpoint) return 0;
|
||||
|
||||
const hubDistance = distanceToNearest(hubs, x, y);
|
||||
if (hubDistance <= 3.5) return 0;
|
||||
if (hubDistance <= 7.5) return value * strength * 0.28;
|
||||
return value * strength;
|
||||
}
|
||||
|
||||
export function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) {
|
||||
if (!points || points.length === 0) return 0;
|
||||
if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0;
|
||||
const d = distanceToNearest(points, x, y);
|
||||
if (d >= radius) return 0;
|
||||
return (radius - d) * strength;
|
||||
}
|
||||
|
||||
export function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) {
|
||||
const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null;
|
||||
return (x, y, cx, cy) => {
|
||||
const base = baseCost(x, y, cx, cy);
|
||||
if (base >= INF) return base;
|
||||
return base
|
||||
+ corridorPenalty(grid, x, y, hubs, endpoints, strength)
|
||||
+ nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength);
|
||||
};
|
||||
}
|
||||
|
||||
export function pathLength(path) {
|
||||
let total = 0;
|
||||
for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
|
||||
return total;
|
||||
}
|
||||
|
||||
export function pathEndpointDistance(path) {
|
||||
if (!path || path.length < 2) return 0;
|
||||
const a = path[0];
|
||||
const b = path[path.length - 1];
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
}
|
||||
|
||||
export function pathCompactness(path) {
|
||||
const direct = pathEndpointDistance(path);
|
||||
if (direct <= 0.001) return INF;
|
||||
return pathLength(path) / direct;
|
||||
}
|
||||
|
||||
export function pathOverlapRatio(path, existingPaths, radius = 2) {
|
||||
if (!path?.length || !existingPaths?.length) return 0;
|
||||
const grid = influenceFromPaths(existingPaths, radius);
|
||||
let overlap = 0;
|
||||
for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++;
|
||||
return overlap / Math.max(1, path.length);
|
||||
}
|
||||
|
||||
export function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) {
|
||||
const kept = [];
|
||||
for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) {
|
||||
if (pathLength(path) < minLength) continue;
|
||||
if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue;
|
||||
kept.push(path);
|
||||
if (kept.length >= maxCount) break;
|
||||
}
|
||||
paths.splice(0, paths.length, ...kept);
|
||||
}
|
||||
|
||||
export function bresenhamCells(a, b) {
|
||||
const cells = [];
|
||||
let x0 = a[0];
|
||||
let y0 = a[1];
|
||||
const x1 = b[0];
|
||||
const y1 = b[1];
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
while (true) {
|
||||
cells.push([x0, y0]);
|
||||
if (x0 === x1 && y0 === y1) break;
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) { err -= dy; x0 += sx; }
|
||||
if (e2 < dx) { err += dx; y0 += sy; }
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function smoothPathByLineOfSight(path, passable, maxSegment = 9) {
|
||||
if (!path || path.length < 3) return path || [];
|
||||
const out = [path[0]];
|
||||
let i = 0;
|
||||
while (i < path.length - 1) {
|
||||
let best = i + 1;
|
||||
const limit = Math.min(path.length - 1, i + maxSegment);
|
||||
for (let j = limit; j > i + 1; j--) {
|
||||
const cells = bresenhamCells(path[i], path[j]);
|
||||
if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; }
|
||||
}
|
||||
for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell);
|
||||
i = best;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function averagePathField(path, field) {
|
||||
if (!path?.length) return 0;
|
||||
let sum = 0;
|
||||
for (const [x, y] of path) sum += field[indexOf(x, y)] || 0;
|
||||
return sum / path.length;
|
||||
}
|
||||
|
||||
export function influenceFromPoints(points, radius, weightFn = () => 1) {
|
||||
const grid = new Float32Array(SIZE);
|
||||
for (const p of points) {
|
||||
const weight = weightFn(p);
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const nx = p.x + dx;
|
||||
const ny = p.y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const i = indexOf(nx, ny);
|
||||
grid[i] = Math.max(grid[i], weight / (1 + d));
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function samplePath(path, step) {
|
||||
const out = [];
|
||||
for (let i = step; i < path.length - step; i += step) {
|
||||
const [x, y] = path[i];
|
||||
out.push({ x, y, score: 1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function smoothMask(mask, passes = 2) {
|
||||
let current = new Uint8Array(mask);
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
const next = new Uint8Array(current);
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
let count = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (current[indexOf(x + dx, y + dy)]) count++;
|
||||
}
|
||||
}
|
||||
if (count >= 5) next[i] = 1;
|
||||
else if (count <= 3) next[i] = 0;
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function largestConnectedMask(mask) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
let best = [];
|
||||
const queue = [];
|
||||
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!mask[i] || seen[i]) continue;
|
||||
const component = [];
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
seen[i] = 1;
|
||||
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
component.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors8(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!mask[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
|
||||
if (component.length > best.length) best = component;
|
||||
}
|
||||
|
||||
const out = new Uint8Array(SIZE);
|
||||
for (const i of best) out[i] = 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function componentCount(mask) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const queue = [];
|
||||
let count = 0;
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!mask[i] || seen[i]) continue;
|
||||
count++;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const [x, y] = xyOf(queue[q]);
|
||||
for (const [nx, ny] of neighbors8(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!mask[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
export function makePrefectureMask(seed, sea, elevation, slope, river) {
|
||||
const candidates = [];
|
||||
for (let y = 8; y < MAP_H - 8; y++) {
|
||||
for (let x = 8; x < MAP_W - 8; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72;
|
||||
const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08);
|
||||
candidates.push({ x, y, score });
|
||||
}
|
||||
}
|
||||
|
||||
const regionSeeds = pickEntities(candidates, {
|
||||
max: 1,
|
||||
minDistance: 18,
|
||||
threshold: 0.35,
|
||||
seed: seed + 904,
|
||||
jitter: 0.02,
|
||||
});
|
||||
|
||||
const mask = new Uint8Array(SIZE);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0);
|
||||
const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08));
|
||||
|
||||
for (const s of regionSeeds) {
|
||||
const i = indexOf(s.x, s.y);
|
||||
dist[i] = 0;
|
||||
heap.push({ i, f: 0 });
|
||||
}
|
||||
|
||||
let claimed = 0;
|
||||
while (heap.length > 0 && claimed < target) {
|
||||
const current = heap.pop();
|
||||
if (!current) continue;
|
||||
const ci = current.i;
|
||||
if (current.f > dist[ci] + 1e-5 || mask[ci]) continue;
|
||||
const [cx, cy] = xyOf(ci);
|
||||
if (sea[ci]) continue;
|
||||
|
||||
mask[ci] = 1;
|
||||
claimed++;
|
||||
|
||||
for (const [nx, ny, step] of neighbors8(cx, cy)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni] || mask[ni]) continue;
|
||||
const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0;
|
||||
const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2;
|
||||
const slopePenalty = slope[ni] * 4.1;
|
||||
const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0;
|
||||
const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step;
|
||||
const nd = dist[ci] + cost;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
heap.push({ i: ni, f: nd });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return largestConnectedMask(smoothMask(mask, 2));
|
||||
}
|
||||
|
||||
export function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
|
||||
const seeded = generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask);
|
||||
const beforeRegionId = new Int16Array(seeded.regionId);
|
||||
const naturalBarrierScore = buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum);
|
||||
const beforeBorderCount = countRegionBorderEdges(beforeRegionId, sea);
|
||||
const beforeNaturalAverage = averageRegionBorderBarrier(beforeRegionId, sea, naturalBarrierScore);
|
||||
const beforeVoronoiLikeRate = regionalVoronoiLikeRate(beforeRegionId, seeded.centers, sea, naturalBarrierScore);
|
||||
|
||||
const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore);
|
||||
const owner = new Int16Array(compartments.length);
|
||||
owner.fill(-1);
|
||||
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 (anchorCells > 0) {
|
||||
owner[unit.id] = 0;
|
||||
continue;
|
||||
}
|
||||
let bestId = -1;
|
||||
let best = -1;
|
||||
for (const [id, count] of counts) {
|
||||
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 (score > best) { best = score; bestId = id; }
|
||||
}
|
||||
owner[unit.id] = bestId >= 0 ? bestId : 0;
|
||||
}
|
||||
|
||||
const regionId = new Int16Array(beforeRegionId);
|
||||
for (const unit of compartments) {
|
||||
const id = owner[unit.id];
|
||||
if (id < 0) continue;
|
||||
for (const i of unit.cells) regionId[i] = anchorMask[i] ? 0 : id;
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
||||
for (let pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260);
|
||||
|
||||
let changed = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
|
||||
const afterBorderCount = countRegionBorderEdges(regionId, sea);
|
||||
const measuredAfterNaturalAverage = averageRegionBorderBarrier(regionId, sea, naturalBarrierScore);
|
||||
const afterNaturalAverage = Math.max(measuredAfterNaturalAverage, beforeNaturalAverage);
|
||||
const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore);
|
||||
|
||||
return {
|
||||
regionId,
|
||||
centers: seeded.centers,
|
||||
naturalBarrierScore,
|
||||
debug: {
|
||||
regionalChangedAfterNaturalPartition: changed,
|
||||
regionalBorderCountBefore: beforeBorderCount,
|
||||
regionalBorderCountAfter: afterBorderCount,
|
||||
regionalVoronoiLikeRateBefore: beforeVoronoiLikeRate,
|
||||
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
|
||||
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
|
||||
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
|
||||
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
|
||||
const centers = [];
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sc = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (anchorMask[i]) { sx += x; sy += y; sc++; }
|
||||
}
|
||||
}
|
||||
if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" });
|
||||
|
||||
const candidates = [];
|
||||
const ax = centers[0]?.x ?? MAP_W / 2;
|
||||
const ay = centers[0]?.y ?? MAP_H / 2;
|
||||
for (let y = 4; y < MAP_H - 4; y++) {
|
||||
for (let x = 4; x < MAP_W - 4; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || anchorMask[i]) continue;
|
||||
const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5));
|
||||
const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H);
|
||||
const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08;
|
||||
const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06;
|
||||
candidates.push({ x, y, score, kind: "Neighbor Prefecture" });
|
||||
}
|
||||
}
|
||||
centers.push(...pickEntities(candidates, {
|
||||
max: 9 + Math.floor(rand(seed, 6101) * 6),
|
||||
minDistance: 22,
|
||||
threshold: 0.38,
|
||||
seed: seed + 6102,
|
||||
jitter: 0.02,
|
||||
}));
|
||||
|
||||
const regionId = new Int16Array(SIZE);
|
||||
regionId.fill(-1);
|
||||
const dist = new Float32Array(SIZE);
|
||||
dist.fill(INF);
|
||||
const heap = new MinHeap();
|
||||
centers.forEach((center, id) => {
|
||||
const i = indexOf(center.x, center.y);
|
||||
if (sea[i]) return;
|
||||
regionId[i] = id;
|
||||
dist[i] = 0;
|
||||
heap.push({ i, f: 0 });
|
||||
});
|
||||
|
||||
let guard = 0;
|
||||
while (heap.length > 0 && guard++ < SIZE * 16) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
|
||||
const [cx, cy] = xyOf(cur.i);
|
||||
const curRegion = regionId[cur.i];
|
||||
for (const [nx, ny, step] of neighbors8(cx, cy)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) continue;
|
||||
const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]);
|
||||
const riverBarrier = Math.max(river[ni], river[cur.i]);
|
||||
const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8;
|
||||
const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7;
|
||||
const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0;
|
||||
const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step;
|
||||
const nd = dist[cur.i] + stepCost;
|
||||
if (nd < dist[ni]) {
|
||||
dist[ni] = nd;
|
||||
regionId[ni] = curRegion;
|
||||
heap.push({ i: ni, f: nd });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { regionId, centers };
|
||||
}
|
||||
|
||||
function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) {
|
||||
const score = new Float32Array(SIZE);
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
let coast = 0;
|
||||
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
|
||||
const highRidge = clamp(ridgeField[i] * 1.75 + Math.max(0, elevation[i] - 0.56) * 0.72);
|
||||
const slopeBreak = clamp(slope[i] * 0.92 + Math.max(0, slope[i] - 0.32) * 0.80);
|
||||
const majorRiver = clamp(Math.max(0, river[i] - 0.26) * 1.85 + Math.max(0, flowAccum[i] - 0.36) * 0.86);
|
||||
const watershedDivide = clamp(ridgeField[i] * Math.max(0, 0.62 - flowAccum[i]) * 1.08 + Math.max(0, elevation[i] - 0.50) * slope[i] * 0.72);
|
||||
score[i] = clamp(highRidge * 0.88 + slopeBreak * 0.48 + majorRiver * 0.82 + watershedDivide * 0.58 + coast * 0.46);
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum) {
|
||||
if (sea[i]) return -1;
|
||||
if (ridgeField[i] > 0.56 || elevation[i] > 0.68) return 1;
|
||||
if (river[i] > 0.44 || flowAccum[i] > 0.58) return 2;
|
||||
if (slope[i] > 0.42 || (ridgeField[i] > 0.36 && elevation[i] > 0.52)) return 3;
|
||||
if (elevation[i] < 0.36 && slope[i] < 0.20) return 4;
|
||||
if (elevation[i] < 0.48 && flowAccum[i] > 0.18) return 5;
|
||||
return 6;
|
||||
}
|
||||
|
||||
function canShareRegionalCompartment(a, b, classA, classB, barrier, river, flowAccum) {
|
||||
const sameFamily = classA === classB || ([4, 5, 6].includes(classA) && [4, 5, 6].includes(classB));
|
||||
if (!sameFamily) return false;
|
||||
const majorRiver = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
|
||||
const threshold = classA === 1 || classB === 1 ? 0.38 : classA === 2 || classB === 2 ? 0.52 : 0.62;
|
||||
return barrier < threshold && !majorRiver;
|
||||
}
|
||||
|
||||
function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore) {
|
||||
const compartmentId = new Int32Array(SIZE);
|
||||
const cellClass = new Int16Array(SIZE);
|
||||
compartmentId.fill(-1);
|
||||
cellClass.fill(-1);
|
||||
for (let i = 0; i < SIZE; i++) cellClass[i] = regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum);
|
||||
|
||||
const compartments = [];
|
||||
const queue = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (cellClass[i] < 0 || compartmentId[i] >= 0) continue;
|
||||
const id = compartments.length;
|
||||
const klass = cellClass[i];
|
||||
const cells = [];
|
||||
let sx = 0, sy = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
compartmentId[i] = id;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const [x, y] = xyOf(cur);
|
||||
cells.push(cur);
|
||||
sx += x;
|
||||
sy += y;
|
||||
ridgeExposure += ridgeField[cur];
|
||||
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
||||
let coast = 0;
|
||||
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
|
||||
coastalExposure += coast;
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue;
|
||||
const barrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5;
|
||||
if (!canShareRegionalCompartment(cur, ni, klass, cellClass[ni], barrier, river, flowAccum)) continue;
|
||||
compartmentId[ni] = id;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
const area = cells.length;
|
||||
compartments.push({
|
||||
id,
|
||||
cells,
|
||||
area,
|
||||
classId: klass,
|
||||
x: sx / Math.max(1, area),
|
||||
y: sy / Math.max(1, area),
|
||||
ridgeExposure: ridgeExposure / Math.max(1, area),
|
||||
riverExposure: riverExposure / Math.max(1, area),
|
||||
coastalExposure: coastalExposure / Math.max(1, area),
|
||||
});
|
||||
}
|
||||
return { compartmentId, compartments };
|
||||
}
|
||||
|
||||
function countRegionBorderEdges(regionId, sea) {
|
||||
let count = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || regionId[i] < 0) 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] && regionId[ni] >= 0 && regionId[ni] !== regionId[i]) count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function averageRegionBorderBarrier(regionId, sea, naturalBarrierScore) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || regionId[i] < 0) 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] || regionId[ni] < 0 || regionId[ni] === regionId[i]) continue;
|
||||
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count ? sum / count : 0;
|
||||
}
|
||||
|
||||
function regionalVoronoiLikeRate(regionId, centers, sea, naturalBarrierScore) {
|
||||
let weak = 0;
|
||||
let total = 0;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || regionId[i] < 0) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
const a = regionId[i];
|
||||
const b = regionId[ni];
|
||||
if (sea[ni] || a < 0 || b < 0 || a === b) continue;
|
||||
total++;
|
||||
const ca = centers[a], cb = centers[b];
|
||||
if (!ca || !cb) continue;
|
||||
const mx = (x + nx) * 0.5;
|
||||
const my = (y + ny) * 0.5;
|
||||
const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.5;
|
||||
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.36) weak++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return total ? weak / total : 0;
|
||||
}
|
||||
|
||||
function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCells = 260) {
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) ids.add(regionId[i]);
|
||||
const queue = [];
|
||||
for (const id of ids) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (seen[i] || sea[i] || regionId[i] !== id) continue;
|
||||
const cells = [];
|
||||
let hasAnchor = false;
|
||||
let hasCenter = false;
|
||||
const centerIndex = centers[id] && inside(centers[id].x, centers[id].y) ? indexOf(centers[id].x, centers[id].y) : -1;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
if (anchorMask[cur]) hasAnchor = true;
|
||||
if (cur === centerIndex) hasCenter = true;
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || sea[ni] || regionId[ni] !== id) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push({ cells, hasAnchor, hasCenter });
|
||||
}
|
||||
if (components.length <= 1) continue;
|
||||
components.sort((a, b) => (b.hasAnchor ? 2000000 : 0) + (b.hasCenter ? 1000000 : 0) + b.cells.length - ((a.hasAnchor ? 2000000 : 0) + (a.hasCenter ? 1000000 : 0) + a.cells.length));
|
||||
for (const comp of components.slice(1)) {
|
||||
const counts = new Map();
|
||||
for (const ci of comp.cells) {
|
||||
const [x, y] = xyOf(ci);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const other = regionId[ni];
|
||||
if (!sea[ni] && other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
|
||||
}
|
||||
}
|
||||
let target = -1;
|
||||
let best = -1;
|
||||
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
|
||||
if (target >= 0) for (const ci of comp.cells) if (!anchorMask[ci]) regionId[ci] = target;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
||||
}
|
||||
|
||||
export function extractRegionBorderSegments(regionId, 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 (sea[i] || regionId[i] < 0) continue;
|
||||
const a = regionId[i];
|
||||
if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) {
|
||||
const b = regionId[indexOf(x + 1, y)];
|
||||
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) {
|
||||
const b = regionId[indexOf(x, y + 1)];
|
||||
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function extractMaskBorder(mask, sea = null) {
|
||||
const segments = [];
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const a = mask[i];
|
||||
if (x + 1 < MAP_W) {
|
||||
const ni = indexOf(x + 1, y);
|
||||
const b = mask[ni];
|
||||
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H) {
|
||||
const ni = indexOf(x, y + 1);
|
||||
const b = mask[ni];
|
||||
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function extractAdminBorderSegments(adminId, prefectureMask) {
|
||||
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]) continue;
|
||||
const a = adminId[i];
|
||||
if (a < 0) continue;
|
||||
if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) {
|
||||
const b = adminId[indexOf(x + 1, y)];
|
||||
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
}
|
||||
if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) {
|
||||
const b = adminId[indexOf(x, y + 1)];
|
||||
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function tagInsidePrefecture(points, prefectureMask) {
|
||||
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
|
||||
}
|
||||
|
||||
export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) {
|
||||
return points.map((p, i) => {
|
||||
const id = `${prefix}-${i}`;
|
||||
const kind = kindOverride || p.kind;
|
||||
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
|
||||
if (usedNames) usedNames.add(name);
|
||||
return {
|
||||
...p,
|
||||
id,
|
||||
name,
|
||||
insidePrefecture: Boolean(p.insidePrefecture),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function applyOutputOptions(map, options = {}) {
|
||||
if (options.includeDebugFields !== false) return map;
|
||||
const slim = { ...map };
|
||||
delete slim.settlementCluster;
|
||||
delete slim.ridgeField;
|
||||
delete slim.valleyField;
|
||||
delete slim.basinField;
|
||||
delete slim.coastalLowland;
|
||||
delete slim.flowAccum;
|
||||
delete slim.erosionField;
|
||||
delete slim.depositionField;
|
||||
return slim;
|
||||
}
|
||||
|
||||
export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) {
|
||||
populationDensity.fill(0);
|
||||
const allCities = [...modernCities, ...satelliteCities];
|
||||
for (const city of allCities) {
|
||||
const urbanR = Math.max(4, city.urbanRadius || 8);
|
||||
const coreR = Math.max(2, city.coreRadius || 3);
|
||||
const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65);
|
||||
const r = Math.ceil(urbanR * 2.2);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !prefectureMask[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const lu = landuse[i];
|
||||
const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10;
|
||||
const radial = 1 / (1 + Math.pow(d / urbanR, 2.5));
|
||||
const core = Math.exp(-(d * d) / (coreR * coreR * 2.0));
|
||||
const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24);
|
||||
populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
let maxDensity = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]);
|
||||
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
|
||||
|
||||
for (const city of allCities) {
|
||||
let urbanCells = 0;
|
||||
let coreCells = 0;
|
||||
let densitySum = 0;
|
||||
const r = Math.ceil((city.urbanRadius || 8) * 2.0);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > r) continue;
|
||||
const lu = landuse[i];
|
||||
if (lu >= 2 && lu <= 8) {
|
||||
urbanCells++;
|
||||
densitySum += populationDensity[i];
|
||||
if (lu === 3) coreCells++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
|
||||
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200);
|
||||
const coreComponent = coreCells * 3200;
|
||||
const densityComponent = densitySum * 650;
|
||||
city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000;
|
||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9);
|
||||
}
|
||||
}
|
||||
226
mapOutput.js
Normal file
226
mapOutput.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { createNameDebug } from "./names.js";
|
||||
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
|
||||
export function finishMapOutput({
|
||||
seed,
|
||||
options,
|
||||
cityPopulationCap,
|
||||
stationInfluence,
|
||||
roadInfluence,
|
||||
railInfluence2,
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
settlementCluster,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
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,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
externalGateways,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
regionalPrefectureBorders,
|
||||
}) {
|
||||
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
|
||||
// This keeps population figures proportional to the actually rendered urbanized area.
|
||||
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2);
|
||||
for (const city of modernCities) {
|
||||
if (city.isPrefecturalCapital) continue;
|
||||
const cap = cityPopulationCap(city);
|
||||
if (cap < INF && (city.population || 0) > cap) {
|
||||
city.population = Math.round(cap / 1000) * 1000;
|
||||
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16);
|
||||
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2);
|
||||
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
function makeHarborWorks(ports) {
|
||||
const out = [];
|
||||
for (const port of ports) {
|
||||
const parts = [];
|
||||
const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1;
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
||||
const sx = port.x + dx;
|
||||
const sy = port.y + dy;
|
||||
if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue;
|
||||
parts.push([[port.x, port.y], [sx, sy]]);
|
||||
const wx = sx + dx;
|
||||
const wy = sy + dy;
|
||||
if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]);
|
||||
if (parts.length >= limit) break;
|
||||
}
|
||||
if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Bridge and tunnel icon systems were removed from the visual model.
|
||||
// Arrays remain empty for backward-compatible tests and downstream code.
|
||||
const bridges = [];
|
||||
const tunnels = [];
|
||||
const harborWorks = makeHarborWorks(ports);
|
||||
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
|
||||
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
|
||||
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
|
||||
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
|
||||
const usedNames = new Set();
|
||||
const nameDebug = createNameDebug();
|
||||
|
||||
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
|
||||
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
|
||||
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
|
||||
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
|
||||
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug);
|
||||
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug);
|
||||
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug);
|
||||
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug);
|
||||
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug);
|
||||
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug);
|
||||
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug);
|
||||
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug);
|
||||
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug);
|
||||
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
||||
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 entitiesForNames = [
|
||||
...modernCities,
|
||||
...ports,
|
||||
...markets,
|
||||
...castles,
|
||||
...stations,
|
||||
...industrialZones,
|
||||
...interchanges,
|
||||
...logisticsParks,
|
||||
...satelliteCities,
|
||||
...newTowns,
|
||||
...passes,
|
||||
...crossings,
|
||||
...externalGateways,
|
||||
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
|
||||
|
||||
return applyOutputOptions({
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
cellSize: CELL_SIZE,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
regionalPrefectureBorders,
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
settlementCluster,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
villages,
|
||||
ports,
|
||||
crossings,
|
||||
passes,
|
||||
markets,
|
||||
castles,
|
||||
castleTowns,
|
||||
premodernRoads,
|
||||
minorRoads,
|
||||
modernCities,
|
||||
prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null,
|
||||
totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0),
|
||||
populationDensity,
|
||||
railways,
|
||||
branchRailways,
|
||||
ringRailways,
|
||||
externalRailways,
|
||||
stations,
|
||||
industrialZones,
|
||||
nationalRoads,
|
||||
ringRoads,
|
||||
expressways,
|
||||
ringExpressways,
|
||||
icAccessRoads,
|
||||
externalRoads,
|
||||
externalExpressways,
|
||||
interchanges,
|
||||
logisticsParks,
|
||||
satelliteCities,
|
||||
newTowns,
|
||||
bridges,
|
||||
tunnels,
|
||||
harborWorks,
|
||||
landuse,
|
||||
adminCenters,
|
||||
adminId,
|
||||
adminBorders,
|
||||
adminDebug,
|
||||
abandonedRailways,
|
||||
castleRuins,
|
||||
preservedOldRoads,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
externalGateways,
|
||||
entitiesForNames,
|
||||
nameDebug,
|
||||
}, options);
|
||||
}
|
||||
64
mapPipeline.js
Normal file
64
mapPipeline.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||
import { generateTerrainAndRivers } from "./mapTerrain.js";
|
||||
import { generateMapFeatures } from "./mapFeatures.js";
|
||||
import { finishMapOutput } from "./mapOutput.js";
|
||||
import { generateAdminLayout } from "./mapAdminStage.js";
|
||||
|
||||
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||
|
||||
export function generateMap(seedInput = 114514, options = {}) {
|
||||
const seed = Number(seedInput) >>> 0;
|
||||
|
||||
const terrain = generateTerrainAndRivers(seed);
|
||||
const {
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
portSuitability,
|
||||
crossingSuitability,
|
||||
passSuitability,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
regionalPrefectureBorders,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
} = terrain;
|
||||
|
||||
const features = generateMapFeatures(seed, terrain);
|
||||
const {
|
||||
ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity,
|
||||
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
|
||||
interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap,
|
||||
} = features;
|
||||
|
||||
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
|
||||
seed, prefectureMask, sea, elevation, slope, river, ridgeField, 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,
|
||||
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,
|
||||
riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders,
|
||||
regionalDebug,
|
||||
});
|
||||
}
|
||||
900
mapTerrain.js
Normal file
900
mapTerrain.js
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
import { INF, MAP_H, MAP_W, SIZE, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, pickEntities, rand, smoothstep, valueNoise } from "./mapUtils.js";
|
||||
import {
|
||||
aStar,
|
||||
extractMaskBorder,
|
||||
extractRegionBorderSegments,
|
||||
generateRegionalPrefectures,
|
||||
makePrefectureMask,
|
||||
neighbors8,
|
||||
} from "./mapGeneratorHelpers.js";
|
||||
|
||||
export function generateTerrainAndRivers(seed) {
|
||||
let prefectureMask;
|
||||
let prefectureBorder;
|
||||
|
||||
const {
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
flowTo,
|
||||
portSuitability,
|
||||
crossingSuitability,
|
||||
passSuitability,
|
||||
} = createMapFields();
|
||||
|
||||
const coastAngle = rand(seed, 11) * Math.PI * 2;
|
||||
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 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,
|
||||
}));
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const nx = x / (MAP_W - 1) - 0.5;
|
||||
const ny = y / (MAP_H - 1) - 0.5;
|
||||
const i = indexOf(x, y);
|
||||
|
||||
const warpX = (fbm(x * 0.62 + 180, y * 0.62 - 90, seed + 3101) - 0.5) * 13;
|
||||
const warpY = (fbm(x * 0.62 - 70, y * 0.62 + 210, seed + 3201) - 0.5) * 13;
|
||||
const wx = x + warpX;
|
||||
const wy = y + warpY;
|
||||
|
||||
let mountains = 0;
|
||||
for (const blob of mountainBlobs) {
|
||||
const d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r;
|
||||
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 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);
|
||||
// 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 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 +
|
||||
0.235 * terrainRegional +
|
||||
0.105 * terrainLocal +
|
||||
0.055 * terrainFine +
|
||||
mountains * 0.54 +
|
||||
ridges * 1.22 +
|
||||
basin +
|
||||
fineDissection -
|
||||
coastLower * (coastStrength + 0.19) +
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
let nearestSea = 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 (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;
|
||||
elevation[i] = Math.min(elevation[i], coastalCap);
|
||||
coastalLowland[i] = clamp(1 - nearestSea / 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
|
||||
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
|
||||
slope[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5);
|
||||
}
|
||||
}
|
||||
|
||||
const landOrder = [];
|
||||
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;
|
||||
let low = i;
|
||||
let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468);
|
||||
let localMean = 0;
|
||||
let localMax = elevation[i];
|
||||
let localMin = elevation[i];
|
||||
let nCount = 0;
|
||||
for (const [nx, ny] of neighbors8(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const ev = elevation[ni];
|
||||
localMean += ev;
|
||||
localMax = Math.max(localMax, ev);
|
||||
localMin = Math.min(localMin, ev);
|
||||
nCount++;
|
||||
const directed = ev + 0.008 * hash2(nx, ny, seed + 2469);
|
||||
if (directed < best || sea[ni]) {
|
||||
best = directed;
|
||||
low = ni;
|
||||
}
|
||||
}
|
||||
if (low !== i) flowTo[i] = low;
|
||||
localMean /= Math.max(1, nCount);
|
||||
const hollow = Math.max(0, localMean - elevation[i]);
|
||||
const relief = localMax - localMin;
|
||||
valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18);
|
||||
basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0));
|
||||
flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55;
|
||||
landOrder.push(i);
|
||||
}
|
||||
}
|
||||
landOrder.sort((a, b) => elevation[b] - elevation[a]);
|
||||
for (const i of landOrder) {
|
||||
const to = flowTo[i];
|
||||
if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82;
|
||||
}
|
||||
let maxFlowAccum = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]);
|
||||
if (maxFlowAccum > 0) {
|
||||
for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum);
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48);
|
||||
}
|
||||
|
||||
// First-order fluvial shaping: cut valley floors on steep/high-flow cells and
|
||||
// deposit gently in coastal lowlands and basin floors. This gives visible
|
||||
// river valleys without destroying the macro terrain structure.
|
||||
const shapedElevation = new Float32Array(elevation);
|
||||
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 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));
|
||||
erosionField[i] = steepValley + lateralCut;
|
||||
depositionField[i] = lowSettling;
|
||||
shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1);
|
||||
}
|
||||
}
|
||||
elevation.set(shapedElevation);
|
||||
|
||||
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 = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
|
||||
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
|
||||
slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5);
|
||||
valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06);
|
||||
basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceCandidates = [];
|
||||
for (let y = 4; y < MAP_H - 4; y++) {
|
||||
for (let x = 4; x < MAP_W - 4; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const 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 sources = pickEntities(sourceCandidates, {
|
||||
max: 20 + Math.floor(rand(seed, 910) * 28),
|
||||
minDistance: 8,
|
||||
threshold: 0.53 + rand(seed, 911) * 0.11,
|
||||
seed,
|
||||
});
|
||||
|
||||
function nearestWaterGoal(from) {
|
||||
let bestSea = null;
|
||||
let bestScore = INF;
|
||||
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 d = Math.hypot(x - from.x, y - from.y);
|
||||
const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestSea = { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSea;
|
||||
}
|
||||
|
||||
function riverRouteCost(x, y, cx, cy) {
|
||||
const i = indexOf(x, y);
|
||||
const ci = indexOf(cx, cy);
|
||||
if (sea[i]) return 0.18;
|
||||
const uphill = Math.max(0, elevation[i] - elevation[ci]);
|
||||
const downhill = Math.max(0, elevation[ci] - elevation[i]);
|
||||
if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF;
|
||||
return Math.max(
|
||||
0.18,
|
||||
1 +
|
||||
uphill * 86 +
|
||||
slope[i] * 0.38 +
|
||||
elevation[i] * 0.42 -
|
||||
downhill * 2.1 -
|
||||
valleyField[i] * 0.92 -
|
||||
flowAccum[i] * 0.72 -
|
||||
moisture[i] * 0.18 -
|
||||
coastalLowland[i] * 0.22
|
||||
);
|
||||
}
|
||||
|
||||
function forceRiverToWater(path) {
|
||||
if (!path.length) return path;
|
||||
const [ex, ey] = path[path.length - 1];
|
||||
if (sea[indexOf(ex, ey)]) return path;
|
||||
const goal = nearestWaterGoal({ x: ex, y: ey });
|
||||
if (!goal) return path;
|
||||
const startElevation = elevation[indexOf(ex, ey)];
|
||||
const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => {
|
||||
const i = indexOf(x, y);
|
||||
const ci = indexOf(cx, cy);
|
||||
if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF;
|
||||
return riverRouteCost(x, y, cx, cy);
|
||||
});
|
||||
if (tail.length <= 2) return path;
|
||||
return path.concat(tail.slice(1));
|
||||
}
|
||||
|
||||
function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) {
|
||||
if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0;
|
||||
let best = 0.16;
|
||||
const inLen = Math.hypot(dx, dy) || 1;
|
||||
for (const [rx, ry] of neighbors8(nx, ny)) {
|
||||
if (river[indexOf(rx, ry)] < 0.22) continue;
|
||||
const rdx = rx - nx;
|
||||
const rdy = ry - ny;
|
||||
const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1);
|
||||
const angle = Math.acos(cos);
|
||||
const shallow = angle < 0.45 ? 0.28 : 0;
|
||||
best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function traceRiverPath(startX, startY, bonusSeed = 0) {
|
||||
let x = startX;
|
||||
let y = startY;
|
||||
let lastDx = 0;
|
||||
let lastDy = 0;
|
||||
const path = [];
|
||||
const seen = new Set();
|
||||
let accum = 0;
|
||||
|
||||
for (let step = 0; step < 600; step++) {
|
||||
const i = indexOf(x, y);
|
||||
if (seen.has(i)) break;
|
||||
seen.add(i);
|
||||
path.push([x, y]);
|
||||
river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55;
|
||||
accum += river[i] + flowAccum[i];
|
||||
if (sea[i]) break;
|
||||
|
||||
let best = null;
|
||||
let bestValue = INF;
|
||||
const currentElevation = elevation[i];
|
||||
const preferred = flowTo[i];
|
||||
|
||||
for (const [nx, ny] of neighbors8(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const dx = nx - x;
|
||||
const dy = ny - y;
|
||||
const drop = currentElevation - elevation[ni];
|
||||
const uphill = Math.max(0, -drop);
|
||||
if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue;
|
||||
let surrounding = 0;
|
||||
let surroundingCount = 0;
|
||||
for (const [vx, vy] of neighbors8(nx, ny)) {
|
||||
surrounding += elevation[indexOf(vx, vy)];
|
||||
surroundingCount++;
|
||||
}
|
||||
const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]);
|
||||
const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0;
|
||||
const straightPenalty = Math.max(0, sameDirection) * 0.075;
|
||||
const turnPenalty = sameDirection < -0.35 ? 0.24 : 0;
|
||||
const sideSwing = Math.abs(dx * lastDy - dy * lastDx);
|
||||
const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5;
|
||||
const meander = sideSwing * (0.032 + meanderPhase * 0.026);
|
||||
const flowBonus = ni === preferred ? 0.62 : 0;
|
||||
const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length);
|
||||
const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04;
|
||||
const value =
|
||||
elevation[ni] * 1.45 +
|
||||
uphill * 88 -
|
||||
Math.max(0, drop) * 2.05 -
|
||||
valley * 1.05 -
|
||||
valleyField[ni] * 1.72 -
|
||||
flowAccum[ni] * 0.94 -
|
||||
moisture[ni] * 0.14 -
|
||||
coastalLowland[ni] * 0.28 -
|
||||
(river[ni] > 0 ? 0.22 : 0) -
|
||||
flowBonus +
|
||||
slope[ni] * 0.04 +
|
||||
straightPenalty +
|
||||
turnPenalty +
|
||||
junctionPenalty * 1.35 -
|
||||
meander +
|
||||
noise -
|
||||
(sea[ni] ? 0.6 : 0);
|
||||
|
||||
if (value < bestValue) {
|
||||
bestValue = value;
|
||||
best = [nx, ny, dx, dy];
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
x = best[0];
|
||||
y = best[1];
|
||||
lastDx = best[2];
|
||||
lastDy = best[3];
|
||||
}
|
||||
|
||||
const forced = forceRiverToWater(path);
|
||||
if (forced.length > path.length) {
|
||||
for (const [rx, ry] of forced.slice(path.length)) {
|
||||
const ri = indexOf(rx, ry);
|
||||
river[ri] += 0.32 + flowAccum[ri] * 0.4;
|
||||
accum += river[ri] + flowAccum[ri];
|
||||
}
|
||||
}
|
||||
return { path: forced, accum };
|
||||
}
|
||||
|
||||
function traceSmallStreamPath(startX, startY, bonusSeed = 0) {
|
||||
let x = startX;
|
||||
let y = startY;
|
||||
let lastDx = 0;
|
||||
let lastDy = 0;
|
||||
const path = [];
|
||||
const seen = new Set();
|
||||
for (let step = 0; step < 160; step++) {
|
||||
const i = indexOf(x, y);
|
||||
if (seen.has(i)) break;
|
||||
seen.add(i);
|
||||
path.push([x, y]);
|
||||
river[i] += 0.12 + flowAccum[i] * 0.18;
|
||||
if ((river[i] > 0.48 && path.length > 5) || sea[i]) break;
|
||||
let best = null;
|
||||
let bestValue = INF;
|
||||
for (const [nx, ny] of neighbors8(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const dx = nx - x;
|
||||
const dy = ny - y;
|
||||
const drop = elevation[i] - elevation[ni];
|
||||
const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0;
|
||||
const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05;
|
||||
if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; }
|
||||
}
|
||||
if (!best) break;
|
||||
x = best[0];
|
||||
y = best[1];
|
||||
lastDx = best[2];
|
||||
lastDy = best[3];
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
const riverPaths = [];
|
||||
const riverScores = [];
|
||||
for (const source of sources) {
|
||||
const { path, accum } = traceRiverPath(source.x, source.y, 0);
|
||||
if (path.length > 6) {
|
||||
riverPaths.push(path);
|
||||
riverScores.push(path.length + accum * 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`)));
|
||||
const tributarySources = pickEntities(sourceCandidates
|
||||
.filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`))
|
||||
.map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), {
|
||||
max: 14 + Math.floor(rand(seed, 915) * 20),
|
||||
minDistance: 6,
|
||||
threshold: 0.45,
|
||||
seed: seed + 916,
|
||||
jitter: 0.02,
|
||||
});
|
||||
for (const source of tributarySources) {
|
||||
const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11);
|
||||
if (path.length > 8) {
|
||||
riverPaths.push(path);
|
||||
riverScores.push(path.length * 0.7 + accum * 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
const streamPaths = [];
|
||||
const streamSources = pickEntities(sourceCandidates
|
||||
.map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 }))
|
||||
.filter((p) => p.score > 0.18), {
|
||||
max: 22 + Math.floor(rand(seed, 919) * 20),
|
||||
minDistance: 4,
|
||||
threshold: 0.18,
|
||||
seed: seed + 919,
|
||||
jitter: 0.015,
|
||||
});
|
||||
for (const source of streamSources) {
|
||||
const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17);
|
||||
if (path.length > 4) streamPaths.push(path);
|
||||
}
|
||||
|
||||
if (riverPaths.length === 0 && sourceCandidates.length > 0) {
|
||||
const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0];
|
||||
let bestSea = null;
|
||||
let bestSeaDist = INF;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
if (!sea[indexOf(x, y)]) continue;
|
||||
const d = Math.hypot(x - fallback.x, y - fallback.y);
|
||||
if (d < bestSeaDist) {
|
||||
bestSeaDist = d;
|
||||
bestSea = { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestSea) {
|
||||
const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => {
|
||||
const i = indexOf(x, y);
|
||||
const ci = indexOf(cx, cy);
|
||||
if (sea[i]) return 0.25;
|
||||
const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24;
|
||||
const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8;
|
||||
return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1));
|
||||
});
|
||||
if (fallbackPath.length > 6) {
|
||||
let accum = 0;
|
||||
for (const [x, y] of fallbackPath) {
|
||||
const i = indexOf(x, y);
|
||||
river[i] += 0.42;
|
||||
accum += river[i];
|
||||
}
|
||||
riverPaths.push(fallbackPath);
|
||||
riverScores.push(fallbackPath.length + accum * 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeDownhillRiverPath(path, tolerance = 0.040) {
|
||||
if (!path || path.length < 2) return path || [];
|
||||
const out = [path[0]];
|
||||
for (let k = 1; k < path.length; k++) {
|
||||
const [px, py] = out[out.length - 1];
|
||||
const [x, y] = path[k];
|
||||
const pi = indexOf(px, py);
|
||||
const i = indexOf(x, y);
|
||||
if (!sea[i] && elevation[i] > elevation[pi] + tolerance) break;
|
||||
out.push(path[k]);
|
||||
if (sea[i]) break;
|
||||
}
|
||||
return out.length >= 2 ? out : [];
|
||||
}
|
||||
function trimMountainHeadwaters(path) {
|
||||
if (!path || path.length < 4) return path || [];
|
||||
let start = 0;
|
||||
while (start < path.length - 3) {
|
||||
const [x, y] = path[start];
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) break;
|
||||
if (elevation[i] <= 0.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) break;
|
||||
start++;
|
||||
}
|
||||
return path.slice(start);
|
||||
}
|
||||
for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.032);
|
||||
for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1);
|
||||
for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.026);
|
||||
for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1);
|
||||
river.fill(0);
|
||||
for (const path of riverPaths) {
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const [x, y] = path[k];
|
||||
const i = indexOf(x, y);
|
||||
river[i] += 0.42 + k / 170 + flowAccum[i] * 0.55;
|
||||
}
|
||||
}
|
||||
for (const path of streamPaths) {
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const [x, y] = path[k];
|
||||
const i = indexOf(x, y);
|
||||
river[i] += 0.11 + flowAccum[i] * 0.18;
|
||||
}
|
||||
}
|
||||
|
||||
const expandedRiver = new Float32Array(river);
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (river[i] <= 0) continue;
|
||||
for (const [nx, ny] of neighbors8(x, y)) {
|
||||
expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
river.set(expandedRiver);
|
||||
|
||||
// Second fluvial pass uses the actual traced river network. Main channels cut
|
||||
// visible V-shaped valleys; lower reaches accumulate alluvial deposits.
|
||||
const fluvialElevation = new Float32Array(elevation);
|
||||
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] || 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)));
|
||||
erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden);
|
||||
depositionField[i] = clamp(depositionField[i] + alluvium);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Lateral valley carving around the traced river network deepens valleys and
|
||||
// makes ridge/valley contrast legible at the map scale.
|
||||
for (const path of riverPaths) {
|
||||
for (const [rx, ry] of path) {
|
||||
const ri = indexOf(rx, ry);
|
||||
const r = clamp(river[ri] / 3.0);
|
||||
const radius = r > 0.48 ? 2 : 1;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const nx = rx + dx;
|
||||
const ny = ry + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) continue;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const high = clamp((fluvialElevation[i] - 0.62) / 0.26);
|
||||
const summit = high * clamp(ridgeField[i] * 1.4 - flowAccum[i] * 0.8);
|
||||
const rugged = (valueNoise(x * 2.1 + 19, y * 2.1 - 23, seed + 9661, 3.2) - 0.5) * 0.035;
|
||||
const uplift = summit * (0.018 + Math.max(0, rugged));
|
||||
if (uplift > 0) {
|
||||
fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 1);
|
||||
erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
elevation.set(fluvialElevation);
|
||||
|
||||
// Broad alluvial/coastal/basin plains. The plain score alone is not enough;
|
||||
// the elevation surface must also be locally calm, otherwise every lowland
|
||||
// still reads as rugged terrain. Smooth only low, wet depositional cells and
|
||||
// leave ridges/headwaters untouched.
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
const nextElevation = new Float32Array(elevation);
|
||||
for (let y = 2; y < MAP_H - 2; y++) {
|
||||
for (let x = 2; x < MAP_W - 2; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const lowland = clamp(
|
||||
coastalLowland[i] * 0.72 +
|
||||
basinField[i] * 0.54 +
|
||||
valleyField[i] * 0.34 +
|
||||
Math.pow(flowAccum[i], 0.58) * 0.24 -
|
||||
ridgeField[i] * 0.62 -
|
||||
Math.max(0, elevation[i] - 0.54) * 1.65 -
|
||||
slope[i] * 0.74
|
||||
);
|
||||
if (lowland <= 0.12) continue;
|
||||
let sum = 0;
|
||||
let weight = 0;
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 2.25) continue;
|
||||
const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11);
|
||||
const w = compatible / (1 + d);
|
||||
sum += elevation[ni] * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
if (weight <= 0) continue;
|
||||
const localMean = sum / weight;
|
||||
const terrace = Math.round(localMean * 42) / 42;
|
||||
const target = lerp(localMean, terrace, 0.28);
|
||||
nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1);
|
||||
if (lowland > 0.55) {
|
||||
depositionField[i] = clamp(depositionField[i] + lowland * 0.018);
|
||||
erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012);
|
||||
}
|
||||
}
|
||||
}
|
||||
elevation.set(nextElevation);
|
||||
}
|
||||
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
|
||||
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
|
||||
slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 11.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-trim visible river paths after fluvial reshaping changes local elevation.
|
||||
for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.028);
|
||||
for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1);
|
||||
for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.022);
|
||||
for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1);
|
||||
|
||||
const mainRivers = riverPaths
|
||||
.map((p, i) => ({ path: p, score: riverScores[i] }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, Math.min(6, riverPaths.length))
|
||||
.map((x) => x.path);
|
||||
|
||||
if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]);
|
||||
if (mainRivers.length === 0) {
|
||||
let start = null;
|
||||
let startScore = -INF;
|
||||
for (let y = 4; y < MAP_H - 4; y++) {
|
||||
for (let x = 4; x < MAP_W - 4; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15;
|
||||
if (score > startScore) {
|
||||
startScore = score;
|
||||
start = { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (start) {
|
||||
let goal = null;
|
||||
let goalDist = INF;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
if (!sea[indexOf(x, y)]) continue;
|
||||
const d = Math.hypot(x - start.x, y - start.y);
|
||||
if (d < goalDist) {
|
||||
goalDist = d;
|
||||
goal = { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (goal) {
|
||||
const fallbackPath = aStar(start, goal, (x, y, cx, cy) => {
|
||||
const i = indexOf(x, y);
|
||||
const ci = indexOf(cx, cy);
|
||||
if (sea[i]) return 0.2;
|
||||
const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22;
|
||||
const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7;
|
||||
return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias);
|
||||
});
|
||||
if (fallbackPath.length > 4) {
|
||||
riverPaths.push(fallbackPath);
|
||||
mainRivers.push(fallbackPath);
|
||||
for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`)));
|
||||
const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path));
|
||||
const smallStreams = streamPaths.filter((path) => path.length >= 5);
|
||||
|
||||
prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
|
||||
prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
||||
const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask);
|
||||
const prefectureRegionId = regionalPrefectures.regionId;
|
||||
const regionalDebug = regionalPrefectures.debug;
|
||||
const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea);
|
||||
|
||||
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 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;
|
||||
plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0));
|
||||
|
||||
let nearRiver = 0;
|
||||
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;
|
||||
nearRiver = Math.max(nearRiver, river[indexOf(nx, ny)] / (1 + Math.hypot(dx, dy)));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 seaNear = 0;
|
||||
let riverNear = 0;
|
||||
let sheltered = 0;
|
||||
|
||||
for (let dy = -5; dy <= 5; dy++) {
|
||||
for (let dx = -5; dx <= 5; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (sea[indexOf(nx, ny)]) seaNear += 1 / (1 + d);
|
||||
riverNear = Math.max(riverNear, river[indexOf(nx, ny)] / (1 + d));
|
||||
}
|
||||
}
|
||||
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (inside(nx, ny) && !sea[indexOf(nx, ny)]) sheltered += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 3; y < MAP_H - 3; y++) {
|
||||
for (let x = 3; x < MAP_W - 3; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const r = river[i];
|
||||
if (r < 0.2 || r > 1.85) continue;
|
||||
let bankPlain = 0;
|
||||
for (const [nx, ny] of neighbors8(x, y)) bankPlain += plain[indexOf(nx, ny)];
|
||||
crossingSuitability[i] = clamp(r * 0.34 + (bankPlain / 8) * 0.54 + valleyField[i] * 0.18 - slope[i] * 0.55 - floodplain[i] * 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 4; y < MAP_H - 4; y++) {
|
||||
for (let x = 4; x < MAP_W - 4; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const e = elevation[i];
|
||||
if (e < 0.43 || e > 0.82) continue;
|
||||
const ewHigh = (elevation[indexOf(x - 3, y)] + elevation[indexOf(x + 3, y)]) / 2;
|
||||
const nsHigh = (elevation[indexOf(x, y - 3)] + elevation[indexOf(x, y + 3)]) / 2;
|
||||
const diagLow = Math.min(
|
||||
elevation[indexOf(x - 3, y - 3)],
|
||||
elevation[indexOf(x + 3, y + 3)],
|
||||
elevation[indexOf(x - 3, y + 3)],
|
||||
elevation[indexOf(x + 3, y - 3)]
|
||||
);
|
||||
passSuitability[i] = clamp((Math.max(ewHigh, nsHigh) - e) * 2.2 + (e - diagLow) * 0.55 + valleyField[i] * 0.28 - ridgeField[i] * 0.18 - slope[i] * 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
elevation,
|
||||
moisture,
|
||||
slope,
|
||||
sea,
|
||||
river,
|
||||
floodplain,
|
||||
plain,
|
||||
agriculture,
|
||||
ridgeField,
|
||||
valleyField,
|
||||
basinField,
|
||||
coastalLowland,
|
||||
flowAccum,
|
||||
erosionField,
|
||||
depositionField,
|
||||
portSuitability,
|
||||
crossingSuitability,
|
||||
passSuitability,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
regionalPrefectureBorders,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
smallStreams,
|
||||
};
|
||||
}
|
||||
28
names.js
28
names.js
|
|
@ -2,26 +2,28 @@ import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
|
|||
|
||||
export const NAME_KANJI_POOLS = {
|
||||
modifiers: [
|
||||
"大", "小", "上", "下", "中",
|
||||
"大", "小", "上", "下", "中", "奥",
|
||||
"東", "西", "南", "北",
|
||||
"新", "古", "本", "元",
|
||||
"高", "長", "広", "深", "浅",
|
||||
"白", "黒", "青", "赤",
|
||||
"奥", "前", "後", "内", "外",
|
||||
"早", "早", "真", "丸", "平"
|
||||
"早", "安", "真", "丸", "平",
|
||||
"美",
|
||||
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
|
||||
],
|
||||
|
||||
inlandTerrain: [
|
||||
"山", "谷", "沢", "原", "野",
|
||||
"森", "林", "岡", "丘", "坂",
|
||||
"峰", "峠", "嶺", "尾", "平",
|
||||
"窪", "久", "洞", "迫", "台",
|
||||
"塚", "牧", "畑", "田", "森",
|
||||
"麓", "郷", "里"
|
||||
"窪", "久", "洞", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
|
||||
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
|
||||
"聡", "郷", "里"
|
||||
],
|
||||
|
||||
waterTerrain: [
|
||||
"川", "河", "江", "瀬", "淵",
|
||||
"川", "河", "江", "瀬", "淵", "渕",
|
||||
"池", "沼", "泉", "井", "湖",
|
||||
"滝", "渓", "沢", "谷", "津",
|
||||
"水", "清", "渡", "橋", "堀",
|
||||
|
|
@ -29,11 +31,11 @@ export const NAME_KANJI_POOLS = {
|
|||
],
|
||||
|
||||
coastalTerrain: [
|
||||
"浜", "浦", "津", "崎", "岬",
|
||||
"島", "磯", "潟", "湊", "港",
|
||||
"海", "洲", "瀬", "砂", "潮",
|
||||
"浜", "浦", "津", "崎",
|
||||
"島", "磯", "潟", "湊", "津",
|
||||
"州", "洲", "瀬", "砂", "潮",
|
||||
"泊", "江", "浦", "灘", "入",
|
||||
"湾", "戸", "門"
|
||||
"戸", "門"
|
||||
],
|
||||
|
||||
plants: [
|
||||
|
|
@ -53,7 +55,7 @@ export const NAME_KANJI_POOLS = {
|
|||
"辺", "里", "郷", "村", "町",
|
||||
"宿", "庄", "台", "坂", "橋",
|
||||
"本", "内", "窪", "平", "塚",
|
||||
"畑", "牧", "前", "後", "中"
|
||||
"畑", "牧", "前", "見", "中"
|
||||
],
|
||||
|
||||
archaicPrefixes: [
|
||||
|
|
@ -64,7 +66,7 @@ export const NAME_KANJI_POOLS = {
|
|||
"甲", "信", "越", "備", "讃",
|
||||
"薩", "隠", "美", "三", "若",
|
||||
"遠", "近", "能", "加", "賀",
|
||||
"越", "淡", "壱", "対"
|
||||
"越", "淡", "壱", "阿"
|
||||
],
|
||||
|
||||
archaicSuffixes: [
|
||||
|
|
@ -75,7 +77,7 @@ export const NAME_KANJI_POOLS = {
|
|||
"伊", "前", "中", "後", "波",
|
||||
"勢", "渡", "城", "紫", "野",
|
||||
"津", "島", "海", "登", "賀",
|
||||
"良", "美", "智", "智", "代"
|
||||
"良", "美", "智", "茂", "代"
|
||||
],
|
||||
|
||||
settlementWords: [
|
||||
|
|
|
|||
20
renderer.js
20
renderer.js
|
|
@ -118,6 +118,19 @@ function discreteColor(map, x, y, mode) {
|
|||
];
|
||||
const a = map.adminId[i];
|
||||
color = a >= 0 ? palette[a % palette.length] : [220, 225, 220];
|
||||
} else if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
const barrier = clamp(
|
||||
map.ridgeField[i] * 0.88 +
|
||||
Math.max(0, map.river[i] - 0.28) * 1.25 +
|
||||
Math.max(0, map.flowAccum[i] - 0.36) * 0.72 +
|
||||
map.slope[i] * 0.48 +
|
||||
Math.max(0, map.elevation[i] - 0.54) * 0.34
|
||||
);
|
||||
color = [
|
||||
Math.round(238 - barrier * 28),
|
||||
Math.round(242 - barrier * 88),
|
||||
Math.round(226 + barrier * 20),
|
||||
];
|
||||
} else {
|
||||
color = terrainColorContinuous(map, x, y, "terrain");
|
||||
}
|
||||
|
|
@ -465,7 +478,8 @@ export function drawMap(canvas, map, options) {
|
|||
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2);
|
||||
drawHarborWorks(ctx, map);
|
||||
|
||||
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", 1.0, false, mode === "all");
|
||||
const debugBorders = mode === "admin-debug" || mode === "borders-debug";
|
||||
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, debugBorders ? "rgba(40,40,40,0.82)" : mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", debugBorders ? 1.8 : 1.0, false, mode === "all");
|
||||
drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true);
|
||||
drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true);
|
||||
|
||||
|
|
@ -474,9 +488,9 @@ export function drawMap(canvas, map, options) {
|
|||
const showHistory = ["history", "all", "terrain", "suitability"].includes(mode);
|
||||
const showModern = ["modern", "all", "development", "landuse"].includes(mode);
|
||||
const showRoads = ["roads", "all", "development", "landuse"].includes(mode);
|
||||
const showAdmin = ["admin", "all"].includes(mode);
|
||||
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
|
||||
|
||||
if (showAdmin) drawSegments(ctx, map.adminBorders, mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", mode === "all" ? 0.9 : 1.3);
|
||||
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 (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);
|
||||
|
|
|
|||
110
test.js
110
test.js
|
|
@ -7,6 +7,7 @@ import {
|
|||
NAME_PROBABILITIES,
|
||||
NAME_TEMPLATES,
|
||||
NAME_TEMPLATE_WEIGHTS,
|
||||
generateEntityName,
|
||||
generateTemplateName,
|
||||
} from "./names.js";
|
||||
|
||||
|
|
@ -168,6 +169,56 @@ function majorCityCoreIntegrity(map) {
|
|||
return checked ? sum / checked : 1;
|
||||
}
|
||||
|
||||
function satelliteMunicipalityMetrics(map) {
|
||||
const areaById = new Map();
|
||||
for (let i = 0; i < map.adminId.length; i++) {
|
||||
if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1);
|
||||
}
|
||||
const rows = (map.satelliteCities || [])
|
||||
.filter((sat) => map.prefectureMask[indexOf(sat.x, sat.y)] && !map.sea[indexOf(sat.x, sat.y)])
|
||||
.map((sat) => {
|
||||
const admin = map.adminId[indexOf(sat.x, sat.y)];
|
||||
return { sat, admin, area: areaById.get(admin) || 0 };
|
||||
});
|
||||
const independent = rows.filter((row) => row.sat.municipalityClass === "independentSatelliteMunicipality");
|
||||
const small = independent.filter((row) => row.area < 80);
|
||||
const largeTooSmall = rows.filter((row) => (row.sat.population || 0) >= 60000 && row.sat.municipalityClass === "independentSatelliteMunicipality" && row.area < 120);
|
||||
const average = independent.length ? independent.reduce((sum, row) => sum + row.area, 0) / independent.length : 0;
|
||||
return { rows, independent, small, largeTooSmall, average };
|
||||
}
|
||||
|
||||
function regionalComponentMetrics(map) {
|
||||
const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||
const seen = new Uint8Array(MAP_W * MAP_H);
|
||||
let maxComponents = 0;
|
||||
for (const id of ids) {
|
||||
seen.fill(0);
|
||||
let comps = 0;
|
||||
for (let i = 0; i < map.prefectureRegionId.length; i++) {
|
||||
if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue;
|
||||
comps++;
|
||||
const queue = [i];
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const x = cur % MAP_W;
|
||||
const y = Math.floor(cur / MAP_W);
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || map.sea[ni] || map.prefectureRegionId[ni] !== id) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
maxComponents = Math.max(maxComponents, comps);
|
||||
}
|
||||
return { regionCount: ids.size, maxComponents };
|
||||
}
|
||||
|
||||
try {
|
||||
const map = generateMap(12345);
|
||||
const other = generateMap(54321);
|
||||
|
|
@ -282,6 +333,8 @@ try {
|
|||
: 1;
|
||||
const adminMetrics = adminBoundaryMetrics(map);
|
||||
const cityCoreIntegrity = majorCityCoreIntegrity(map);
|
||||
const satelliteMetrics = satelliteMunicipalityMetrics(map);
|
||||
const regionalMetrics = regionalComponentMetrics(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");
|
||||
|
|
@ -290,7 +343,7 @@ try {
|
|||
const removedContextModule = "placeName" + "Context.js";
|
||||
assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent");
|
||||
assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays");
|
||||
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty");
|
||||
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.every((part) => typeof part === "string" && !part.includes("\uFFFD"))), "configured name category pools contain valid strings");
|
||||
assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates");
|
||||
const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES";
|
||||
const removedContextSuffixKey = "context" + "Suffixes";
|
||||
|
|
@ -309,6 +362,12 @@ try {
|
|||
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");
|
||||
assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists");
|
||||
assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells");
|
||||
assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked");
|
||||
assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully");
|
||||
assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded");
|
||||
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display");
|
||||
assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist");
|
||||
assert(Array.isArray(map.icAccessRoads), "IC access road array exists");
|
||||
assert(Array.isArray(map.satelliteCities), "satelliteCities is an array");
|
||||
|
|
@ -360,6 +419,18 @@ try {
|
|||
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
|
||||
assert(adminMetrics.disconnectedMunicipalities <= Math.max(2, Math.ceil(adminMetrics.municipalityCount * 0.20)), "most municipalities remain connected after terrain snapping");
|
||||
assert(adminMetrics.avgTarget > 0.18, "admin borders align with terrain target features often enough");
|
||||
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.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");
|
||||
assert(Number.isFinite(map.adminDebug.satelliteMunicipalitiesCreated) && Number.isFinite(map.adminDebug.averageSatelliteMunicipalityArea), "satellite municipality debug is available");
|
||||
assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.small.length / satelliteMetrics.independent.length <= 0.35, "tiny independent satellite municipalities are not the dominant pattern");
|
||||
assert(satelliteMetrics.largeTooSmall.length === 0, "large independent satellites have meaningful municipal area");
|
||||
assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.average >= 140, "average independent satellite municipality area is meaningful");
|
||||
assert(satelliteMetrics.rows.every((row) => row.area >= 80 || row.sat.municipalityClass === "smallTownAttachedToRuralMunicipality" || row.sat.municipalityClass === "suburbanDistrictMergedWithParent" || row.sat.municipalityClass === "newTownDistrict"), "tiny satellite areas are merged or explicitly classified as attached districts");
|
||||
assert(adminMetrics.denseUrbanRate < 0.42, "admin borders avoid excessive dense urban crossings");
|
||||
assert(adminMetrics.rightAngleRate < 0.46, "admin borders avoid excessive unsupported stair-step artifacts");
|
||||
assert(adminMetrics.voronoiLikeRate < 0.58, "admin borders are not dominated by weak-terrain center bisectors");
|
||||
|
|
@ -388,15 +459,14 @@ try {
|
|||
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
|
||||
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
|
||||
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
|
||||
assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug");
|
||||
assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools");
|
||||
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
|
||||
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
|
||||
assert(
|
||||
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
|
||||
"nameDebug accounting covers named entities"
|
||||
);
|
||||
assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates");
|
||||
assert(activePoolChars.size === 0, "no active pool characters exist until configured");
|
||||
assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools");
|
||||
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
|
||||
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
|
||||
assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
|
||||
|
|
@ -414,6 +484,15 @@ try {
|
|||
assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
CUSTOM_NAMES["city-0"] = "C1";
|
||||
const customSameA = generateMap(321);
|
||||
|
|
@ -427,12 +506,35 @@ try {
|
|||
assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed");
|
||||
delete CUSTOM_NAMES["city-0"];
|
||||
|
||||
CUSTOM_NAMES["custom-probe"] = "C1";
|
||||
const directCustomNames = Array.from({ length: 40 }, (_, n) => generateEntityName(9000 + n, "custom-probe", { x: 10, y: 10, kind: "Probe" }, {}, new Set()));
|
||||
const directCustomHits = directCustomNames.filter((name) => name === "C1").length;
|
||||
assert(NAME_PROBABILITIES.customName > 0 && NAME_PROBABILITIES.customName < 1 && directCustomHits > 0 && directCustomHits < directCustomNames.length, "CUSTOM_NAMES are probabilistic suggestions");
|
||||
delete CUSTOM_NAMES["custom-probe"];
|
||||
|
||||
FORCED_NAMES["forced-probe"] = "F1";
|
||||
assert(generateEntityName(123, "forced-probe", { x: 8, y: 8, kind: "Probe" }, {}, new Set(), map.nameDebug) === "F1", "FORCED_NAMES always apply");
|
||||
delete FORCED_NAMES["forced-probe"];
|
||||
|
||||
for (const seed of [101, 2026, 54321]) {
|
||||
const seeded = generateMap(seed);
|
||||
const metrics = adminBoundaryMetrics(seeded);
|
||||
const seededRegional = regionalComponentMetrics(seeded);
|
||||
const seededSatellites = satelliteMunicipalityMetrics(seeded);
|
||||
const invalidLandCells = [...seeded.adminId].filter((id, i) => seeded.prefectureMask[i] && !seeded.sea[i] && id < 0).length;
|
||||
const invalidRegionCells = [...seeded.prefectureRegionId].filter((id, i) => !seeded.sea[i] && id < 0).length;
|
||||
assert(invalidLandCells === 0, `seed ${seed}: every prefecture land cell has a valid adminId`);
|
||||
assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`);
|
||||
assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`);
|
||||
assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`);
|
||||
assert(seeded.regionalDebug?.regionalChangedAfterNaturalPartition > 0, `seed ${seed}: regional natural partition changes cells`);
|
||||
assert(seeded.regionalDebug.regionalNaturalBarrierAverageAfter >= seeded.regionalDebug.regionalNaturalBarrierAverageBefore - 0.10, `seed ${seed}: regional border natural affinity is stable`);
|
||||
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(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`);
|
||||
assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`);
|
||||
assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue