Names generator
This commit is contained in:
parent
a14a602f23
commit
2bedecb677
|
|
@ -17,7 +17,7 @@
|
|||
* rng.setState(saved); // restore
|
||||
*/
|
||||
|
||||
import { stringHash } from "./utils";
|
||||
import { capitalize, stringHash } from "./utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -26,6 +26,34 @@ import { stringHash } from "./utils";
|
|||
/** Four-word xoshiro128** state. Serialisable to / from JSON. */
|
||||
export type RNGState = [number, number, number, number];
|
||||
|
||||
export interface NameParts {
|
||||
onsets?: string[];
|
||||
vowels?: string[];
|
||||
codas?: string[];
|
||||
prefixes?: string[];
|
||||
suffixes?: string[];
|
||||
}
|
||||
|
||||
export interface NameOptions {
|
||||
parts?: NameParts;
|
||||
|
||||
minSyllables?: number;
|
||||
maxSyllables?: number;
|
||||
|
||||
prefixChance?: number;
|
||||
onsetChance?: number;
|
||||
codaChance?: number;
|
||||
suffixChance?: number;
|
||||
|
||||
capitalize?: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
|
||||
maxAttempts?: number;
|
||||
|
||||
bannedPatterns?: RegExp[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (module-private)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -59,6 +87,43 @@ function buildState(seed: number | string): RNGState {
|
|||
return [s0, s1, s2, s3];
|
||||
}
|
||||
|
||||
function cleanupName(raw: string): string {
|
||||
let s = raw.toLowerCase();
|
||||
|
||||
// Keep letters, apostrophes and hyphens only.
|
||||
s = s.replace(/[^a-z'-]/g, "");
|
||||
|
||||
// Collapse repeated separators.
|
||||
s = s.replace(/(['-])\1+/g, "$1");
|
||||
|
||||
// Collapse extreme repeats.
|
||||
s = s.replace(/([bcdfghjklmnpqrstvwxyz])\1{2,}/g, "$1$1");
|
||||
s = s.replace(/([aeiouy])\1{2,}/g, "$1$1");
|
||||
|
||||
// Remove separators at edges.
|
||||
s = s.replace(/^['-]+|['-]+$/g, "");
|
||||
|
||||
// Remove awkward separator chains.
|
||||
s = s.replace(/['-]{2,}/g, "-");
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function looksBad(name: string, bannedPatterns: RegExp[]): boolean {
|
||||
if (name.length < 2) return true;
|
||||
|
||||
const tests = [
|
||||
/[bcdfghjklmnpqrstvwxyz]{5,}/i,
|
||||
/[aeiouy]{5,}/i,
|
||||
/(.)\1\1/i,
|
||||
/^['-]|['-]$/i,
|
||||
/['-]{2,}/i,
|
||||
...bannedPatterns,
|
||||
];
|
||||
|
||||
return tests.some((re) => re.test(name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -143,6 +208,77 @@ export class SeededRandom {
|
|||
return (this.next() & 1) === 1;
|
||||
}
|
||||
|
||||
nextName(options: NameOptions = {}): string {
|
||||
const parts: Required<NameParts> = {
|
||||
onsets: options.parts?.onsets?.length ? options.parts.onsets : DEFAULT_PARTS.onsets,
|
||||
vowels: options.parts?.vowels?.length ? options.parts.vowels : DEFAULT_PARTS.vowels,
|
||||
codas: options.parts?.codas?.length ? options.parts.codas : DEFAULT_PARTS.codas,
|
||||
prefixes: options.parts?.prefixes?.length ? options.parts.prefixes : DEFAULT_PARTS.prefixes,
|
||||
suffixes: options.parts?.suffixes?.length ? options.parts.suffixes : DEFAULT_PARTS.suffixes
|
||||
};
|
||||
|
||||
const minSyllables = options.minSyllables ?? 2;
|
||||
const maxSyllables = options.maxSyllables ?? 3;
|
||||
|
||||
const prefixChance = options.prefixChance ?? 0.18;
|
||||
const onsetChance = options.onsetChance ?? 0.92;
|
||||
const codaChance = options.codaChance ?? 0.58;
|
||||
const suffixChance = options.suffixChance ?? 0.12;
|
||||
|
||||
const capitalizeOutput = options.capitalize ?? true;
|
||||
const minLength = options.minLength ?? 3;
|
||||
const maxLength = options.maxLength ?? 12;
|
||||
|
||||
const maxAttempts = options.maxAttempts ?? 32;
|
||||
const bannedPatterns = options.bannedPatterns ?? DEFAULT_BANNED;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
let name = "";
|
||||
|
||||
if (parts.prefixes.length > 0 && this.nextFloat() < prefixChance) {
|
||||
name += this.choice(parts.prefixes);
|
||||
}
|
||||
|
||||
const syllables = this.nextInt(minSyllables, maxSyllables);
|
||||
|
||||
for (let i = 0; i < syllables; i++) {
|
||||
if (parts.onsets.length > 0 && this.nextFloat() < onsetChance) {
|
||||
name += this.choice(parts.onsets);
|
||||
}
|
||||
|
||||
name += this.choice(parts.vowels);
|
||||
|
||||
if (parts.codas.length > 0 && this.nextFloat() < codaChance) {
|
||||
name += this.choice(parts.codas);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.suffixes.length > 0 && this.nextFloat() < suffixChance) {
|
||||
name += this.choice(parts.suffixes);
|
||||
}
|
||||
|
||||
name = cleanupName(name);
|
||||
|
||||
if (
|
||||
name.length >= minLength &&
|
||||
name.length <= maxLength &&
|
||||
!looksBad(name, bannedPatterns)
|
||||
) {
|
||||
return capitalizeOutput ? capitalize(name) : name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: force something usable even if the filters were too strict.
|
||||
let fallback = cleanupName(
|
||||
this.choice(parts.onsets) + this.choice(parts.vowels) + this.choice(parts.codas)
|
||||
);
|
||||
|
||||
if (!fallback) fallback = "name";
|
||||
fallback = fallback.slice(0, Math.max(minLength, Math.min(maxLength, fallback.length)));
|
||||
|
||||
return capitalizeOutput ? capitalize(fallback) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniform random element from any iterable.
|
||||
* Arrays are read in O(1); other iterables are materialised first.
|
||||
|
|
@ -293,16 +429,16 @@ export class SeededRandom {
|
|||
* Create an independent child RNG for a sub-resource (room, NPC, particle
|
||||
* system, …).
|
||||
*
|
||||
* The child starts from the *current* state. The parent then advances by
|
||||
* one step so parent and child immediately diverge — neither will reproduce
|
||||
* The child starts from the *current* state. The parent then advances
|
||||
* so parent and child immediately diverge — neither will reproduce
|
||||
* the other's sequence.
|
||||
*
|
||||
* Typical pattern:
|
||||
* const worldRng = new SeededRandom('seed-42');
|
||||
* const dungeonRng = worldRng.clone(); // independent dungeon stream
|
||||
* const npcRng = worldRng.clone(); // independent NPC stream
|
||||
* const dungeonRng = worldRng.fork(); // independent dungeon stream
|
||||
* const npcRng = worldRng.fork(); // independent NPC stream
|
||||
*/
|
||||
clone(): SeededRandom {
|
||||
fork(): SeededRandom {
|
||||
const child = new SeededRandom(this.getState());
|
||||
this.jump(); // diverge parent so the two streams never overlap
|
||||
return child;
|
||||
|
|
@ -369,3 +505,173 @@ export class SeededRandom {
|
|||
return new SeededRandom(json.state);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_PARTS: Required<NameParts> = {
|
||||
onsets: [
|
||||
"", "", "",
|
||||
"b", "br", "c", "cr", "d", "dr", "f", "f", "g", "gr",
|
||||
"k", "kr", "l", "m", "n", "p", "pr", "r", "s", "sh",
|
||||
"st", "str", "t", "th", "tr", "v", "w", "z"
|
||||
],
|
||||
vowels: [
|
||||
"a", "a", "a", "a", "e", "e", "e", "i", "i", "o", "o", "o", "o", "u", "u",
|
||||
"ae", "ai", "ea", "ie", "oa", "ou", "ui"
|
||||
],
|
||||
codas: [
|
||||
"", "", "", "", "n", "r", "s", "th", "k", "l", "m", "nd",
|
||||
"rd", "st", "sh", "x", "z"
|
||||
],
|
||||
prefixes: [
|
||||
"Al", "Bel", "Cor", "Dar", "El", "Fae", "Gal", "Har",
|
||||
"Is", "Kel", "Mar", "Nor", "Or", "Val", "Wil"
|
||||
],
|
||||
suffixes: [
|
||||
"a", "an", "ar", "as", "en", "er", "eth", "ia", "in",
|
||||
"ir", "or", "os", "um", "us", "wyn", "iel"
|
||||
]
|
||||
};
|
||||
|
||||
const DEFAULT_BANNED: RegExp[] = [
|
||||
/aaa/i,
|
||||
/eee/i,
|
||||
/iii/i,
|
||||
/ooo/i,
|
||||
/uuu/i,
|
||||
/[bcdfghjklmnpqrstvwxyz]{5,}/i,
|
||||
/[aeiouy]{5,}/i,
|
||||
/q(?!u)/i,
|
||||
/xq|qx|zx|qz/i,
|
||||
/vv/i
|
||||
];
|
||||
|
||||
export const MALE_NAME_OPTIONS: NameOptions = {
|
||||
minSyllables: 2,
|
||||
maxSyllables: 3,
|
||||
|
||||
prefixChance: 0.12,
|
||||
suffixChance: 0.08,
|
||||
|
||||
maxLength: 11,
|
||||
|
||||
parts: {
|
||||
onsets: [
|
||||
"", "",
|
||||
"b", "br", "d", "dr", "f", "g", "gr",
|
||||
"k", "kr", "l", "m", "n", "p", "pr",
|
||||
"r", "s", "st", "t", "th", "tr", "v", "w"
|
||||
],
|
||||
|
||||
vowels: [
|
||||
"a", "a", "e", "e", "i", "o", "o", "u",
|
||||
"ae", "ai", "oa"
|
||||
],
|
||||
|
||||
codas: [
|
||||
"", "", "",
|
||||
"n", "r", "s", "k", "l", "m",
|
||||
"nd", "rd", "st", "th"
|
||||
],
|
||||
|
||||
prefixes: [
|
||||
"Al", "Bar", "Cor", "Dar", "Ed",
|
||||
"Gar", "Hal", "Kor", "Mor", "Tor", "Var"
|
||||
],
|
||||
|
||||
suffixes: [
|
||||
"an", "ar", "en", "or", "ric",
|
||||
"us", "ik", "vald", "mir"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export const FEMALE_NAME_OPTIONS: NameOptions = {
|
||||
minSyllables: 2,
|
||||
maxSyllables: 4,
|
||||
|
||||
prefixChance: 0.18,
|
||||
suffixChance: 0.22,
|
||||
|
||||
maxLength: 12,
|
||||
|
||||
parts: {
|
||||
onsets: [
|
||||
"", "", "",
|
||||
"l", "l", "m", "n", "r", "s",
|
||||
"sh", "th", "v", "f", "el", "al",
|
||||
"cel", "ly", "my"
|
||||
],
|
||||
|
||||
vowels: [
|
||||
"a", "a", "e", "e", "i", "i", "o",
|
||||
"u", "ae", "ia", "ea", "ie", "ou"
|
||||
],
|
||||
|
||||
codas: [
|
||||
"", "", "", "",
|
||||
"l", "n", "ra", "na", "s", "th"
|
||||
],
|
||||
|
||||
prefixes: [
|
||||
"Ael", "Ela", "Fae", "Lia",
|
||||
"Mira", "Ny", "Syl", "Vela"
|
||||
],
|
||||
|
||||
suffixes: [
|
||||
"a", "ia", "elle", "eth", "ina",
|
||||
"ira", "is", "wyn", "ara", "iel"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export const ANIMAL_NAME_OPTIONS: NameOptions = {
|
||||
minSyllables: 1,
|
||||
maxSyllables: 2,
|
||||
|
||||
prefixChance: 0.05,
|
||||
suffixChance: 0.02,
|
||||
|
||||
minLength: 3,
|
||||
maxLength: 9,
|
||||
|
||||
parts: {
|
||||
onsets: [
|
||||
"", "",
|
||||
"b", "br", "ch", "cl", "cr",
|
||||
"f", "fl", "gr", "h", "k",
|
||||
"kr", "m", "p", "pr", "r",
|
||||
"sc", "sk", "sn", "sp", "st",
|
||||
"t", "tr", "w", "z"
|
||||
],
|
||||
|
||||
vowels: [
|
||||
"a", "e", "i", "o", "u",
|
||||
"oo", "ee", "ai"
|
||||
],
|
||||
|
||||
codas: [
|
||||
"", "", "",
|
||||
"f", "g", "k", "l", "m",
|
||||
"n", "p", "r", "rk", "sh",
|
||||
"sk", "t", "x", "z"
|
||||
],
|
||||
|
||||
prefixes: [
|
||||
"Black", "Grey", "Red", "Swift",
|
||||
"Wild", "Ash", "Stone"
|
||||
],
|
||||
|
||||
suffixes: [
|
||||
"claw", "fang", "fur",
|
||||
"tail", "paw", "snout"
|
||||
]
|
||||
},
|
||||
|
||||
bannedPatterns: [
|
||||
...DEFAULT_BANNED,
|
||||
/human/i
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -119,6 +119,11 @@ export const throttle = function <T, A extends unknown[], R>(func: F<T, A, R>, m
|
|||
export const callUpdater = <T>(f: StateUpdater<T>, prev: T) =>
|
||||
typeof f === 'function' ? (f as Function)(prev) : f;
|
||||
|
||||
export function capitalize(text: string): string {
|
||||
if (!text) return text;
|
||||
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export const formatNumber = (n: number): string => {
|
||||
if (n === 0) return '0';
|
||||
if (n < 0) return `-${formatNumber(-n)}`;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
import { createCanvas } from "@common/display/canvas";
|
||||
import { BSP } from "@common/level/bsp";
|
||||
import { stringHash } from "@common/utils";
|
||||
|
||||
const S = 512;
|
||||
import { SeededRandom, ANIMAL_NAME_OPTIONS, FEMALE_NAME_OPTIONS, MALE_NAME_OPTIONS } from "@common/random";
|
||||
|
||||
export default async function main() {
|
||||
const canvas = createCanvas(S, S);
|
||||
const rnd = new SeededRandom(42);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
BSP.generateLevel(S, S, (x, y, node) => {
|
||||
ctx.fillStyle = node?.id ? `hsl(${stringHash(node.id) % 360}, 100%, 20%)`: 'black';
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}, { depth: 16, minWidth: 8, minHeight: 8 });
|
||||
|
||||
console.log('done');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(rnd.nextName(MALE_NAME_OPTIONS));
|
||||
}
|
||||
console.log('------------------------------------');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(rnd.nextName(FEMALE_NAME_OPTIONS));
|
||||
}
|
||||
console.log('------------------------------------');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(rnd.nextName(ANIMAL_NAME_OPTIONS));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue