1
0
Fork 0

Names generator

This commit is contained in:
Pabloader 2026-05-08 09:02:19 +00:00
parent a14a602f23
commit 2bedecb677
3 changed files with 334 additions and 25 deletions

View File

@ -17,7 +17,7 @@
* rng.setState(saved); // restore * rng.setState(saved); // restore
*/ */
import { stringHash } from "./utils"; import { capitalize, stringHash } from "./utils";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@ -26,6 +26,34 @@ import { stringHash } from "./utils";
/** Four-word xoshiro128** state. Serialisable to / from JSON. */ /** Four-word xoshiro128** state. Serialisable to / from JSON. */
export type RNGState = [number, number, number, number]; 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) // Helpers (module-private)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -59,6 +87,43 @@ function buildState(seed: number | string): RNGState {
return [s0, s1, s2, s3]; 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 // Main class
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -143,6 +208,77 @@ export class SeededRandom {
return (this.next() & 1) === 1; 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. * Uniform random element from any iterable.
* Arrays are read in O(1); other iterables are materialised first. * 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 * Create an independent child RNG for a sub-resource (room, NPC, particle
* system, ). * system, ).
* *
* The child starts from the *current* state. The parent then advances by * The child starts from the *current* state. The parent then advances
* one step so parent and child immediately diverge neither will reproduce * so parent and child immediately diverge neither will reproduce
* the other's sequence. * the other's sequence.
* *
* Typical pattern: * Typical pattern:
* const worldRng = new SeededRandom('seed-42'); * const worldRng = new SeededRandom('seed-42');
* const dungeonRng = worldRng.clone(); // independent dungeon stream * const dungeonRng = worldRng.fork(); // independent dungeon stream
* const npcRng = worldRng.clone(); // independent NPC stream * const npcRng = worldRng.fork(); // independent NPC stream
*/ */
clone(): SeededRandom { fork(): SeededRandom {
const child = new SeededRandom(this.getState()); const child = new SeededRandom(this.getState());
this.jump(); // diverge parent so the two streams never overlap this.jump(); // diverge parent so the two streams never overlap
return child; return child;
@ -369,3 +505,173 @@ export class SeededRandom {
return new SeededRandom(json.state); 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
]
};

View File

@ -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) => export const callUpdater = <T>(f: StateUpdater<T>, prev: T) =>
typeof f === 'function' ? (f as Function)(prev) : f; 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 => { export const formatNumber = (n: number): string => {
if (n === 0) return '0'; if (n === 0) return '0';
if (n < 0) return `-${formatNumber(-n)}`; if (n < 0) return `-${formatNumber(-n)}`;

View File

@ -1,19 +1,17 @@
import { createCanvas } from "@common/display/canvas"; import { SeededRandom, ANIMAL_NAME_OPTIONS, FEMALE_NAME_OPTIONS, MALE_NAME_OPTIONS } from "@common/random";
import { BSP } from "@common/level/bsp";
import { stringHash } from "@common/utils";
const S = 512;
export default async function main() { export default async function main() {
const canvas = createCanvas(S, S); const rnd = new SeededRandom(42);
const ctx = canvas.getContext("2d"); for (let i = 0; i < 10; i++) {
if (!ctx) return; console.log(rnd.nextName(MALE_NAME_OPTIONS));
}
BSP.generateLevel(S, S, (x, y, node) => { console.log('------------------------------------');
ctx.fillStyle = node?.id ? `hsl(${stringHash(node.id) % 360}, 100%, 20%)`: 'black'; for (let i = 0; i < 10; i++) {
ctx.fillRect(x, y, 1, 1); console.log(rnd.nextName(FEMALE_NAME_OPTIONS));
}, { depth: 16, minWidth: 8, minHeight: 8 }); }
console.log('------------------------------------');
console.log('done'); for (let i = 0; i < 10; i++) {
console.log(rnd.nextName(ANIMAL_NAME_OPTIONS));
}
} }