map/names.js
2026-05-20 23:00:29 +09:00

231 lines
11 KiB
JavaScript

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 = {
// May appear only at the beginning of a name.
prefixOnly: ["青", "白", "黒", "赤", "藍", "大", "小", "新", "古", "浅", "深", "上", "下", "東", "西", "南", "北", "中", "本", "広", "元", "奥", "前", "高", "長", "丸", "一", "壱", "二", "三", "四", "五", "六", "七", "八", "九", "十", "五十", "百", "千", "万", "阿", "佐々", "代々", "千代", "御", "宇", "須", "伊", "牟", "美",],
// Generic morphemes that may appear at the beginning or in the middle.
free: [
//nature
"川", "山", "花", "森", "松", "竹", "梅", "柳", "栃", "杉", "萩", "荻", "柿", "栗", "椎", "榊", "篠", "笹", "蓮", "橘", "柏", "樫", "麻", "桜", "岡", "木", "林", "森", "根", "葉", "葦", "日", "月", "君",
"野", "尾", "砂", "鮎", "鯉", "鯖", "雁", "菊", "錦", "稲", "稗", "朝", "霧", "坂", "葵", "茜", "犬", "馬", "熊", "猿", "鶴", "鹿", "鷲", "鷹", "亀", "龍", "竜", "燕", "鴨",
//objects
"畠", "坪", "室", "門", "脇", "末", "硯", "釜", "鞍", "倉", "蔵", "鎌", "窯", "弓", "巻", "槙", "薪", "秦", "幡", "井", "真", "田", "井", "口", "関", "矢", "手", "戸", "庄", "屋", "土", "鋸", "琴", "笙", "彦", "比", "碑", "多", "垣", "辻",
//others
"豊", "志", "野辺", "那", "納", "延", "芳", "越", "喜", "久", "方", "賀", "雅", "角", "刈", "神", "乃", "曲", "成", "幸", "徳", "甲", "由", "和", "清", "弘", "保", "徳", "福", "富", "住", "幸", "弥", "住", "生", "昌", "相", "香", "吉", "𠮷", "佐", "多", "保", "加", "間", "免"
],
// 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: ["城", "館", "宮", "寺", "社", "陣", "府", "町", "斎", "斉", "妙", "忌", "地蔵", "裏", "表"],
suburban: [],
},
// Context-aware morphemes that may appear only at the end of a name.
contextSuffixes: {
coastal: ["浜", "湊", "灘", "浦", "津", "崎", "嶋", "潟", "洲"],
river: ["川", "瀬", "橋", "渡", "沼", "沢", "滝", "瀧"],
plain: ["原", "野", "田", "里", "村", "平", "鼻"],
mountain: ["谷", "峡", "尾根", "山", "沢", "麓", "嶽"],
historic: ["城", "通", "館", "宮", "町", "寺", "院", "門", "柵", "稲荷"],
suburban: ["丘", "台", "野", "原", "新町", "ヶ丘"],
},
};
export const NAME_PROBABILITIES = {
prefixOnly: 0.36,
contextFreeAsMain: 0.34,
additionalContextFree: 0.22,
secondFree: 0.18,
finalContextSuffix: 0.10,
finalSuffixOnly: 0.50,
finalFree: 0.20,
twoCharacter: 0.80,
};
export const CONTEXT_SUFFIXES = NAME_PARTS.contextSuffixes;
export const CUSTOM_NAMES = {
// "city-0": "Aohara",
// "port-0": "Shirahama",
// "castle-0": "Kurono",
};
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) || "原";
}