human tweaks
This commit is contained in:
parent
1ea8ba1701
commit
f5e7a1df1d
9 changed files with 1201 additions and 45 deletions
|
|
@ -409,25 +409,31 @@ function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, po
|
||||||
if (same4 === 1) energy += 1.7;
|
if (same4 === 1) energy += 1.7;
|
||||||
if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25;
|
if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25;
|
||||||
if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42;
|
if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42;
|
||||||
if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) {
|
if (candidateId !== oldId) {
|
||||||
const drift = centerDist[candidateId][i] - centerDist[oldId][i];
|
const candidateDistance = centerDistanceAt(centerDist, candidateId, i);
|
||||||
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
|
const oldDistance = centerDistanceAt(centerDist, oldId, i);
|
||||||
|
if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) {
|
||||||
|
const drift = candidateDistance - oldDistance;
|
||||||
|
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return energy;
|
return energy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function centerDistanceAt(centerDist, id, i) {
|
||||||
|
const field = centerDist?.fields?.[id] || centerDist?.[id];
|
||||||
|
if (field) return field[i];
|
||||||
|
const center = centerDist?.centers?.[id];
|
||||||
|
if (!center || !inside(center.x, center.y)) return 24;
|
||||||
|
const [x, y] = xyOf(i);
|
||||||
|
return Math.hypot(x - center.x, y - center.y);
|
||||||
|
}
|
||||||
|
|
||||||
function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) {
|
function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) {
|
||||||
const fields = [];
|
// Older versions materialized one full SIZE Float32Array per municipality.
|
||||||
for (const id of adminIds) {
|
// In multi-prefecture generation this can create heavy transient memory use.
|
||||||
const center = adminCenters[id];
|
// Keep the same interface conceptually, but compute distances on demand.
|
||||||
const field = new Float32Array(SIZE);
|
return { ids: adminIds, centers: adminCenters, prefectureMask, sea };
|
||||||
if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)] || !prefectureMask[indexOf(center.x, center.y)]) field.fill(24);
|
|
||||||
else {
|
|
||||||
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) field[indexOf(x, y)] = Math.hypot(x - center.x, y - center.y);
|
|
||||||
}
|
|
||||||
fields[id] = field;
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) {
|
function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) {
|
||||||
|
|
@ -981,6 +987,7 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
||||||
|
const progress = typeof options.progress === "function" ? options.progress : null;
|
||||||
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||||
const cellClass = new Int16Array(SIZE);
|
const cellClass = new Int16Array(SIZE);
|
||||||
cellClass.fill(-1);
|
cellClass.fill(-1);
|
||||||
|
|
@ -991,6 +998,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
||||||
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
|
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
|
||||||
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
|
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
|
||||||
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0);
|
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0);
|
||||||
|
progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`);
|
||||||
const compartmentId = new Int32Array(SIZE);
|
const compartmentId = new Int32Array(SIZE);
|
||||||
compartmentId.fill(-1);
|
compartmentId.fill(-1);
|
||||||
const dist = new Float32Array(SIZE);
|
const dist = new Float32Array(SIZE);
|
||||||
|
|
@ -1017,22 +1025,25 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0;
|
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0;
|
||||||
|
progress?.("natural seeded growth complete");
|
||||||
|
|
||||||
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
|
||||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||||
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
|
||||||
|
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
||||||
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||||
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
|
||||||
refreshAllCompartmentStats(compartments, fields);
|
refreshAllCompartmentStats(compartments, fields);
|
||||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||||
|
progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
|
||||||
|
|
||||||
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
|
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
|
||||||
let guard = Math.max(80, targetCount * 3);
|
let guard = Math.max(60, targetCount * 2);
|
||||||
while (guard-- > 0) {
|
while (guard-- > 0) {
|
||||||
let active = compartments.filter((unit) => unit && unit.area > 0);
|
let active = compartments.filter((unit) => unit && unit.area > 0);
|
||||||
const needMore = active.length < targetCount;
|
const needMore = active.length < targetCount;
|
||||||
const worst = active
|
const worst = active
|
||||||
.filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
|
.filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2;
|
const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2;
|
||||||
const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2;
|
const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2;
|
||||||
|
|
@ -1490,10 +1501,13 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) {
|
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) {
|
||||||
|
const progress = typeof options.progress === "function" ? options.progress : null;
|
||||||
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
|
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
|
||||||
|
progress?.("natural compartments built");
|
||||||
const adminId = new Int16Array(SIZE);
|
const adminId = new Int16Array(SIZE);
|
||||||
adminId.fill(-1);
|
adminId.fill(-1);
|
||||||
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
|
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
|
||||||
|
progress?.("natural compartments assigned");
|
||||||
for (const unit of compartments) {
|
for (const unit of compartments) {
|
||||||
const assigned = owner[unit.id];
|
const assigned = owner[unit.id];
|
||||||
if (assigned < 0) continue;
|
if (assigned < 0) continue;
|
||||||
|
|
@ -1505,6 +1519,7 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
|
||||||
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
|
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
|
||||||
}
|
}
|
||||||
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
|
||||||
|
progress?.("natural topology repaired");
|
||||||
const activeCompartments = compartments.filter((unit) => unit.area > 0);
|
const activeCompartments = compartments.filter((unit) => unit.area > 0);
|
||||||
const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0);
|
const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
66
app.js
66
app.js
|
|
@ -34,6 +34,9 @@ const modeGrid = document.getElementById("modeGrid");
|
||||||
const statsEl = document.getElementById("stats");
|
const statsEl = document.getElementById("stats");
|
||||||
const idsEl = document.getElementById("nameIds");
|
const idsEl = document.getElementById("nameIds");
|
||||||
const tooltipEl = document.getElementById("mapTooltip");
|
const tooltipEl = document.getElementById("mapTooltip");
|
||||||
|
const progressEl = document.getElementById("generationProgress");
|
||||||
|
const progressStageEl = document.getElementById("generationProgressStage");
|
||||||
|
const progressTimingsEl = document.getElementById("generationProgressTimings");
|
||||||
|
|
||||||
function parseSeed(seedText) {
|
function parseSeed(seedText) {
|
||||||
const numeric = Number.parseInt(seedText, 10);
|
const numeric = Number.parseInt(seedText, 10);
|
||||||
|
|
@ -56,10 +59,53 @@ function countText(items) {
|
||||||
return `${insideCount(items)} / outside ${outsideCount(items)}`;
|
return `${insideCount(items)} / outside ${outsideCount(items)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMs(ms) {
|
||||||
|
if (!Number.isFinite(ms)) return "-";
|
||||||
|
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimingRows(timings = []) {
|
||||||
|
if (!progressTimingsEl) return;
|
||||||
|
progressTimingsEl.innerHTML = "";
|
||||||
|
for (const row of timings) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "progress-timing-row";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = row.label;
|
||||||
|
const value = document.createElement("strong");
|
||||||
|
value.textContent = formatMs(row.ms);
|
||||||
|
item.append(label, value);
|
||||||
|
progressTimingsEl.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGenerationProgress(event) {
|
||||||
|
if (!progressEl) return;
|
||||||
|
progressEl.classList.remove("hidden");
|
||||||
|
if (progressStageEl) {
|
||||||
|
progressStageEl.textContent = event?.status === "done"
|
||||||
|
? `Completed: ${event.label} / ${formatMs(event.ms)}`
|
||||||
|
: `Running: ${event?.label || "Preparing"}`;
|
||||||
|
}
|
||||||
|
renderTimingRows(event?.timings || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgressVisible(visible, message = "Preparing") {
|
||||||
|
if (!progressEl) return;
|
||||||
|
progressEl.classList.toggle("hidden", !visible);
|
||||||
|
if (progressStageEl) progressStageEl.textContent = message;
|
||||||
|
if (visible) renderTimingRows([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFrame() {
|
||||||
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
function getStats(map) {
|
function getStats(map) {
|
||||||
return [
|
return [
|
||||||
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
|
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
|
||||||
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
|
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
|
||||||
|
["Generation Time", map.generationTotalMs ? `${formatMs(map.generationTotalMs)} / slowest ${(map.generationTimings || []).slice().sort((a, b) => b.ms - a.ms)[0]?.label || "-"}` : "-"],
|
||||||
["Villages", countText(map.villages)],
|
["Villages", countText(map.villages)],
|
||||||
["Market Towns", countText(map.markets)],
|
["Market Towns", countText(map.markets)],
|
||||||
["Castles", countText(map.castles)],
|
["Castles", countText(map.castles)],
|
||||||
|
|
@ -202,12 +248,22 @@ function renderModeButtons() { modeGrid.innerHTML = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function regenerate() {
|
async function regenerate() {
|
||||||
state.seedText = seedInput.value;
|
state.seedText = seedInput.value;
|
||||||
state.map = generateMap(parseSeed(state.seedText));
|
setProgressVisible(true, "Preparing generation...");
|
||||||
renderStats(state.map);
|
await nextFrame();
|
||||||
renderNameIds(state.map);
|
try {
|
||||||
redraw();
|
state.map = generateMap(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
|
||||||
|
renderStats(state.map);
|
||||||
|
renderNameIds(state.map);
|
||||||
|
redraw();
|
||||||
|
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
|
||||||
|
renderTimingRows(state.map.generationTimings || []);
|
||||||
|
window.setTimeout(() => setProgressVisible(false), 900);
|
||||||
|
} catch (error) {
|
||||||
|
if (progressStageEl) progressStageEl.textContent = `Generation failed: ${error?.message || error}`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function redraw() {
|
function redraw() {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@
|
||||||
|
|
||||||
<div class="canvas-shell">
|
<div class="canvas-shell">
|
||||||
<canvas id="mapCanvas" class="map-canvas"></canvas>
|
<canvas id="mapCanvas" class="map-canvas"></canvas>
|
||||||
|
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
|
||||||
|
<div class="progress-title">Generating map...</div>
|
||||||
|
<div id="generationProgressStage" class="progress-stage">Preparing</div>
|
||||||
|
<div id="generationProgressTimings" class="progress-timings"></div>
|
||||||
|
</div>
|
||||||
<div id="mapTooltip" class="map-tooltip" role="status" aria-live="polite"></div>
|
<div id="mapTooltip" class="map-tooltip" role="status" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
182
mapAdminStage.js
182
mapAdminStage.js
|
|
@ -286,7 +286,22 @@ function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compart
|
||||||
return { changedCells, restoredSeeds };
|
return { changedCells, restoredSeeds };
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) {
|
function municipalityCountBoundsForRegion(landCells, meta = {}) {
|
||||||
|
const focused = meta.isFocusedRegion !== false;
|
||||||
|
if (focused) return { min: 20, max: 50 };
|
||||||
|
// Neighbor prefectures are often visible only as clipped map-edge slivers.
|
||||||
|
// Avoid giving every tiny visible fragment the full 20-municipality floor.
|
||||||
|
let min = 1;
|
||||||
|
if (landCells >= 500) min = 2;
|
||||||
|
if (landCells >= 950) min = 3;
|
||||||
|
if (landCells >= 1700) min = 5;
|
||||||
|
if (landCells >= 2800) min = 7;
|
||||||
|
if (landCells >= 4300) min = 10;
|
||||||
|
const max = clamp(Math.round(landCells / 260 + 2), Math.max(min, 2), 34);
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) {
|
||||||
let landCells = 0;
|
let landCells = 0;
|
||||||
let habitableCells = 0;
|
let habitableCells = 0;
|
||||||
let lowlandCells = 0;
|
let lowlandCells = 0;
|
||||||
|
|
@ -314,8 +329,9 @@ 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 basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
|
||||||
const mountainRatio = landCells ? mountainCells / landCells : 0;
|
const mountainRatio = landCells ? mountainCells / landCells : 0;
|
||||||
const lowlandBonus = Math.min(7, lowlandCells / 430);
|
const lowlandBonus = Math.min(7, lowlandCells / 430);
|
||||||
const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
|
const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
|
||||||
return clamp(target, 20, 50);
|
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
|
||||||
|
return clamp(rawTarget, min, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
|
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
|
||||||
|
|
@ -620,14 +636,24 @@ function generateAdminLayoutForMask({
|
||||||
stations,
|
stations,
|
||||||
industrialZones,
|
industrialZones,
|
||||||
logisticsParks,
|
logisticsParks,
|
||||||
|
adminRegionMeta = {},
|
||||||
|
adminProgress = null,
|
||||||
}) {
|
}) {
|
||||||
const boundaryRidgeField = naturalBarrierScore
|
const boundaryRidgeField = naturalBarrierScore
|
||||||
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
|
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
|
||||||
: ridgeField;
|
: ridgeField;
|
||||||
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
|
||||||
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
|
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
|
||||||
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages });
|
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta });
|
||||||
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
|
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
|
||||||
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360);
|
const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
|
||||||
|
const minCompartmentTarget = adminRegionMeta.isFocusedRegion === false
|
||||||
|
? clamp(Math.round(Math.max(targetMunicipalityCount * 3.2, regionLandArea / 75)), 18, 90)
|
||||||
|
: 120;
|
||||||
|
const maxCompartmentTarget = adminRegionMeta.isFocusedRegion === false
|
||||||
|
? clamp(Math.round(Math.max(targetMunicipalityCount * 5.8, regionLandArea / 38)), minCompartmentTarget, 220)
|
||||||
|
: 360;
|
||||||
|
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
|
||||||
let adminCentersRaw = buildLowlandAdminSeeds({
|
let adminCentersRaw = buildLowlandAdminSeeds({
|
||||||
seed,
|
seed,
|
||||||
targetMunicipalityCount,
|
targetMunicipalityCount,
|
||||||
|
|
@ -652,12 +678,14 @@ function generateAdminLayoutForMask({
|
||||||
newTowns,
|
newTowns,
|
||||||
stations,
|
stations,
|
||||||
});
|
});
|
||||||
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120);
|
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget);
|
||||||
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
|
||||||
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
|
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
|
||||||
seed,
|
seed,
|
||||||
targetMunicipalityCount,
|
targetMunicipalityCount,
|
||||||
targetCompartmentCount,
|
targetCompartmentCount,
|
||||||
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
|
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
|
||||||
|
progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
|
||||||
});
|
});
|
||||||
const adminId = compartmentAssignment.adminId;
|
const adminId = compartmentAssignment.adminId;
|
||||||
let previousSnapshot = new Int16Array(adminId);
|
let previousSnapshot = new Int16Array(adminId);
|
||||||
|
|
@ -710,6 +738,7 @@ function generateAdminLayoutForMask({
|
||||||
satelliteMunicipalityStats: satelliteClassificationDebug,
|
satelliteMunicipalityStats: satelliteClassificationDebug,
|
||||||
...compartmentAssignment.debug,
|
...compartmentAssignment.debug,
|
||||||
};
|
};
|
||||||
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" });
|
||||||
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
|
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
|
||||||
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
|
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
|
||||||
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
|
||||||
|
|
@ -735,6 +764,7 @@ function generateAdminLayoutForMask({
|
||||||
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
|
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
|
||||||
previousSnapshot = new Int16Array(adminId);
|
previousSnapshot = new Int16Array(adminId);
|
||||||
}
|
}
|
||||||
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" });
|
||||||
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
|
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
|
||||||
markChanged("changedAfterSmooth");
|
markChanged("changedAfterSmooth");
|
||||||
|
|
||||||
|
|
@ -797,19 +827,34 @@ function generateAdminLayoutForMask({
|
||||||
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
|
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
|
||||||
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
|
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
|
||||||
markChanged("changedAfterInitialMerge");
|
markChanged("changedAfterInitialMerge");
|
||||||
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" });
|
||||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
|
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
|
||||||
markChanged("changedAfterInitialExclaveRemoval");
|
markChanged("changedAfterInitialExclaveRemoval");
|
||||||
// The initial compartment graph assignment is now the primary natural partition.
|
// The initial compartment graph assignment is now the primary natural partition.
|
||||||
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
|
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
|
||||||
markChanged("changedAfterLandscapePartition");
|
markChanged("changedAfterLandscapePartition");
|
||||||
const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]);
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
|
||||||
|
// The older oversized-lowland pass rebuilds natural compartments a second time.
|
||||||
|
// The current pipeline already performs pending-seed lowland splitting on the active
|
||||||
|
// compartment graph above, so keep the full admin layout while avoiding the duplicate
|
||||||
|
// high-cost recomputation.
|
||||||
|
const oversizedSplitDebug = {
|
||||||
|
changedCells: 0,
|
||||||
|
splitMunicipalities: 0,
|
||||||
|
rejectedMunicipalities: 0,
|
||||||
|
skippedDuplicateCompartmentRebuild: true,
|
||||||
|
skippedForVisibleFragment: adminRegionMeta.isFocusedRegion === false && regionLandArea < 6500,
|
||||||
|
};
|
||||||
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
|
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
|
||||||
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
|
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
|
||||||
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
|
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
|
||||||
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
|
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
|
||||||
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
|
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
|
||||||
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
|
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
|
||||||
|
adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment);
|
||||||
|
adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild);
|
||||||
previousSnapshot = new Int16Array(adminId);
|
previousSnapshot = new Int16Array(adminId);
|
||||||
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" });
|
||||||
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
|
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
|
||||||
markChanged("changedAfterSnap");
|
markChanged("changedAfterSnap");
|
||||||
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
|
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
|
||||||
|
|
@ -881,6 +926,7 @@ function generateAdminLayoutForMask({
|
||||||
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
|
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
|
||||||
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
|
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
|
||||||
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
|
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
|
||||||
|
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" });
|
||||||
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
|
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -889,7 +935,9 @@ function generateAdminLayoutForMask({
|
||||||
|
|
||||||
|
|
||||||
function filterPointsForMask(points = [], mask, sea) {
|
function filterPointsForMask(points = [], mask, sea) {
|
||||||
return (points || []).filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
return (points || [])
|
||||||
|
.filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)])
|
||||||
|
.map((p) => ({ ...p }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) {
|
function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) {
|
||||||
|
|
@ -907,6 +955,85 @@ function maskLandArea(mask, sea) {
|
||||||
return area;
|
return area;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields = {}) {
|
||||||
|
const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))].sort((a, b) => a - b);
|
||||||
|
const idMap = new Map(activeIds.map((oldId, newId) => [oldId, newId]));
|
||||||
|
const newAdminId = new Int16Array(SIZE);
|
||||||
|
newAdminId.fill(-1);
|
||||||
|
const cellsByNewId = Array.from({ length: activeIds.length }, () => []);
|
||||||
|
for (let i = 0; i < SIZE; i++) {
|
||||||
|
if (!humanMask[i] || sea[i]) continue;
|
||||||
|
const newId = idMap.get(adminId[i]);
|
||||||
|
if (newId === undefined) continue;
|
||||||
|
newAdminId[i] = newId;
|
||||||
|
cellsByNewId[newId].push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chooseOffice = (newId, oldId) => {
|
||||||
|
const cells = cellsByNewId[newId] || [];
|
||||||
|
const current = centers[oldId];
|
||||||
|
if (current && inside(current.x, current.y)) {
|
||||||
|
const ci = indexOf(current.x, current.y);
|
||||||
|
if (newAdminId[ci] === newId && humanMask[ci] && !sea[ci]) {
|
||||||
|
return { ...current, localAdminId: newId, oldAdminId: oldId, municipalityOffice: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sx = 0, sy = 0;
|
||||||
|
for (const i of cells) {
|
||||||
|
const [x, y] = xyOf(i);
|
||||||
|
sx += x;
|
||||||
|
sy += y;
|
||||||
|
}
|
||||||
|
const cx = cells.length ? sx / cells.length : current?.x || 0;
|
||||||
|
const cy = cells.length ? sy / cells.length : current?.y || 0;
|
||||||
|
let bestI = cells[0] ?? -1;
|
||||||
|
let bestScore = -INF;
|
||||||
|
for (const i of cells) {
|
||||||
|
const [x, y] = xyOf(i);
|
||||||
|
const land = fields.landuse?.[i] ?? 0;
|
||||||
|
const urbanBonus = land === 3 ? 1.2 : land === 2 ? 1.0 : land === 4 || land === 7 || land === 8 ? 0.55 : land === 1 ? 0.24 : 0;
|
||||||
|
const density = fields.populationDensity?.[i] || 0;
|
||||||
|
const settlement = fields.settlementScore?.[i] || 0;
|
||||||
|
const score =
|
||||||
|
density * 2.25 +
|
||||||
|
settlement * 0.75 +
|
||||||
|
urbanBonus +
|
||||||
|
(fields.plain?.[i] || 0) * 0.32 +
|
||||||
|
(fields.basinField?.[i] || 0) * 0.24 +
|
||||||
|
(fields.coastalLowland?.[i] || 0) * 0.18 +
|
||||||
|
(fields.roadInfluence?.[i] || 0) * 0.34 +
|
||||||
|
(fields.stationInfluence?.[i] || 0) * 0.45 -
|
||||||
|
(fields.slope?.[i] || 0) * 0.52 -
|
||||||
|
Math.hypot(x - cx, y - cy) * 0.018 +
|
||||||
|
hash2(x, y, 91337 + newId) * 0.012;
|
||||||
|
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||||
|
}
|
||||||
|
const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)];
|
||||||
|
return {
|
||||||
|
...(current || {}),
|
||||||
|
x: bx,
|
||||||
|
y: by,
|
||||||
|
score: bestScore > -INF ? bestScore : 0,
|
||||||
|
seedKind: current?.seedKind || "generatedMunicipalOffice",
|
||||||
|
invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
|
||||||
|
localAdminId: newId,
|
||||||
|
oldAdminId: oldId,
|
||||||
|
municipalityOffice: true,
|
||||||
|
generatedOfficePoint: !current || !inside(current.x, current.y) || newAdminId[indexOf(current.x, current.y)] !== newId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminCenters = activeIds.map((oldId, newId) => chooseOffice(newId, oldId));
|
||||||
|
return {
|
||||||
|
adminId: newAdminId,
|
||||||
|
adminCenters,
|
||||||
|
activeMunicipalityCount: activeIds.length,
|
||||||
|
removedUnusedAdminCenterCount: Math.max(0, centers.length - activeIds.length),
|
||||||
|
generatedOfficePointCount: adminCenters.filter((p) => p.generatedOfficePoint).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
|
function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
for (let i = 0; i < SIZE; i++) {
|
for (let i = 0; i < SIZE; i++) {
|
||||||
|
|
@ -918,16 +1045,17 @@ function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateAdminLayout(context) {
|
export function generateAdminLayout(context) {
|
||||||
const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope } = context;
|
const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope, adminProgress } = context;
|
||||||
|
const minFullAdminRegionArea = 1500;
|
||||||
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
|
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
|
||||||
.filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120);
|
.filter((regionId) => regionId === 0 || (regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea));
|
||||||
|
|
||||||
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
|
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
|
||||||
|
|
||||||
const combinedAdminId = new Int16Array(SIZE);
|
let combinedAdminId = new Int16Array(SIZE);
|
||||||
combinedAdminId.fill(-1);
|
combinedAdminId.fill(-1);
|
||||||
const combinedHumanMask = new Uint8Array(SIZE);
|
const combinedHumanMask = new Uint8Array(SIZE);
|
||||||
const combinedCenters = [];
|
let combinedCenters = [];
|
||||||
const combinedCompartmentBorders = [];
|
const combinedCompartmentBorders = [];
|
||||||
const perRegion = [];
|
const perRegion = [];
|
||||||
let idOffset = 0;
|
let idOffset = 0;
|
||||||
|
|
@ -935,7 +1063,7 @@ export function generateAdminLayout(context) {
|
||||||
for (const regionId of regionIds) {
|
for (const regionId of regionIds) {
|
||||||
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
|
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
|
||||||
const regionArea = maskLandArea(regionMask, sea);
|
const regionArea = maskLandArea(regionMask, sea);
|
||||||
if (regionArea < 120) continue;
|
if (regionId !== 0 && regionArea < minFullAdminRegionArea) continue;
|
||||||
|
|
||||||
const localContext = {
|
const localContext = {
|
||||||
...context,
|
...context,
|
||||||
|
|
@ -950,9 +1078,18 @@ export function generateAdminLayout(context) {
|
||||||
stations: filterPointsForMask(context.stations, regionMask, sea),
|
stations: filterPointsForMask(context.stations, regionMask, sea),
|
||||||
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
|
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
|
||||||
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
|
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
|
||||||
|
adminRegionMeta: {
|
||||||
|
regionId,
|
||||||
|
landArea: regionArea,
|
||||||
|
isFocusedRegion: regionId === 0,
|
||||||
|
isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID,
|
||||||
|
},
|
||||||
|
adminProgress,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
adminProgress?.({ status: "region-start", regionId, area: regionArea });
|
||||||
const local = generateAdminLayoutForMask(localContext);
|
const local = generateAdminLayoutForMask(localContext);
|
||||||
|
adminProgress?.({ status: "region-done", regionId, area: regionArea, municipalities: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || 0 });
|
||||||
if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders);
|
if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders);
|
||||||
let localMaxAdminId = -1;
|
let localMaxAdminId = -1;
|
||||||
for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i];
|
for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i];
|
||||||
|
|
@ -1036,8 +1173,21 @@ export function generateAdminLayout(context) {
|
||||||
perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
|
perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, {
|
||||||
|
populationDensity,
|
||||||
|
plain,
|
||||||
|
slope,
|
||||||
|
settlementScore: context.settlementScore,
|
||||||
|
landuse: context.landuse,
|
||||||
|
basinField: context.basinField,
|
||||||
|
coastalLowland: context.coastalLowland,
|
||||||
|
roadInfluence: context.roadInfluence,
|
||||||
|
stationInfluence: context.stationInfluence,
|
||||||
|
});
|
||||||
|
combinedAdminId = compactedAdmin.adminId;
|
||||||
|
combinedCenters = compactedAdmin.adminCenters;
|
||||||
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
|
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
|
||||||
const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size;
|
const totalMunicipalityCount = compactedAdmin.activeMunicipalityCount;
|
||||||
const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0);
|
const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0);
|
||||||
const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0);
|
const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0);
|
||||||
const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0);
|
const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0);
|
||||||
|
|
@ -1045,10 +1195,14 @@ export function generateAdminLayout(context) {
|
||||||
const adminDebug = {
|
const adminDebug = {
|
||||||
multiRegionAdmin: true,
|
multiRegionAdmin: true,
|
||||||
adminRegionCount: perRegion.length,
|
adminRegionCount: perRegion.length,
|
||||||
|
minFullAdminRegionArea,
|
||||||
perRegion,
|
perRegion,
|
||||||
finalMunicipalityCount: totalMunicipalityCount,
|
finalMunicipalityCount: totalMunicipalityCount,
|
||||||
actualMunicipalityCount: totalMunicipalityCount,
|
actualMunicipalityCount: totalMunicipalityCount,
|
||||||
candidateSeedCount: combinedCenters.length,
|
candidateSeedCount: combinedCenters.length,
|
||||||
|
municipalOfficePointCount: combinedCenters.length,
|
||||||
|
generatedOfficePointCount: compactedAdmin.generatedOfficePointCount,
|
||||||
|
removedUnusedAdminCenterCount: compactedAdmin.removedUnusedAdminCenterCount,
|
||||||
naturalCompartmentCount: totalNaturalCompartmentCount,
|
naturalCompartmentCount: totalNaturalCompartmentCount,
|
||||||
compartmentCount: totalNaturalCompartmentCount,
|
compartmentCount: totalNaturalCompartmentCount,
|
||||||
targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount,
|
targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount,
|
||||||
|
|
|
||||||
869
mapFeaturesV2.js
Normal file
869
mapFeaturesV2.js
Normal file
|
|
@ -0,0 +1,869 @@
|
||||||
|
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js";
|
||||||
|
import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
|
||||||
|
import { LANDUSE } from "./landuseCodes.js";
|
||||||
|
|
||||||
|
// Lightweight Human Geography V2
|
||||||
|
// --------------------------------
|
||||||
|
// This replaces the heavy iterative human stage with a sparse skeleton + raster
|
||||||
|
// synthesis model:
|
||||||
|
// 1. build terrain-derived human context once
|
||||||
|
// 2. place villages/towns/cities by region quotas
|
||||||
|
// 3. make sparse approximate transport paths without full-resolution A*
|
||||||
|
// 4. synthesize population and land-use fields in one raster pass
|
||||||
|
|
||||||
|
export function generateMapFeatures(seed, terrain) {
|
||||||
|
const {
|
||||||
|
elevation,
|
||||||
|
moisture,
|
||||||
|
slope,
|
||||||
|
sea,
|
||||||
|
river,
|
||||||
|
floodplain,
|
||||||
|
plain,
|
||||||
|
agriculture,
|
||||||
|
ridgeField,
|
||||||
|
valleyField,
|
||||||
|
basinField,
|
||||||
|
coastalLowland,
|
||||||
|
flowAccum,
|
||||||
|
arcSpineField,
|
||||||
|
branchRidgeField,
|
||||||
|
depositionalLowland,
|
||||||
|
alluvialFanField,
|
||||||
|
deltaField,
|
||||||
|
portSuitability,
|
||||||
|
crossingSuitability,
|
||||||
|
passSuitability,
|
||||||
|
prefectureMask,
|
||||||
|
prefectureRegionId,
|
||||||
|
naturalBarrierScore,
|
||||||
|
} = terrain;
|
||||||
|
|
||||||
|
function regionIdAt(x, y) {
|
||||||
|
if (!inside(x, y)) return -1;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) return -1;
|
||||||
|
if (prefectureMask?.[i]) return 0;
|
||||||
|
const id = prefectureRegionId?.[i];
|
||||||
|
return id !== undefined && id >= 0 ? id : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inFocusedPrefecture(p) {
|
||||||
|
return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localConfluenceScore(x, y) {
|
||||||
|
let arms = 0;
|
||||||
|
let strong = 0;
|
||||||
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
||||||
|
const nx = x + dx;
|
||||||
|
const ny = y + dy;
|
||||||
|
if (!inside(nx, ny)) continue;
|
||||||
|
const rv = river[indexOf(nx, ny)];
|
||||||
|
if (rv > 0.18) arms++;
|
||||||
|
if (rv > 0.34) strong++;
|
||||||
|
}
|
||||||
|
return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. Human context: one full raster pass -----------------------------
|
||||||
|
const developable = new Float32Array(SIZE);
|
||||||
|
const ruralSuitability = new Float32Array(SIZE);
|
||||||
|
const townSuitability = new Float32Array(SIZE);
|
||||||
|
const valleySettlement = new Float32Array(SIZE);
|
||||||
|
const coastalSettlement = new Float32Array(SIZE);
|
||||||
|
const confluenceField = new Float32Array(SIZE);
|
||||||
|
const barrierCost = new Float32Array(SIZE);
|
||||||
|
const corridorCost = new Float32Array(SIZE);
|
||||||
|
const settlementCluster = new Float32Array(SIZE);
|
||||||
|
const settlementScore = new Float32Array(SIZE);
|
||||||
|
|
||||||
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
|
for (let x = 0; x < MAP_W; x++) {
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) {
|
||||||
|
barrierCost[i] = INF;
|
||||||
|
corridorCost[i] = INF;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90;
|
||||||
|
const highPenalty = Math.max(0, elevation[i] - 0.56);
|
||||||
|
const lowSlope = clamp(1 - slope[i] * 2.3);
|
||||||
|
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
|
||||||
|
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
|
||||||
|
confluenceField[i] = confluence;
|
||||||
|
|
||||||
|
developable[i] = clamp(
|
||||||
|
plain[i] * 0.34 +
|
||||||
|
agriculture[i] * 0.24 +
|
||||||
|
basinField[i] * 0.24 +
|
||||||
|
valleyField[i] * 0.24 +
|
||||||
|
coastalLowland[i] * 0.18 +
|
||||||
|
depositional * 0.22 +
|
||||||
|
lowSlope * 0.10 -
|
||||||
|
slope[i] * 0.82 -
|
||||||
|
ridgeField[i] * 0.52 -
|
||||||
|
spine * 0.24 -
|
||||||
|
highPenalty * 1.14 -
|
||||||
|
floodplain[i] * 0.03
|
||||||
|
);
|
||||||
|
valleySettlement[i] = clamp(
|
||||||
|
valleyField[i] * 0.50 +
|
||||||
|
river[i] * 0.16 +
|
||||||
|
confluence * 0.72 +
|
||||||
|
depositional * 0.16 +
|
||||||
|
basinField[i] * 0.10 +
|
||||||
|
lowSlope * 0.10 -
|
||||||
|
slope[i] * 0.58 -
|
||||||
|
ridgeField[i] * 0.34 -
|
||||||
|
spine * 0.18 -
|
||||||
|
highPenalty * 0.72
|
||||||
|
);
|
||||||
|
coastalSettlement[i] = clamp(
|
||||||
|
coastalLowland[i] * 0.50 +
|
||||||
|
(portSuitability?.[i] || 0) * 0.30 +
|
||||||
|
(deltaField?.[i] || 0) * 0.20 +
|
||||||
|
plain[i] * 0.10 -
|
||||||
|
slope[i] * 0.52 -
|
||||||
|
ridgeField[i] * 0.24 -
|
||||||
|
spine * 0.12
|
||||||
|
);
|
||||||
|
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
|
||||||
|
settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise);
|
||||||
|
ruralSuitability[i] = clamp(
|
||||||
|
agriculture[i] * 0.42 +
|
||||||
|
developable[i] * 0.28 +
|
||||||
|
valleySettlement[i] * 0.24 +
|
||||||
|
coastalSettlement[i] * 0.15 +
|
||||||
|
settlementCluster[i] * 0.24 -
|
||||||
|
Math.max(0, elevation[i] - 0.64) * 0.56
|
||||||
|
);
|
||||||
|
townSuitability[i] = clamp(
|
||||||
|
developable[i] * 0.40 +
|
||||||
|
valleySettlement[i] * 0.26 +
|
||||||
|
coastalSettlement[i] * 0.20 +
|
||||||
|
confluence * 0.34 +
|
||||||
|
basinField[i] * 0.16 +
|
||||||
|
plain[i] * 0.12 +
|
||||||
|
settlementCluster[i] * 0.16 -
|
||||||
|
slope[i] * 0.34 -
|
||||||
|
ridgeField[i] * 0.17 -
|
||||||
|
spine * 0.10
|
||||||
|
);
|
||||||
|
settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10);
|
||||||
|
const naturalBarrier = naturalBarrierScore?.[i] || 0;
|
||||||
|
barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14;
|
||||||
|
corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- region statistics ---------------------------------------------------
|
||||||
|
const regionStats = new Map();
|
||||||
|
function ensureRegion(regionId) {
|
||||||
|
let st = regionStats.get(regionId);
|
||||||
|
if (!st) {
|
||||||
|
st = {
|
||||||
|
id: regionId,
|
||||||
|
area: 0,
|
||||||
|
developableCells: 0,
|
||||||
|
developableSum: 0,
|
||||||
|
valleyCells: 0,
|
||||||
|
coastCells: 0,
|
||||||
|
townCells: 0,
|
||||||
|
plainCells: 0,
|
||||||
|
minX: MAP_W,
|
||||||
|
minY: MAP_H,
|
||||||
|
maxX: 0,
|
||||||
|
maxY: 0,
|
||||||
|
};
|
||||||
|
regionStats.set(regionId, st);
|
||||||
|
}
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
for (let y = 0; y < MAP_H; y++) {
|
||||||
|
for (let x = 0; x < MAP_W; x++) {
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const regionId = regionIdAt(x, y);
|
||||||
|
if (regionId < 0) continue;
|
||||||
|
const st = ensureRegion(regionId);
|
||||||
|
st.area++;
|
||||||
|
st.developableSum += developable[i];
|
||||||
|
if (developable[i] > 0.16) st.developableCells++;
|
||||||
|
if (valleySettlement[i] > 0.24) st.valleyCells++;
|
||||||
|
if (coastalSettlement[i] > 0.25) st.coastCells++;
|
||||||
|
if (townSuitability[i] > 0.28) st.townCells++;
|
||||||
|
if (plain[i] > 0.24) st.plainCells++;
|
||||||
|
st.minX = Math.min(st.minX, x);
|
||||||
|
st.minY = Math.min(st.minY, y);
|
||||||
|
st.maxX = Math.max(st.maxX, x);
|
||||||
|
st.maxY = Math.max(st.maxY, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibilityFactor(regionId, st) {
|
||||||
|
if (regionId === 0) return 1.15;
|
||||||
|
if (!st || st.area <= 0) return 0;
|
||||||
|
// Small map-edge slivers should not get the same municipal/human density
|
||||||
|
// as full neighboring prefectures. This keeps external regions legible.
|
||||||
|
return clamp(Math.sqrt(st.area / 1700), 0.28, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRegionalPoints(scoreArray, {
|
||||||
|
stride = 1,
|
||||||
|
threshold = 0.25,
|
||||||
|
minDistance = 6,
|
||||||
|
totalMax = 100,
|
||||||
|
seedOffset = 0,
|
||||||
|
quotaForRegion,
|
||||||
|
predicate = () => true,
|
||||||
|
kind = "Point",
|
||||||
|
extraScore = () => 0,
|
||||||
|
}) {
|
||||||
|
const byRegion = new Map();
|
||||||
|
for (let y = 2; y < MAP_H - 2; y += stride) {
|
||||||
|
for (let x = 2; x < MAP_W - 2; x += stride) {
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i] || !predicate(x, y, i)) continue;
|
||||||
|
const regionId = regionIdAt(x, y);
|
||||||
|
if (regionId < 0) continue;
|
||||||
|
const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055;
|
||||||
|
if (score < threshold) continue;
|
||||||
|
if (!byRegion.has(regionId)) byRegion.set(regionId, []);
|
||||||
|
byRegion.get(regionId).push({ x, y, score, kind, regionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
const st = regionStats.get(regionId);
|
||||||
|
const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0;
|
||||||
|
if (quota <= 0) continue;
|
||||||
|
out.push(...pickEntities(candidates, {
|
||||||
|
max: quota,
|
||||||
|
minDistance,
|
||||||
|
threshold,
|
||||||
|
seed: seed + seedOffset + regionId * 1009,
|
||||||
|
jitter: 0.04,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.score - a.score).slice(0, totalMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) {
|
||||||
|
const candidates = [];
|
||||||
|
for (let y = 2; y < MAP_H - 2; y += stride) {
|
||||||
|
for (let x = 2; x < MAP_W - 2; x += stride) {
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i] || !predicate(x, y, i)) continue;
|
||||||
|
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07;
|
||||||
|
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Sparse points ----------------------------------------------------
|
||||||
|
let ports = pickGlobalPoints(portSuitability || coastalSettlement, {
|
||||||
|
threshold: 0.30 + rand(seed, 1001) * 0.08,
|
||||||
|
max: 10,
|
||||||
|
minDistance: 13,
|
||||||
|
seedOffset: 1000,
|
||||||
|
predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25,
|
||||||
|
}).map((p, n) => {
|
||||||
|
const i = indexOf(p.x, p.y);
|
||||||
|
const harborPotential = (portSuitability?.[i] || 0) + coastalLowland[i] * 0.22 + (deltaField?.[i] || 0) * 0.08 - slope[i] * 0.18;
|
||||||
|
const portClass = n === 0 ? "major" : n < 3 && harborPotential > 0.34 ? "regional" : harborPotential > 0.24 ? "fishing" : "lake";
|
||||||
|
const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port";
|
||||||
|
return { ...p, harborPotential, portClass, kind, score: harborPotential };
|
||||||
|
}).sort((a, b) => b.harborPotential - a.harborPotential);
|
||||||
|
if (ports.length && !ports.some((p) => p.portClass === "major")) {
|
||||||
|
ports[0].portClass = "major";
|
||||||
|
ports[0].kind = "Major Port";
|
||||||
|
}
|
||||||
|
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
|
||||||
|
|
||||||
|
const crossings = pickGlobalPoints(crossingSuitability || confluenceField, {
|
||||||
|
threshold: 0.30 + rand(seed, 1011) * 0.06,
|
||||||
|
max: 18,
|
||||||
|
minDistance: 9,
|
||||||
|
seedOffset: 1010,
|
||||||
|
predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09,
|
||||||
|
}).map((p) => ({ ...p, kind: "River Crossing" }));
|
||||||
|
|
||||||
|
const passes = pickGlobalPoints(passSuitability || valleySettlement, {
|
||||||
|
threshold: 0.18 + rand(seed, 1021) * 0.06,
|
||||||
|
max: 12,
|
||||||
|
minDistance: 11,
|
||||||
|
seedOffset: 1020,
|
||||||
|
predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i],
|
||||||
|
}).map((p) => ({ ...p, kind: "Pass" }));
|
||||||
|
|
||||||
|
const villageScore = new Float32Array(SIZE);
|
||||||
|
for (let i = 0; i < SIZE; i++) {
|
||||||
|
if (sea[i]) continue;
|
||||||
|
villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08);
|
||||||
|
}
|
||||||
|
const villages = pickRegionalPoints(villageScore, {
|
||||||
|
stride: 2,
|
||||||
|
threshold: 0.25 + rand(seed, 1031) * 0.04,
|
||||||
|
totalMax: 140,
|
||||||
|
minDistance: 5,
|
||||||
|
seedOffset: 1030,
|
||||||
|
kind: "Village",
|
||||||
|
quotaForRegion: (regionId, st) => {
|
||||||
|
if (!st || st.developableCells < 10) return 0;
|
||||||
|
const vf = visibilityFactor(regionId, st);
|
||||||
|
const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf;
|
||||||
|
const min = regionId === 0 ? 10 : st.area > 1100 ? 3 : st.area > 280 ? 1 : 0;
|
||||||
|
const max = regionId === 0 ? 30 : st.area > 1800 ? 13 : st.area > 600 ? 7 : 3;
|
||||||
|
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
|
||||||
|
},
|
||||||
|
}).map((p, n) => {
|
||||||
|
const i = indexOf(p.x, p.y);
|
||||||
|
const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village";
|
||||||
|
const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100;
|
||||||
|
return { ...p, kind, population };
|
||||||
|
});
|
||||||
|
|
||||||
|
const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
|
||||||
|
|
||||||
|
const marketScore = new Float32Array(SIZE);
|
||||||
|
for (let y = 2; y < MAP_H - 2; y++) {
|
||||||
|
for (let x = 2; x < MAP_W - 2; x++) {
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const featurePull = Math.max(
|
||||||
|
distanceToNearest(ports, x, y) < 8 ? 0.12 : 0,
|
||||||
|
distanceToNearest(crossings, x, y) < 6 ? 0.10 : 0,
|
||||||
|
confluenceField[i] * 0.26
|
||||||
|
);
|
||||||
|
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.12 : 0;
|
||||||
|
marketScore[i] = clamp(
|
||||||
|
townSuitability[i] * 0.66 +
|
||||||
|
villageInfluence[i] * 0.42 +
|
||||||
|
featurePull +
|
||||||
|
valleyMouth +
|
||||||
|
basinField[i] * 0.10 +
|
||||||
|
plain[i] * 0.09 -
|
||||||
|
slope[i] * 0.20 -
|
||||||
|
ridgeField[i] * 0.10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markets = pickRegionalPoints(marketScore, {
|
||||||
|
stride: 2,
|
||||||
|
threshold: 0.31 + rand(seed, 1041) * 0.045,
|
||||||
|
totalMax: 52,
|
||||||
|
minDistance: 9,
|
||||||
|
seedOffset: 1040,
|
||||||
|
kind: "Market Town",
|
||||||
|
quotaForRegion: (regionId, st) => {
|
||||||
|
if (!st || st.townCells < 8) return 0;
|
||||||
|
const vf = visibilityFactor(regionId, st);
|
||||||
|
const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf;
|
||||||
|
const min = regionId === 0 ? 4 : st.area > 1300 ? 1 : 0;
|
||||||
|
const max = regionId === 0 ? 11 : st.area > 1800 ? 5 : st.area > 650 ? 3 : 1;
|
||||||
|
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
|
||||||
|
},
|
||||||
|
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08,
|
||||||
|
}).map((p, n) => {
|
||||||
|
const i = indexOf(p.x, p.y);
|
||||||
|
const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town";
|
||||||
|
const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000;
|
||||||
|
return { ...p, kind, population };
|
||||||
|
});
|
||||||
|
|
||||||
|
const defenseScore = new Float32Array(SIZE);
|
||||||
|
for (let i = 0; i < SIZE; i++) {
|
||||||
|
if (sea[i]) continue;
|
||||||
|
defenseScore[i] = clamp(
|
||||||
|
confluenceField[i] * 0.38 +
|
||||||
|
townSuitability[i] * 0.16 +
|
||||||
|
ridgeField[i] * clamp(1 - Math.abs(elevation[i] - 0.52) / 0.25) * 0.42 +
|
||||||
|
plain[i] * 0.08 -
|
||||||
|
floodplain[i] * 0.36 -
|
||||||
|
coastalLowland[i] * 0.08
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const castles = pickGlobalPoints(defenseScore, {
|
||||||
|
threshold: 0.34 + rand(seed, 1051) * 0.06,
|
||||||
|
max: 5,
|
||||||
|
minDistance: 16,
|
||||||
|
seedOffset: 1050,
|
||||||
|
}).map((p) => ({
|
||||||
|
...p,
|
||||||
|
kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const castleTowns = castles.map((c, n) => {
|
||||||
|
const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0];
|
||||||
|
const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x;
|
||||||
|
const y = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.y : c.y;
|
||||||
|
return { x, y, score: c.score, kind: "Castle Town", population: 12000 + Math.round(rand(seed, 1060 + n) * 22000 / 1000) * 1000, regionId: regionIdAt(x, y) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 3. Cities by region, without detailed urban flood-fill --------------
|
||||||
|
function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) {
|
||||||
|
if (!p || !inside(p.x, p.y)) return 0;
|
||||||
|
const centerRegion = regionIdAt(p.x, p.y);
|
||||||
|
let capacity = 0;
|
||||||
|
const r = Math.ceil(radius);
|
||||||
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
const x = p.x + dx;
|
||||||
|
const y = p.y + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d > radius) continue;
|
||||||
|
const dev = developable[i];
|
||||||
|
if (dev < 0.04) continue;
|
||||||
|
const radial = clamp(1 - d / Math.max(1, radius));
|
||||||
|
const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24);
|
||||||
|
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(26000, Math.round(capacity / 1000) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urbanCandidates = [
|
||||||
|
...markets.map((p) => ({ ...p, candidateKind: "town" })),
|
||||||
|
...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })),
|
||||||
|
...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })),
|
||||||
|
...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const cityCandidateByRegion = new Map();
|
||||||
|
for (const p of urbanCandidates) {
|
||||||
|
const i = indexOf(p.x, p.y);
|
||||||
|
const regionId = regionIdAt(p.x, p.y);
|
||||||
|
if (regionId < 0) continue;
|
||||||
|
const capacity = estimateUrbanCapacity(p, regionId === 0 ? 30 : 24, regionId === 0 ? 1.12 : 1.0);
|
||||||
|
const score =
|
||||||
|
Math.log10(capacity + 1) * 0.72 +
|
||||||
|
townSuitability[i] * 1.40 +
|
||||||
|
developable[i] * 1.05 +
|
||||||
|
confluenceField[i] * 0.50 +
|
||||||
|
(p.candidateKind === "port" ? 0.48 : 0) +
|
||||||
|
(p.candidateKind === "castleTown" ? 0.22 : 0) +
|
||||||
|
hash2(p.x, p.y, seed + 12000) * 0.16;
|
||||||
|
if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []);
|
||||||
|
cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const modernCities = [];
|
||||||
|
const usedCitySites = [];
|
||||||
|
for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
const st = regionStats.get(regionId);
|
||||||
|
if (!st || st.developableCells < 30) continue;
|
||||||
|
const vf = visibilityFactor(regionId, st);
|
||||||
|
const maxCities = regionId === 0
|
||||||
|
? clamp(Math.round(3 + st.developableCells / 520 + rand(seed, 12100) * 2), 5, 9)
|
||||||
|
: clamp(Math.round((st.developableCells / 850 + 0.8) * vf), st.area > 1500 ? 1 : 0, st.area > 2600 ? 4 : st.area > 950 ? 2 : 1);
|
||||||
|
const selected = pickEntities(list, {
|
||||||
|
max: maxCities,
|
||||||
|
minDistance: regionId === 0 ? 16 : 18,
|
||||||
|
threshold: 0,
|
||||||
|
seed: seed + 12110 + regionId * 313,
|
||||||
|
jitter: 0.02,
|
||||||
|
});
|
||||||
|
for (const p of selected) {
|
||||||
|
if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue;
|
||||||
|
usedCitySites.push(p);
|
||||||
|
modernCities.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modernCities.some((p) => inFocusedPrefecture(p))) {
|
||||||
|
const focusCandidates = [...markets, ...commercialPorts, ...villages].filter((p) => inFocusedPrefecture(p));
|
||||||
|
let fallback = focusCandidates.sort((a, b) => {
|
||||||
|
const ai = indexOf(a.x, a.y);
|
||||||
|
const bi = indexOf(b.x, b.y);
|
||||||
|
return (townSuitability[bi] + developable[bi]) - (townSuitability[ai] + developable[ai]);
|
||||||
|
})[0];
|
||||||
|
if (!fallback) {
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -INF;
|
||||||
|
for (let y = 2; y < MAP_H - 2; y += 2) {
|
||||||
|
for (let x = 2; x < MAP_W - 2; x += 2) {
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (!prefectureMask[i] || sea[i]) continue;
|
||||||
|
const score = townSuitability[i] + developable[i] + hash2(x, y, seed + 12199) * 0.04;
|
||||||
|
if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Local City", regionId: 0 }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback = best;
|
||||||
|
}
|
||||||
|
if (fallback) modernCities.push({
|
||||||
|
...fallback,
|
||||||
|
candidateKind: fallback.candidateKind || "fallback",
|
||||||
|
score: fallback.score || 0.5,
|
||||||
|
capacity: estimateUrbanCapacity(fallback, 30, 1.15),
|
||||||
|
regionId: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
|
||||||
|
for (const [rank, city] of modernCities.entries()) {
|
||||||
|
const isFocused = inFocusedPrefecture(city);
|
||||||
|
const isPrefecturalCapital = isFocused && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
|
||||||
|
const isRegionalCapital = !isFocused && !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId && c.isRegionalCapital);
|
||||||
|
const rawPop = isPrefecturalCapital
|
||||||
|
? 450000 + rand(seed, 12200) * 1150000
|
||||||
|
: isRegionalCapital
|
||||||
|
? 160000 + rand(seed, 12201 + city.regionId * 17) * 460000
|
||||||
|
: 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000;
|
||||||
|
const capMultiplier = isPrefecturalCapital ? 1.22 : isRegionalCapital ? 1.08 : 1.0;
|
||||||
|
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
|
||||||
|
city.population = Math.max(isPrefecturalCapital ? 260000 : isRegionalCapital ? 90000 : 24000, population);
|
||||||
|
city.isPrefecturalCapital = isPrefecturalCapital;
|
||||||
|
city.isRegionalCapital = isRegionalCapital;
|
||||||
|
city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
|
||||||
|
city.kind = city.rank;
|
||||||
|
city.urbanRadius = clamp(4.5 + Math.sqrt(city.population) / 95, 7, isPrefecturalCapital ? 32 : isRegionalCapital ? 27 : 22);
|
||||||
|
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isPrefecturalCapital ? 8.5 : 6.5);
|
||||||
|
city.urbanWeight = clamp(0.85 + Math.log10(Math.max(10000, city.population)) * 0.25, 1.0, 2.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityPopulationCap(city) {
|
||||||
|
const radius = city?.isPrefecturalCapital ? 34 : city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18;
|
||||||
|
const bias = city?.isPrefecturalCapital ? 1.25 : city?.isRegionalCapital ? 1.12 : 1.0;
|
||||||
|
return estimateUrbanCapacity(city, radius, bias);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Lightweight corridors -------------------------------------------
|
||||||
|
function routeLight(a, b, snapRadius = 3) {
|
||||||
|
if (!a || !b) return [];
|
||||||
|
const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15));
|
||||||
|
const out = [];
|
||||||
|
let lastKey = "";
|
||||||
|
for (let s = 0; s <= steps; s++) {
|
||||||
|
const t = s / steps;
|
||||||
|
const fx = a.x + (b.x - a.x) * t;
|
||||||
|
const fy = a.y + (b.y - a.y) * t;
|
||||||
|
let best = null;
|
||||||
|
let bestCost = INF;
|
||||||
|
const radius = snapRadius + (s > 0 && s < steps ? 1 : 0);
|
||||||
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
const x = Math.round(fx + dx);
|
||||||
|
const y = Math.round(fy + dy);
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const lineDist = Math.hypot(x - fx, y - fy);
|
||||||
|
const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05;
|
||||||
|
if (cost < bestCost) {
|
||||||
|
bestCost = cost;
|
||||||
|
best = [x, y];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) best = [Math.round(fx), Math.round(fy)];
|
||||||
|
const key = `${best[0]},${best[1]}`;
|
||||||
|
if (key !== lastKey) {
|
||||||
|
out.push(best);
|
||||||
|
lastKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importantNodesForRegion(regionId) {
|
||||||
|
const inRegion = (p) => regionIdAt(p.x, p.y) === regionId;
|
||||||
|
return [
|
||||||
|
...modernCities.filter(inRegion).map((p) => ({ ...p, nodeWeight: 8 + (p.population || 0) / 120000 })),
|
||||||
|
...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })),
|
||||||
|
...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })),
|
||||||
|
...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })),
|
||||||
|
].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, regionId === 0 ? 18 : 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const premodernRoads = [];
|
||||||
|
const nationalRoads = [];
|
||||||
|
const minorRoads = [];
|
||||||
|
const railways = [];
|
||||||
|
const branchRailways = [];
|
||||||
|
const externalRoads = [];
|
||||||
|
const externalRailways = [];
|
||||||
|
const expressways = [];
|
||||||
|
const ringRoads = [];
|
||||||
|
const ringRailways = [];
|
||||||
|
const ringExpressways = [];
|
||||||
|
const externalExpressways = [];
|
||||||
|
const icAccessRoads = [];
|
||||||
|
const externalGateways = [];
|
||||||
|
|
||||||
|
// Premodern roads connect castles/markets/ports sparsely.
|
||||||
|
for (const c of castles) {
|
||||||
|
const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2);
|
||||||
|
for (const n of near) {
|
||||||
|
const path = routeLight(c, n, 2);
|
||||||
|
if (path.length > 2) premodernRoads.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
|
||||||
|
const nodes = importantNodesForRegion(regionId);
|
||||||
|
if (nodes.length < 2) continue;
|
||||||
|
const connected = [nodes[0]];
|
||||||
|
const remaining = nodes.slice(1);
|
||||||
|
const maxEdges = regionId === 0 ? Math.min(14, nodes.length + 3) : Math.min(7, nodes.length + 1);
|
||||||
|
while (remaining.length && nationalRoads.length < 48) {
|
||||||
|
let best = null;
|
||||||
|
let bestScore = INF;
|
||||||
|
for (const a of connected) {
|
||||||
|
for (const b of remaining) {
|
||||||
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
const score = d - (a.nodeWeight + b.nodeWeight) * 0.9;
|
||||||
|
if (score < bestScore) { bestScore = score; best = { a, b }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) break;
|
||||||
|
const path = routeLight(best.a, best.b, 3);
|
||||||
|
if (path.length > 2) nationalRoads.push(path);
|
||||||
|
connected.push(best.b);
|
||||||
|
remaining.splice(remaining.indexOf(best.b), 1);
|
||||||
|
if (connected.length - 1 >= maxEdges) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A few k-nearest shortcuts for urbanized regions.
|
||||||
|
const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, regionId === 0 ? 8 : 4);
|
||||||
|
for (let i = 0; i < urbanNodes.length; i++) {
|
||||||
|
const a = urbanNodes[i];
|
||||||
|
const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0];
|
||||||
|
if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue;
|
||||||
|
const path = routeLight(a, b, 3);
|
||||||
|
if (path.length > 2) nationalRoads.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Railways: only high-order cities/ports, as a lightweight placeholder.
|
||||||
|
const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, regionId === 0 ? 7 : 4);
|
||||||
|
railNodes.sort((a, b) => a.x - b.x || a.y - b.y);
|
||||||
|
for (let i = 1; i < railNodes.length; i++) {
|
||||||
|
const path = routeLight(railNodes[i - 1], railNodes[i], 4);
|
||||||
|
if (path.length > 4) railways.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// External gateways at land edges; used by naming/UI and later transport work.
|
||||||
|
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
|
||||||
|
const st = regionStats.get(regionId);
|
||||||
|
if (!st || st.area < 140) continue;
|
||||||
|
const edgeCandidates = [];
|
||||||
|
for (let y = st.minY; y <= st.maxY; y += 3) {
|
||||||
|
for (const x of [st.minX, st.maxX]) {
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let x = st.minX; x <= st.maxX; x += 3) {
|
||||||
|
for (const y of [st.minY, st.maxY]) {
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gateway = pickEntities(edgeCandidates, { max: regionId === 0 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0];
|
||||||
|
if (gateway) {
|
||||||
|
gateway.kind = "External Gateway";
|
||||||
|
gateway.regionId = regionId;
|
||||||
|
externalGateways.push(gateway);
|
||||||
|
const target = importantNodesForRegion(regionId)[0];
|
||||||
|
if (target) {
|
||||||
|
const path = routeLight(gateway, target, 3);
|
||||||
|
if (path.length > 2) externalRoads.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate expressways as a very small subset of top inter-city links.
|
||||||
|
const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6);
|
||||||
|
for (let i = 1; i < topCities.length && expressways.length < 4; i++) {
|
||||||
|
const a = topCities[i - 1];
|
||||||
|
const b = topCities[i];
|
||||||
|
if (Math.hypot(a.x - b.x, a.y - b.y) < 85) {
|
||||||
|
const path = routeLight(a, b, 5);
|
||||||
|
if (path.length > 5) expressways.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads, ...expressways], 5);
|
||||||
|
const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4);
|
||||||
|
|
||||||
|
const stations = [];
|
||||||
|
const usedStationKeys = new Set();
|
||||||
|
function addStation(x, y, kind = "Station", score = 1) {
|
||||||
|
x = Math.round(x); y = Math.round(y);
|
||||||
|
if (!inside(x, y) || sea[indexOf(x, y)]) return;
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
if (usedStationKeys.has(key)) return;
|
||||||
|
usedStationKeys.add(key);
|
||||||
|
stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) });
|
||||||
|
}
|
||||||
|
for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5);
|
||||||
|
for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8);
|
||||||
|
const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85);
|
||||||
|
|
||||||
|
// --- 5. Approximate city/town influence and land-use ---------------------
|
||||||
|
const cityInfluence = new Float32Array(SIZE);
|
||||||
|
const coreInfluence = new Float32Array(SIZE);
|
||||||
|
const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9);
|
||||||
|
const populationDensity = new Float32Array(SIZE);
|
||||||
|
|
||||||
|
function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true) {
|
||||||
|
const r = Math.ceil(radius);
|
||||||
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
const x = p.x + dx;
|
||||||
|
const y = p.y + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d > radius) continue;
|
||||||
|
const terrain = terrainWeighted ? clamp(0.20 + developable[i] * 1.05 + valleySettlement[i] * 0.18 + coastalSettlement[i] * 0.10 - slope[i] * 0.22 - ridgeField[i] * 0.14, 0, 1.28) : 1;
|
||||||
|
const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain;
|
||||||
|
if (v > grid[i]) grid[i] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const city of modernCities) {
|
||||||
|
addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.25, true);
|
||||||
|
addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true);
|
||||||
|
}
|
||||||
|
const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25));
|
||||||
|
|
||||||
|
// Industrial/logistics/new town placeholders remain lightweight. They are
|
||||||
|
// routed by land-use proximity rather than expensive search passes.
|
||||||
|
const industrialZones = [];
|
||||||
|
for (const p of [...commercialPorts, ...modernCities.slice(0, 5)]) {
|
||||||
|
const candidates = [];
|
||||||
|
for (let dy = -10; dy <= 10; dy++) {
|
||||||
|
for (let dx = -10; dx <= 10; dx++) {
|
||||||
|
const x = p.x + dx;
|
||||||
|
const y = p.y + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d < 3 || d > 10) continue;
|
||||||
|
const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06;
|
||||||
|
if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const z = pickEntities(candidates, { max: 1, minDistance: 6, seed: seed + 14010 + p.x * 3 + p.y })[0];
|
||||||
|
if (z && industrialZones.every((q) => Math.hypot(q.x - z.x, q.y - z.y) > 13)) industrialZones.push(z);
|
||||||
|
if (industrialZones.length >= 8) break;
|
||||||
|
}
|
||||||
|
const industrialInfluence = influenceFromPoints(industrialZones, 5, () => 1.0);
|
||||||
|
|
||||||
|
const satelliteCities = [];
|
||||||
|
const newTowns = [];
|
||||||
|
const logisticsParks = [];
|
||||||
|
const interchanges = [];
|
||||||
|
var landuse = new Uint8Array(SIZE);
|
||||||
|
|
||||||
|
// Re-run land-use classification after landuse allocation. The loop above is
|
||||||
|
// intentionally inside a helper to keep all thresholds in one place.
|
||||||
|
function classifyLanduse() {
|
||||||
|
landuse.fill(LANDUSE.RURAL);
|
||||||
|
let maxDensity = 0;
|
||||||
|
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 urban = cityInfluence[i] * 0.78 + stationInfluence[i] * 0.22 + roadInfluence[i] * 0.08;
|
||||||
|
const core = coreInfluence[i];
|
||||||
|
const oldTown = oldTownInfluence[i] * 0.72 + townInfluence[i] * 0.42;
|
||||||
|
const rural = villageInfluence[i] * 0.25 + ruralSuitability[i] * 0.30;
|
||||||
|
populationDensity[i] = clamp(urban * 0.74 + core * 0.32 + oldTown * 0.28 + townInfluence[i] * 0.18 + villageInfluence[i] * 0.16 + roadInfluence[i] * 0.05);
|
||||||
|
maxDensity = Math.max(maxDensity, populationDensity[i]);
|
||||||
|
if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) landuse[i] = LANDUSE.FOREST;
|
||||||
|
else if (industrialInfluence[i] > 0.22 && developable[i] > 0.08) landuse[i] = LANDUSE.INDUSTRIAL;
|
||||||
|
else if (core > 0.38 && developable[i] > 0.07) landuse[i] = LANDUSE.CBD;
|
||||||
|
else if (oldTown > 0.24 && developable[i] > 0.06) landuse[i] = LANDUSE.OLD_URBAN;
|
||||||
|
else if (urban > 0.22 && developable[i] > 0.08) landuse[i] = LANDUSE.SUBURB;
|
||||||
|
else if (roadInfluence[i] > 0.22 && developable[i] > 0.16 && townInfluence[i] > 0.07) landuse[i] = LANDUSE.ROADSIDE;
|
||||||
|
else if (agriculture[i] > 0.22 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.16)) landuse[i] = LANDUSE.FARMLAND;
|
||||||
|
else landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
|
||||||
|
}
|
||||||
|
classifyLanduse();
|
||||||
|
|
||||||
|
for (const city of modernCities) {
|
||||||
|
let urbanFootprintCells = 0;
|
||||||
|
let coreFootprintCells = 0;
|
||||||
|
const r = Math.ceil((city.urbanRadius || 8) * 1.3);
|
||||||
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
const x = city.x + dx;
|
||||||
|
const y = city.y + dy;
|
||||||
|
if (!inside(x, y)) continue;
|
||||||
|
const i = indexOf(x, y);
|
||||||
|
if (sea[i]) continue;
|
||||||
|
if (Math.hypot(dx, dy) > r) continue;
|
||||||
|
if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++;
|
||||||
|
if (landuse[i] === LANDUSE.CBD) coreFootprintCells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
city.urbanFootprintCells = urbanFootprintCells;
|
||||||
|
city.coreFootprintCells = coreFootprintCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transportDebug = {
|
||||||
|
humanStageVersion: "v2-sparse-raster",
|
||||||
|
aStarRoutes: 0,
|
||||||
|
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
|
||||||
|
nationalRoadPopulationCoverage: 0,
|
||||||
|
nationalRoadUncoveredPopulation: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ports,
|
||||||
|
crossings,
|
||||||
|
passes,
|
||||||
|
settlementCluster,
|
||||||
|
settlementScore,
|
||||||
|
villages,
|
||||||
|
markets,
|
||||||
|
castles,
|
||||||
|
castleTowns,
|
||||||
|
premodernRoads,
|
||||||
|
minorRoads,
|
||||||
|
modernCities,
|
||||||
|
populationDensity,
|
||||||
|
railways,
|
||||||
|
branchRailways,
|
||||||
|
ringRailways,
|
||||||
|
externalRailways,
|
||||||
|
stations,
|
||||||
|
industrialZones,
|
||||||
|
nationalRoads,
|
||||||
|
ringRoads,
|
||||||
|
expressways,
|
||||||
|
ringExpressways,
|
||||||
|
icAccessRoads,
|
||||||
|
externalRoads,
|
||||||
|
externalExpressways,
|
||||||
|
interchanges,
|
||||||
|
logisticsParks,
|
||||||
|
satelliteCities,
|
||||||
|
newTowns,
|
||||||
|
landuse,
|
||||||
|
stationInfluence,
|
||||||
|
roadInfluence,
|
||||||
|
railInfluence2,
|
||||||
|
villageInfluence,
|
||||||
|
externalGateways,
|
||||||
|
cityPopulationCap,
|
||||||
|
transportDebug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -105,6 +105,8 @@ export function finishMapOutput({
|
||||||
terrainDebug,
|
terrainDebug,
|
||||||
regionalPrefectureBorders,
|
regionalPrefectureBorders,
|
||||||
}) {
|
}) {
|
||||||
|
const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step });
|
||||||
|
outputProgress("population recalculation");
|
||||||
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
|
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
|
||||||
// Use all generated prefecture regions for human-geography density, not only the focused prefecture.
|
// Use all generated prefecture regions for human-geography density, not only the focused prefecture.
|
||||||
const humanRegionMask = new Uint8Array(MAP_W * MAP_H);
|
const humanRegionMask = new Uint8Array(MAP_W * MAP_H);
|
||||||
|
|
@ -123,6 +125,8 @@ export function finishMapOutput({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outputProgress("harbor works");
|
||||||
|
|
||||||
function makeHarborWorks(ports) {
|
function makeHarborWorks(ports) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const port of ports) {
|
for (const port of ports) {
|
||||||
|
|
@ -155,6 +159,7 @@ export function finishMapOutput({
|
||||||
const usedNames = new Set();
|
const usedNames = new Set();
|
||||||
const nameDebug = createNameDebug();
|
const nameDebug = createNameDebug();
|
||||||
|
|
||||||
|
outputProgress("feature naming");
|
||||||
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
|
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
|
||||||
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
|
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
|
||||||
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
|
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
|
||||||
|
|
@ -171,6 +176,7 @@ export function finishMapOutput({
|
||||||
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
|
||||||
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
|
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
|
||||||
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
|
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
|
||||||
|
outputProgress("municipality naming");
|
||||||
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
|
||||||
const representativeFeatures = [
|
const representativeFeatures = [
|
||||||
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
|
||||||
|
|
@ -206,6 +212,8 @@ export function finishMapOutput({
|
||||||
}
|
}
|
||||||
const usedAdminNames = new Set();
|
const usedAdminNames = new Set();
|
||||||
for (const [index, center] of adminCenters.entries()) {
|
for (const [index, center] of adminCenters.entries()) {
|
||||||
|
center.adminNumericId = index;
|
||||||
|
center.municipalityId = index;
|
||||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, 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)) {
|
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||||
|
|
@ -223,6 +231,7 @@ export function finishMapOutput({
|
||||||
}
|
}
|
||||||
nameDebug.maxDerivedPerBase = 0;
|
nameDebug.maxDerivedPerBase = 0;
|
||||||
|
|
||||||
|
outputProgress("final package");
|
||||||
const entitiesForNames = [
|
const entitiesForNames = [
|
||||||
...modernCities,
|
...modernCities,
|
||||||
...ports,
|
...ports,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,34 @@
|
||||||
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||||
import { generateTerrainAndRivers } from "./mapTerrain.js";
|
import { generateTerrainAndRivers } from "./mapTerrain.js";
|
||||||
import { generateMapFeatures } from "./mapFeatures.js";
|
import { generateMapFeatures } from "./mapFeaturesV2.js";
|
||||||
import { finishMapOutput } from "./mapOutput.js";
|
import { finishMapOutput } from "./mapOutput.js";
|
||||||
import { generateAdminLayout } from "./mapAdminStage.js";
|
import { generateAdminLayout } from "./mapAdminStage.js";
|
||||||
|
|
||||||
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
|
||||||
|
|
||||||
|
function nowMs() {
|
||||||
|
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function timedStage(timings, options, key, label, fn) {
|
||||||
|
options?.onProgress?.({ status: "start", key, label, timings: timings.slice() });
|
||||||
|
const t0 = nowMs();
|
||||||
|
const value = fn();
|
||||||
|
const ms = Math.round((nowMs() - t0) * 10) / 10;
|
||||||
|
const entry = { key, label, ms };
|
||||||
|
timings.push(entry);
|
||||||
|
options?.onProgress?.({ status: "done", key, label, ms, timings: timings.slice() });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function generateMap(seedInput = 114514, options = {}) {
|
export function generateMap(seedInput = 114514, options = {}) {
|
||||||
const seed = Number(seedInput) >>> 0;
|
const seed = Number(seedInput) >>> 0;
|
||||||
|
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
|
||||||
|
|
||||||
const terrain = generateTerrainAndRivers(seed);
|
const generationTimings = [];
|
||||||
|
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
|
||||||
|
|
||||||
|
const terrain = stage("terrain", "Terrain, rivers, and prefecture regions", () => generateTerrainAndRivers(seed));
|
||||||
const {
|
const {
|
||||||
terrainTemplate,
|
terrainTemplate,
|
||||||
seaLevel,
|
seaLevel,
|
||||||
|
|
@ -54,19 +73,29 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
smallStreams,
|
smallStreams,
|
||||||
} = terrain;
|
} = terrain;
|
||||||
|
|
||||||
const features = generateMapFeatures(seed, terrain);
|
const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain));
|
||||||
const {
|
const {
|
||||||
ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity,
|
ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity,
|
||||||
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
|
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
|
||||||
interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug,
|
interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug,
|
||||||
} = features;
|
} = features;
|
||||||
|
|
||||||
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
|
const { adminCentersRaw, adminId, adminBorders, adminDebug } = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||||
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||||
});
|
adminProgress: (event) => options?.onProgress?.({
|
||||||
|
...event,
|
||||||
|
key: "admin",
|
||||||
|
label: event.status === "region-done"
|
||||||
|
? `Admin region ${event.regionId} done`
|
||||||
|
: event.status === "admin-step"
|
||||||
|
? `Admin region ${event.regionId}: ${event.step}`
|
||||||
|
: `Admin region ${event.regionId}`,
|
||||||
|
timings: generationTimings.slice(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
return finishMapOutput({
|
const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
|
||||||
seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
|
seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
|
||||||
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
|
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
|
||||||
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
|
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
|
||||||
|
|
@ -75,5 +104,8 @@ export function generateMap(seedInput = 114514, options = {}) {
|
||||||
interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug,
|
interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug,
|
||||||
riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders,
|
riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders,
|
||||||
regionalDebug, terrainDebug, transportDebug,
|
regionalDebug, terrainDebug, transportDebug,
|
||||||
});
|
}));
|
||||||
|
output.generationTimings = generationTimings;
|
||||||
|
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
renderer.js
11
renderer.js
|
|
@ -765,7 +765,12 @@ export function drawMap(canvas, map, options) {
|
||||||
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`);
|
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);
|
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)");
|
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 (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
}
|
||||||
|
|
||||||
|
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
|
||||||
|
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 });
|
||||||
|
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||||
|
|
@ -818,6 +823,10 @@ export function drawMap(canvas, map, options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Icons & Labels
|
// 6. Icons & Labels
|
||||||
|
if (["admin", "admin-debug", "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)");
|
||||||
|
}
|
||||||
|
|
||||||
if (showModern) {
|
if (showModern) {
|
||||||
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
|
||||||
for (const p of map.modernCities) {
|
for (const p of map.modernCities) {
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,10 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
||||||
|
|
||||||
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
|
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
|
||||||
.map-tooltip.visible{opacity:1;transform:translateY(0)}
|
.map-tooltip.visible{opacity:1;transform:translateY(0)}
|
||||||
|
|
||||||
|
.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5}
|
||||||
|
.generation-progress.hidden{display:none}
|
||||||
|
.progress-title{font-weight:700;margin-bottom:4px}
|
||||||
|
.progress-stage{color:#5f6368;margin-bottom:10px}
|
||||||
|
.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043}
|
||||||
|
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue