road update
This commit is contained in:
parent
71b58cf033
commit
a6e66f2838
9 changed files with 2174 additions and 258 deletions
|
|
@ -703,7 +703,7 @@ 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 === 7) return `valley:${Math.round(unit.x / 11)}:${Math.round(unit.y / 11)}`;
|
||||
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)}`;
|
||||
}
|
||||
|
|
@ -1030,6 +1030,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
|||
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
||||
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5);
|
||||
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
|
|
@ -1067,6 +1068,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
|||
}
|
||||
}
|
||||
|
||||
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4);
|
||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||
refreshAllCompartmentStats(compartments, fields);
|
||||
|
|
@ -1137,10 +1139,11 @@ function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMas
|
|||
const b = unitId[ni];
|
||||
if (b < 0 || b === a || !units[b] || units[b].area === 0) continue;
|
||||
const v = (targetScore[i] + targetScore[ni]) * 0.5;
|
||||
const keyA = units[a].adjacent.get(b) || { count: 0, target: 0 };
|
||||
keyA.count++; keyA.target += v; units[a].adjacent.set(b, keyA);
|
||||
const keyB = units[b].adjacent.get(a) || { count: 0, target: 0 };
|
||||
keyB.count++; keyB.target += v; units[b].adjacent.set(a, keyB);
|
||||
const vertical = nx !== x ? 1 : 0;
|
||||
const keyA = units[a].adjacent.get(b) || { count: 0, target: 0, vertical: 0, horizontal: 0 };
|
||||
keyA.count++; keyA.target += v; if (vertical) keyA.vertical++; else keyA.horizontal++; units[a].adjacent.set(b, keyA);
|
||||
const keyB = units[b].adjacent.get(a) || { count: 0, target: 0, vertical: 0, horizontal: 0 };
|
||||
keyB.count++; keyB.target += v; if (vertical) keyB.vertical++; else keyB.horizontal++; units[b].adjacent.set(a, keyB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1172,6 +1175,65 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function mergeUnitInto(unitId, units, fromId, toId, fields) {
|
||||
const from = units[fromId];
|
||||
const to = units[toId];
|
||||
if (!from || !to || from.area === 0 || to.area === 0 || fromId === toId) return false;
|
||||
for (const ci of from.cells) { unitId[ci] = toId; to.cells.push(ci); }
|
||||
from.area = 0;
|
||||
from.cells = [];
|
||||
refreshCompartmentStats(to, fields.elevation, fields.slope, fields.ridgeField, fields.valleyField, fields.basinField, fields.coastalLowland, fields.plain, fields.agriculture, fields.populationDensity, fields.landuse);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask, sea, maxMergedArea = 84, passes = 5) {
|
||||
// Seeded graph growth can create diagonal stair-step borders in uniform plains
|
||||
// and gentle hills. If the shared edge is weak, balanced H/V, and the two
|
||||
// sides are the same natural group, merge it instead of preserving an
|
||||
// artificial Voronoi-like cut.
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
rebuildLandscapeUnitAdjacency(unitId, units, fields.naturalBarrierScore, prefectureMask, sea);
|
||||
let best = null;
|
||||
let bestScore = 0.0;
|
||||
for (const unit of units) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
for (const [otherId, edge] of unit.adjacent) {
|
||||
if (otherId <= unit.id) continue;
|
||||
const other = units[otherId];
|
||||
if (!other || other.area === 0) continue;
|
||||
const avgBarrier = edge.target / Math.max(1, edge.count);
|
||||
const sameClass = unit.classId === other.classId;
|
||||
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(other);
|
||||
const combinedArea = unit.area + other.area;
|
||||
if (!sameClass && !sameGroup) continue;
|
||||
if (combinedArea > maxMergedArea && unit.area > 18 && other.area > 18) continue;
|
||||
const h = edge.horizontal || 0;
|
||||
const v = edge.vertical || 0;
|
||||
const balancedStair = Math.min(h, v) / Math.max(1, h + v);
|
||||
const lowlandContinuity = Math.min(unit.lowlandFitness || 0, other.lowlandFitness || 0);
|
||||
const mountainContinuity = Math.min(unit.mountainFitness || 0, other.mountainFitness || 0);
|
||||
const urbanGuard = Math.max(unit.urbanWeight || 0, other.urbanWeight || 0);
|
||||
const weakDivider = avgBarrier < (sameClass ? 0.46 : 0.36);
|
||||
if (!weakDivider) continue;
|
||||
const score =
|
||||
(sameClass ? 1.7 : 0) +
|
||||
(sameGroup ? 1.2 : 0) +
|
||||
balancedStair * 1.4 +
|
||||
edge.count * 0.018 +
|
||||
lowlandContinuity * 0.85 +
|
||||
mountainContinuity * 0.35 -
|
||||
avgBarrier * 4.8 -
|
||||
Math.max(0, combinedArea - maxMergedArea) * 0.020 -
|
||||
urbanGuard * 0.20;
|
||||
if (score > bestScore) best = { fromId: unit.area <= other.area ? unit.id : otherId, toId: unit.area <= other.area ? otherId : unit.id }, bestScore = score;
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
mergeUnitInto(unitId, units, best.fromId, best.toId, fields);
|
||||
}
|
||||
}
|
||||
|
||||
function naturalOwnershipAffinity(unit, neighbor, edge) {
|
||||
const boundaryTarget = edge.target / Math.max(1, edge.count);
|
||||
const sameClass = unit.classId === neighbor.classId ? 1.0 : 0;
|
||||
|
|
|
|||
3
app.js
3
app.js
|
|
@ -10,8 +10,8 @@ const modes = [
|
|||
["development", "Development"],
|
||||
["landuse", "Land Use"],
|
||||
["admin", "Municipal Borders"],
|
||||
["admin-debug", "Admin Debug"],
|
||||
["borders-debug", "Borders Debug"],
|
||||
["transport-debug", "Transport Debug"],
|
||||
];
|
||||
|
||||
const state = {
|
||||
|
|
@ -148,7 +148,6 @@ 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` : "-"],
|
||||
["Prefecture source", map.regionalDebug?.prefectureSource ?? "-"],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Prefecture Map Generator v17</title>
|
||||
<title>Prefecture Map Generator</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
814
mapAdminStage.js
814
mapAdminStage.js
|
|
@ -99,13 +99,440 @@ function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compart
|
|||
return changed;
|
||||
}
|
||||
|
||||
function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity) {
|
||||
|
||||
function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) {
|
||||
if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 };
|
||||
let totalChangedCells = 0;
|
||||
let mergedMunicipalities = 0;
|
||||
let remainingSingles = 0;
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
const owner = dominantCompartmentOwners(compartments, adminId);
|
||||
const byOwner = new Map();
|
||||
const areaByOwner = new Map();
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
const id = owner[unit.id];
|
||||
if (id < 0) continue;
|
||||
if (!byOwner.has(id)) byOwner.set(id, []);
|
||||
byOwner.get(id).push(unit);
|
||||
areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area);
|
||||
}
|
||||
const singles = [...byOwner.entries()]
|
||||
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
|
||||
.sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]);
|
||||
remainingSingles = singles.length;
|
||||
if (!singles.length) break;
|
||||
let passChanged = 0;
|
||||
for (const [id, units] of singles) {
|
||||
// The old rule allowed one natural compartment to become one municipality.
|
||||
// That produces many tiny office-only municipalities and makes the hierarchy
|
||||
// hard to read. Merge such municipalities into the strongest adjacent owner.
|
||||
const neighborScores = new Map();
|
||||
for (const unit of units) {
|
||||
for (const [neighborId, edge] of unit.adjacent || []) {
|
||||
const candidate = owner[neighborId];
|
||||
if (candidate < 0 || candidate === id) continue;
|
||||
const neighborUnit = compartments[neighborId];
|
||||
const shared = edge.count || 1;
|
||||
const barrier = edge.target ? edge.target / Math.max(1, shared) : 0;
|
||||
const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0;
|
||||
const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05;
|
||||
neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score);
|
||||
}
|
||||
}
|
||||
let best = -1, bestScore = -INF;
|
||||
for (const [candidate, score] of neighborScores) {
|
||||
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
|
||||
}
|
||||
if (best < 0) {
|
||||
// One-cell islets have no land adjacency. Attach them to the nearest
|
||||
// existing municipality instead of leaving a one-compartment municipality.
|
||||
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
||||
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
||||
let bestDist = INF;
|
||||
for (const [candidate, candidateUnits] of byOwner) {
|
||||
if (candidate === id || candidateUnits.length < minCompartments) continue;
|
||||
for (const unit of candidateUnits) {
|
||||
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
|
||||
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
|
||||
}
|
||||
}
|
||||
if (bestDist > 28) best = -1;
|
||||
}
|
||||
if (best < 0) continue;
|
||||
for (const unit of units) {
|
||||
for (const i of unit.cells || []) {
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
if (adminId[i] !== best) {
|
||||
adminId[i] = best;
|
||||
passChanged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
mergedMunicipalities++;
|
||||
}
|
||||
totalChangedCells += passChanged;
|
||||
if (!passChanged) break;
|
||||
}
|
||||
const finalOwner = dominantCompartmentOwners(compartments, adminId);
|
||||
const finalCounts = new Map();
|
||||
for (const unit of compartments) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
const id = finalOwner[unit.id];
|
||||
if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1);
|
||||
}
|
||||
remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length;
|
||||
return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles };
|
||||
}
|
||||
|
||||
function ownerAreaByCompartment(owner, compartments) {
|
||||
const area = new Map();
|
||||
const count = new Map();
|
||||
for (const unit of compartments || []) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
const id = owner[unit.id];
|
||||
if (id < 0) continue;
|
||||
area.set(id, (area.get(id) || 0) + (unit.area || 0));
|
||||
count.set(id, (count.get(id) || 0) + 1);
|
||||
}
|
||||
return { area, count };
|
||||
}
|
||||
|
||||
function compartmentTouchesOutside(unit, prefectureMask, sea) {
|
||||
if (!unit?.cells?.length) return true;
|
||||
for (const i of unit.cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny)) return true;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!prefectureMask[ni] || sea[ni]) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) {
|
||||
const { area } = ownerAreaByCompartment(owner, compartments);
|
||||
const scores = new Map();
|
||||
const blocked = new Set(units.map((unit) => owner[unit.id]));
|
||||
for (const unit of units) {
|
||||
for (const [neighborId, edge] of unit.adjacent || []) {
|
||||
const candidate = owner[neighborId];
|
||||
if (candidate < 0 || blocked.has(candidate)) continue;
|
||||
const neighbor = compartments[neighborId];
|
||||
const shared = edge.count || 1;
|
||||
const barrier = (edge.target || 0) / Math.max(1, shared);
|
||||
const landscape = neighbor?.classId === unit.classId ? 0.75 : 0;
|
||||
const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5;
|
||||
const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04;
|
||||
scores.set(candidate, (scores.get(candidate) || 0) + score);
|
||||
}
|
||||
}
|
||||
let best = -1, bestScore = -INF;
|
||||
for (const [candidate, score] of scores) {
|
||||
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
|
||||
}
|
||||
if (best >= 0 || !allowNearestFallback) return best;
|
||||
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
||||
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
|
||||
let bestDist = INF;
|
||||
for (const unit of compartments || []) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
const candidate = owner[unit.id];
|
||||
if (candidate < 0 || blocked.has(candidate)) continue;
|
||||
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
|
||||
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
|
||||
}
|
||||
return bestDist <= 32 ? best : -1;
|
||||
}
|
||||
|
||||
function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) {
|
||||
let changedCells = 0;
|
||||
let mergedMunicipalities = 0;
|
||||
let remainingSingleCompartmentMunicipalities = 0;
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
const byOwner = new Map();
|
||||
for (const unit of compartments || []) {
|
||||
if (!unit || unit.area === 0) continue;
|
||||
const id = owner[unit.id];
|
||||
if (id < 0) continue;
|
||||
if (!byOwner.has(id)) byOwner.set(id, []);
|
||||
byOwner.get(id).push(unit);
|
||||
}
|
||||
const small = [...byOwner.entries()]
|
||||
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
|
||||
.sort((a, b) => a[1].length - b[1].length || a[0] - b[0]);
|
||||
remainingSingleCompartmentMunicipalities = small.length;
|
||||
if (!small.length) break;
|
||||
let passChanged = 0;
|
||||
for (const [id, units] of small) {
|
||||
const target = bestNeighborOwnerForUnits(units, owner, compartments, true);
|
||||
if (target < 0 || target === id) continue;
|
||||
for (const unit of units) {
|
||||
if (owner[unit.id] === target) continue;
|
||||
owner[unit.id] = target;
|
||||
changedCells += unit.area || 0;
|
||||
passChanged += unit.area || 0;
|
||||
}
|
||||
mergedMunicipalities++;
|
||||
}
|
||||
if (!passChanged) break;
|
||||
}
|
||||
const counts = ownerAreaByCompartment(owner, compartments).count;
|
||||
remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length;
|
||||
return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities };
|
||||
}
|
||||
|
||||
function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) {
|
||||
let changedCells = 0;
|
||||
let changedComponents = 0;
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
|
||||
let passChanged = 0;
|
||||
for (const id of ownerIds) {
|
||||
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
|
||||
if (members.length <= 1) continue;
|
||||
const memberSet = new Set(members.map((unit) => unit.id));
|
||||
const seen = new Set();
|
||||
const components = [];
|
||||
for (const unit of members) {
|
||||
if (seen.has(unit.id)) continue;
|
||||
const queue = [unit.id];
|
||||
const comp = [];
|
||||
seen.add(unit.id);
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
comp.push(compartments[cur]);
|
||||
for (const next of compartments[cur]?.adjacent?.keys?.() || []) {
|
||||
if (!memberSet.has(next) || seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
components.push(comp);
|
||||
}
|
||||
if (components.length <= 1) continue;
|
||||
components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0));
|
||||
for (const comp of components.slice(1)) {
|
||||
const target = bestNeighborOwnerForUnits(comp, owner, compartments, true);
|
||||
if (target < 0 || target === id) continue;
|
||||
for (const unit of comp) {
|
||||
owner[unit.id] = target;
|
||||
changedCells += unit.area || 0;
|
||||
passChanged += unit.area || 0;
|
||||
}
|
||||
changedComponents++;
|
||||
}
|
||||
}
|
||||
if (!passChanged) break;
|
||||
}
|
||||
return { changedCells, changedComponents };
|
||||
}
|
||||
|
||||
function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) {
|
||||
let changedCells = 0;
|
||||
let changedComponents = 0;
|
||||
const outsideCache = new Map();
|
||||
const touchesOutside = (unit) => {
|
||||
if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea));
|
||||
return outsideCache.get(unit.id);
|
||||
};
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
|
||||
let passChanged = 0;
|
||||
for (const id of ownerIds) {
|
||||
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
|
||||
if (!members.length) continue;
|
||||
const memberSet = new Set(members.map((unit) => unit.id));
|
||||
const seen = new Set();
|
||||
for (const unit of members) {
|
||||
if (seen.has(unit.id)) continue;
|
||||
const queue = [unit.id];
|
||||
const comp = [];
|
||||
const boundaryOwners = new Map();
|
||||
let outside = false;
|
||||
seen.add(unit.id);
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const curUnit = compartments[cur];
|
||||
if (!curUnit) continue;
|
||||
comp.push(curUnit);
|
||||
if (touchesOutside(curUnit)) outside = true;
|
||||
for (const [next, edge] of curUnit.adjacent || []) {
|
||||
const nextOwner = owner[next];
|
||||
if (nextOwner === id) {
|
||||
if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); }
|
||||
} else if (nextOwner >= 0) {
|
||||
boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (outside || boundaryOwners.size !== 1) continue;
|
||||
const [target] = boundaryOwners.keys();
|
||||
if (target < 0 || target === id) continue;
|
||||
for (const compUnit of comp) {
|
||||
owner[compUnit.id] = target;
|
||||
changedCells += compUnit.area || 0;
|
||||
passChanged += compUnit.area || 0;
|
||||
}
|
||||
changedComponents++;
|
||||
}
|
||||
}
|
||||
if (!passChanged) break;
|
||||
}
|
||||
return { changedCells, changedComponents };
|
||||
}
|
||||
|
||||
function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) {
|
||||
const isUrbanUnit = (unit) => unit && unit.area > 0 && (
|
||||
unit.classId <= 3 ||
|
||||
(unit.urbanWeight || 0) >= 0.34 ||
|
||||
((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34)
|
||||
);
|
||||
const seen = new Set();
|
||||
let changedCells = 0;
|
||||
let unifiedComponents = 0;
|
||||
for (const start of compartments || []) {
|
||||
if (!isUrbanUnit(start) || seen.has(start.id)) continue;
|
||||
const queue = [start.id];
|
||||
const comp = [];
|
||||
seen.add(start.id);
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const unit = compartments[cur];
|
||||
if (!isUrbanUnit(unit)) continue;
|
||||
comp.push(unit);
|
||||
for (const next of unit.adjacent?.keys?.() || []) {
|
||||
if (seen.has(next) || !isUrbanUnit(compartments[next])) continue;
|
||||
seen.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0);
|
||||
if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue;
|
||||
const ownerScore = new Map();
|
||||
for (const unit of comp) {
|
||||
const id = owner[unit.id];
|
||||
if (id < 0) continue;
|
||||
const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8);
|
||||
ownerScore.set(id, (ownerScore.get(id) || 0) + score);
|
||||
}
|
||||
let best = -1, bestScore = -INF;
|
||||
for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
|
||||
if (best < 0) continue;
|
||||
let localChanged = 0;
|
||||
for (const unit of comp) {
|
||||
if (owner[unit.id] === best) continue;
|
||||
owner[unit.id] = best;
|
||||
localChanged += unit.area || 0;
|
||||
}
|
||||
if (localChanged > 0) {
|
||||
changedCells += localChanged;
|
||||
unifiedComponents++;
|
||||
}
|
||||
}
|
||||
return { changedCells, unifiedComponents };
|
||||
}
|
||||
|
||||
|
||||
function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) {
|
||||
const { modernCities = [] } = fields;
|
||||
if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 };
|
||||
let changedCells = 0;
|
||||
let unifiedCities = 0;
|
||||
const urbanUnit = (unit) => unit && unit.area > 0 && (
|
||||
unit.classId <= 3 ||
|
||||
(unit.urbanWeight || 0) >= 0.24 ||
|
||||
((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40)
|
||||
);
|
||||
for (const city of modernCities) {
|
||||
if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue;
|
||||
const radius = clamp(
|
||||
(city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35),
|
||||
8,
|
||||
(city.population || 0) >= 200000 ? 34 : 24
|
||||
);
|
||||
const units = [];
|
||||
for (const unit of compartments) {
|
||||
if (!urbanUnit(unit)) continue;
|
||||
const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y);
|
||||
if (d > radius) continue;
|
||||
const weight = (unit.area || 1) *
|
||||
(1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) *
|
||||
Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58);
|
||||
units.push({ unit, weight, d });
|
||||
}
|
||||
if (units.length <= 1) continue;
|
||||
const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0);
|
||||
// Large multi-core conurbations may legitimately contain multiple municipalities.
|
||||
// This pass targets compact urban areas that visually read as one city.
|
||||
const maxArea = (city.population || 0) >= 200000 ? 1800 : 900;
|
||||
if (totalArea > maxArea) continue;
|
||||
const ownerScore = new Map();
|
||||
for (const row of units) {
|
||||
const id = owner[row.unit.id];
|
||||
if (id < 0) continue;
|
||||
ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight);
|
||||
}
|
||||
if (ownerScore.size <= 1) continue;
|
||||
let best = -1, bestScore = -INF, totalScore = 0;
|
||||
for (const [id, score] of ownerScore) {
|
||||
totalScore += score;
|
||||
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
|
||||
}
|
||||
if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue;
|
||||
let localChanged = 0;
|
||||
for (const row of units) {
|
||||
if (owner[row.unit.id] === best) continue;
|
||||
owner[row.unit.id] = best;
|
||||
localChanged += row.unit.area || 0;
|
||||
}
|
||||
if (localChanged > 0) {
|
||||
changedCells += localChanged;
|
||||
unifiedCities++;
|
||||
}
|
||||
}
|
||||
return { changedCells, unifiedCities };
|
||||
}
|
||||
|
||||
function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) {
|
||||
const owner = dominantCompartmentOwners(compartments, adminId);
|
||||
const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100);
|
||||
const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields);
|
||||
const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
|
||||
const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
|
||||
const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8);
|
||||
const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
|
||||
const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
|
||||
const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4);
|
||||
const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4);
|
||||
const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4);
|
||||
applyCompartmentOwners(adminId, compartments, owner);
|
||||
const counts = ownerAreaByCompartment(owner, compartments).count;
|
||||
return {
|
||||
changedAfterUrbanUnification: urban.changedCells + metro.changedCells,
|
||||
urbanComponentsUnified: urban.unifiedComponents,
|
||||
changedAfterCityMetroMunicipalityUnification: metro.changedCells,
|
||||
cityMetroMunicipalitiesUnified: metro.unifiedCities,
|
||||
changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells,
|
||||
disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents,
|
||||
changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells,
|
||||
compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents,
|
||||
changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells,
|
||||
singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities,
|
||||
remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = []) {
|
||||
const nodes = new Map();
|
||||
const edges = new Map();
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
|
||||
const id = adminId[i];
|
||||
if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, sx: 0, sy: 0, touchesOutside: false });
|
||||
if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false });
|
||||
const node = nodes.get(id);
|
||||
const [x, y] = xyOf(i);
|
||||
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true;
|
||||
|
|
@ -131,6 +558,21 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
|
|||
edges.set(key, edge);
|
||||
}
|
||||
}
|
||||
for (const feature of settlementFeatures || []) {
|
||||
if (!feature || !inside(feature.x, feature.y)) continue;
|
||||
const i = indexOf(feature.x, feature.y);
|
||||
if (!prefectureMask[i] || sea[i]) continue;
|
||||
const id = adminId[i];
|
||||
const node = nodes.get(id);
|
||||
if (!node) continue;
|
||||
const pop = Math.max(0, feature.population || 0);
|
||||
node.settlementPopulation += pop;
|
||||
if (feature.kind === "Regional Capital" || feature.kind === "Prefectural Capital" || feature.kind === "Regional City" || feature.kind === "Local City" || feature.isRegionalCapital || feature.isPrefecturalCapital) {
|
||||
node.cityPopulation += pop;
|
||||
node.majorCityCount += pop >= 120000 ? 1 : 0;
|
||||
}
|
||||
node.population += pop / 16000;
|
||||
}
|
||||
for (const node of nodes.values()) {
|
||||
node.x = node.sx / Math.max(1, node.area);
|
||||
node.y = node.sy / Math.max(1, node.area);
|
||||
|
|
@ -138,6 +580,11 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
|
|||
}
|
||||
for (const edge of edges.values()) {
|
||||
edge.barrier /= Math.max(1, edge.count);
|
||||
// Prefecture grouping should pay the same natural-compartment crossing
|
||||
// cost that municipality generation uses: ridges, rivers, valley walls and
|
||||
// other strong natural dividers should be expensive to cross. Short shared
|
||||
// boundaries are also unstable, so they get a small extra penalty.
|
||||
edge.crossingCost = 1.0 + edge.barrier * 8.5 + 2.6 / Math.sqrt(Math.max(1, edge.count));
|
||||
nodes.get(edge.a)?.adjacent.set(edge.b, edge);
|
||||
nodes.get(edge.b)?.adjacent.set(edge.a, edge);
|
||||
}
|
||||
|
|
@ -147,21 +594,44 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
|
|||
function choosePrefectureMunicipalitySeeds(nodes, seed) {
|
||||
const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id);
|
||||
const totalArea = active.reduce((sum, node) => sum + node.area, 0);
|
||||
const targetCount = clamp(Math.round(totalArea / 7200), 3, 6);
|
||||
// Real Japan's smallest prefecture by municipality count is roughly Toyama's 15.
|
||||
// Keep generated prefectures near that scale by limiting prefecture count unless
|
||||
// enough municipalities exist to give each prefecture a meaningful set.
|
||||
const minMunicipalitiesPerPrefecture = 14;
|
||||
const maxByMunicipalityCount = Math.max(3, Math.floor(active.length / minMunicipalitiesPerPrefecture));
|
||||
const areaBased = clamp(Math.round(totalArea / 7800), 3, 6);
|
||||
const targetCount = clamp(Math.min(areaBased, maxByMunicipalityCount || areaBased), 3, 6);
|
||||
const seeds = [];
|
||||
const first = active.sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0];
|
||||
if (first) seeds.push(first);
|
||||
const minSpacing = Math.max(18, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.46);
|
||||
function tryAdd(node, relaxed = false) {
|
||||
if (!node || seeds.includes(node) || seeds.length >= targetCount) return false;
|
||||
const nearest = seeds.length ? Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))) : INF;
|
||||
if (!relaxed && nearest < minSpacing) return false;
|
||||
seeds.push(node);
|
||||
return true;
|
||||
}
|
||||
const capitalLike = active
|
||||
.filter((node) => (node.cityPopulation || 0) >= 120000 || node.majorCityCount > 0)
|
||||
.sort((a, b) => (b.cityPopulation || 0) - (a.cityPopulation || 0) || b.population - a.population || a.id - b.id);
|
||||
for (const node of capitalLike) tryAdd(node, false);
|
||||
for (const node of capitalLike) tryAdd(node, true);
|
||||
if (!seeds.length) {
|
||||
const first = active.slice().sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0];
|
||||
if (first) seeds.push(first);
|
||||
}
|
||||
while (seeds.length < targetCount) {
|
||||
let best = null, bestScore = -INF;
|
||||
for (const node of active) {
|
||||
if (seeds.includes(node)) continue;
|
||||
const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y)));
|
||||
const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28;
|
||||
const capitalBonus = Math.sqrt(Math.max(0, node.cityPopulation || 0)) * 0.05 + (node.majorCityCount || 0) * 8;
|
||||
const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28 + capitalBonus;
|
||||
if (score > bestScore) { bestScore = score; best = node; }
|
||||
}
|
||||
if (!best) break;
|
||||
seeds.push(best);
|
||||
}
|
||||
seeds.minMunicipalitiesPerPrefecture = minMunicipalitiesPerPrefecture;
|
||||
return seeds;
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +656,7 @@ function assignMunicipalitiesToPrefectures(nodes, seeds) {
|
|||
const next = nodes.get(nextId);
|
||||
if (!next) continue;
|
||||
const areaPressure = Math.max(0, ((area.get(cur.id) || 0) + next.area - maxArea) / Math.max(1, maxArea));
|
||||
const cost = cur.f + 1.0 + edge.barrier * 5.5 + areaPressure * 14 + hash2(cur.id, nextId) * 0.05;
|
||||
const cost = cur.f + (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) + areaPressure * 14 + hash2(cur.id, nextId) * 0.05;
|
||||
owner.set(nextId, cur.id);
|
||||
area.set(cur.id, (area.get(cur.id) || 0) + next.area);
|
||||
heap.push({ i: nextId, id: cur.id, f: cost });
|
||||
|
|
@ -291,6 +761,108 @@ function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) {
|
|||
return changed;
|
||||
}
|
||||
|
||||
function lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, context, maxCells = 2600) {
|
||||
const { prefectureMask, sea, landuse, populationDensity } = context;
|
||||
if (!owner || !adminId || !landuse) return 0;
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
||||
const isUrban = (i) => {
|
||||
const lu = landuse[i];
|
||||
return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.18;
|
||||
};
|
||||
let changedMunicipalities = 0;
|
||||
for (let start = 0; start < SIZE; start++) {
|
||||
if (seen[start] || !prefectureMask[start] || sea[start] || adminId[start] < 0 || !isUrban(start)) continue;
|
||||
const queue = [start];
|
||||
const comp = [];
|
||||
seen[start] = 1;
|
||||
const municipalityWeights = new Map();
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
comp.push(cur);
|
||||
const id = adminId[cur];
|
||||
const weight = 1 + Math.max(0, (populationDensity?.[cur] || 0) - 0.12) * 2.4 + (landuse[cur] === 3 ? 2.0 : landuse[cur] === 2 ? 1.2 : 0);
|
||||
municipalityWeights.set(id, (municipalityWeights.get(id) || 0) + weight);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [dx, dy] of dirs) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || !prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || !isUrban(ni)) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
if (comp.length < 14 || comp.length > maxCells || municipalityWeights.size <= 1) continue;
|
||||
const prefectureWeights = new Map();
|
||||
for (const [munId, weight] of municipalityWeights) {
|
||||
const prefId = owner.get(munId);
|
||||
if (prefId < 0) continue;
|
||||
prefectureWeights.set(prefId, (prefectureWeights.get(prefId) || 0) + weight);
|
||||
}
|
||||
if (prefectureWeights.size <= 1) continue;
|
||||
let bestPref = -1, bestWeight = -INF, totalWeight = 0;
|
||||
for (const [prefId, weight] of prefectureWeights) {
|
||||
totalWeight += weight;
|
||||
if (weight > bestWeight || (weight === bestWeight && prefId < bestPref)) { bestPref = prefId; bestWeight = weight; }
|
||||
}
|
||||
if (bestPref < 0 || bestWeight / Math.max(1, totalWeight) < 0.34) continue;
|
||||
for (const munId of municipalityWeights.keys()) {
|
||||
if (owner.get(munId) === bestPref) continue;
|
||||
owner.set(munId, bestPref);
|
||||
changedMunicipalities++;
|
||||
}
|
||||
}
|
||||
return changedMunicipalities;
|
||||
}
|
||||
|
||||
function lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, context) {
|
||||
const { prefectureMask, sea, landuse, populationDensity, modernCities = [] } = context;
|
||||
if (!owner || !adminId || !modernCities?.length) return 0;
|
||||
let changed = 0;
|
||||
const isUrban = (i) => {
|
||||
const lu = landuse?.[i];
|
||||
return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.14;
|
||||
};
|
||||
for (const city of modernCities) {
|
||||
if (!city || (city.population || 0) < 24000 || !inside(city.x, city.y)) continue;
|
||||
const centerAdmin = adminId[indexOf(city.x, city.y)];
|
||||
if (centerAdmin < 0) continue;
|
||||
const centerPref = owner.get(centerAdmin);
|
||||
if (centerPref < 0) continue;
|
||||
const radius = clamp(Math.round((city.urbanRadius || 8) * ((city.population || 0) >= 160000 ? 1.55 : 1.25)), 7, 26);
|
||||
const municipalityWeights = new Map();
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const x = city.x + dx;
|
||||
const y = city.y + dy;
|
||||
if (!inside(x, y) || Math.hypot(dx, dy) > radius) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (!prefectureMask[i] || sea[i] || adminId[i] < 0 || !isUrban(i)) continue;
|
||||
const dist = Math.hypot(dx, dy) / Math.max(1, radius);
|
||||
const weight = (1 - dist * 0.55) * (1 + (populationDensity?.[i] || 0) * 2.6 + (landuse?.[i] === 3 ? 1.6 : 0));
|
||||
municipalityWeights.set(adminId[i], (municipalityWeights.get(adminId[i]) || 0) + weight);
|
||||
}
|
||||
}
|
||||
if (municipalityWeights.size <= 1) continue;
|
||||
let total = 0, centerOwnedWeight = 0;
|
||||
for (const [munId, weight] of municipalityWeights) {
|
||||
total += weight;
|
||||
if (owner.get(munId) === centerPref) centerOwnedWeight += weight;
|
||||
}
|
||||
// Only force compact city regions. If the center prefecture has almost no
|
||||
// share, this is probably a genuine cross-prefecture conurbation or a city
|
||||
// center on the edge; leave it to the graph repair.
|
||||
if (centerOwnedWeight / Math.max(1, total) < 0.24) continue;
|
||||
for (const munId of municipalityWeights.keys()) {
|
||||
if (owner.get(munId) === centerPref) continue;
|
||||
owner.set(munId, centerPref);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) {
|
||||
let changed = 0;
|
||||
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
|
||||
|
|
@ -452,7 +1024,7 @@ function mergeTinyMunicipalityPrefectures(nodes, owner) {
|
|||
for (const [nextId, edge] of node.adjacent) {
|
||||
const nextPref = owner.get(nextId);
|
||||
if (nextPref === tinyPref || nextPref < 0) continue;
|
||||
const score = (neighborScores.get(nextPref) || 0) + edge.count * (0.6 + edge.barrier);
|
||||
const score = (neighborScores.get(nextPref) || 0) + edge.count * 0.8 - (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) * 0.65;
|
||||
neighborScores.set(nextPref, score);
|
||||
}
|
||||
}
|
||||
|
|
@ -593,17 +1165,192 @@ function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefe
|
|||
return segments;
|
||||
}
|
||||
|
||||
|
||||
function prefectureMunicipalityCounts(owner) {
|
||||
const counts = new Map();
|
||||
for (const pref of owner.values()) counts.set(pref, (counts.get(pref) || 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
function ownerMembersByPref(owner) {
|
||||
const by = new Map();
|
||||
for (const [id, pref] of owner) {
|
||||
if (!by.has(pref)) by.set(pref, []);
|
||||
by.get(pref).push(id);
|
||||
}
|
||||
return by;
|
||||
}
|
||||
|
||||
function wouldRemainConnectedAfterRemoval(nodes, owner, adminId, prefId) {
|
||||
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId && id !== adminId);
|
||||
if (members.length <= 1) return true;
|
||||
const memberSet = new Set(members);
|
||||
const seen = new Set([members[0]]);
|
||||
const queue = [members[0]];
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
for (const next of nodes.get(cur)?.adjacent.keys() || []) {
|
||||
if (!memberSet.has(next) || seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
return seen.size === members.length;
|
||||
}
|
||||
|
||||
function rebalanceSmallPrefecturesByMunicipalityCount(nodes, owner, minCount = 14, maxPasses = 96) {
|
||||
let changed = 0;
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
const counts = prefectureMunicipalityCounts(owner);
|
||||
const small = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
|
||||
if (!small) break;
|
||||
const [smallPref, smallCount] = small;
|
||||
let best = null, bestScore = -INF;
|
||||
for (const [id, pref] of owner) {
|
||||
if (pref === smallPref) continue;
|
||||
const donorCount = counts.get(pref) || 0;
|
||||
if (donorCount <= minCount + 1) continue;
|
||||
const node = nodes.get(id);
|
||||
if (!node) continue;
|
||||
let edgeToSmall = null;
|
||||
for (const [nextId, edge] of node.adjacent || []) {
|
||||
if (owner.get(nextId) === smallPref) {
|
||||
edgeToSmall = edge;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!edgeToSmall) continue;
|
||||
if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue;
|
||||
const capitalPenalty = (node.cityPopulation || 0) >= 150000 ? 18 : 0;
|
||||
const donorSurplus = donorCount - minCount;
|
||||
const score = (edgeToSmall.count || 1) * 2.5 - (edgeToSmall.crossingCost ?? (1.0 + (edgeToSmall.barrier || 0) * 8.5)) * 1.15 + donorSurplus * 1.6 - Math.sqrt(node.area || 1) * 0.03 - capitalPenalty;
|
||||
if (score > bestScore || (score === bestScore && id < best?.id)) best = { id, pref, score };
|
||||
}
|
||||
if (!best) break;
|
||||
owner.set(best.id, smallPref);
|
||||
changed++;
|
||||
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function mergePersistentlyTinyPrefecturesByCount(nodes, owner, minCount = 12, minRemainingPrefectures = 2) {
|
||||
let changed = 0;
|
||||
for (let pass = 0; pass < 16; pass++) {
|
||||
const counts = prefectureMunicipalityCounts(owner);
|
||||
if (counts.size <= minRemainingPrefectures) break;
|
||||
const tiny = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
|
||||
if (!tiny) break;
|
||||
const [tinyPref] = tiny;
|
||||
const neighborScores = new Map();
|
||||
for (const [id, pref] of owner) {
|
||||
if (pref !== tinyPref) continue;
|
||||
const node = nodes.get(id);
|
||||
for (const [nextId, edge] of node?.adjacent || []) {
|
||||
const other = owner.get(nextId);
|
||||
if (other === undefined || other === tinyPref) continue;
|
||||
const score = (edge.count || 1) * 2.4 - (edge.crossingCost ?? (1.0 + (edge.barrier || 0) * 8.5)) * 1.0 + Math.min(18, counts.get(other) || 0) * 0.20;
|
||||
neighborScores.set(other, (neighborScores.get(other) || 0) + score);
|
||||
}
|
||||
}
|
||||
let best = -1, bestScore = -INF;
|
||||
for (const [candidate, score] of neighborScores) {
|
||||
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
|
||||
}
|
||||
if (best < 0) {
|
||||
const tinyNodes = [...owner.keys()].filter((id) => owner.get(id) === tinyPref).map((id) => nodes.get(id)).filter(Boolean);
|
||||
const tx = tinyNodes.reduce((sum, node) => sum + node.x * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0));
|
||||
const ty = tinyNodes.reduce((sum, node) => sum + node.y * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0));
|
||||
let bestDist = INF;
|
||||
for (const [candidate, count] of counts) {
|
||||
if (candidate === tinyPref || count <= 0) continue;
|
||||
const candidateNodes = [...owner.keys()].filter((id) => owner.get(id) === candidate).map((id) => nodes.get(id)).filter(Boolean);
|
||||
for (const node of candidateNodes) {
|
||||
const d = Math.hypot(node.x - tx, node.y - ty);
|
||||
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best < 0) break;
|
||||
for (const [id, pref] of owner) if (pref === tinyPref) { owner.set(id, best); changed++; }
|
||||
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
||||
}
|
||||
// Renumber compactly so labels/debug do not expose deleted prefecture IDs.
|
||||
const active = [...new Set(owner.values())].sort((a, b) => a - b);
|
||||
const remap = new Map(active.map((id, n) => [id, n]));
|
||||
for (const [id, pref] of owner) owner.set(id, remap.get(pref));
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
||||
function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCount = 88, maxPrefectures = 8) {
|
||||
let changed = 0;
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
const counts = prefectureMunicipalityCounts(owner);
|
||||
const oversized = [...counts.entries()].filter(([, count]) => count > maxCount).sort((a, b) => b[1] - a[1] || a[0] - b[0])[0];
|
||||
if (!oversized || counts.size >= maxPrefectures) break;
|
||||
const [prefId, count] = oversized;
|
||||
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
|
||||
if (members.length <= maxCount) break;
|
||||
let sx = 0, sy = 0, area = 0;
|
||||
for (const id of members) {
|
||||
const node = nodes.get(id);
|
||||
if (!node) continue;
|
||||
sx += node.x * Math.max(1, node.area || 1);
|
||||
sy += node.y * Math.max(1, node.area || 1);
|
||||
area += Math.max(1, node.area || 1);
|
||||
}
|
||||
const cx = sx / Math.max(1, area);
|
||||
const cy = sy / Math.max(1, area);
|
||||
const seedNode = members
|
||||
.map((id) => nodes.get(id))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id)[0];
|
||||
if (!seedNode) break;
|
||||
const newPref = Math.max(-1, ...counts.keys()) + 1;
|
||||
const target = Math.max(count - maxCount, Math.floor(count * 0.42));
|
||||
const queue = [seedNode.id];
|
||||
const picked = new Set([seedNode.id]);
|
||||
for (let q = 0; q < queue.length && picked.size < target; q++) {
|
||||
const cur = queue[q];
|
||||
const nexts = [...(nodes.get(cur)?.adjacent.keys() || [])]
|
||||
.filter((id) => owner.get(id) === prefId && !picked.has(id))
|
||||
.map((id) => nodes.get(id))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id);
|
||||
for (const next of nexts) {
|
||||
picked.add(next.id);
|
||||
queue.push(next.id);
|
||||
if (picked.size >= target) break;
|
||||
}
|
||||
}
|
||||
if (picked.size < Math.max(8, target * 0.55)) break;
|
||||
for (const id of picked) owner.set(id, newPref);
|
||||
changed += picked.size;
|
||||
repairPrefectureMunicipalityConnectivity(nodes, owner);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function generatePrefecturesFromMunicipalities(context, adminResult) {
|
||||
const { adminId } = adminResult;
|
||||
const { prefectureMask, sea, naturalBarrierScore, populationDensity, seed } = context;
|
||||
const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity);
|
||||
const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, seed } = context;
|
||||
const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || [])]);
|
||||
const seeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001);
|
||||
const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds);
|
||||
const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner);
|
||||
let changedForMetroUnification = lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600);
|
||||
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
|
||||
let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner);
|
||||
changedForMetroUnification += lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600);
|
||||
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
|
||||
const changedForMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, seeds.minMunicipalitiesPerPrefecture || 14);
|
||||
const changedForTinyPrefectureCountMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, Math.max(13, (seeds.minMunicipalitiesPerPrefecture || 14) - 1));
|
||||
const changedForPostMergeMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, Math.max(12, (seeds.minMunicipalitiesPerPrefecture || 14) - 1), 64);
|
||||
const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 88, 8);
|
||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
changedForEnclaveRepair += repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea);
|
||||
// Keep prefectures as connected groups of municipalities; do not perform cell-level prefecture repair here.
|
||||
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
|
||||
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
|
||||
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
|
||||
|
|
@ -637,6 +1384,13 @@ function generatePrefecturesFromMunicipalities(context, adminResult) {
|
|||
prefectureTinyMergeChangedMunicipalities: changedForTinyMerge,
|
||||
prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity,
|
||||
prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair,
|
||||
prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification,
|
||||
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
|
||||
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
|
||||
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
|
||||
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
|
||||
finalRegionalMunicipalityCountCap: 88,
|
||||
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
|
||||
finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0,
|
||||
finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length,
|
||||
finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length,
|
||||
|
|
@ -938,7 +1692,7 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope,
|
|||
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
|
||||
const mountainRatio = landCells ? mountainCells / landCells : 0;
|
||||
const lowlandBonus = Math.min(7, lowlandCells / 430);
|
||||
const rawTarget = Math.round(habitableCells / 175 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.65 + lowlandBonus * 1.15 - mountainRatio * 1.8);
|
||||
const rawTarget = Math.round(habitableCells / 150 + settlementWeight * 1.08 + coastlineComplexity * 0.035 + basinBonus * 0.75 + lowlandBonus * 1.25 - mountainRatio * 1.45);
|
||||
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
|
||||
return clamp(rawTarget, min, max);
|
||||
}
|
||||
|
|
@ -1378,14 +2132,25 @@ function generateAdminLayoutForMask({
|
|||
const oversizedCompartmentSplit = splitOversizedCompartmentMunicipalities(adminId, adminCentersRaw, compartmentAssignment.compartments, prefectureMask, sea, {
|
||||
elevation, slope, ridgeField: boundaryRidgeField, plain, agriculture, basinField, coastalLowland, populationDensity,
|
||||
}, seed + 21900);
|
||||
const changedAfterInitialCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
||||
const hierarchyRepair = enforceSimpleAdministrativeHierarchy(adminId, compartmentAssignment.compartments, prefectureMask, sea, {
|
||||
minCompartmentsPerMunicipality: 2,
|
||||
maxUrbanClusterCells: 1600,
|
||||
modernCities,
|
||||
});
|
||||
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
||||
const changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
|
||||
const changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
|
||||
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
|
||||
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
|
||||
const actualMunicipalityCount = compacted.activeMunicipalityCount;
|
||||
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
|
||||
const adminDebug = {
|
||||
...compartmentAssignment.debug,
|
||||
simpleHierarchyPrototype: true,
|
||||
administrativeHierarchySpec: "natural-compartments->municipalities->prefectures",
|
||||
naturalCompartmentsImmutable: true,
|
||||
municipalitiesAreCompartmentGroups: true,
|
||||
prefecturesAreMunicipalityGroups: true,
|
||||
cellLevelAdminSmoothingDisabled: true,
|
||||
sharedNaturalCompartmentLayer: true,
|
||||
skippedLegacyCellCleanupForHierarchy: true,
|
||||
targetMunicipalityCount,
|
||||
|
|
@ -1398,12 +2163,23 @@ function generateAdminLayoutForMask({
|
|||
pendingSeedCount: 0,
|
||||
absorbedSeedCount: Math.max(0, adminCentersRaw.length - compacted.activeMunicipalityCount),
|
||||
targetNaturalCompartmentCount: targetCompartmentCount,
|
||||
naturalCompartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length,
|
||||
compartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length,
|
||||
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
|
||||
naturalCompartmentCount,
|
||||
compartmentCount: naturalCompartmentCount,
|
||||
changedAfterCompartmentAssignment: naturalCompartmentCount,
|
||||
changedAfterInitialCompartmentOwnership,
|
||||
changedAfterFinalCompartmentOwnership,
|
||||
changedAfterUrbanUnification,
|
||||
changedAfterAdminEnclaveRepair,
|
||||
changedAfterUrbanUnification: hierarchyRepair.changedAfterUrbanUnification,
|
||||
urbanComponentsUnified: hierarchyRepair.urbanComponentsUnified,
|
||||
changedAfterCityMetroMunicipalityUnification: hierarchyRepair.changedAfterCityMetroMunicipalityUnification,
|
||||
cityMetroMunicipalitiesUnified: hierarchyRepair.cityMetroMunicipalitiesUnified,
|
||||
changedAfterCompartmentConnectivity: hierarchyRepair.changedAfterCompartmentConnectivity,
|
||||
disconnectedCompartmentComponentsMerged: hierarchyRepair.disconnectedCompartmentComponentsMerged,
|
||||
changedAfterAdminEnclaveRepair: hierarchyRepair.changedAfterCompartmentEnclaveRepair,
|
||||
compartmentEnclaveComponentsMerged: hierarchyRepair.compartmentEnclaveComponentsMerged,
|
||||
changedAfterSingleCompartmentMunicipalityMerge: hierarchyRepair.changedAfterSingleCompartmentMunicipalityMerge,
|
||||
singleCompartmentMunicipalitiesMerged: hierarchyRepair.singleCompartmentMunicipalitiesMerged,
|
||||
remainingSingleCompartmentMunicipalities: hierarchyRepair.remainingSingleCompartmentMunicipalities,
|
||||
changedAfterPostMergeCompartmentOwnership: changedAfterFinalCompartmentOwnership,
|
||||
changedAfterOversizedCompartmentSplit: oversizedCompartmentSplit.changedCells,
|
||||
oversizedCompartmentMunicipalitiesSplit: oversizedCompartmentSplit.splitMunicipalities,
|
||||
oversizedCompartmentSplitAddedCenters: oversizedCompartmentSplit.addedCenters,
|
||||
|
|
|
|||
813
mapFeatures.js
813
mapFeatures.js
|
|
@ -1,4 +1,4 @@
|
|||
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js";
|
||||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
|
||||
import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
|
||||
import { LANDUSE } from "./landuseCodes.js";
|
||||
|
||||
|
|
@ -91,6 +91,7 @@ export function generateMapFeatures(seed, terrain) {
|
|||
const highPenalty = Math.max(0, elevation[i] - 0.56);
|
||||
const lowSlope = clamp(1 - slope[i] * 2.3);
|
||||
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
|
||||
const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26);
|
||||
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
|
||||
confluenceField[i] = confluence;
|
||||
|
||||
|
|
@ -132,23 +133,25 @@ export function generateMapFeatures(seed, terrain) {
|
|||
spine * 0.12
|
||||
);
|
||||
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
|
||||
settlementCluster[i] = clamp((developable[i] * 0.50 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.34 + plain[i] * 0.12) * clusterNoise);
|
||||
settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise);
|
||||
ruralSuitability[i] = clamp(
|
||||
agriculture[i] * 0.54 +
|
||||
developable[i] * 0.30 +
|
||||
valleySettlement[i] * 0.24 +
|
||||
coastalSettlement[i] * 0.15 +
|
||||
valleySettlement[i] * 0.18 +
|
||||
coastalSettlement[i] * 0.20 +
|
||||
openPlainPotential * 0.34 +
|
||||
settlementCluster[i] * 0.30 -
|
||||
Math.max(0, elevation[i] - 0.64) * 0.56
|
||||
);
|
||||
townSuitability[i] = clamp(
|
||||
developable[i] * 0.38 +
|
||||
agriculture[i] * 0.18 +
|
||||
valleySettlement[i] * 0.24 +
|
||||
coastalSettlement[i] * 0.20 +
|
||||
confluence * 0.34 +
|
||||
basinField[i] * 0.16 +
|
||||
plain[i] * 0.18 +
|
||||
valleySettlement[i] * 0.16 +
|
||||
coastalSettlement[i] * 0.30 +
|
||||
confluence * 0.20 +
|
||||
basinField[i] * 0.18 +
|
||||
plain[i] * 0.26 +
|
||||
openPlainPotential * 0.44 +
|
||||
settlementCluster[i] * 0.22 -
|
||||
slope[i] * 0.34 -
|
||||
ridgeField[i] * 0.17 -
|
||||
|
|
@ -304,9 +307,9 @@ export function generateMapFeatures(seed, terrain) {
|
|||
const villageScore = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
villageScore[i] = clamp(ruralSuitability[i] * 0.60 + agriculture[i] * 0.30 + plain[i] * 0.15 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.16 + settlementCluster[i] * 0.16);
|
||||
villageScore[i] = clamp(ruralSuitability[i] * 0.50 + agriculture[i] * 0.48 + plain[i] * 0.42 + Math.max(0, plain[i] * 1.12 + agriculture[i] * 0.78 + basinField[i] * 0.30 - river[i] * 0.36 - valleyField[i] * 0.14 - flowAccum[i] * 0.10) * 0.58 + valleySettlement[i] * 0.06 + coastalSettlement[i] * 0.34 + settlementCluster[i] * 0.22 - river[i] * 0.10 - flowAccum[i] * 0.04);
|
||||
}
|
||||
const villages = pickRegionalPoints(villageScore, {
|
||||
let villages = pickRegionalPoints(villageScore, {
|
||||
stride: 2,
|
||||
threshold: 0.18 + rand(seed, 1031) * 0.030,
|
||||
totalMax: 280,
|
||||
|
|
@ -316,18 +319,51 @@ export function generateMapFeatures(seed, terrain) {
|
|||
quotaForRegion: (regionId, st) => {
|
||||
if (!st || st.developableCells < 10) return 0;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 46 + 3.2) * vf;
|
||||
const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 32 + 3.2) * vf;
|
||||
const min = st.area > 2600 ? 20 : st.area > 1400 ? 12 : st.area > 520 ? 5 : st.area > 220 ? 2 : 0;
|
||||
const max = st.area > 3600 ? 72 : st.area > 2200 ? 50 : st.area > 900 ? 25 : 10;
|
||||
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
|
||||
},
|
||||
}).map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village";
|
||||
const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100;
|
||||
const kind = coastalSettlement[i] > 0.36 ? "Coastal Village" : valleySettlement[i] > 0.46 ? "Valley Village" : "Village";
|
||||
const population = Math.round((900 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.30) * 9800 + ruralSuitability[i] * 4700 + agriculture[i] * 3600) / 100) * 100;
|
||||
return { ...p, kind, population };
|
||||
});
|
||||
|
||||
// Supplemental open-plain villages: broad Japanese-style farmland should not be empty
|
||||
// just because it lacks a river/confluence anchor.
|
||||
const openPlainVillageScore = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const open = Math.max(0, plain[i] * 0.92 + agriculture[i] * 0.72 + basinField[i] * 0.26 + (depositionalLowland?.[i] || 0) * 0.20 - river[i] * 0.22 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.14);
|
||||
openPlainVillageScore[i] = clamp(open + settlementCluster[i] * 0.16 + ruralSuitability[i] * 0.18);
|
||||
}
|
||||
const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, {
|
||||
stride: 2,
|
||||
threshold: 0.235 + rand(seed, 1036) * 0.020,
|
||||
totalMax: 120,
|
||||
minDistance: 5,
|
||||
seedOffset: 1035,
|
||||
kind: "Plain Village",
|
||||
predicate: (x, y, i) => plain[i] > 0.20 && agriculture[i] > 0.16 && river[i] < 0.30 && valleyField[i] < 0.52 && slope[i] < 0.32,
|
||||
quotaForRegion: (regionId, st) => {
|
||||
if (!st || st.plainCells < 24) return 0;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const raw = (st.plainCells / 62 + st.developableCells / 180 + 1.4) * vf;
|
||||
const min = st.plainCells > 360 ? 6 : st.plainCells > 160 ? 3 : st.plainCells > 70 ? 1 : 0;
|
||||
const max = st.plainCells > 720 ? 24 : st.plainCells > 360 ? 16 : st.plainCells > 140 ? 8 : 3;
|
||||
return Math.round(clamp(raw + rand(seed, 1037 + regionId * 29) * 1.4, min, max));
|
||||
},
|
||||
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.34 + agriculture[i] * 0.26 - river[i] * 0.20 - valleyField[i] * 0.12),
|
||||
}).filter((p) => distanceToNearest(villages, p.x, p.y) >= 4.5)
|
||||
.map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const population = Math.round((1100 + Math.pow(rand(seed, 18220 + n * 31 + p.x * 7 + p.y), 1.12) * 7600 + agriculture[i] * 3900 + plain[i] * 2200) / 100) * 100;
|
||||
return { ...p, kind: "Plain Village", population };
|
||||
});
|
||||
villages = [...villages, ...supplementalPlainVillages];
|
||||
|
||||
const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
|
||||
|
||||
const marketScore = new Float32Array(SIZE);
|
||||
|
|
@ -335,29 +371,34 @@ export function generateMapFeatures(seed, terrain) {
|
|||
for (let x = 2; x < MAP_W - 2; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const openPlainMarket = Math.max(0, plain[i] * 0.68 + agriculture[i] * 0.52 + basinField[i] * 0.24 + (depositionalLowland?.[i] || 0) * 0.18 - river[i] * 0.16 - valleyField[i] * 0.06 - slope[i] * 0.18);
|
||||
const featurePull = Math.max(
|
||||
distanceToNearest(ports, x, y) < 8 ? 0.10 : 0,
|
||||
distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0,
|
||||
confluenceField[i] * 0.16
|
||||
distanceToNearest(ports, x, y) < 10 ? 0.16 : 0,
|
||||
distanceToNearest(crossings, x, y) < 6 ? 0.035 : 0,
|
||||
confluenceField[i] * 0.08
|
||||
);
|
||||
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
|
||||
marketScore[i] = clamp(
|
||||
townSuitability[i] * 0.58 +
|
||||
agriculture[i] * 0.16 +
|
||||
plain[i] * 0.14 +
|
||||
villageInfluence[i] * 0.40 +
|
||||
townSuitability[i] * 0.50 +
|
||||
agriculture[i] * 0.30 +
|
||||
plain[i] * 0.28 +
|
||||
openPlainMarket * 0.74 +
|
||||
coastalSettlement[i] * 0.22 +
|
||||
villageInfluence[i] * 0.28 +
|
||||
featurePull +
|
||||
valleyMouth +
|
||||
basinField[i] * 0.12 +
|
||||
plain[i] * 0.14 +
|
||||
basinField[i] * 0.14 +
|
||||
plain[i] * 0.22 +
|
||||
coastalLowland[i] * 0.08 -
|
||||
slope[i] * 0.18 -
|
||||
ridgeField[i] * 0.08
|
||||
ridgeField[i] * 0.08 -
|
||||
river[i] * 0.08 -
|
||||
flowAccum[i] * 0.035
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const markets = pickRegionalPoints(marketScore, {
|
||||
let markets = pickRegionalPoints(marketScore, {
|
||||
stride: 2,
|
||||
threshold: 0.245 + rand(seed, 1041) * 0.035,
|
||||
totalMax: 110,
|
||||
|
|
@ -367,19 +408,50 @@ export function generateMapFeatures(seed, terrain) {
|
|||
quotaForRegion: (regionId, st) => {
|
||||
if (!st || st.townCells < 8) return 0;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const raw = (st.developableCells / 105 + st.plainCells / 125 + st.valleyCells / 105 + st.coastCells / 130 + 2.1) * vf;
|
||||
const raw = (st.developableCells / 92 + st.plainCells / 108 + st.valleyCells / 92 + st.coastCells / 72 + 2.7) * vf;
|
||||
const min = st.area > 2600 ? 9 : st.area > 1200 ? 5 : st.area > 520 ? 2 : 0;
|
||||
const max = st.area > 3600 ? 30 : st.area > 2200 ? 22 : st.area > 800 ? 10 : 5;
|
||||
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
|
||||
},
|
||||
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08,
|
||||
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 10 ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035,
|
||||
}).map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town";
|
||||
const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000;
|
||||
const kind = coastalSettlement[i] > 0.38 && distanceToNearest(ports, p.x, p.y) < 11 ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
|
||||
const population = Math.round((9000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.02) * 70000 + marketScore[i] * 40000 + villageInfluence[i] * 7800 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 22000 + coastalSettlement[i] * 12000) / 1000) * 1000;
|
||||
return { ...p, kind, population };
|
||||
});
|
||||
|
||||
const openPlainMarketScore = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const open = Math.max(0, plain[i] * 0.86 + agriculture[i] * 0.64 + basinField[i] * 0.28 + (depositionalLowland?.[i] || 0) * 0.22 - river[i] * 0.20 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.16);
|
||||
openPlainMarketScore[i] = clamp(open + townSuitability[i] * 0.20 + villageInfluence[i] * 0.18 + settlementCluster[i] * 0.12);
|
||||
}
|
||||
const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, {
|
||||
stride: 2,
|
||||
threshold: 0.335 + rand(seed, 1046) * 0.025,
|
||||
totalMax: 55,
|
||||
minDistance: 9,
|
||||
seedOffset: 1045,
|
||||
kind: "Plain Market Town",
|
||||
predicate: (x, y, i) => plain[i] > 0.22 && agriculture[i] > 0.18 && river[i] < 0.28 && valleyField[i] < 0.50 && slope[i] < 0.30,
|
||||
quotaForRegion: (regionId, st) => {
|
||||
if (!st || st.plainCells < 60) return 0;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const raw = (st.plainCells / 260 + st.developableCells / 520 + 0.45) * vf;
|
||||
const min = st.plainCells > 520 ? 2 : st.plainCells > 220 ? 1 : 0;
|
||||
const max = st.plainCells > 900 ? 8 : st.plainCells > 420 ? 5 : st.plainCells > 160 ? 2 : 1;
|
||||
return Math.round(clamp(raw + rand(seed, 1047 + regionId * 31) * 0.9, min, max));
|
||||
},
|
||||
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.24 + agriculture[i] * 0.18 - river[i] * 0.14 - valleyField[i] * 0.08),
|
||||
}).filter((p) => distanceToNearest(markets, p.x, p.y) >= 8.5 && distanceToNearest(villages, p.x, p.y) >= 3.5)
|
||||
.map((p, n) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const population = Math.round((10000 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.02) * 52000 + openPlainMarketScore[i] * 26000 + agriculture[i] * 9000 + plain[i] * 8000) / 1000) * 1000;
|
||||
return { ...p, kind: "Plain Market Town", population };
|
||||
});
|
||||
markets = [...markets, ...supplementalPlainMarkets];
|
||||
|
||||
const defenseScore = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
|
|
@ -428,7 +500,7 @@ export function generateMapFeatures(seed, terrain) {
|
|||
const dev = developable[i];
|
||||
if (dev < 0.04) continue;
|
||||
const radial = clamp(1 - d / Math.max(1, radius));
|
||||
const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24);
|
||||
const terrainMultiplier = clamp(0.60 + plain[i] * 0.48 + agriculture[i] * 0.22 + basinField[i] * 0.28 + coastalLowland[i] * 0.18 + valleyField[i] * 0.08 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.38);
|
||||
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
|
||||
}
|
||||
}
|
||||
|
|
@ -448,8 +520,8 @@ export function generateMapFeatures(seed, terrain) {
|
|||
const regionId = regionIdAt(p.x, p.y);
|
||||
if (regionId < 0) continue;
|
||||
const st = regionStats.get(regionId);
|
||||
const cityRadius = st && st.area > 2400 ? 28 : st && st.area > 900 ? 24 : 20;
|
||||
const capacity = estimateUrbanCapacity(p, cityRadius, 1.0);
|
||||
const cityRadius = st && st.area > 2400 ? 30 : st && st.area > 900 ? 26 : 22;
|
||||
const capacity = estimateUrbanCapacity(p, cityRadius, p.candidateKind === "town" ? 1.14 : p.candidateKind === "port" ? 1.10 : 1.06);
|
||||
const score =
|
||||
Math.log10(capacity + 1) * 0.72 +
|
||||
townSuitability[i] * 1.40 +
|
||||
|
|
@ -469,8 +541,8 @@ export function generateMapFeatures(seed, terrain) {
|
|||
if (!st || st.developableCells < 30) continue;
|
||||
const vf = visibilityFactor(regionId, st);
|
||||
const maxCities = clamp(
|
||||
Math.round((st.developableCells / 720 + 0.9) * vf + rand(seed, 12100 + regionId * 17) * 1.2),
|
||||
st.area > 1600 ? 1 : 0,
|
||||
Math.round((st.developableCells / 560 + 1.15) * vf + rand(seed, 12100 + regionId * 17) * 1.4),
|
||||
(st.area > 900 || st.developableCells > 180) ? 1 : 0,
|
||||
st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1
|
||||
);
|
||||
const selected = pickEntities(list, {
|
||||
|
|
@ -497,11 +569,11 @@ export function generateMapFeatures(seed, terrain) {
|
|||
const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
|
||||
const isRegionalCapital = isFirstInRegion;
|
||||
const rawPop = isRegionalCapital
|
||||
? 150000 + rand(seed, 12201 + city.regionId * 17) * 520000
|
||||
: 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000;
|
||||
const capMultiplier = isRegionalCapital ? 1.10 : 1.0;
|
||||
? 300000 + rand(seed, 12201 + city.regionId * 17) * 740000
|
||||
: 68000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.62) * 430000;
|
||||
const capMultiplier = isRegionalCapital ? 1.68 : 1.28;
|
||||
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
|
||||
city.population = Math.max(isRegionalCapital ? 90000 : 24000, population);
|
||||
city.population = Math.max(isRegionalCapital ? 260000 : 52000, population);
|
||||
city.isPrefecturalCapital = isPrefecturalCapital;
|
||||
city.isRegionalCapital = isRegionalCapital;
|
||||
city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
|
||||
|
|
@ -518,8 +590,422 @@ export function generateMapFeatures(seed, terrain) {
|
|||
return estimateUrbanCapacity(city, radius, bias);
|
||||
}
|
||||
|
||||
// --- 4. Lightweight corridors -------------------------------------------
|
||||
function routeLight(a, b, snapRadius = 3) {
|
||||
// --- 4. Field-derived transport corridors -------------------------------
|
||||
const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0));
|
||||
const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1));
|
||||
const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9));
|
||||
const settlementDemand = new Float32Array(SIZE);
|
||||
const urbanEdge = new Float32Array(SIZE);
|
||||
const logisticsPreSuitability = new Float32Array(SIZE);
|
||||
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16);
|
||||
settlementDemand[i] = density;
|
||||
urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32);
|
||||
logisticsPreSuitability[i] = clamp(
|
||||
agriculture[i] * 0.30 +
|
||||
plain[i] * 0.24 +
|
||||
basinField[i] * 0.14 +
|
||||
coastalLowland[i] * 0.12 +
|
||||
preliminaryTownInfluence[i] * 0.18 +
|
||||
urbanEdge[i] * 0.34 -
|
||||
preliminaryUrbanInfluence[i] * 0.20 -
|
||||
slope[i] * 0.50 -
|
||||
ridgeField[i] * 0.32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTransportCostFields() {
|
||||
const expressway = new Float32Array(SIZE);
|
||||
const rail = new Float32Array(SIZE);
|
||||
const national = new Float32Array(SIZE);
|
||||
const local = new Float32Array(SIZE);
|
||||
const expresswayPotential = new Float32Array(SIZE);
|
||||
const railPotential = new Float32Array(SIZE);
|
||||
const nationalPotential = new Float32Array(SIZE);
|
||||
const localPotential = 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]) {
|
||||
expressway[i] = rail[i] = national[i] = local[i] = INF;
|
||||
continue;
|
||||
}
|
||||
if (elevation[i] > 0.72) {
|
||||
expressway[i] = rail[i] = national[i] = INF;
|
||||
local[i] = Math.max(2.8, 1.4 + slope[i] * 2.2 + ridgeField[i] * 1.4);
|
||||
continue;
|
||||
}
|
||||
const density = settlementDemand[i];
|
||||
const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30);
|
||||
const highDensity = clamp((density - 0.32) / 0.50);
|
||||
const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16);
|
||||
const pass = passSuitability?.[i] || 0;
|
||||
const crossing = crossingSuitability?.[i] || 0;
|
||||
const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.60 + river[i] * 1.1) : 0;
|
||||
const highMountain = clamp((elevation[i] - 0.58) * 2.6 + ridgeField[i] * 0.65);
|
||||
const denseCorePenalty = clamp((density - 0.66) / 0.28);
|
||||
const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12);
|
||||
|
||||
expresswayPotential[i] = clamp(
|
||||
mediumDensity * 0.62 +
|
||||
urbanEdge[i] * 0.38 +
|
||||
logisticsPreSuitability[i] * 0.54 +
|
||||
lowland * 0.36 +
|
||||
agriculture[i] * 0.16 -
|
||||
denseCorePenalty * 0.54 -
|
||||
slope[i] * 0.64 -
|
||||
highMountain * 0.58 -
|
||||
river[i] * 0.14
|
||||
);
|
||||
railPotential[i] = clamp(
|
||||
highDensity * 0.80 +
|
||||
preliminaryTownInfluence[i] * 0.22 +
|
||||
lowland * 0.46 +
|
||||
valleyField[i] * 0.22 +
|
||||
coastalLowland[i] * 0.22 -
|
||||
slope[i] * 1.05 -
|
||||
highMountain * 0.82 -
|
||||
ridgeField[i] * 0.34
|
||||
);
|
||||
nationalPotential[i] = clamp(
|
||||
density * 0.46 +
|
||||
preliminaryTownInfluence[i] * 0.32 +
|
||||
preliminaryVillageInfluence[i] * 0.20 +
|
||||
agriculture[i] * 0.24 +
|
||||
valleyField[i] * 0.28 +
|
||||
coastalLowland[i] * 0.26 +
|
||||
pass * 0.18 +
|
||||
crossing * 0.18 -
|
||||
slope[i] * 0.36 -
|
||||
ridgeField[i] * 0.18
|
||||
);
|
||||
localPotential[i] = clamp(
|
||||
preliminaryVillageInfluence[i] * 0.52 +
|
||||
agriculture[i] * 0.38 +
|
||||
coastalSettlement[i] * 0.30 +
|
||||
valleySettlement[i] * 0.30 +
|
||||
developable[i] * 0.18 -
|
||||
slope[i] * 0.26 -
|
||||
ridgeField[i] * 0.10
|
||||
);
|
||||
|
||||
expressway[i] = Math.max(0.18, 1.45 - expresswayPotential[i] * 1.06 + denseCorePenalty * 1.25 + slope[i] * 3.5 + highMountain * 2.9 + waterCrossingPenalty * 1.4 + openPlainParallelPenalty * 0.10 + hash2(x, y, seed + 13301) * 0.05);
|
||||
rail[i] = Math.max(0.16, 1.38 - railPotential[i] * 1.08 + slope[i] * 5.6 + highMountain * 4.2 + waterCrossingPenalty * 1.1 + hash2(x, y, seed + 13302) * 0.04);
|
||||
national[i] = Math.max(0.16, 1.22 - nationalPotential[i] * 0.88 + slope[i] * 1.65 + ridgeField[i] * 0.72 + Math.max(0, elevation[i] - 0.68) * 1.2 - pass * 0.30 + waterCrossingPenalty * 0.72 + hash2(x, y, seed + 13303) * 0.06);
|
||||
local[i] = Math.max(0.14, 1.10 - localPotential[i] * 0.88 + slope[i] * 1.05 + ridgeField[i] * 0.42 + Math.max(0, elevation[i] - 0.72) * 0.90 + waterCrossingPenalty * 0.45 + hash2(x, y, seed + 13304) * 0.08);
|
||||
}
|
||||
}
|
||||
return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential };
|
||||
}
|
||||
|
||||
const transportFields = buildTransportCostFields();
|
||||
|
||||
function chooseCorridorSeeds(potentialField, spacing, maxCount, threshold, predicate = () => true, seedOffset = 0) {
|
||||
const candidates = [];
|
||||
for (let y = 3; y < MAP_H - 3; y += 2) {
|
||||
for (let x = 3; x < MAP_W - 3; x += 2) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || !predicate(x, y, i)) continue;
|
||||
const score = potentialField[i] + hash2(x, y, seed + seedOffset) * 0.055;
|
||||
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
|
||||
}
|
||||
}
|
||||
return pickEntities(candidates, { max: maxCount, minDistance: spacing, threshold, seed: seed + seedOffset, jitter: 0.03 });
|
||||
}
|
||||
|
||||
function corridorAllowance(i) {
|
||||
return clamp(settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36 - plain[i] * 0.18 - agriculture[i] * 0.14);
|
||||
}
|
||||
|
||||
function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) {
|
||||
if (!start || !inside(start.x, start.y)) return [];
|
||||
const startIndex = indexOf(start.x, start.y);
|
||||
if (sea[startIndex] || costField[startIndex] >= INF) return [];
|
||||
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: 0 });
|
||||
const curvePenalty = options.curvePenalty ?? 0.12;
|
||||
const penaltyStrength = options.penaltyStrength ?? 1.0;
|
||||
const sameRegion = options.regionId ?? regionIdAt(start.x, start.y);
|
||||
const minGoalDistance = options.minGoalDistance ?? 18;
|
||||
const maxExpanded = options.maxExpanded ?? SIZE * 2;
|
||||
let goalIndex = -1;
|
||||
let expanded = 0;
|
||||
|
||||
while (heap.length && expanded++ < maxExpanded) {
|
||||
const current = heap.pop();
|
||||
if (!current || closed[current.i]) continue;
|
||||
closed[current.i] = 1;
|
||||
const [cx, cy] = xyOf(current.i);
|
||||
if (current.i !== startIndex && Math.hypot(cx - start.x, cy - start.y) >= minGoalDistance && goalRegionPredicate(cx, cy, current.i)) {
|
||||
goalIndex = current.i;
|
||||
break;
|
||||
}
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = cx + dx;
|
||||
const ny = cy + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (closed[ni] || sea[ni] || costField[ni] >= INF) continue;
|
||||
if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue;
|
||||
const prev = cameFrom[current.i];
|
||||
let turn = 0;
|
||||
if (prev >= 0) {
|
||||
const [px, py] = xyOf(prev);
|
||||
const ax = cx - px;
|
||||
const ay = cy - py;
|
||||
turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0;
|
||||
}
|
||||
const existing = penaltyField?.[ni] || 0;
|
||||
const antiConcentration = existing * penaltyStrength * (1 - corridorAllowance(ni) * 0.72);
|
||||
const nd = score[current.i] + (costField[ni] + antiConcentration + turn) * Math.hypot(dx, dy);
|
||||
if (nd < score[ni]) {
|
||||
score[ni] = nd;
|
||||
cameFrom[ni] = current.i;
|
||||
heap.push({ i: ni, f: nd });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (goalIndex < 0) return [];
|
||||
const path = [];
|
||||
for (let p = goalIndex; p >= 0; p = cameFrom[p]) {
|
||||
path.push(xyOf(p));
|
||||
if (p === startIndex) break;
|
||||
}
|
||||
return path.reverse();
|
||||
}
|
||||
|
||||
function addCorridorInfluencePenalty(penaltyField, corridor, radius = 7, strength = 0.35) {
|
||||
for (const [x, y] of corridor || []) {
|
||||
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);
|
||||
if (sea[i]) continue;
|
||||
const openPlain = clamp(plain[i] * 0.54 + agriculture[i] * 0.34 - settlementDemand[i] * 0.24 - valleyField[i] * 0.22 - coastalLowland[i] * 0.18);
|
||||
const allowParallel = corridorAllowance(i);
|
||||
penaltyField[i] = Math.max(penaltyField[i], strength * (1 - d / radius) * (0.48 + openPlain * 1.15 - allowParallel * 0.42));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromPath(path) {
|
||||
const p = path?.[path.length - 1];
|
||||
return p ? { x: p[0], y: p[1], regionId: regionIdAt(p[0], p[1]) } : null;
|
||||
}
|
||||
|
||||
function generateCorridorsFromField({ potentialField, costField, spacing, maxCount, threshold, minLength, penaltyRadius, penaltyStrength, curvePenalty, seedOffset, startPredicate, goalPredicate }) {
|
||||
const paths = [];
|
||||
const penaltyField = new Float32Array(SIZE);
|
||||
const seeds = chooseCorridorSeeds(potentialField, spacing, maxCount * 2, threshold, startPredicate, seedOffset);
|
||||
const usedEndpoints = [];
|
||||
for (const start of seeds) {
|
||||
if (paths.length >= maxCount) break;
|
||||
if (distanceToNearest(usedEndpoints, start.x, start.y) < spacing * 0.55) continue;
|
||||
const path = traceCorridorByCost(
|
||||
start,
|
||||
(x, y, i) => goalPredicate(start, x, y, i, usedEndpoints),
|
||||
costField,
|
||||
penaltyField,
|
||||
{ curvePenalty, penaltyStrength: penaltyStrength * 2.2, minGoalDistance: minLength, regionId: start.regionId }
|
||||
);
|
||||
if (path.length < minLength) continue;
|
||||
paths.push(path);
|
||||
usedEndpoints.push(start);
|
||||
const end = endpointFromPath(path);
|
||||
if (end) usedEndpoints.push(end);
|
||||
addCorridorInfluencePenalty(penaltyField, path, penaltyRadius, penaltyStrength);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function rasterizeNetworkComponents(paths, mode, potentialField) {
|
||||
const occupied = new Uint8Array(SIZE);
|
||||
for (const path of paths) {
|
||||
for (const [x, y] of path || []) {
|
||||
if (inside(x, y) && !sea[indexOf(x, y)]) occupied[indexOf(x, y)] = 1;
|
||||
}
|
||||
}
|
||||
const componentId = new Int32Array(SIZE);
|
||||
componentId.fill(-1);
|
||||
const components = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!occupied[i] || componentId[i] >= 0) continue;
|
||||
const id = components.length;
|
||||
const queue = [i];
|
||||
const cells = [];
|
||||
const boundary = [];
|
||||
componentId[i] = id;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
const x = cur % MAP_W;
|
||||
const y = Math.floor(cur / MAP_W);
|
||||
let edge = false;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) { edge = true; continue; }
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!occupied[ni]) {
|
||||
edge = true;
|
||||
continue;
|
||||
}
|
||||
if (componentId[ni] < 0) {
|
||||
componentId[ni] = id;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (edge) boundary.push({ x, y, i: cur });
|
||||
}
|
||||
let lengthScore = 0;
|
||||
let densityScore = 0;
|
||||
let townScore = 0;
|
||||
let logisticsScore = 0;
|
||||
let capitalScore = 0;
|
||||
const sampleStride = Math.max(1, Math.floor(cells.length / 80));
|
||||
for (let c = 0; c < cells.length; c += sampleStride) {
|
||||
const ci = cells[c];
|
||||
const x = ci % MAP_W;
|
||||
const y = Math.floor(ci / MAP_W);
|
||||
lengthScore += sampleStride;
|
||||
densityScore += settlementDemand[ci] * sampleStride;
|
||||
logisticsScore += logisticsPreSuitability[ci] * sampleStride;
|
||||
for (const p of [...modernCities, ...markets]) {
|
||||
const d = Math.hypot(p.x - x, p.y - y);
|
||||
if (d <= 9) townScore += ((p.population || 8000) / 50000) * (1 - d / 9);
|
||||
}
|
||||
for (const cty of modernCities) {
|
||||
if (!cty.isPrefecturalCapital) continue;
|
||||
const d = Math.hypot(cty.x - x, cty.y - y);
|
||||
if (d <= 14) capitalScore += 5.0 * (1 - d / 14);
|
||||
}
|
||||
}
|
||||
const importance =
|
||||
Math.sqrt(lengthScore) * 1.20 +
|
||||
densityScore * 0.38 +
|
||||
townScore * 0.55 +
|
||||
logisticsScore * 0.24 +
|
||||
capitalScore;
|
||||
components.push({ id, mode, cells, boundary, importance, length: cells.length, repairCount: 0, potential: cells.reduce((sum, ci) => sum + (potentialField?.[ci] || 0), 0) / Math.max(1, cells.length) });
|
||||
}
|
||||
return { occupied, componentId, components };
|
||||
}
|
||||
|
||||
function componentAnchor(component, target, costField, usedAnchors = []) {
|
||||
if (!component?.boundary?.length) return null;
|
||||
let best = null;
|
||||
let bestScore = INF;
|
||||
const stride = Math.max(1, Math.floor(component.boundary.length / 90));
|
||||
for (let k = 0; k < component.boundary.length; k += stride) {
|
||||
const p = component.boundary[k];
|
||||
if (costField[p.i] >= INF) continue;
|
||||
if (distanceToNearest(usedAnchors, p.x, p.y) < 8) continue;
|
||||
const d = target ? Math.hypot(p.x - target.x, p.y - target.y) : 0;
|
||||
const score = d + costField[p.i] * 3.5 - corridorAllowance(p.i) * 2.4 + hash2(p.x, p.y, seed + 13701) * 1.8;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = { x: p.x, y: p.y, componentId: component.id, regionId: regionIdAt(p.x, p.y) };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function repairTransportConnectivity(paths, mode, costField, potentialField, options = {}) {
|
||||
const debug = { mode, components: [], repairs: [] };
|
||||
const raster = rasterizeNetworkComponents(paths, mode, potentialField);
|
||||
debug.components = raster.components.map((c) => ({
|
||||
id: c.id,
|
||||
mode,
|
||||
importance: c.importance,
|
||||
length: c.length,
|
||||
potential: c.potential,
|
||||
cells: c.cells.filter((_, k) => k % Math.max(1, Math.floor(c.cells.length / 140)) === 0).map((i) => xyOf(i)),
|
||||
}));
|
||||
const important = raster.components
|
||||
.filter((c) => c.length >= (options.minComponentCells ?? 18) && c.importance >= (options.minImportance ?? 8))
|
||||
.sort((a, b) => b.importance - a.importance)
|
||||
.slice(0, options.maxComponents ?? 8);
|
||||
if (important.length < 2) return debug;
|
||||
|
||||
const networkPenalty = influenceFromPaths(paths, options.penaltyRadius ?? 8);
|
||||
const usedAnchors = [];
|
||||
const maxRepairs = options.maxRepairs ?? 4;
|
||||
for (let r = 0; r < maxRepairs; r++) {
|
||||
let bestPair = null;
|
||||
let bestScore = INF;
|
||||
for (let a = 0; a < important.length; a++) {
|
||||
for (let b = a + 1; b < important.length; b++) {
|
||||
const ca = important[a];
|
||||
const cb = important[b];
|
||||
if (ca.repairCount >= 2 || cb.repairCount >= 2) continue;
|
||||
const centerA = ca.boundary[Math.floor(ca.boundary.length / 2)] || { x: 0, y: 0 };
|
||||
const centerB = cb.boundary[Math.floor(cb.boundary.length / 2)] || { x: 0, y: 0 };
|
||||
const d = Math.hypot(centerA.x - centerB.x, centerA.y - centerB.y);
|
||||
if (d < (options.minRepairDistance ?? 14) || d > (options.maxRepairDistance ?? 120)) continue;
|
||||
const score = d / Math.sqrt(ca.importance + cb.importance) + (ca.repairCount + cb.repairCount) * 18;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestPair = [ca, cb];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bestPair) break;
|
||||
const [aComp, bComp] = bestPair;
|
||||
const roughTarget = bComp.boundary[Math.floor(bComp.boundary.length / 2)];
|
||||
const start = componentAnchor(aComp, roughTarget, costField, usedAnchors);
|
||||
const goalTarget = start ? componentAnchor(bComp, start, costField, usedAnchors) : null;
|
||||
if (!start || !goalTarget) break;
|
||||
const path = traceCorridorByCost(
|
||||
start,
|
||||
(x, y, i) => raster.componentId[i] === bComp.id || (potentialField[i] > (options.highPotentialThreshold ?? 0.42) && Math.hypot(x - goalTarget.x, y - goalTarget.y) < 5),
|
||||
costField,
|
||||
networkPenalty,
|
||||
{
|
||||
curvePenalty: options.curvePenalty ?? 0.14,
|
||||
penaltyStrength: options.penaltyStrength ?? 1.8,
|
||||
minGoalDistance: Math.min(12, Math.max(5, Math.hypot(start.x - goalTarget.x, start.y - goalTarget.y) * 0.35)),
|
||||
keepRegion: false,
|
||||
maxExpanded: SIZE,
|
||||
}
|
||||
);
|
||||
if (path.length < (options.minAddedLength ?? 6) || path.length > (options.maxAddedLength ?? 120)) {
|
||||
aComp.repairCount++;
|
||||
continue;
|
||||
}
|
||||
paths.push(path);
|
||||
debug.repairs.push({ mode, path, from: aComp.id, to: bComp.id });
|
||||
usedAnchors.push(start, goalTarget);
|
||||
aComp.repairCount++;
|
||||
bComp.repairCount++;
|
||||
addCorridorInfluencePenalty(networkPenalty, path, options.penaltyRadius ?? 8, options.addedPenalty ?? 0.38);
|
||||
}
|
||||
return debug;
|
||||
}
|
||||
|
||||
function routeLight(a, b, snapRadius = 3, costField = transportFields.local) {
|
||||
if (!a || !b) return [];
|
||||
const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15));
|
||||
const out = [];
|
||||
|
|
@ -537,16 +1023,16 @@ export function generateMapFeatures(seed, terrain) {
|
|||
const y = Math.round(fy + dy);
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
if (sea[i] || costField[i] >= INF) continue;
|
||||
const lineDist = Math.hypot(x - fx, y - fy);
|
||||
const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05;
|
||||
const cost = lineDist * 0.72 + costField[i] * 0.74 - valleySettlement[i] * 0.20 - developable[i] * 0.12 + hash2(x, y, seed + 13000 + s) * 0.05;
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
best = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) best = [Math.round(fx), Math.round(fy)];
|
||||
if (!best) continue;
|
||||
const key = `${best[0]},${best[1]}`;
|
||||
if (key !== lastKey) {
|
||||
out.push(best);
|
||||
|
|
@ -579,6 +1065,7 @@ export function generateMapFeatures(seed, terrain) {
|
|||
const ringExpressways = [];
|
||||
const externalExpressways = [];
|
||||
const icAccessRoads = [];
|
||||
const interchanges = [];
|
||||
const externalGateways = [];
|
||||
|
||||
// Premodern roads connect castles/markets/ports sparsely.
|
||||
|
|
@ -590,48 +1077,68 @@ export function generateMapFeatures(seed, terrain) {
|
|||
}
|
||||
}
|
||||
|
||||
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
|
||||
const nodes = importantNodesForRegion(regionId);
|
||||
if (nodes.length < 2) continue;
|
||||
const connected = [nodes[0]];
|
||||
const remaining = nodes.slice(1);
|
||||
const maxEdges = (regionStats.get(regionId)?.area || 0) > 2200 ? Math.min(13, nodes.length + 3) : Math.min(7, nodes.length + 1);
|
||||
while (remaining.length && nationalRoads.length < 48) {
|
||||
let best = null;
|
||||
let bestScore = INF;
|
||||
for (const a of connected) {
|
||||
for (const b of remaining) {
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const score = d - (a.nodeWeight + b.nodeWeight) * 0.9;
|
||||
if (score < bestScore) { bestScore = score; best = { a, b }; }
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
const path = routeLight(best.a, best.b, 3);
|
||||
if (path.length > 2) nationalRoads.push(path);
|
||||
connected.push(best.b);
|
||||
remaining.splice(remaining.indexOf(best.b), 1);
|
||||
if (connected.length - 1 >= maxEdges) break;
|
||||
}
|
||||
nationalRoads.push(...generateCorridorsFromField({
|
||||
potentialField: transportFields.nationalPotential,
|
||||
costField: transportFields.national,
|
||||
spacing: 17,
|
||||
maxCount: 42,
|
||||
threshold: 0.30,
|
||||
minLength: 20,
|
||||
penaltyRadius: 6,
|
||||
penaltyStrength: 0.34,
|
||||
curvePenalty: 0.10,
|
||||
seedOffset: 13400,
|
||||
startPredicate: (x, y, i) => transportFields.nationalPotential[i] > 0.27 && regionIdAt(x, y) >= 0,
|
||||
goalPredicate: (start, x, y, i, used) => {
|
||||
if (regionIdAt(x, y) !== start.regionId) return false;
|
||||
if (transportFields.nationalPotential[i] < 0.34) return false;
|
||||
if (distanceToNearest(used, x, y) < 11) return false;
|
||||
const d = Math.hypot(x - start.x, y - start.y);
|
||||
return d > 22 && d < 76;
|
||||
},
|
||||
}));
|
||||
|
||||
// A few k-nearest shortcuts for urbanized regions.
|
||||
const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 7 : 4);
|
||||
for (let i = 0; i < urbanNodes.length; i++) {
|
||||
const a = urbanNodes[i];
|
||||
const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0];
|
||||
if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue;
|
||||
const path = routeLight(a, b, 3);
|
||||
if (path.length > 2) nationalRoads.push(path);
|
||||
}
|
||||
railways.push(...generateCorridorsFromField({
|
||||
potentialField: transportFields.railPotential,
|
||||
costField: transportFields.rail,
|
||||
spacing: 24,
|
||||
maxCount: 18,
|
||||
threshold: 0.30,
|
||||
minLength: 24,
|
||||
penaltyRadius: 7,
|
||||
penaltyStrength: 0.42,
|
||||
curvePenalty: 0.30,
|
||||
seedOffset: 13500,
|
||||
startPredicate: (x, y, i) => transportFields.railPotential[i] > 0.26 && settlementDemand[i] > 0.16 && regionIdAt(x, y) >= 0,
|
||||
goalPredicate: (start, x, y, i, used) => {
|
||||
if (regionIdAt(x, y) !== start.regionId) return false;
|
||||
if (transportFields.railPotential[i] < 0.30 || settlementDemand[i] < 0.18) return false;
|
||||
if (distanceToNearest(used, x, y) < 15) return false;
|
||||
const d = Math.hypot(x - start.x, y - start.y);
|
||||
return d > 28 && d < 88;
|
||||
},
|
||||
}));
|
||||
|
||||
// Railways: only high-order cities/ports, as a lightweight placeholder.
|
||||
const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 6 : 4);
|
||||
railNodes.sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
for (let i = 1; i < railNodes.length; i++) {
|
||||
const path = routeLight(railNodes[i - 1], railNodes[i], 4);
|
||||
if (path.length > 4) railways.push(path);
|
||||
}
|
||||
}
|
||||
expressways.push(...generateCorridorsFromField({
|
||||
potentialField: transportFields.expresswayPotential,
|
||||
costField: transportFields.expressway,
|
||||
spacing: 29,
|
||||
maxCount: 10,
|
||||
threshold: 0.30,
|
||||
minLength: 30,
|
||||
penaltyRadius: 9,
|
||||
penaltyStrength: 0.55,
|
||||
curvePenalty: 0.16,
|
||||
seedOffset: 13600,
|
||||
startPredicate: (x, y, i) => transportFields.expresswayPotential[i] > 0.27 && regionIdAt(x, y) >= 0,
|
||||
goalPredicate: (start, x, y, i, used) => {
|
||||
if (regionIdAt(x, y) !== start.regionId) return false;
|
||||
if (transportFields.expresswayPotential[i] < 0.30) return false;
|
||||
if (distanceToNearest(used, x, y) < 18) return false;
|
||||
const d = Math.hypot(x - start.x, y - start.y);
|
||||
return d > 34 && d < 104;
|
||||
},
|
||||
}));
|
||||
|
||||
// External gateways at land edges; used by naming/UI and later transport work.
|
||||
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
|
||||
|
|
@ -659,20 +1166,105 @@ export function generateMapFeatures(seed, terrain) {
|
|||
externalGateways.push(gateway);
|
||||
const target = importantNodesForRegion(regionId)[0];
|
||||
if (target) {
|
||||
const path = routeLight(gateway, target, 3);
|
||||
const path = routeLight(gateway, target, 3, transportFields.national);
|
||||
if (path.length > 2) externalRoads.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approximate expressways as a very small subset of top inter-city links.
|
||||
const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6);
|
||||
for (let i = 1; i < topCities.length && expressways.length < 4; i++) {
|
||||
const a = topCities[i - 1];
|
||||
const b = topCities[i];
|
||||
if (Math.hypot(a.x - b.x, a.y - b.y) < 85) {
|
||||
const path = routeLight(a, b, 5);
|
||||
if (path.length > 5) expressways.push(path);
|
||||
const transportDebugLayers = {
|
||||
expresswayPotential: transportFields.expresswayPotential,
|
||||
railPotential: transportFields.railPotential,
|
||||
nationalRoadPotential: transportFields.nationalPotential,
|
||||
slopeSeaPenalty: (() => {
|
||||
const out = new Float32Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) out[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42);
|
||||
return out;
|
||||
})(),
|
||||
components: [],
|
||||
repairedSegments: [],
|
||||
unservedSettlements: [],
|
||||
};
|
||||
for (const repair of [
|
||||
repairTransportConnectivity(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, {
|
||||
minImportance: 8.5,
|
||||
minComponentCells: 18,
|
||||
maxComponents: 7,
|
||||
maxRepairs: 3,
|
||||
maxRepairDistance: 115,
|
||||
penaltyRadius: 9,
|
||||
penaltyStrength: 2.3,
|
||||
curvePenalty: 0.16,
|
||||
highPotentialThreshold: 0.38,
|
||||
}),
|
||||
repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, {
|
||||
minImportance: 9.5,
|
||||
minComponentCells: 16,
|
||||
maxComponents: 8,
|
||||
maxRepairs: 4,
|
||||
maxRepairDistance: 105,
|
||||
penaltyRadius: 7,
|
||||
penaltyStrength: 2.0,
|
||||
curvePenalty: 0.34,
|
||||
highPotentialThreshold: 0.36,
|
||||
}),
|
||||
repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
|
||||
minImportance: 7.5,
|
||||
minComponentCells: 12,
|
||||
maxComponents: 10,
|
||||
maxRepairs: 6,
|
||||
maxRepairDistance: 95,
|
||||
penaltyRadius: 6,
|
||||
penaltyStrength: 1.7,
|
||||
curvePenalty: 0.11,
|
||||
highPotentialThreshold: 0.34,
|
||||
}),
|
||||
]) {
|
||||
transportDebugLayers.components.push(...repair.components);
|
||||
transportDebugLayers.repairedSegments.push(...repair.repairs);
|
||||
}
|
||||
|
||||
function generateLocalRoadsForUnservedSettlements() {
|
||||
const trunkInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8);
|
||||
const candidates = [...villages, ...markets, ...ports]
|
||||
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)])
|
||||
.map((p) => {
|
||||
const i = indexOf(p.x, p.y);
|
||||
const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45;
|
||||
return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 };
|
||||
})
|
||||
.filter((p) => p.score > 0.18 && trunkInfluence[indexOf(p.x, p.y)] < 0.26)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 90);
|
||||
const localPenalty = new Float32Array(SIZE);
|
||||
const paths = [];
|
||||
const served = [];
|
||||
for (const start of candidates) {
|
||||
if (paths.length >= 75) break;
|
||||
if (distanceToNearest(served, start.x, start.y) < 4.5) continue;
|
||||
const path = traceCorridorByCost(
|
||||
start,
|
||||
(x, y, i) => trunkInfluence[i] > 0.20 || (paths.length > 8 && localPenalty[i] > 0.05),
|
||||
transportFields.local,
|
||||
localPenalty,
|
||||
{ curvePenalty: 0.08, penaltyStrength: 1.15, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE }
|
||||
);
|
||||
if (path.length < 4 || path.length > 70) continue;
|
||||
paths.push(path);
|
||||
served.push(start);
|
||||
addCorridorInfluencePenalty(localPenalty, path, 4, 0.22);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
|
||||
for (const path of expressways) {
|
||||
for (const p of samplePath(path, 24)) {
|
||||
const i = indexOf(p.x, p.y);
|
||||
if (sea[i] || transportFields.expresswayPotential[i] < 0.24) continue;
|
||||
if (interchanges.every((q) => Math.hypot(q.x - p.x, q.y - p.y) > 14)) {
|
||||
interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i], regionId: regionIdAt(p.x, p.y) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -782,7 +1374,6 @@ export function generateMapFeatures(seed, terrain) {
|
|||
|
||||
const satelliteCities = [];
|
||||
const newTowns = [];
|
||||
const interchanges = [];
|
||||
const logisticsScore = new Float32Array(SIZE);
|
||||
for (let y = 2; y < MAP_H - 2; y++) {
|
||||
for (let x = 2; x < MAP_W - 2; x++) {
|
||||
|
|
@ -812,6 +1403,36 @@ export function generateMapFeatures(seed, terrain) {
|
|||
predicate: (x, y, i) => logisticsScore[i] > 0.30 && (roadInfluence[i] > 0.10 || railInfluence2[i] > 0.08 || stationInfluence[i] > 0.08),
|
||||
}).map((p) => ({ ...p, kind: "Logistics Park", score: logisticsScore[indexOf(p.x, p.y)], population: 0 }));
|
||||
const logisticsInfluence = influenceFromPoints(logisticsParks, 4.8, () => 1.0);
|
||||
|
||||
function addFinalLocalAccessForUnservedSettlements() {
|
||||
const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7);
|
||||
const candidates = [
|
||||
...markets.filter((p) => (p.population || 0) >= 5000),
|
||||
...ports,
|
||||
...logisticsParks,
|
||||
...villages.filter((p) => (elevation[indexOf(p.x, p.y)] > 0.48 || slope[indexOf(p.x, p.y)] > 0.30 || p.kind === "Valley Village") && (p.population || 0) >= 1200),
|
||||
]
|
||||
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.18)
|
||||
.map((p) => ({ ...p, score: (p.population || 7000) / 18000 + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[indexOf(p.x, p.y)] }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 55);
|
||||
const localPenalty = influenceFromPaths(minorRoads, 4);
|
||||
for (const start of candidates) {
|
||||
const path = traceCorridorByCost(
|
||||
start,
|
||||
(x, y, i) => accessInfluence[i] > 0.20 || localPenalty[i] > 0.10,
|
||||
transportFields.local,
|
||||
localPenalty,
|
||||
{ curvePenalty: 0.08, penaltyStrength: 1.0, minGoalDistance: 4, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.55) }
|
||||
);
|
||||
transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: "local-access", repaired: path.length >= 4 && path.length <= 64 });
|
||||
if (path.length < 4 || path.length > 64) continue;
|
||||
minorRoads.push(path);
|
||||
transportDebugLayers.repairedSegments.push({ mode: "local", path, from: "unserved", to: "network" });
|
||||
addCorridorInfluencePenalty(localPenalty, path, 4, 0.20);
|
||||
}
|
||||
}
|
||||
addFinalLocalAccessForUnservedSettlements();
|
||||
var landuse = new Uint8Array(SIZE);
|
||||
|
||||
// Re-run land-use classification after landuse allocation. The loop above is
|
||||
|
|
@ -1014,6 +1635,12 @@ export function generateMapFeatures(seed, terrain) {
|
|||
humanStageVersion: "v2-sparse-raster",
|
||||
aStarRoutes: 0,
|
||||
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
|
||||
fieldCorridorTransport: true,
|
||||
expresswayFieldCorridors: expressways.length,
|
||||
railFieldCorridors: railways.length,
|
||||
nationalRoadFieldCorridors: nationalRoads.length,
|
||||
localRoadFieldCorridors: minorRoads.length,
|
||||
layers: transportDebugLayers,
|
||||
nationalRoadPopulationCoverage: 0,
|
||||
nationalRoadUncoveredPopulation: 0,
|
||||
};
|
||||
|
|
|
|||
280
mapOutput.js
280
mapOutput.js
|
|
@ -1,9 +1,13 @@
|
|||
import { createNameDebug } from "./names.js";
|
||||
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
|
||||
|
||||
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
|
||||
|
||||
function stripMunicipalSuffix(name) {
|
||||
return String(name || "").replace(/[市町村区]$/u, "").trim();
|
||||
}
|
||||
|
||||
function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
|
||||
const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0;
|
||||
const density = fields.populationDensity?.[i] || 0;
|
||||
|
|
@ -18,16 +22,20 @@ function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
|
|||
function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
|
||||
let value = String(root || center?.name || "").trim();
|
||||
if (!value) value = `自治${ordinal + 1}`;
|
||||
value = value.replace(/[駅港城跡宿]$/u, "");
|
||||
if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3);
|
||||
if (MUNICIPAL_SUFFIX_RE.test(value)) return value;
|
||||
value = value.replace(/[市町村区駅港城跡宿]$/gu, "");
|
||||
const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, "");
|
||||
if (Array.from(value).length < 2) value = `${value}${fallback || "里"}`;
|
||||
// Municipality roots should be at most two toponymic elements. The admin
|
||||
// suffix is separate; avoid direction+root+suffix three-element names.
|
||||
value = Array.from(value).slice(0, 2).join("");
|
||||
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
|
||||
}
|
||||
|
||||
|
||||
function assignMunicipalityPopulations(adminCenters, adminId, fields) {
|
||||
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
|
||||
if (!adminCenters?.length || !adminId) return;
|
||||
const totals = new Float64Array(adminCenters.length);
|
||||
const settlementTotals = new Float64Array(adminCenters.length);
|
||||
for (let i = 0; i < adminId.length; i++) {
|
||||
const id = adminId[i];
|
||||
if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
|
||||
|
|
@ -39,15 +47,108 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields) {
|
|||
const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0;
|
||||
totals[id] += density * builtWeight + ruralFloor;
|
||||
}
|
||||
// Population-bearing generated settlements are canonical entities, so add
|
||||
// their explicit populations exactly once to the municipality containing the
|
||||
// point. Some towns are promoted to cities later, so the same coordinate can
|
||||
// appear in both `markets` and `modernCities`; keep only the strongest record
|
||||
// per coordinate to avoid double counting.
|
||||
const uniqueSettlementByCell = new Map();
|
||||
for (const feature of settlementFeatures || []) {
|
||||
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0) continue;
|
||||
if (!inside(feature.x, feature.y)) continue;
|
||||
const i = indexOf(feature.x, feature.y);
|
||||
if (fields.sea?.[i]) continue;
|
||||
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
|
||||
const current = uniqueSettlementByCell.get(key);
|
||||
const priority = (feature.isPrefecturalCapital ? 4_000_000 : 0) + (feature.isRegionalCapital ? 1_000_000 : 0) + (feature.population || 0);
|
||||
if (!current || priority > current.priority) uniqueSettlementByCell.set(key, { feature, priority, i });
|
||||
}
|
||||
let skippedDuplicateSettlementPopulation = 0;
|
||||
for (const { feature, i } of uniqueSettlementByCell.values()) {
|
||||
const id = adminId[i];
|
||||
if (id < 0 || id >= totals.length) continue;
|
||||
settlementTotals[id] += feature.population;
|
||||
}
|
||||
for (const feature of settlementFeatures || []) {
|
||||
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0 || !inside(feature.x, feature.y)) continue;
|
||||
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
|
||||
const kept = uniqueSettlementByCell.get(key)?.feature;
|
||||
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
|
||||
}
|
||||
for (let id = 0; id < adminCenters.length; id++) {
|
||||
const raw = totals[id] || 0;
|
||||
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
|
||||
const rounded = raw >= 10000 ? Math.round(raw / 1000) * 1000 : Math.round(raw / 100) * 100;
|
||||
adminCenters[id].municipalityPopulation = Math.max(0, rounded);
|
||||
adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
|
||||
adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug) {
|
||||
|
||||
function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000) {
|
||||
if (!prefectureRegionId) return 0;
|
||||
const prefIds = new Set();
|
||||
for (let i = 0; i < prefectureRegionId.length; i++) {
|
||||
const id = prefectureRegionId[i];
|
||||
if (!sea[i] && id >= 0) prefIds.add(id);
|
||||
}
|
||||
let promoted = 0;
|
||||
function prefAt(p) {
|
||||
if (!p || !inside(p.x, p.y)) return -1;
|
||||
return prefectureRegionId[indexOf(p.x, p.y)] ?? -1;
|
||||
}
|
||||
for (const prefId of [...prefIds].sort((a, b) => a - b)) {
|
||||
const cities = (modernCities || []).filter((p) => prefAt(p) === prefId);
|
||||
let target = cities.slice().sort((a, b) =>
|
||||
((b.isRegionalCapital ? 800000 : 0) + (b.population || 0)) - ((a.isRegionalCapital ? 800000 : 0) + (a.population || 0))
|
||||
)[0];
|
||||
if (!target) {
|
||||
const market = (markets || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.population || 0) - (a.population || 0))[0];
|
||||
if (market) {
|
||||
target = {
|
||||
...market,
|
||||
kind: "Regional Capital",
|
||||
rank: "Regional Capital",
|
||||
promotedFromMarketTown: true,
|
||||
labelPriorityBase: 900,
|
||||
};
|
||||
modernCities.push(target);
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
const center = (adminCenters || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))[0];
|
||||
if (center) {
|
||||
target = {
|
||||
...center,
|
||||
kind: "Regional Capital",
|
||||
rank: "Regional Capital",
|
||||
promotedFromMunicipalCenter: true,
|
||||
labelPriorityBase: 880,
|
||||
};
|
||||
modernCities.push(target);
|
||||
}
|
||||
}
|
||||
if (!target) continue;
|
||||
const promotedPopulation = Math.round((minPopulation + rand(seed + 52000, prefId * 37 + 11) * 120000) / 1000) * 1000;
|
||||
if ((target.population || 0) < promotedPopulation) {
|
||||
target.population = promotedPopulation;
|
||||
promoted++;
|
||||
}
|
||||
target.isPrefecturalCapital = true;
|
||||
target.isRegionalCapital = true;
|
||||
target.rank = target.rank || "Regional Capital";
|
||||
target.kind = target.kind === "Market Town" || target.kind === "Port Town" || target.kind === "Valley Market Town" ? "Regional Capital" : (target.kind || "Regional Capital");
|
||||
target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30);
|
||||
target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4);
|
||||
target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38);
|
||||
target.urbanWeight = clamp(1.05 + Math.log10(Math.max(10000, target.population)) * 0.30, 1.25, 2.55);
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug, options = {}) {
|
||||
if (!prefectureRegionId) return [];
|
||||
const { modernCities = [], adminCenters = [] } = options;
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < prefectureRegionId.length; i++) {
|
||||
const id = prefectureRegionId[i];
|
||||
|
|
@ -62,6 +163,21 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
|
|||
row.cells.push(i);
|
||||
}
|
||||
const regions = [];
|
||||
const capitalNameByPref = new Map();
|
||||
for (const city of modernCities || []) {
|
||||
if (!city || !inside(city.x, city.y) || !city.name) continue;
|
||||
const prefId = prefectureRegionId[indexOf(city.x, city.y)];
|
||||
if (prefId < 0) continue;
|
||||
const current = capitalNameByPref.get(prefId);
|
||||
const score = (city.isPrefecturalCapital ? 2_000_000 : 0) + (city.isRegionalCapital ? 500_000 : 0) + (city.population || 0);
|
||||
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: "city" });
|
||||
}
|
||||
for (const center of adminCenters || []) {
|
||||
if (!center || !inside(center.x, center.y) || !center.name) continue;
|
||||
const prefId = prefectureRegionId[indexOf(center.x, center.y)];
|
||||
if (prefId < 0 || capitalNameByPref.has(prefId)) continue;
|
||||
capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(center.name), score: center.municipalityPopulation || 0, x: center.x, y: center.y, population: center.municipalityPopulation || 0, source: "admin" });
|
||||
}
|
||||
for (const row of [...byId.values()].sort((a, b) => a.id - b.id)) {
|
||||
const cx = row.sx / Math.max(1, row.area);
|
||||
const cy = row.sy / Math.max(1, row.area);
|
||||
|
|
@ -80,14 +196,54 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
|
|||
Math.hypot(x - cx, y - cy) * 0.08;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const x = bestI % MAP_W;
|
||||
const y = Math.floor(bestI / MAP_W);
|
||||
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area) });
|
||||
let x = bestI % MAP_W;
|
||||
let y = Math.floor(bestI / MAP_W);
|
||||
const capitalInfo = capitalNameByPref.get(row.id);
|
||||
if (capitalInfo && inside(capitalInfo.x, capitalInfo.y)) {
|
||||
const targetRing = clamp(5.5 + Math.sqrt(row.area) / 52, 6, 15);
|
||||
let labelI = bestI;
|
||||
let labelScore = -INF;
|
||||
for (const i of row.cells) {
|
||||
const lx = i % MAP_W;
|
||||
const ly = Math.floor(i / MAP_W);
|
||||
const d = Math.hypot(lx - capitalInfo.x, ly - capitalInfo.y);
|
||||
if (d < 2 || d > Math.max(19, targetRing * 2.2)) continue;
|
||||
const lu = fields.landuse?.[i] ?? 0;
|
||||
const builtPenalty = lu === 3 ? 1.2 : lu === 2 || lu === 4 || lu === 7 || lu === 8 ? 0.70 : 0;
|
||||
const score =
|
||||
-Math.abs(d - targetRing) * 0.38 -
|
||||
(fields.populationDensity?.[i] || 0) * 1.1 -
|
||||
builtPenalty +
|
||||
(fields.plain?.[i] || 0) * 0.22 +
|
||||
(fields.basinField?.[i] || 0) * 0.14 +
|
||||
(fields.coastalLowland?.[i] || 0) * 0.10 -
|
||||
(fields.slope?.[i] || 0) * 0.30 -
|
||||
(fields.ridgeField?.[i] || 0) * 0.24;
|
||||
if (score > labelScore) { labelScore = score; labelI = i; }
|
||||
}
|
||||
x = labelI % MAP_W;
|
||||
y = Math.floor(labelI / MAP_W);
|
||||
}
|
||||
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area), capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
|
||||
}
|
||||
return attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
|
||||
const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
|
||||
.map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name }));
|
||||
const usedPrefNames = new Set();
|
||||
for (const region of named) {
|
||||
const capital = capitalNameByPref.get(region.id)?.name;
|
||||
let candidate = capital && Array.from(capital).length >= 2 ? capital : stripMunicipalSuffix(region.name);
|
||||
if (!candidate) candidate = stripMunicipalSuffix(region.name) || `県域${region.id + 1}`;
|
||||
if (usedPrefNames.has(candidate)) candidate = `${candidate}${region.id + 1}`;
|
||||
candidate = `${String(candidate).replace(/[都道府県]$/u, "")}県`;
|
||||
region.name = candidate;
|
||||
region.labelName = candidate;
|
||||
region.prefectureCapitalDerivedName = Boolean(capital);
|
||||
usedPrefNames.add(candidate);
|
||||
}
|
||||
return named;
|
||||
}
|
||||
|
||||
|
||||
export function finishMapOutput({
|
||||
seed,
|
||||
options,
|
||||
|
|
@ -284,18 +440,24 @@ export function finishMapOutput({
|
|||
for (const [index, center] of adminCenters.entries()) {
|
||||
center.adminNumericId = index;
|
||||
center.municipalityId = index;
|
||||
let candidate = center.canonicalSettlementName || municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
|
||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||
candidate = generated;
|
||||
}
|
||||
if (usedAdminNames.has(candidate)) {
|
||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
|
||||
const root = String(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
|
||||
const directions = ["東", "西", "南", "北", "上", "下", "中"];
|
||||
for (let attempt = 0; attempt < directions.length + 3 && usedAdminNames.has(candidate); attempt++) {
|
||||
const prefix = directions[attempt % directions.length];
|
||||
candidate = attempt < directions.length ? `${prefix}${root}${suffix}` : `${root}${index + 1}${suffix}`;
|
||||
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
|
||||
const chars = Array.from(rootSource || "里郷");
|
||||
const alternates = [
|
||||
chars.slice(0, 2).join(""),
|
||||
chars.slice(-2).join(""),
|
||||
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`,
|
||||
`${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`,
|
||||
].filter((v) => Array.from(v).length >= 2);
|
||||
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
|
||||
const root = attempt < alternates.length ? alternates[attempt] : `第${(index + attempt) % 10}`;
|
||||
candidate = `${Array.from(root).slice(0, 2).join("")}${suffix}`;
|
||||
}
|
||||
}
|
||||
center.name = candidate;
|
||||
|
|
@ -303,13 +465,93 @@ export function finishMapOutput({
|
|||
center.municipalityName = candidate;
|
||||
usedAdminNames.add(center.name);
|
||||
}
|
||||
assignMunicipalityPopulations(adminCenters, adminId, nameFields);
|
||||
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000);
|
||||
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
|
||||
...modernCities,
|
||||
...markets,
|
||||
...villages,
|
||||
...satelliteCities,
|
||||
...newTowns,
|
||||
]);
|
||||
|
||||
function addMunicipalCenterLocalAccess() {
|
||||
if (!transportDebug) return;
|
||||
const debugLayers = transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] });
|
||||
debugLayers.repairedSegments ||= [];
|
||||
debugLayers.unservedSettlements ||= [];
|
||||
const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7);
|
||||
const localPenalty = influenceFromPaths(minorRoads, 4);
|
||||
const candidates = adminCenters
|
||||
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.16)
|
||||
.sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))
|
||||
.slice(0, 45);
|
||||
|
||||
function routeAccess(start) {
|
||||
const maxSteps = 72;
|
||||
const out = [];
|
||||
const visited = new Set([`${start.x},${start.y}`]);
|
||||
let x = start.x;
|
||||
let y = start.y;
|
||||
let lastKey = "";
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
const i = indexOf(x, y);
|
||||
if (step > 3 && (accessInfluence[i] > 0.20 || localPenalty[i] > 0.10)) return out;
|
||||
let best = null;
|
||||
let bestScore = INF;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (!dx && !dy) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) continue;
|
||||
if (visited.has(`${nx},${ny}`)) continue;
|
||||
const cost =
|
||||
1.1 +
|
||||
slope[ni] * 1.4 +
|
||||
ridgeField[ni] * 0.55 +
|
||||
Math.max(0, elevation[ni] - 0.70) * 1.2 -
|
||||
Math.max(roadInfluence[ni], railInfluence2[ni]) * 2.8 -
|
||||
plain[ni] * 0.28 -
|
||||
valleyField[ni] * 0.18 -
|
||||
coastalLowland[ni] * 0.16 +
|
||||
localPenalty[ni] * 0.55;
|
||||
if (cost < bestScore) {
|
||||
bestScore = cost;
|
||||
best = [nx, ny];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
x = best[0];
|
||||
y = best[1];
|
||||
const key = `${x},${y}`;
|
||||
visited.add(key);
|
||||
if (key !== lastKey) {
|
||||
out.push([x, y]);
|
||||
lastKey = key;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const center of candidates) {
|
||||
const path = routeAccess(center);
|
||||
debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 });
|
||||
if (path.length < 4) continue;
|
||||
minorRoads.push(path);
|
||||
debugLayers.repairedSegments.push({ mode: "municipal-local", path, from: "municipal-center", to: "network" });
|
||||
}
|
||||
}
|
||||
addMunicipalCenterLocalAccess();
|
||||
nameDebug.maxDerivedPerBase = 0;
|
||||
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug);
|
||||
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
|
||||
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
|
||||
if (regionalDebug) {
|
||||
regionalDebug.finalRegionalPrefectureBorderCount = regionalPrefectureBorders.length;
|
||||
regionalDebug.regionalPrefectureBordersRebuiltFromFinalId = true;
|
||||
regionalDebug.promotedPrefectureCapitals = promotedPrefectureCapitals;
|
||||
}
|
||||
|
||||
outputProgress("final package");
|
||||
|
|
|
|||
282
mapTerrain.js
282
mapTerrain.js
|
|
@ -166,7 +166,7 @@ const TERRAIN_TYPES = [
|
|||
seaRatioRange: [0.13, 0.23],
|
||||
twoSidedChance: 0.96,
|
||||
mountainOffsetRange: [0.47, 0.53],
|
||||
baseHeightRange: [0.74, 1.10],
|
||||
baseHeightRange: [0.56, 0.82],
|
||||
primaryLengthRange: [0.76, 0.96],
|
||||
primaryWidthRange: [0.13, 0.22],
|
||||
systemCountRange: [12, 16],
|
||||
|
|
@ -175,7 +175,7 @@ const TERRAIN_TYPES = [
|
|||
crossSpread: 0.38,
|
||||
lengthScale: 1.22,
|
||||
widthScale: 0.92,
|
||||
heightScale: 1.24,
|
||||
heightScale: 0.86,
|
||||
coastStrength: 0.90,
|
||||
plainBiasRange: [0.16, 0.34],
|
||||
riverRichnessRange: [0.70, 1.18],
|
||||
|
|
@ -188,10 +188,10 @@ const TERRAIN_TYPES = [
|
|||
coastStyle: "outer_coast",
|
||||
mountainMode: "massif",
|
||||
massifnessRange: [0.42, 0.74],
|
||||
seaRatioRange: [0.10, 0.20],
|
||||
twoSidedChance: 0.20,
|
||||
seaRatioRange: [0.000, 0.030],
|
||||
twoSidedChance: 0.10,
|
||||
mountainOffsetRange: [0.16, 0.36],
|
||||
baseHeightRange: [0.68, 1.04],
|
||||
baseHeightRange: [0.76, 1.12],
|
||||
primaryLengthRange: [0.62, 0.92],
|
||||
primaryWidthRange: [0.30, 0.58],
|
||||
systemCountRange: [16, 20],
|
||||
|
|
@ -200,11 +200,11 @@ const TERRAIN_TYPES = [
|
|||
crossSpread: 0.82,
|
||||
lengthScale: 1.34,
|
||||
widthScale: 1.30,
|
||||
heightScale: 1.12,
|
||||
coastStrength: 0.74,
|
||||
heightScale: 1.32,
|
||||
coastStrength: 0.30,
|
||||
plainBiasRange: [0.08, 0.24],
|
||||
riverRichnessRange: [0.72, 1.12],
|
||||
bigRiverChanceRange: [0.28, 0.58],
|
||||
riverRichnessRange: [0.74, 1.14],
|
||||
bigRiverChanceRange: [0.30, 0.60],
|
||||
},
|
||||
{
|
||||
id: "setouchi_inland_sea",
|
||||
|
|
@ -212,20 +212,20 @@ const TERRAIN_TYPES = [
|
|||
weight: 0.16,
|
||||
coastStyle: "inland_sea",
|
||||
mountainMode: "mixed",
|
||||
massifnessRange: [0.24, 0.48],
|
||||
massifnessRange: [0.34, 0.62],
|
||||
seaRatioRange: [0.20, 0.33],
|
||||
twoSidedChance: 0.92,
|
||||
mountainOffsetRange: [0.22, 0.34],
|
||||
baseHeightRange: [0.56, 1.00],
|
||||
primaryLengthRange: [0.52, 0.76],
|
||||
primaryWidthRange: [0.18, 0.34],
|
||||
systemCountRange: [12, 16],
|
||||
beltCountRange: [2, 3],
|
||||
angleSpread: 0.24,
|
||||
crossSpread: 0.70,
|
||||
lengthScale: 0.98,
|
||||
widthScale: 1.08,
|
||||
heightScale: 1.08,
|
||||
baseHeightRange: [0.46, 0.78],
|
||||
primaryLengthRange: [0.52, 0.78],
|
||||
primaryWidthRange: [0.20, 0.38],
|
||||
systemCountRange: [16, 22],
|
||||
beltCountRange: [3, 4],
|
||||
angleSpread: 0.34,
|
||||
crossSpread: 0.86,
|
||||
lengthScale: 1.00,
|
||||
widthScale: 1.18,
|
||||
heightScale: 0.82,
|
||||
coastStrength: 1.10,
|
||||
plainBiasRange: [0.26, 0.50],
|
||||
riverRichnessRange: [0.58, 0.96],
|
||||
|
|
@ -237,24 +237,24 @@ const TERRAIN_TYPES = [
|
|||
weight: 0.16,
|
||||
coastStyle: "open_bay",
|
||||
mountainMode: "range",
|
||||
massifnessRange: [0.18, 0.44],
|
||||
seaRatioRange: [0.15, 0.26],
|
||||
twoSidedChance: 0.18,
|
||||
massifnessRange: [0.10, 0.30],
|
||||
seaRatioRange: [0.08, 0.17],
|
||||
twoSidedChance: 0.10,
|
||||
mountainOffsetRange: [0.28, 0.46],
|
||||
baseHeightRange: [0.62, 1.04],
|
||||
primaryLengthRange: [0.42, 0.70],
|
||||
primaryWidthRange: [0.20, 0.36],
|
||||
systemCountRange: [10, 14],
|
||||
baseHeightRange: [0.48, 0.82],
|
||||
primaryLengthRange: [0.38, 0.62],
|
||||
primaryWidthRange: [0.16, 0.30],
|
||||
systemCountRange: [7, 11],
|
||||
beltCountRange: [2, 3],
|
||||
angleSpread: 0.34,
|
||||
crossSpread: 0.62,
|
||||
lengthScale: 0.92,
|
||||
widthScale: 1.10,
|
||||
heightScale: 1.10,
|
||||
coastStrength: 0.92,
|
||||
plainBiasRange: [0.56, 0.86],
|
||||
riverRichnessRange: [0.98, 1.38],
|
||||
bigRiverChanceRange: [0.62, 0.90],
|
||||
heightScale: 0.84,
|
||||
coastStrength: 0.68,
|
||||
plainBiasRange: [0.70, 0.96],
|
||||
riverRichnessRange: [1.18, 1.58],
|
||||
bigRiverChanceRange: [0.80, 0.98],
|
||||
},
|
||||
{
|
||||
id: "mixed_archipelago",
|
||||
|
|
@ -284,13 +284,11 @@ const TERRAIN_TYPES = [
|
|||
];
|
||||
|
||||
function pickTerrainType(seed) {
|
||||
const total = TERRAIN_TYPES.reduce((sum, type) => sum + type.weight, 0);
|
||||
let r = rand(seed, 10001) * total;
|
||||
for (const type of TERRAIN_TYPES) {
|
||||
r -= type.weight;
|
||||
if (r <= 0) return type;
|
||||
}
|
||||
return TERRAIN_TYPES[TERRAIN_TYPES.length - 1];
|
||||
// Terrain type selection is intentionally uniform. Individual terrain
|
||||
// templates still contain their own parameter ranges, but there is no
|
||||
// terrain-type appearance weighting.
|
||||
const index = Math.floor(rand(seed, 10001) * TERRAIN_TYPES.length) % TERRAIN_TYPES.length;
|
||||
return TERRAIN_TYPES[index];
|
||||
}
|
||||
|
||||
function rangeValue(seed, salt, [lo, hi]) {
|
||||
|
|
@ -315,11 +313,12 @@ export function buildTerrainTemplate(seed) {
|
|||
const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange);
|
||||
let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange));
|
||||
if (terrainType.id === "tohoku_spine") {
|
||||
// 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。
|
||||
// coastAngle は海へ向かう勾配方向、等値線としての海岸線は +90° 方向。
|
||||
coastAngle = (rand(seed, 2101) < 0.5 ? 0 : Math.PI / 2) + (rand(seed, 2102) - 0.5) * 0.10;
|
||||
// 東北型の脊梁は南北/東西だけでなく斜め軸も許容する。
|
||||
// 海岸勾配は脊梁軸に概ね直交させるが、山脈自体の向きは独立に選ぶ。
|
||||
const axisChoices = [0, Math.PI / 2, Math.PI / 4, -Math.PI / 4, Math.PI * 0.35, Math.PI * 0.65];
|
||||
mountainAngle = axisChoices[Math.floor(rand(seed, 2101) * axisChoices.length) % axisChoices.length] + (rand(seed, 2102) - 0.5) * 0.24;
|
||||
coastAngle = mountainAngle - Math.PI / 2 + (rand(seed, 2104) - 0.5) * 0.12;
|
||||
twoSidedCoast = true;
|
||||
mountainAngle = coastAngle + Math.PI / 2 + (rand(seed, 2103) - 0.5) * 0.16;
|
||||
}
|
||||
const baseHeight = rangeValue(seed, 25, terrainType.baseHeightRange);
|
||||
const primaryLength = rangeValue(seed, 26, terrainType.primaryLengthRange);
|
||||
|
|
@ -445,7 +444,7 @@ function buildMountainSystems(template, seed) {
|
|||
angle: centralAngle,
|
||||
length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale,
|
||||
width: (0.12 + rand(seed, 3334) * 0.07) * widthScale,
|
||||
height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18),
|
||||
height: template.mountainBaseHeight * heightScale * (0.50 + rand(seed, 3335) * 0.15),
|
||||
scratchCount: Math.round(20 + rand(seed, 3336) * 10),
|
||||
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55),
|
||||
role: "central-primary",
|
||||
|
|
@ -462,7 +461,7 @@ function buildMountainSystems(template, seed) {
|
|||
angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08,
|
||||
length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale,
|
||||
width: (0.075 + rand(seed, 3343) * 0.055) * widthScale,
|
||||
height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16),
|
||||
height: template.mountainBaseHeight * heightScale * (0.33 + rand(seed, 3344) * 0.13),
|
||||
scratchCount: Math.round(12 + rand(seed, 3345) * 8),
|
||||
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65),
|
||||
role: "central-secondary",
|
||||
|
|
@ -756,6 +755,140 @@ function traceFlowPath(start, sea, flowTo, maxSteps = 900) {
|
|||
return path;
|
||||
}
|
||||
|
||||
|
||||
function lineCellsBetween(ax, ay, bx, by) {
|
||||
const cells = [];
|
||||
const steps = Math.max(Math.abs(bx - ax), Math.abs(by - ay), 1);
|
||||
for (let t = 0; t <= steps; t++) {
|
||||
const x = Math.round(ax + (bx - ax) * t / steps);
|
||||
const y = Math.round(ay + (by - ay) * t / steps);
|
||||
if (inside(x, y) && (!cells.length || cells[cells.length - 1][0] !== x || cells[cells.length - 1][1] !== y)) cells.push([x, y]);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function meanderRiverPath(path, seed, salt, sea, lake, elevation, slope) {
|
||||
if (!path || path.length < 18) return path;
|
||||
// Add a visible but controlled meander before valley incision. The meander
|
||||
// diameter is about 3-10 cells. Mountain reaches are no longer damped;
|
||||
// the same low-frequency bend model is applied throughout the course.
|
||||
const lengthFactor = clamp(path.length / 190);
|
||||
const controlStep = Math.max(4, Math.round(8 - lengthFactor * 3));
|
||||
const phase = hash2(seed + salt, 17) * Math.PI * 2;
|
||||
const waveCells = 14 + Math.floor(hash2(seed, salt + 31) * 16); // broad wavelength
|
||||
const secondaryCells = 28 + Math.floor(hash2(seed + 3, salt + 53) * 24);
|
||||
const maxDiameter = 3 + hash2(seed + 5, salt + 71) * 7;
|
||||
const baseAmp = maxDiameter * 0.5;
|
||||
const controls = [];
|
||||
for (let k = 0; k < path.length; k += controlStep) controls.push(k);
|
||||
if (controls[controls.length - 1] !== path.length - 1) controls.push(path.length - 1);
|
||||
|
||||
const displacedControls = [];
|
||||
for (const k of controls) {
|
||||
const [x, y] = path[k];
|
||||
if (k === 0 || k === path.length - 1) { displacedControls.push([x, y]); continue; }
|
||||
const [px, py] = path[Math.max(0, k - controlStep * 2)];
|
||||
const [nx0, ny0] = path[Math.min(path.length - 1, k + controlStep * 2)];
|
||||
const tx = nx0 - px;
|
||||
const ty = ny0 - py;
|
||||
const len = Math.hypot(tx, ty) || 1;
|
||||
const normalX = -ty / len;
|
||||
const normalY = tx / len;
|
||||
const primary = Math.sin(k / waveCells * Math.PI * 2 + phase);
|
||||
const secondary = Math.sin(k / secondaryCells * Math.PI * 2 + phase * 0.43) * 0.35;
|
||||
const amp = baseAmp * 0.82 * (primary + secondary);
|
||||
let mx = Math.round(x + normalX * amp);
|
||||
let my = Math.round(y + normalY * amp);
|
||||
if (!inside(mx, my)) { displacedControls.push([x, y]); continue; }
|
||||
const mi = indexOf(mx, my);
|
||||
if (sea[mi] && !lake[mi] && k < path.length - controlStep) { displacedControls.push([x, y]); continue; }
|
||||
displacedControls.push([mx, my]);
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (let c = 0; c < displacedControls.length - 1; c++) {
|
||||
const [ax, ay] = displacedControls[c];
|
||||
const [bx, by] = displacedControls[c + 1];
|
||||
const line = lineCellsBetween(ax, ay, bx, by);
|
||||
for (const cell of line) {
|
||||
if (out.length && out[out.length - 1][0] === cell[0] && out[out.length - 1][1] === cell[1]) continue;
|
||||
const ci = indexOf(cell[0], cell[1]);
|
||||
if (sea[ci] && !lake[ci] && c < displacedControls.length - 3) continue;
|
||||
out.push(cell);
|
||||
}
|
||||
}
|
||||
return out.length >= Math.max(8, path.length * 0.42) ? out : path;
|
||||
}
|
||||
|
||||
function scoreRiverPathForDedup(path, flowAccum) {
|
||||
let maxFlow = 0;
|
||||
let meanFlow = 0;
|
||||
for (const [x, y] of path) {
|
||||
const f = flowAccum[indexOf(x, y)] || 0;
|
||||
maxFlow = Math.max(maxFlow, f);
|
||||
meanFlow += f;
|
||||
}
|
||||
meanFlow /= Math.max(1, path.length);
|
||||
return path.length * 0.75 + maxFlow * 90 + meanFlow * 35;
|
||||
}
|
||||
|
||||
function dedupeRiverPaths(paths, flowAccum, sea, lake) {
|
||||
// Multiple traces often share, or run one cell beside, the same downstream
|
||||
// trunk. Trim later traces at the first near-confluence so visually only one
|
||||
// river occupies a channel, while true tributaries remain visible upstream.
|
||||
const sorted = paths
|
||||
.map((path) => ({ path, score: scoreRiverPathForDedup(path, flowAccum) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const occupied = new Uint8Array(SIZE);
|
||||
const accepted = [];
|
||||
const nearOccupied = (x, y) => {
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
if (occupied[indexOf(nx, ny)]) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const markNear = (x, y) => {
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (inside(nx, ny)) occupied[indexOf(nx, ny)] = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const item of sorted) {
|
||||
const path = item.path;
|
||||
if (!path || path.length < 8) continue;
|
||||
let joinAt = -1;
|
||||
let nearRun = 0;
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const [x, y] = path[k];
|
||||
if (nearOccupied(x, y) && k > 6) {
|
||||
nearRun++;
|
||||
if (nearRun >= 2) { joinAt = Math.max(6, k - 1); break; }
|
||||
} else {
|
||||
nearRun = 0;
|
||||
}
|
||||
}
|
||||
const trimmed = joinAt >= 0 ? path.slice(0, Math.min(path.length, joinAt + 1)) : path;
|
||||
let uniqueCells = 0;
|
||||
for (const [x, y] of trimmed) if (!nearOccupied(x, y)) uniqueCells++;
|
||||
if (trimmed.length < 8 || uniqueCells < Math.max(5, Math.min(18, trimmed.length * 0.38))) continue;
|
||||
accepted.push(trimmed);
|
||||
for (const [x, y] of trimmed) {
|
||||
const i = indexOf(x, y);
|
||||
if (!sea[i] || lake[i]) markNear(x, y);
|
||||
}
|
||||
if (accepted.length >= 62) break;
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField) {
|
||||
river.fill(0);
|
||||
const candidates = [];
|
||||
|
|
@ -770,46 +903,39 @@ function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo,
|
|||
if (score > 0.24) candidates.push({ x, y, score });
|
||||
}
|
||||
}
|
||||
const desired = 44 + Math.floor(template.riverRichness * 28);
|
||||
const sources = pickEntities(candidates, { max: desired, minDistance: 6, threshold: 0.26, seed: seed + 12100, jitter: 0.035 });
|
||||
const desired = 26 + Math.floor(template.riverRichness * 15);
|
||||
const sources = pickEntities(candidates, { max: desired, minDistance: 8, threshold: 0.27, seed: seed + 12100, jitter: 0.035 });
|
||||
const riverPaths = [];
|
||||
for (const s of sources) {
|
||||
const path = traceFlowPath(indexOf(s.x, s.y), sea, flowTo);
|
||||
const traced = traceFlowPath(indexOf(s.x, s.y), sea, flowTo);
|
||||
const path = meanderRiverPath(traced, seed, s.x * 4096 + s.y, sea, lake, elevation, slope);
|
||||
if (path.length >= 8 && path.some(([x, y], k) => k > 5 && (sea[indexOf(x, y)] || lake[indexOf(x, y)]))) riverPaths.push(path);
|
||||
else if (path.length >= 14) riverPaths.push(path);
|
||||
}
|
||||
|
||||
const longPaths = riverPaths
|
||||
.map((path) => {
|
||||
let maxFlow = 0;
|
||||
let meanFlow = 0;
|
||||
for (const [x, y] of path) {
|
||||
const f = flowAccum[indexOf(x, y)];
|
||||
maxFlow = Math.max(maxFlow, f);
|
||||
meanFlow += f;
|
||||
}
|
||||
meanFlow /= Math.max(1, path.length);
|
||||
return { path, score: path.length * 0.75 + maxFlow * 90 + meanFlow * 35 };
|
||||
})
|
||||
const visibleRiverPaths = dedupeRiverPaths(riverPaths, flowAccum, sea, lake);
|
||||
const longPaths = visibleRiverPaths
|
||||
.map((path) => ({ path, score: scoreRiverPathForDedup(path, flowAccum) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const mainCount = Math.min(longPaths.length, template.bigRiverChance > 0.56 ? 4 : 3);
|
||||
const mainCount = Math.min(longPaths.length, template.terrainType === "kanto_alluvial" ? 5 : template.bigRiverChance > 0.56 ? 4 : 3);
|
||||
const mainSet = new Set(longPaths.slice(0, mainCount).map((p) => p.path));
|
||||
|
||||
const riverThreshold = 0.26 - template.riverRichness * 0.035 - (template.bigRiverChance > 0.56 ? 0.030 : 0);
|
||||
const riverThreshold = 0.26 - template.riverRichness * 0.040 - (template.bigRiverChance > 0.56 ? 0.038 : 0) - (template.terrainType === "kanto_alluvial" ? 0.035 : 0);
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (sea[i]) continue;
|
||||
const f = flowAccum[i];
|
||||
if (f > riverThreshold) river[i] = clamp((f - riverThreshold) / (0.55 - riverThreshold));
|
||||
}
|
||||
for (const path of riverPaths) {
|
||||
for (const path of visibleRiverPaths) {
|
||||
const main = mainSet.has(path);
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const [x, y] = path[k];
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const downstream = k / Math.max(1, path.length - 1);
|
||||
const boost = main ? 0.54 + downstream * 0.42 : 0.30 + downstream * 0.22;
|
||||
river[i] = clamp(Math.max(river[i], boost + flowAccum[i] * (main ? 0.70 : 0.42)));
|
||||
const kantoMain = main && template.terrainType === "kanto_alluvial";
|
||||
const boost = main ? (kantoMain ? 0.66 : 0.54) + downstream * (kantoMain ? 0.50 : 0.42) : 0.30 + downstream * 0.22;
|
||||
river[i] = clamp(Math.max(river[i], boost + flowAccum[i] * (main ? (kantoMain ? 0.88 : 0.70) : 0.42)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -835,8 +961,9 @@ function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo,
|
|||
|
||||
const sortedPaths = longPaths.map((p) => p.path);
|
||||
const mainRivers = sortedPaths.filter((p) => mainSet.has(p)).slice(0, mainCount);
|
||||
const tributaryRivers = sortedPaths.filter((p) => !mainSet.has(p)).slice(0, 24);
|
||||
const smallStreams = sortedPaths.slice(mainCount + 8, mainCount + 58);
|
||||
const nonMainPaths = sortedPaths.filter((p) => !mainSet.has(p));
|
||||
const tributaryRivers = nonMainPaths.slice(0, 24);
|
||||
const smallStreams = nonMainPaths.slice(24, 74);
|
||||
return { riverPaths: sortedPaths.slice(0, 80), mainRivers, tributaryRivers, smallStreams };
|
||||
}
|
||||
|
||||
|
|
@ -990,7 +1117,24 @@ export function generateTerrainAndRivers(seed) {
|
|||
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
||||
e += global * 0.020;
|
||||
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
||||
elevation[i] = clamp(softCapElevation(e, terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91, 1.08), 0.025, 1.08);
|
||||
if (terrainTemplate.terrainType === "tohoku_spine") {
|
||||
// Keep the broad Ou/backbone footprint, but compress peak height so the
|
||||
// range reads as a long Japanese spine rather than an alpine wall.
|
||||
const high = Math.max(0, e - 0.48);
|
||||
e -= high * clamp(0.18 + mountainMaskMax * 0.22, 0.18, 0.40);
|
||||
}
|
||||
if (terrainTemplate.terrainType === "setouchi_inland_sea") {
|
||||
// Setouchi maps should have many low hills and island backbones rather
|
||||
// than a few high alpine ridges. Add broad low relief, then cap peaks.
|
||||
const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9);
|
||||
const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
|
||||
e += lowHillMask * 0.145;
|
||||
const high = Math.max(0, e - 0.62);
|
||||
e -= high * 0.42;
|
||||
}
|
||||
const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
|
||||
const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : 1.08;
|
||||
elevation[i] = clamp(softCapElevation(e, softCapStart, softCapMax), 0.025, softCapMax);
|
||||
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
|
||||
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
|
||||
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
|
||||
|
|
@ -1018,7 +1162,7 @@ export function generateTerrainAndRivers(seed) {
|
|||
const natural = buildNaturalCompartments(
|
||||
landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||
null, plain, agriculture, zeroDensity, zeroLanduse,
|
||||
{ seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 30), 80, 520) }
|
||||
{ seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360) }
|
||||
);
|
||||
const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore;
|
||||
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
||||
|
|
|
|||
60
names.js
60
names.js
|
|
@ -1,6 +1,17 @@
|
|||
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
|
||||
|
||||
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "二軒屋", "三軒家", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "観音寺",];
|
||||
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山"];
|
||||
|
||||
function nameCharCount(value) {
|
||||
return Array.from(String(value || "")).length;
|
||||
}
|
||||
|
||||
function isAtomicNamePart(value) {
|
||||
// The generator used to create visibly synthetic three-part names by joining
|
||||
// a prefix with a compound terrain word. For template generation, keep each
|
||||
// lexical slot atomic so a generated root is at most two visible elements.
|
||||
return nameCharCount(value) <= 1;
|
||||
}
|
||||
|
||||
export const NAME_KANJI_POOLS = {
|
||||
modifiers: [
|
||||
|
|
@ -11,20 +22,20 @@ export const NAME_KANJI_POOLS = {
|
|||
"白", "黒", "青", "赤", "藍",
|
||||
"奥", "前", "後", "内", "外",
|
||||
"美", "吉", "福", "幸", "徳",
|
||||
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
|
||||
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千",
|
||||
"霞", "朝", "日", "天",
|
||||
"土", "砂", "石", "岩",
|
||||
"丑", "卯", "辰", "巳", "酉",
|
||||
"早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌",
|
||||
"卯", "辰",
|
||||
"荒", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
|
||||
],
|
||||
|
||||
inlandTerrain: [
|
||||
"山", "野", "荒", "野", "沢",
|
||||
"山", "野", "野", "沢",
|
||||
"森", "林", "岡", "丘", "坂",
|
||||
"峰", "峠", "嶺", "尾", "平", "坪", "延",
|
||||
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古",
|
||||
"窪", "久", "迫", "久保", "玖保", "佐古", "作古",
|
||||
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
|
||||
"聡", "郷", "里",
|
||||
"郷", "里",
|
||||
"馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥",
|
||||
"湯",
|
||||
],
|
||||
|
|
@ -53,7 +64,7 @@ export const NAME_KANJI_POOLS = {
|
|||
"橘", "柏", "槙", "柿", "桃",
|
||||
"梨", "桑", "麻", "芦", "茅",
|
||||
"粟", "稲", "稗", "米", "飯", "糠", "茜", "葵",
|
||||
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜"
|
||||
"榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜"
|
||||
],
|
||||
|
||||
postfixes: [
|
||||
|
|
@ -63,13 +74,13 @@ export const NAME_KANJI_POOLS = {
|
|||
"辺", "里", "郷", "村", "町",
|
||||
"宿", "庄", "台", "坂", "橋",
|
||||
"本", "内", "窪", "平", "塚",
|
||||
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部",
|
||||
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部", "栄", "永",
|
||||
],
|
||||
|
||||
archaicPrefixes: [
|
||||
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "於",
|
||||
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小",
|
||||
"可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己",
|
||||
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇",
|
||||
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇", "総",
|
||||
"多", "太", "知", "津", "土",
|
||||
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
|
||||
"波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂",
|
||||
|
|
@ -81,14 +92,14 @@ export const NAME_KANJI_POOLS = {
|
|||
"日", "紀", "志", "尾", "駿",
|
||||
"甲", "信", "越", "備", "能",
|
||||
"薩", "隠", "美", "三", "若",
|
||||
"遠", "近", "能", "加", "賀", "度",
|
||||
"遠", "近", "能", "加", "賀", "度", "飾",
|
||||
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治", "加茂",
|
||||
],
|
||||
|
||||
archaicSuffixes: [
|
||||
"井", "羽", "江", "恵", "尾", "於",
|
||||
"井", "羽", "江", "恵", "尾",
|
||||
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
|
||||
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇",
|
||||
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総",
|
||||
"多", "太", "知", "津", "豆", "土", "登",
|
||||
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
|
||||
"波", "布", "夫", "戸", "保", "穂",
|
||||
|
|
@ -99,8 +110,8 @@ export const NAME_KANJI_POOLS = {
|
|||
"陀", "芸", "雲",
|
||||
"幡", "耆", "摩",
|
||||
"張", "江", "河", "斐", "濃",
|
||||
"岐", "門", "隅", "向",
|
||||
"居", "前", "中", "後", "波",
|
||||
"岐", "門", "隅", "向", "飾",
|
||||
"居", "前", "中", "後",
|
||||
"勢", "渡", "城", "紫", "野", "度",
|
||||
"津", "島", "信", "登", "賀", "志",
|
||||
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子", "加茂",
|
||||
|
|
@ -110,7 +121,7 @@ export const NAME_KANJI_POOLS = {
|
|||
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
|
||||
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
|
||||
"城", "館", "屋", "家", "所",
|
||||
"市", "場", "関", "地蔵", "辻", "角", "堰",
|
||||
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
|
||||
]
|
||||
};
|
||||
|
||||
|
|
@ -471,6 +482,7 @@ export function validateGeneratedName(name, options = {}) {
|
|||
return { valid: false, reason: "asciiDiagnostic" };
|
||||
}
|
||||
if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" };
|
||||
if (Number.isFinite(options.maxLength) && length > options.maxLength) return { valid: false, reason: "tooLong" };
|
||||
if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" };
|
||||
if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" };
|
||||
return { valid: true, reason: "valid" };
|
||||
|
|
@ -489,20 +501,24 @@ function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedName
|
|||
const context = chooseNameContext(entity, fields);
|
||||
const templateKey = chooseTemplate(context, seed, id, attempt);
|
||||
const template = NAME_TEMPLATES[templateKey];
|
||||
if (!template) return { name: null, context, templateKey: null };
|
||||
// Do not generate old-style three-part random toponyms; names are now either
|
||||
// curated list entries or at most two lexical elements plus any administrative suffix.
|
||||
if (!template || (template.slots?.length || 0) > 2) return { name: null, context, templateKey: null };
|
||||
|
||||
const parts = [];
|
||||
for (let slotIndex = 0; slotIndex < template.slots.length; slotIndex++) {
|
||||
const slot = template.slots[slotIndex];
|
||||
const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex);
|
||||
if (!slotPool?.pool?.length) return { name: null, context, templateKey };
|
||||
const part = pick(slotPool.pool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
|
||||
const atomicPool = slotPool.pool.filter(isAtomicNamePart);
|
||||
if (!atomicPool.length) return { name: null, context, templateKey };
|
||||
const part = pick(atomicPool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
|
||||
if (!part) return { name: null, context, templateKey };
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
const name = parts.join("");
|
||||
const validation = validateGeneratedName(name);
|
||||
const validation = validateGeneratedName(name, { allowLong: false, maxLength: 2 });
|
||||
if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason };
|
||||
if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true };
|
||||
return { name, context, templateKey };
|
||||
|
|
@ -533,6 +549,10 @@ function tryCustomNameList(seed, id, usedNames, debug) {
|
|||
const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length;
|
||||
for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) {
|
||||
const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length];
|
||||
if (nameCharCount(customName) > 2) {
|
||||
debug.invalidNamesRejected++;
|
||||
continue;
|
||||
}
|
||||
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
|
||||
if (!validation.valid) {
|
||||
if (validation.reason === "oneCharacter") {
|
||||
|
|
|
|||
106
renderer.js
106
renderer.js
|
|
@ -314,7 +314,8 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
|
||||
if (isWaterSample(map, fx, fy)) {
|
||||
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
|
||||
color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)];
|
||||
// Calm sky-blue water; less saturated than the previous bright cyan.
|
||||
color = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
|
||||
} else if (mode === "development") {
|
||||
const dCity = distToNearest(map.modernCities, fx, fy);
|
||||
const urban = clamp(1 - dCity / 25);
|
||||
|
|
@ -644,6 +645,36 @@ function drawDebugCells(ctx, map, field, color) {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawTransportDebug(ctx, map) {
|
||||
const layers = map.transportDebug?.layers;
|
||||
if (!layers) return;
|
||||
drawDebugCells(ctx, map, layers.expresswayPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(80, 190, 110, ${0.035 + v * 0.13})`);
|
||||
drawDebugCells(ctx, map, layers.railPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(70, 120, 230, ${0.035 + v * 0.13})`);
|
||||
drawDebugCells(ctx, map, layers.nationalRoadPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(245, 205, 65, ${0.030 + v * 0.12})`);
|
||||
drawDebugCells(ctx, map, layers.slopeSeaPenalty, (v) => v < 0.45 ? "rgba(0,0,0,0)" : `rgba(80, 30, 30, ${0.025 + v * 0.10})`);
|
||||
|
||||
const componentColors = {
|
||||
expressway: "rgba(60, 165, 80, 0.42)",
|
||||
rail: "rgba(65, 95, 210, 0.42)",
|
||||
national: "rgba(210, 155, 20, 0.42)",
|
||||
};
|
||||
ctx.save();
|
||||
for (const comp of layers.components || []) {
|
||||
ctx.fillStyle = componentColors[comp.mode] || "rgba(150,150,150,0.35)";
|
||||
for (const [x, y] of comp.cells || []) ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
for (const repair of layers.repairedSegments || []) {
|
||||
const color = repair.mode === "expressway" ? "rgba(0, 115, 40, 0.95)"
|
||||
: repair.mode === "rail" ? "rgba(35, 70, 210, 0.95)"
|
||||
: repair.mode === "national" ? "rgba(180, 120, 0, 0.95)"
|
||||
: "rgba(210, 35, 155, 0.92)";
|
||||
drawPath(ctx, repair.path, "rgba(255,255,255,0.92)", 5.0);
|
||||
drawPath(ctx, repair.path, color, 2.4);
|
||||
}
|
||||
}
|
||||
|
||||
function prefectureRegionColor(id) {
|
||||
const palette = [
|
||||
[234, 220, 214], [218, 232, 218], [218, 224, 238], [238, 232, 208],
|
||||
|
|
@ -653,10 +684,10 @@ function prefectureRegionColor(id) {
|
|||
}
|
||||
|
||||
function drawPrefectureRegionFill(ctx, map, mode) {
|
||||
if (!["all", "admin", "admin-debug", "borders-debug"].includes(mode)) return;
|
||||
if (!["all", "admin", "borders-debug"].includes(mode)) return;
|
||||
const ids = map.prefectureRegionId;
|
||||
if (!ids) return;
|
||||
const alpha = mode === "borders-debug" ? 0.34 : mode === "admin-debug" ? 0.26 : 0.18;
|
||||
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
|
|
@ -688,30 +719,38 @@ function boxesOverlap(a, b, pad = 3) {
|
|||
|
||||
function labelWithCollision(ctx, p, occupied) {
|
||||
if (!p.name) return false;
|
||||
const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel;
|
||||
ctx.save();
|
||||
ctx.font = "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
|
||||
ctx.font = isPrefectureLabel
|
||||
? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif"
|
||||
: "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
|
||||
const baseX = p.x * CELL_SIZE + CELL_SIZE / 2;
|
||||
const baseY = p.y * CELL_SIZE + CELL_SIZE / 2;
|
||||
const textW = ctx.measureText(p.name).width;
|
||||
const textH = 12;
|
||||
const candidates = [
|
||||
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
|
||||
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
|
||||
];
|
||||
const textH = isPrefectureLabel ? 22 : 12;
|
||||
const candidates = isPrefectureLabel
|
||||
? [
|
||||
[-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24],
|
||||
[10, 6], [-textW - 10, 6],
|
||||
]
|
||||
: [
|
||||
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
|
||||
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
|
||||
];
|
||||
|
||||
for (const [ox, oy] of candidates) {
|
||||
const x = baseX + ox;
|
||||
const y = baseY + oy;
|
||||
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 4 };
|
||||
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
|
||||
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
|
||||
if (occupied.some((b) => boxesOverlap(box, b))) continue;
|
||||
|
||||
if (occupied.some((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : 3))) continue;
|
||||
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineWidth = 3.5;
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
|
||||
ctx.lineWidth = isPrefectureLabel ? 6.2 : 3.5;
|
||||
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
|
||||
ctx.strokeText(p.name, x, y);
|
||||
|
||||
ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333";
|
||||
|
||||
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : "#333333";
|
||||
ctx.fillText(p.name, x, y);
|
||||
occupied.push(box);
|
||||
ctx.restore();
|
||||
|
|
@ -787,8 +826,8 @@ export function drawMap(canvas, map, options) {
|
|||
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
|
||||
// 2. Rivers
|
||||
const waterBlue = "rgba(160, 205, 240, 1)";
|
||||
const mediumBlue = "rgba(160, 205, 240, 0.88)";
|
||||
const waterBlue = "rgba(116, 165, 202, 0.92)";
|
||||
const mediumBlue = "rgba(132, 184, 220, 0.78)";
|
||||
const riverStrengthForPath = (path) => {
|
||||
if (!path || path.length === 0) return 0;
|
||||
let peak = 0;
|
||||
|
|
@ -813,7 +852,7 @@ export function drawMap(canvas, map, options) {
|
|||
for (const path of map.smallStreams || []) {
|
||||
const strength = riverStrengthForPath(path);
|
||||
if ((path?.length || 0) < 5 || strength < 0.045) continue;
|
||||
drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34);
|
||||
drawRiverPath(ctx, map, path, "rgba(140, 190, 224, 0.82)", (s) => s > 0.45 ? 0.52 : s > 0.22 ? 0.42 : 0.32, 0.28);
|
||||
}
|
||||
for (const path of map.tributaryRivers || []) {
|
||||
const strength = riverStrengthForPath(path);
|
||||
|
|
@ -837,26 +876,28 @@ export function drawMap(canvas, map, options) {
|
|||
}
|
||||
|
||||
const showHistory = ["history", "all", "terrain"].includes(mode);
|
||||
const showModern = ["modern", "all", "development", "landuse", "admin-debug", "borders-debug"].includes(mode);
|
||||
const showRoads = ["all", "development"].includes(mode);
|
||||
const showMinorRoads = ["all", "modern", "development"].includes(mode);
|
||||
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
|
||||
const showTransportDebug = mode === "transport-debug";
|
||||
const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode);
|
||||
const showRoads = ["all", "development", "transport-debug"].includes(mode);
|
||||
const showMinorRoads = ["all", "modern", "development", "transport-debug"].includes(mode);
|
||||
const showAdmin = ["admin", "all", "borders-debug"].includes(mode);
|
||||
|
||||
// 3. Borders
|
||||
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
|
||||
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
|
||||
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
|
||||
|
||||
if (showAdmin && map.adminBorders) {
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
if (mode === "borders-debug") {
|
||||
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
|
||||
// artificial horizontal hatching, so only strong terrain dividers are shown.
|
||||
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`);
|
||||
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true);
|
||||
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
|
||||
}
|
||||
if (showTransportDebug) drawTransportDebug(ctx, map);
|
||||
|
||||
if (showPrefectureRegions && map.regionalPrefectureBorders) {
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
|
|
@ -916,7 +957,7 @@ export function drawMap(canvas, map, options) {
|
|||
}
|
||||
|
||||
// 6. Icons & Labels
|
||||
if (["admin", "admin-debug", "borders-debug"].includes(mode)) {
|
||||
if (["admin", "borders-debug"].includes(mode)) {
|
||||
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
|
||||
}
|
||||
|
||||
|
|
@ -937,20 +978,25 @@ export function drawMap(canvas, map, options) {
|
|||
}
|
||||
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
|
||||
for (const p of map.logisticsParks || []) dot(ctx, p, 2.4, "rgba(235, 238, 230, 0.95)", "rgba(105, 125, 105, 0.88)");
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
if (mode === "borders-debug") {
|
||||
for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)");
|
||||
for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)");
|
||||
}
|
||||
if (showTransportDebug) {
|
||||
for (const p of map.transportDebug?.layers?.unservedSettlements || []) {
|
||||
dot(ctx, p, p.repaired ? 3.4 : 4.8, p.repaired ? "rgba(255,255,255,0.92)" : "rgba(255,80,140,0.95)", "rgba(125,35,105,0.95)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
const prefectureLabels = showPrefectureRegions ? (map.prefectureRegions || []).map((p) => ({ ...p, labelPriorityBase: p.labelPriorityBase || 900 })) : [];
|
||||
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, labelPriorityBase: p.labelPriorityBase || 1700 }));
|
||||
if (mode === "admin") {
|
||||
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
|
||||
drawScaleBar(ctx);
|
||||
return;
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
if (mode === "borders-debug") {
|
||||
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
|
||||
drawScaleBar(ctx);
|
||||
return;
|
||||
|
|
@ -970,7 +1016,7 @@ export function drawMap(canvas, map, options) {
|
|||
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
|
||||
...(map.satelliteCities || []),
|
||||
...allLayerTowns,
|
||||
].filter((p) => p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
|
||||
].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
|
||||
drawLabels(ctx, important, mode === "all" ? 85 : 60);
|
||||
}
|
||||
drawScaleBar(ctx);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue