diff --git a/mapGenerator.js b/mapGenerator.js index 5766a82..2ec0252 100644 --- a/mapGenerator.js +++ b/mapGenerator.js @@ -1,5 +1,4 @@ import { CUSTOM_NAMES, NAME_PARTS } from "./names.js"; -import { NAME_PROBABILITIES, contextualDefaultName } from "./placeNameContext.js"; import { applyLandscapeUnitAdminPartition, generateAdminRegions, @@ -607,7 +606,6 @@ function applyOutputOptions(map, options = {}) { delete slim.basinField; delete slim.coastalLowland; delete slim.flowAccum; - delete slim.crestCrossingScore; delete slim.erosionField; delete slim.depositionField; return slim; @@ -674,43 +672,6 @@ function recalculatePopulationAfterLanduse(modernCities, satelliteCities, popula } } -function computeCrestCrossingScore(elevation, sea, ridgeField, valleyField, coastalLowland, flowAccum, out) { - out.fill(0); - const pairs = [ - [[1, 0], [-1, 0]], - [[0, 1], [0, -1]], - [[1, 1], [-1, -1]], - [[1, -1], [-1, 1]], - ]; - - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - let bestProminence = 0; - for (const [[ax, ay], [bx, by]] of pairs) { - const ai = indexOf(x + ax, y + ay); - const bi = indexOf(x + bx, y + by); - if (sea[ai] || sea[bi]) continue; - if (elevation[i] <= elevation[ai] || elevation[i] <= elevation[bi]) continue; - bestProminence = Math.max(bestProminence, elevation[i] - Math.max(elevation[ai], elevation[bi])); - } - - if (bestProminence < 0.018) continue; - const ridge = ridgeField[i]; - if (elevation[i] < 0.38 && ridge < 0.34) continue; - - const elevationBoost = Math.exp(Math.max(0, elevation[i] - 0.42) * 3.2); - const ridgeBoost = 0.55 + ridge * 1.9; - const divideSupport = clamp(ridge * 1.25 + Math.max(0, elevation[i] - 0.56) * 0.85); - const valleyPenalty = valleyField[i] * (divideSupport > 0.58 ? 0.22 : 0.72); - const coastPenalty = coastalLowland[i] * (ridge > 0.50 ? 0.20 : 0.82); - const flowPenalty = flowAccum[i] * (ridge > 0.52 ? 0.18 : 0.88); - out[i] = clamp(bestProminence * 18.0 * elevationBoost * ridgeBoost - valleyPenalty - coastPenalty - flowPenalty); - } - } -} - export function generateMap(seedInput = 114514, options = {}) { const seed = Number(seedInput) >>> 0; @@ -731,7 +692,6 @@ export function generateMap(seedInput = 114514, options = {}) { basinField, coastalLowland, flowAccum, - crestCrossingScore, erosionField, depositionField, flowTo, @@ -1478,8 +1438,6 @@ export function generateMap(seedInput = 114514, options = {}) { const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path)); const smallStreams = streamPaths.filter((path) => path.length >= 5); - computeCrestCrossingScore(elevation, sea, ridgeField, valleyField, coastalLowland, flowAccum, crestCrossingScore); - prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); prefectureBorder = extractMaskBorder(prefectureMask, sea); const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); @@ -2857,7 +2815,7 @@ export function generateMap(seedInput = 114514, options = {}) { const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); } - const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, crestCrossingScore, populationDensity, landuse); + const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { @@ -2917,8 +2875,8 @@ export function generateMap(seedInput = 114514, options = {}) { lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 260); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 180); - applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, adminCentersRaw); - snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, crestCrossingScore, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); + applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 360); mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 220); const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); @@ -3024,7 +2982,6 @@ export function generateMap(seedInput = 114514, options = {}) { basinField, coastalLowland, flowAccum, - crestCrossingScore, erosionField, depositionField, villages, diff --git a/names.js b/names.js index 01f8cdc..df76c42 100644 --- a/names.js +++ b/names.js @@ -1,15 +1,245 @@ +import { MAP_H, MAP_W, indexOf, inside } from "./grid.js"; +import { hash2 } from "./random.js"; + // Default and custom place-name resources. // Edit only this file to change generated names or override individual IDs. export const NAME_PARTS = { - prefixes: ["青", "白", "黒", "赤", "東", "西", "南", "北", "中", "大", "小", "浅", "深", "高", "川", "山", "森", "松", "原", "桜", "海", "新", "古", "上", "下", "福", "岡", "宮", "伊", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "木", "林", "富", "住", "根", "葉", "由", "葦", "牟", "日", "月", "君",], - infixes: ["野", "原", "川", "ヶ", "浜", "矢", "丘", "谷", "津", "浦", "沢", "島", "尾", "井", "瀬", "関", "橋", "口", "田", "津", "森", "阿", "見"], - suffixes: ["島", "郷", "台", "丘", "浦", "吉", "里", "野", "沢", "原", "橋", "森", "浜", "呂", "峠"], + // May appear only at the beginning of a name. + prefixOnly: ["青", "白", "黒", "赤", "浅", "深", "大", "小", "新", "古", "上", "下", "東", "西", "南", "北", "中", "本", "元", "奥", "遠", "近", "前", "高", "佐々", "長", "丸", "伊", "阿", "千代", "茨", "美", "宇", "徳", "広", "", "牟", "一", "壱", "二", "三", "四", "五", "六", "七", "八", "九", "十", "五十", "百", "千", "万"], + + // Generic morphemes that may appear at the beginning or in the middle. + free: [ + "前", "内", "母", "父", + "川", "山", "森", "松", "原", "黍", "栃", "梨", "柏", "桜", "杉", "柳", "葛", "竹", "梅", "藤", "篠", "米", "馬", "石", "岩", "鷲", "鳥", "鷹", "犬", "猿", "鹿", "熊", "羽", "木", "林", "根", "葉", "葦", "岐","日", "月", "島", "畠", "野", "田", + "井", "守", "盛", "幡", "河内", "敷", "鍋", "凪", "太", "嘉", "倉", "昌", "芸", "相", "生", "折", "摂", "香", "佳", "加", "笠", "行", "油", "柚", "関", "伯", "桶", "久", "代", "旭", "手", "蓑", "仙", "豊", "飾", "戸", "矢", "玉", "須", "津", "佐", "志", "布", "蘇", "曾", "庄", "甲", "尾", "朝", "比古", "目", "賀", "彦", "姫", "輪", "和", "福", "岡", "富", "住", "由", "君", "飯", "那", "奈", "肥", "越", "曲輪", "車", "渋", "幸", + ], + + // May appear only at the end of a name. + suffixOnly: ["宿", "郷", "里", "新田", "磨", "摩", "原", "口", "泉"], + + // Context-aware morphemes that may appear at the beginning or in the middle. + contextFree: { + coastal: ["浜", "浦", "津", "湊", "潮", "磯", "島", "潮", "塩", "汐", "波", "江"], + river: ["瀬", "川", "橋", "渡", "沢", "淵", "沼", "堀"], + plain: ["原", "野", "田", "畠", "里", "平", "牧", "塚"], + mountain: ["山", "谷", "沢", "峰", "尾", "峠", "湯", "磐", "迫","久保","窪","玖保","硲"], + historic: ["館", "宮", "寺", "社", "陣", "府", "町"], + }, + + // Context-aware morphemes that may appear only at the end of a name. + contextSuffixes: { + coastal: ["浜", "湊", "灘", "浦", "津", "崎"], + river: ["川", "瀬", "橋", "渡", "沼", "沢"], + plain: ["原", "野", "田", "里", "村", "平"], + mountain: ["谷", "峡", "峰", "山", "沢", "岳"], + historic: ["城", "通", "館", "宮", "町"], + }, }; +export const NAME_PROBABILITIES = { + prefixOnly: 0.36, + contextFreeAsMain: 0.34, + additionalContextFree: 0.22, + secondFree: 0.18, + finalContextSuffix: 0.10, + finalSuffixOnly: 0.10, + finalFree: 0.20, + twoCharacter: 0.90, + customName: 0.20, +}; + +export const CONTEXT_SUFFIXES = NAME_PARTS.contextSuffixes; export const CUSTOM_NAMES = { // "city-0": "Aohara", // "port-0": "Shirahama", // "castle-0": "Kurono", + "city-0": "加茂", + "city-1": "高砂", + "city-2": "八幡", + "city-3": "国府", + "city-4": "春日", + "city-5": "御幸", + "city-6": "住吉", + "city-7": "日向", + "city-8": "加茂", + "city-9": "加茂", + "city-10": "飛鳥", + "city-11": "諏訪", + "city-12": "相模", + "city-13": "鹿島", + "city-14": "香取", + "city-15": "住吉", + "city-16": "国文", + "city-17": "愛宕", + "city-18": "安曇", + "castle-0": "佐古", }; + +function isNearSea(x, y, sea, radius = 3) { + if (!sea) return false; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny) && sea[indexOf(nx, ny)]) return true; + } + } + return false; +} + +function boundedIndex(entity) { + return indexOf(Math.max(0, Math.min(MAP_W - 1, entity.x)), Math.max(0, Math.min(MAP_H - 1, entity.y))); +} + +function chooseNameContext(entity, fields) { + const kind = String(entity.kind || "").toLowerCase(); + const i = boundedIndex(entity); + const coastal = (fields?.coastalLowland?.[i] || 0) > 0.35 || isNearSea(entity.x, entity.y, fields?.sea); + const river = (fields?.river?.[i] || 0) > 0.35 || (fields?.valleyField?.[i] || 0) > 0.42; + const plain = (fields?.plain?.[i] || 0) > 0.48 || (fields?.agriculture?.[i] || 0) > 0.46 || (fields?.basinField?.[i] || 0) > 0.35; + const mountain = (fields?.elevation?.[i] || 0) > 0.52 || (fields?.slope?.[i] || 0) > 0.35 || (fields?.ridgeField?.[i] || 0) > 0.42; + + if (kind.includes("port") || kind.includes("harbor") || entity.portClass) return "coastal"; + if (kind.includes("crossing") || kind.includes("bridge")) return "river"; + if (kind.includes("pass") || kind.includes("mountain")) return "mountain"; + if (kind.includes("castle") || kind.includes("market")) return "historic"; + if (kind.includes("new town") || kind.includes("satellite")) return "suburban"; + if (coastal && !mountain) return "coastal"; + if (river) return "river"; + if (mountain && !plain) return "mountain"; + if (plain) return "plain"; + return "plain"; +} + +export function chooseNameSuffixPool(entity, fields) { + const context = chooseNameContext(entity, fields); + return NAME_PARTS.contextSuffixes[context] || NAME_PARTS.contextSuffixes.plain; +} + +function chooseContextFreePool(entity, fields, nameParts) { + const context = chooseNameContext(entity, fields); + return nameParts.contextFree?.[context] || nameParts.contextFree?.plain || nameParts.free || [""]; +} + +function pick(pool, seed, n, salt) { + if (!pool?.length) return ""; + const index = Math.floor(hash2(n + salt * 101, seed + salt * 1009, seed + n * 37 + salt) * pool.length) % pool.length; + return pool[index]; +} + +function chance(seed, n, salt, probability) { + return hash2(n + salt * 173, seed + salt * 811, seed + n * 43 + salt) < probability; +} + +function characterLength(value) { + return Array.from(String(value || "")).length; +} + +function oneCharacterPool(pool, fallbackPool = pool) { + const filtered = (pool || []).filter((part) => characterLength(part) === 1); + return filtered.length ? filtered : (fallbackPool || [""]); +} + +function appendPart(parts, part) { + if (!part) return; + const previous = parts[parts.length - 1] || ""; + if (previous && (previous === part || previous.endsWith(part) || part.startsWith(previous))) return; + parts.push(part); +} + +function appendFinalPart(parts, part, fallbackPool, seed, n, salt) { + let finalPart = part; + if (finalPart && parts.some((existing) => existing === finalPart || existing.endsWith(finalPart))) { + finalPart = pick(fallbackPool, seed, n, salt + 1); + } + appendPart(parts, finalPart); +} + +function trimNameParts(parts) { + if (parts.length <= 3) return parts; + return [parts[0], ...parts.slice(-2)]; +} + +export function contextualDefaultName(seed, id, entity, nameParts = NAME_PARTS, fields, attempt = 0) { + const prefixKey = id.split("-")[0]; + const n = Number(id.split("-")[1] || 0) + attempt * 997; + + const prefixOnly = nameParts.prefixOnly || nameParts.prefixes || [""]; + const free = nameParts.free || nameParts.infixes || [""]; + const suffixOnly = nameParts.suffixOnly || nameParts.suffixes || [""]; + const contextFree = chooseContextFreePool(entity, fields, nameParts); + const contextSuffixes = chooseNameSuffixPool(entity, fields); + const probabilities = nameParts.probabilities || NAME_PROBABILITIES; + + if (chance(seed, n, 7 + prefixKey.length, probabilities.twoCharacter ?? 0.80)) { + const onePrefixOnly = oneCharacterPool(prefixOnly); + const oneFree = oneCharacterPool(free); + const oneSuffixOnly = oneCharacterPool(suffixOnly, oneFree); + const oneContextFree = oneCharacterPool(contextFree, oneFree); + const oneContextSuffixes = oneCharacterPool(contextSuffixes, oneSuffixOnly); + + const usePrefix = chance(seed, n, 11 + prefixKey.length, probabilities.prefixOnly); + const useContextAsMain = chance(seed, n, 13 + prefixKey.length, probabilities.contextFreeAsMain ?? probabilities.contextFree ?? 0.34); + const firstPart = usePrefix + ? pick(onePrefixOnly, seed, n, 101) + : pick(useContextAsMain ? oneContextFree : oneFree, seed, n, useContextAsMain ? 211 : 223); + + const finalContextProbability = probabilities.finalContextSuffix ?? 0.10; + const finalSuffixProbability = probabilities.finalSuffixOnly ?? 0.50; + const finalFreeProbability = probabilities.finalFree ?? Math.max(0, 1 - finalContextProbability - finalSuffixProbability); + const finalTotal = Math.max(0.0001, finalContextProbability + finalSuffixProbability + finalFreeProbability); + const finalRoll = hash2(n + prefixKey.length * 23, seed + n * 37, seed + 7919); + const contextThreshold = finalContextProbability / finalTotal; + const suffixThreshold = (finalContextProbability + finalSuffixProbability) / finalTotal; + let finalPart; + if (finalRoll < contextThreshold) { + finalPart = pick(oneContextSuffixes, seed, n, 503); + } else if (finalRoll < suffixThreshold) { + finalPart = pick(oneSuffixOnly, seed, n, 509); + } else { + finalPart = pick(oneFree, seed, n, 521); + } + if (finalPart === firstPart) finalPart = pick(oneSuffixOnly, seed, n, 601); + if (finalPart === firstPart) finalPart = pick(oneFree, seed, n, 607); + return `${firstPart || pick(oneFree, seed, n, 613)}${finalPart || pick(oneSuffixOnly, seed, n, 617)}`; + } + + const parts = []; + + if (chance(seed, n, 11 + prefixKey.length, probabilities.prefixOnly)) { + appendPart(parts, pick(prefixOnly, seed, n, 101)); + } + + const useContextAsMain = chance(seed, n, 13 + prefixKey.length, probabilities.contextFreeAsMain ?? probabilities.contextFree ?? 0.34); + appendPart(parts, pick(useContextAsMain ? contextFree : free, seed, n, useContextAsMain ? 211 : 223)); + + if (!useContextAsMain && chance(seed, n, 17 + prefixKey.length, probabilities.additionalContextFree ?? probabilities.contextFree ?? 0.22)) { + appendPart(parts, pick(contextFree, seed, n, 307)); + } + + if (chance(seed, n, 19 + prefixKey.length, probabilities.secondFree)) { + appendPart(parts, pick(free, seed, n, 401)); + } + + const finalRoll = hash2(n + prefixKey.length * 23, seed + n * 37, seed + 7919); + let finalPart; + const finalContextProbability = probabilities.finalContextSuffix ?? 0.30; + const finalSuffixProbability = probabilities.finalSuffixOnly ?? 0.50; + const finalFreeProbability = probabilities.finalFree ?? Math.max(0, 1 - finalContextProbability - finalSuffixProbability); + const finalTotal = Math.max(0.0001, finalContextProbability + finalSuffixProbability + finalFreeProbability); + const contextThreshold = finalContextProbability / finalTotal; + const suffixThreshold = (finalContextProbability + finalSuffixProbability) / finalTotal; + if (finalRoll < contextThreshold) { + finalPart = pick(contextSuffixes, seed, n, 503); + } else if (finalRoll < suffixThreshold) { + finalPart = pick(suffixOnly, seed, n, 509); + } else { + finalPart = pick(free, seed, n, 521); + } + appendFinalPart(parts, finalPart, suffixOnly, seed, n, 601); + + const name = trimNameParts(parts).join(""); + return name || pick(suffixOnly, seed, n, 601) || "原"; +}