From f5e7a1df1da0cf4002afd6ab3bea90df9ca2faf6 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sun, 24 May 2026 19:33:09 +0900 Subject: [PATCH] human tweaks --- adminRegions.js | 47 ++- app.js | 66 +++- index.html | 5 + mapAdminStage.js | 182 +++++++++- mapFeaturesV2.js | 869 +++++++++++++++++++++++++++++++++++++++++++++++ mapOutput.js | 9 + mapPipeline.js | 46 ++- renderer.js | 13 +- styles.css | 9 +- 9 files changed, 1201 insertions(+), 45 deletions(-) create mode 100644 mapFeaturesV2.js diff --git a/adminRegions.js b/adminRegions.js index 877c1cf..15f66a8 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -409,25 +409,31 @@ function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, po if (same4 === 1) energy += 1.7; if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25; if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42; - if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) { - const drift = centerDist[candidateId][i] - centerDist[oldId][i]; - if (drift > 0) energy += Math.min(0.9, drift * 0.012); + if (candidateId !== oldId) { + const candidateDistance = centerDistanceAt(centerDist, candidateId, i); + 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; } +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) { - const fields = []; - for (const id of adminIds) { - const center = adminCenters[id]; - const field = new Float32Array(SIZE); - 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; + // Older versions materialized one full SIZE Float32Array per municipality. + // In multi-prefecture generation this can create heavy transient memory use. + // Keep the same interface conceptually, but compute distances on demand. + return { ids: adminIds, centers: adminCenters, prefectureMask, sea }; } 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 = {}) { + 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 cellClass = new Int16Array(SIZE); 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 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); + progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`); const compartmentId = new Int32Array(SIZE); compartmentId.fill(-1); 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; + progress?.("natural seeded growth complete"); let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); mergeTinyLandscapeUnits(compartmentId, compartments, 9); + progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); refreshAllCompartmentStats(compartments, fields); 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)); - let guard = Math.max(80, targetCount * 3); + let guard = Math.max(60, targetCount * 2); while (guard-- > 0) { let active = compartments.filter((unit) => unit && unit.area > 0); const needMore = active.length < targetCount; 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) => { 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; @@ -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 = {}) { + 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); + progress?.("natural compartments built"); const adminId = new Int16Array(SIZE); adminId.fill(-1); const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); + progress?.("natural compartments assigned"); for (const unit of compartments) { const assigned = owner[unit.id]; 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; } repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + progress?.("natural topology repaired"); const activeCompartments = compartments.filter((unit) => unit.area > 0); const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0); return { diff --git a/app.js b/app.js index 17158eb..c881bb3 100644 --- a/app.js +++ b/app.js @@ -34,6 +34,9 @@ const modeGrid = document.getElementById("modeGrid"); const statsEl = document.getElementById("stats"); const idsEl = document.getElementById("nameIds"); const tooltipEl = document.getElementById("mapTooltip"); +const progressEl = document.getElementById("generationProgress"); +const progressStageEl = document.getElementById("generationProgressStage"); +const progressTimingsEl = document.getElementById("generationProgressTimings"); function parseSeed(seedText) { const numeric = Number.parseInt(seedText, 10); @@ -56,10 +59,53 @@ function countText(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) { return [ ["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"], ["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)], ["Market Towns", countText(map.markets)], ["Castles", countText(map.castles)], @@ -202,12 +248,22 @@ function renderModeButtons() { modeGrid.innerHTML = ""; } } -function regenerate() { +async function regenerate() { state.seedText = seedInput.value; - state.map = generateMap(parseSeed(state.seedText)); - renderStats(state.map); - renderNameIds(state.map); - redraw(); + setProgressVisible(true, "Preparing generation..."); + await nextFrame(); + try { + 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() { diff --git a/index.html b/index.html index 8cb1f7f..92a5cb1 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,11 @@
+
diff --git a/mapAdminStage.js b/mapAdminStage.js index 6a9730a..411efc1 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -286,7 +286,22 @@ function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compart 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 habitableCells = 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 mountainRatio = landCells ? mountainCells / landCells : 0; const lowlandBonus = Math.min(7, lowlandCells / 430); - const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2); - return clamp(target, 20, 50); + const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2); + 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 }) { @@ -620,14 +636,24 @@ function generateAdminLayoutForMask({ stations, industrialZones, logisticsParks, + adminRegionMeta = {}, + adminProgress = null, }) { const boundaryRidgeField = naturalBarrierScore ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) : 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 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); - 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({ seed, targetMunicipalityCount, @@ -652,12 +678,14 @@ function generateAdminLayoutForMask({ newTowns, 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, { seed, targetMunicipalityCount, targetCompartmentCount, 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; let previousSnapshot = new Int16Array(adminId); @@ -710,6 +738,7 @@ function generateAdminLayoutForMask({ satelliteMunicipalityStats: satelliteClassificationDebug, ...compartmentAssignment.debug, }; + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" }); const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35); const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation, @@ -735,6 +764,7 @@ function generateAdminLayoutForMask({ adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea); 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); markChanged("changedAfterSmooth"); @@ -797,19 +827,34 @@ function generateAdminLayoutForMask({ const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id)); mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() }); markChanged("changedAfterInitialMerge"); + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" }); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180); markChanged("changedAfterInitialExclaveRemoval"); // 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. 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.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities; adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities; adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities; adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities; adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0; + adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment); + adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild); 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); markChanged("changedAfterSnap"); 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.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0; adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0; + adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" }); const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); @@ -889,7 +935,9 @@ function generateAdminLayoutForMask({ 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) { @@ -907,6 +955,85 @@ function maskLandArea(mask, sea) { 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) { const ids = new Set(); for (let i = 0; i < SIZE; i++) { @@ -918,16 +1045,17 @@ function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) { } 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) - .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); - const combinedAdminId = new Int16Array(SIZE); + let combinedAdminId = new Int16Array(SIZE); combinedAdminId.fill(-1); const combinedHumanMask = new Uint8Array(SIZE); - const combinedCenters = []; + let combinedCenters = []; const combinedCompartmentBorders = []; const perRegion = []; let idOffset = 0; @@ -935,7 +1063,7 @@ export function generateAdminLayout(context) { for (const regionId of regionIds) { const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId); const regionArea = maskLandArea(regionMask, sea); - if (regionArea < 120) continue; + if (regionId !== 0 && regionArea < minFullAdminRegionArea) continue; const localContext = { ...context, @@ -950,9 +1078,18 @@ export function generateAdminLayout(context) { stations: filterPointsForMask(context.stations, regionMask, sea), industrialZones: filterPointsForMask(context.industrialZones, 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); + 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); let localMaxAdminId = -1; 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 }); } + 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 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 totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 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 = { multiRegionAdmin: true, adminRegionCount: perRegion.length, + minFullAdminRegionArea, perRegion, finalMunicipalityCount: totalMunicipalityCount, actualMunicipalityCount: totalMunicipalityCount, candidateSeedCount: combinedCenters.length, + municipalOfficePointCount: combinedCenters.length, + generatedOfficePointCount: compactedAdmin.generatedOfficePointCount, + removedUnusedAdminCenterCount: compactedAdmin.removedUnusedAdminCenterCount, naturalCompartmentCount: totalNaturalCompartmentCount, compartmentCount: totalNaturalCompartmentCount, targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount, diff --git a/mapFeaturesV2.js b/mapFeaturesV2.js new file mode 100644 index 0000000..eecedf9 --- /dev/null +++ b/mapFeaturesV2.js @@ -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, + }; +} diff --git a/mapOutput.js b/mapOutput.js index 02e21d1..342a3b8 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -105,6 +105,8 @@ export function finishMapOutput({ terrainDebug, 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. // Use all generated prefecture regions for human-geography density, not only the focused prefecture. const humanRegionMask = new Uint8Array(MAP_W * MAP_H); @@ -123,6 +125,8 @@ export function finishMapOutput({ } } + outputProgress("harbor works"); + function makeHarborWorks(ports) { const out = []; for (const port of ports) { @@ -155,6 +159,7 @@ export function finishMapOutput({ const usedNames = new Set(); const nameDebug = createNameDebug(); + outputProgress("feature naming"); villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug); ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug); crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug); @@ -171,6 +176,7 @@ export function finishMapOutput({ newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug); castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); + outputProgress("municipality naming"); const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); const representativeFeatures = [ ...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })), @@ -206,6 +212,8 @@ export function finishMapOutput({ } const usedAdminNames = new Set(); 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); const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index); if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { @@ -223,6 +231,7 @@ export function finishMapOutput({ } nameDebug.maxDerivedPerBase = 0; + outputProgress("final package"); const entitiesForNames = [ ...modernCities, ...ports, diff --git a/mapPipeline.js b/mapPipeline.js index bc32a17..f4f30c6 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -1,15 +1,34 @@ import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; import { generateTerrainAndRivers } from "./mapTerrain.js"; -import { generateMapFeatures } from "./mapFeatures.js"; +import { generateMapFeatures } from "./mapFeaturesV2.js"; import { finishMapOutput } from "./mapOutput.js"; import { generateAdminLayout } from "./mapAdminStage.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 = {}) { 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 { terrainTemplate, seaLevel, @@ -54,19 +73,29 @@ export function generateMap(seedInput = 114514, options = {}) { smallStreams, } = terrain; - const features = generateMapFeatures(seed, terrain); + const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain)); const { ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity, railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug, } = 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, 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, 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, @@ -75,5 +104,8 @@ export function generateMap(seedInput = 114514, options = {}) { interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug, riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders, regionalDebug, terrainDebug, transportDebug, - }); + })); + output.generationTimings = generationTimings; + output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0); + return output; } diff --git a/renderer.js b/renderer.js index f0e2eea..d08051f 100644 --- a/renderer.js +++ b/renderer.js @@ -765,9 +765,14 @@ 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})`); if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true); for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)"); - if (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(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); @@ -818,6 +823,10 @@ export function drawMap(canvas, map, options) { } // 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) { for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444"); for (const p of map.modernCities) { diff --git a/styles.css b/styles.css index 5b45a82..9cf2ea4 100644 --- a/styles.css +++ b/styles.css @@ -65,4 +65,11 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .legend-line.harbor-line{background:transparent; border-top:2px solid #5f7896; height:0} .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)} \ No newline at end of file +.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}