diff --git a/app.js b/app.js index b9bdc54..a5dfe93 100644 --- a/app.js +++ b/app.js @@ -16,6 +16,7 @@ const modes = [ const state = { seedText: "114514", + generationType: "auto", mode: "all", showFeatures: true, showLabels: true, @@ -26,6 +27,7 @@ const state = { const canvas = document.getElementById("mapCanvas"); const canvasShell = document.querySelector(".canvas-shell"); const seedInput = document.getElementById("seed"); +const generationTypeInput = document.getElementById("generationType"); const randomSeedButton = document.getElementById("randomSeed"); const showFeaturesInput = document.getElementById("showFeatures"); const showLabelsInput = document.getElementById("showLabels"); @@ -336,10 +338,11 @@ function renderModeButtons() { async function regenerate() { state.seedText = seedInput.value; + state.generationType = generationTypeInput?.value || "auto"; setProgressVisible(true, "Preparing generation..."); await nextFrame(); try { - state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress }); + state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress, terrainType: state.generationType }); state.hoverEntities = buildHoverEntities(state.map); renderStats(state.map); redraw(); @@ -369,6 +372,8 @@ function init() { if (event.key === "Enter") regenerate(); }); + generationTypeInput?.addEventListener("change", regenerate); + randomSeedButton.addEventListener("click", () => { seedInput.value = String(Math.floor(Math.random() * 9999999)); regenerate(); diff --git a/index.html b/index.html index 1179b32..a60db97 100644 --- a/index.html +++ b/index.html @@ -32,6 +32,15 @@
+ +
diff --git a/mapOutput.js b/mapOutput.js index d83ebd3..f23c24f 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -96,7 +96,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement } -function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000) { +function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000, focusedPrefectureMask = null) { if (!prefectureRegionId) return 0; const prefIds = new Set(); for (let i = 0; i < prefectureRegionId.length; i++) { @@ -135,6 +135,24 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti if (!p || !inside(p.x, p.y)) return -1; return prefectureRegionId[indexOf(p.x, p.y)] ?? -1; } + const focusedPrefCounts = new Map(); + if (focusedPrefectureMask) { + for (let i = 0; i < focusedPrefectureMask.length; i++) { + if (!focusedPrefectureMask[i] || sea[i]) continue; + const prefId = prefectureRegionId[i] ?? -1; + if (prefId >= 0) focusedPrefCounts.set(prefId, (focusedPrefCounts.get(prefId) || 0) + 1); + } + } + const focusedPrefId = focusedPrefCounts.size + ? [...focusedPrefCounts.entries()].sort((a, b) => b[1] - a[1])[0][0] + : 0; + for (const city of modernCities || []) { + if (city.isPrefecturalCapital) { + city.isPrefecturalCapital = false; + city.rank = city.isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; + city.kind = city.rank; + } + } for (const prefId of [...prefIds].sort((a, b) => a - b)) { const cities = (modernCities || []).filter((p) => prefAt(p) === prefId); let target = cities.slice().sort((a, b) => @@ -175,10 +193,10 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti target.population = promotedPopulation; promoted++; } - target.isPrefecturalCapital = true; target.isRegionalCapital = true; - target.rank = "Prefectural Capital"; - target.kind = "Prefectural Capital"; + target.isPrefecturalCapital = prefId === focusedPrefId; + target.rank = target.isPrefecturalCapital ? "Prefectural Capital" : "Regional Capital"; + target.kind = target.rank; target.labelPriorityBase = Math.max(target.labelPriorityBase || 0, 1150); target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30); target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4); @@ -480,6 +498,17 @@ export function finishMapOutput({ center.canonicalSettlementId = best.id; center.canonicalSettlementName = best.name; center.municipalityRootName = best.name; + const bestAdmin = adminId?.[indexOf(best.x, best.y)]; + const canSnapOffice = inside(best.x, best.y) && !sea[indexOf(best.x, best.y)] && ( + centerAdmin == null || centerAdmin < 0 || bestAdmin == null || bestAdmin < 0 || bestAdmin === centerAdmin + ); + if (canSnapOffice) { + center.generatedOfficeX = center.generatedOfficeX ?? center.x; + center.generatedOfficeY = center.generatedOfficeY ?? center.y; + center.x = best.x; + center.y = best.y; + center.officeSnappedToSettlement = true; + } } else { center.municipalityRootName = center.generatedMunicipalityName; } @@ -513,7 +542,7 @@ export function finishMapOutput({ center.municipalityName = candidate; usedAdminNames.add(center.name); } - const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000); + const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000, prefectureMask); assignMunicipalityPopulations(adminCenters, adminId, nameFields, [ ...modernCities, ...markets, diff --git a/mapPipeline.js b/mapPipeline.js index 45aa2b4..4aa046a 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -51,7 +51,7 @@ export function generateMap(seedInput = 114514, options = {}) { const generationTimings = []; const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn); - const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed)); + const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed, options)); const { elevation, slope, @@ -134,7 +134,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) { const generationTimings = []; const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn); - const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed)); + const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed, options)); const { elevation, slope, diff --git a/mapTerrain.js b/mapTerrain.js index e0e4db5..8452d33 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -168,13 +168,13 @@ const TERRAIN_TYPES = [ mountainOffsetRange: [0.47, 0.53], baseHeightRange: [0.56, 0.82], primaryLengthRange: [0.76, 0.96], - primaryWidthRange: [0.13, 0.22], - systemCountRange: [12, 16], - beltCountRange: [3, 4], - angleSpread: 0.14, - crossSpread: 0.38, + primaryWidthRange: [0.18, 0.30], + systemCountRange: [14, 18], + beltCountRange: [4, 5], + angleSpread: 0.18, + crossSpread: 0.58, lengthScale: 1.22, - widthScale: 0.92, + widthScale: 1.16, heightScale: 0.86, coastStrength: 0.90, plainBiasRange: [0.16, 0.34], @@ -283,7 +283,12 @@ const TERRAIN_TYPES = [ }, ]; -function pickTerrainType(seed) { +function pickTerrainType(seed, requestedType = "auto") { + if (requestedType && requestedType !== "auto") { + const normalizedType = requestedType === "touhoku_spine" ? "tohoku_spine" : requestedType; + const selected = TERRAIN_TYPES.find((type) => type.id === normalizedType); + if (selected) return selected; + } // Terrain type selection is intentionally uniform. Individual terrain // templates still contain their own parameter ranges, but there is no // terrain-type appearance weighting. @@ -299,8 +304,8 @@ function rangeInt(seed, salt, [lo, hi]) { return Math.round(lo + rand(seed, salt) * (hi - lo)); } -export function buildTerrainTemplate(seed) { - const terrainType = pickTerrainType(seed); +export function buildTerrainTemplate(seed, options = {}) { + const terrainType = pickTerrainType(seed, options.terrainType || options.generationType || "auto"); const mountainMode = terrainType.mountainMode === "mixed" ? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif") : terrainType.mountainMode; @@ -1132,7 +1137,7 @@ function enforceLandGradient(elevation, sea, seaLevel) { } } -export function generateTerrainAndRivers(seed) { +export function generateTerrainAndRivers(seed, options = {}) { const fields = createMapFields(); fields.visibleRavineField = new Float32Array(SIZE); fields.surfaceTextureField = new Float32Array(SIZE); @@ -1145,7 +1150,7 @@ export function generateTerrainAndRivers(seed) { passSuitability, } = fields; - const terrainTemplate = buildTerrainTemplate(seed); + const terrainTemplate = buildTerrainTemplate(seed, options); const systems = buildMountainSystems(terrainTemplate, seed); const allRidges = systems.flatMap((system, id) => buildScratchRidges(system, seed, id)); diff --git a/mapTransport.js b/mapTransport.js index 6c3f147..3f0bdb0 100644 --- a/mapTransport.js +++ b/mapTransport.js @@ -1367,11 +1367,12 @@ export function buildDensityFlowRoadTransportSystem(ctx) { } return best ? { target: best, d: bestD } : null; } - function stitchEndpoints(paths, mode, targets, maxAdds, radius) { + function stitchEndpoints(paths, mode, targets, maxAdds, radius, probability = 1) { let added = 0; const endpoints = endpointListWithIds(paths); for (const ep of endpoints) { if (added >= maxAdds) break; + if (probability < 1 && hash2(ep.x, ep.y, seed + 18331 + added * 17) > probability) continue; const near = nearestWithin(ep, targets, radius, true); if (near && addShortConnector(paths, ep, near.target, mode)) added++; } @@ -1380,7 +1381,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) { const expresswayCells = sampledCells(expressways, 1); const nationalCells = sampledCells([...nationalRoads, ...externalRoads], 1); const localCells = sampledCells(minorRoads, 1); - debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 8, 34.0); + debug.expressway += stitchEndpoints(expressways, "expressway", expresswayCells, 14, 46.0, 0.62); debug.national += stitchEndpoints(nationalRoads, "national", nationalCells, 48, 14.0); debug.localToNational += stitchEndpoints(minorRoads, "local", nationalCells, 240, 20.0); debug.local += stitchEndpoints(minorRoads, "local", localCells, 260, 16.0); diff --git a/renderer.js b/renderer.js index f16649e..501526f 100644 --- a/renderer.js +++ b/renderer.js @@ -1018,8 +1018,8 @@ export function drawMap(canvas, map, options) { for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3); } if (showRoads) { - for (const path of map.expressways) drawLandPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); - for (const path of map.externalExpressways) drawLandPath(ctx, map, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); } // 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads. @@ -1037,8 +1037,8 @@ export function drawMap(canvas, map, options) { for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); } if (showRoads) { - for (const path of map.expressways) drawLandPath(ctx, map, path, "rgba(135, 160, 135, 0.88)", 2.4); - for (const path of map.externalExpressways) drawLandPath(ctx, map, path, "rgba(135, 160, 135, 0.88)", 2.4); + for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); } // 6. Icons & Labels diff --git a/styles.css b/styles.css index e42808d..a09f2f7 100644 --- a/styles.css +++ b/styles.css @@ -1,6 +1,6 @@ *{box-sizing:border-box} body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} -button,input{font:inherit} +button,input,select{font:inherit} code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .app{min-height:100vh;padding:16px} .layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;max-width:1360px;margin:0 auto} @@ -13,6 +13,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .sidebar{display:flex;flex-direction:column;gap:12px} .card{padding:14px} .label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600} +.inline-label{margin-top:12px} .input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s} .input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)} .primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s}