This commit is contained in:
33333-33333 2026-05-28 16:45:00 +09:00
commit c523bf4380
8 changed files with 76 additions and 26 deletions

7
app.js
View file

@ -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();

View file

@ -32,6 +32,15 @@
<section class="card">
<label class="label" for="seed">Seed</label>
<input id="seed" class="input" value="114514" />
<label class="label inline-label" for="generationType">Generation Type</label>
<select id="generationType" class="input">
<option value="auto">Auto</option>
<option value="tohoku_spine">Tohoku spine</option>
<option value="chubu_mountain">Chubu mountain</option>
<option value="setouchi_inland_sea">Setouchi inland sea</option>
<option value="kanto_alluvial">Kanto alluvial plain</option>
<option value="mixed_archipelago">Mixed archipelago</option>
</select>
<button id="randomSeed" type="button" class="primary-button">Generate Random Seed</button>
</section>

View file

@ -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,

View file

@ -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,

View file

@ -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));

View file

@ -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);

View file

@ -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

View file

@ -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}