GitHub avatar

Fox's Blog

✨ AI Generated Article

Building character-factory: avatars with genetics

A TypeScript module on top of DiceBear: coherent

Building character-factory: avatars with genetics

I needed thousands of believable, distinct avatars for Kurekuta -- a private card game project where every card holds a character "DNA" that the renderer turns into a portrait. Buying a stock pack would have looked stock. Generating one-off DiceBear avatars per seed felt random in the wrong way: a Japanese-flavored card could land on a Scandinavian blonde, and two "siblings" looked like strangers.

So I wrote character-factory -- a TypeScript module on top of DiceBear's Lorelei collection that adds three things DiceBear alone doesn't give you: coherent demographics, a small genetics engine, and a fluent builder that's nice to use from a game loop.

What it does

The smallest useful snippet:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // weighted ethnicity → coherent skin/hair/cut/beard
  .setMood(Mood.Happy)
  .buildSvg();

That single chain picks an ethnicity weighted by Japan's demographic mix, draws a skin tone and hair color that go together, picks a hairstyle from the right gender sub-pool, then locks the eyes/eyebrows/mouth into a "happy" combination. The result renders as SVG or, with sharp installed, as a PNG of any size.

A character is just a CharacterConfig object -- face, hair, accessories, presentation. The builder mutates one internally, and you can pull it out as JSON, base64, or a file, and reload it the same way. For Kurekuta this matters: a card stores the config, not the rendered image, so the art is always reproducible and the file size of a card stays tiny.

Coherent demographics, not just random pixels

DiceBear's options are uniform pickers. Pass ["#ffdbb4", "#2c1b18"] for skin color and you'll get either with equal odds -- fine for a logo, useless for "give me a character from Brazil."

character-factory ships a country → ethnicity → traits pipeline:

// What's actually in the module:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* mostly black/dark brown, no blonde */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Each layer is a weighted draw. The weights aren't a sociology paper -- they're a heuristic that keeps "from Japan" from producing a redhead and "from Sweden" from producing jet-black hair. The whole pipeline collapses into one call: setCountry(country) or randomizeFromCountry(country, gender?).

A small genetics engine

The feature I had the most fun with: projectChild. Two factories can produce a child whose traits are inherited with rough biological dominance:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Under the hood it's a deliberately tiny model. Each parent is treated as carrying a 2-allele genotype, one drawn from each side, combined into dominant or recessive:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Traits that have a real dominance axis (skin, eyes, hair) are resolved against an explicit ordered list -- darker dominant over lighter, brown/black eyes dominant over blue, jet black hair dominant over blonde:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // most recessive
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // most dominant
] as const;

resolveByRank finds each parent's index, picks the higher one on a "dominant" allele combination and the lower one on "recessive." Fantasy colors (pastel pink, lilac) aren't in the order -- they fall back to a 50/50 coin flip, which is the right behavior: they aren't biological, so dominance can't mean anything.

Freckles model MC1R: 75% if both parents have them, 25% if only one carries, 0% if neither. Beard is SRY-linked: stripped if the child is female, otherwise inherited from whichever parent had one. Hairstyle isn't biological at all -- it's a cultural choice, so the child picks from their own gender pool, preserving texture if possible.

None of this is publication-grade genetics. It's a feel layer: kids look like a plausible mix of their parents instead of two strangers averaged together.

The boring engineering parts that mattered

A few things that aren't flashy but earned their space in the diff:

A safer pick. The original returned undefined cast as T on an empty array. With strict + noUncheckedIndexedAccess in TypeScript, that's a lie the compiler signs off on. New version throws a RangeError -- caught immediately at the call site instead of producing undefined props three levels down.

A deepMerge that doesn't corrupt arrays. The old recursion fired whenever the source value was an object, even if the target slot was null or an array. merge({tags: ["a"]}, {tags: ["b"]}) produced {tags: {0: "b"}}. The new version only recurses when both sides are plain objects.

Parallel batch rendering. batchFactory used to render PNGs in a serial loop -- a 1000-card export ran for minutes. It's now a worker pool with a configurable concurrency (default 4), preserving result order by writing into a pre-sized array:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

On a 1000-character export this turned a coffee break into a "did it already finish?" moment.

A sharp error message that says something. buildPng lazy-imports sharp because it's a peer-ish dependency you don't want to force on SVG-only users. The old catch swallowed the real error and always said "sharp is required." If the real failure was a version mismatch or a native binding problem, you'd spend ten minutes installing something that was already installed. New version still tells you to install it, but includes the underlying error.

What's next

The module is at 1.1.1 on the character-factory repo. The genetics engine is the obvious place to keep iterating -- there's no test suite yet, so coherent invariants ("a Brazilian East-Asian-leaning character never has jet-black eyes paired with platinum hair") are only enforced by the weights. Adding bun test or vitest and writing a coherence test that runs ten thousand randomizeFromCountry calls per country is the next step.

Kurekuta itself is private for now, but every card you'll eventually see in it is a CharacterConfig blob and one buildPng() call away from existing.

✨ AI Generated Article

Construction de character-factory : des avatars avec génétique

Un module TypeScript par-dessus DiceBear : génération cohérente

Construire character-factory : des avatars avec un système génétique

J'avais besoin de milliers d'avatars crédibles et distincts pour Kurekuta -- un projet de jeu de cartes privé où chaque carte contient un "ADN" de personnage que le moteur de rendu transforme en portrait. Acheter un pack tout fait, ça se serait vu. Générer des avatars DiceBear au seed par personnage, ça donnait du n'importe quoi : une carte à l'univers japonais pouvait tomber sur une blonde scandinave, et deux "frères et sœurs" ressemblaient à des inconnus.

J'ai donc écrit character-factory -- un module TypeScript par-dessus la collection Lorelei de DiceBear qui apporte trois trucs que DiceBear seul ne donne pas : des profils démographiques cohérents, un petit moteur de génétique, et un builder fluide agréable à utiliser depuis une boucle de jeu.

Ce que ça fait

Le plus petit snippet utile :

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // ethnicité pondérée → peau/cheveux/coupe/barbe cohérents
  .setMood(Mood.Happy)
  .buildSvg();

Cette simple chaîne pioche une ethnie pondérée par la démographie japonaise, tire un teint et une couleur de cheveux qui vont ensemble, choisit une coupe dans le bon sous-groupe de genre, puis verrouille les yeux/sourcils/bouche en mode "joyeux". Le résultat sort en SVG ou, avec sharp installé, en PNG à n'importe quelle taille.

Un personnage n'est qu'un objet CharacterConfig -- visage, cheveux, accessoires, présentation. Le builder le modifie en interne, et tu peux l'exporter en JSON, base64 ou fichier, puis le recharger à l'identique. Pour Kurekuta c'est crucial : une carte stocke la config, pas l'image rendue. Du coup l'art est toujours reproductible et la taille d'une carte reste minuscule.

Des profils démographiques cohérents, pas du pixel aléatoire

Les options DiceBear sont des sélecteurs uniformes. Passe ["#ffdbb4", "#2c1b18"] pour la couleur de peau et t'auras l'un ou l'autre avec la même probabilité -- OK pour un logo, inutile pour "donne-moi un personnage du Brésil."

character-factory embarque un pipeline pays → ethnicité → traits :

// Ce qu'il y a dans le module :
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* surtout noir/brun foncé, pas de blond */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Chaque couche est un tirage pondéré. Les poids ne sont pas une thèse de socio -- c'est une heuristique qui empêche "venu du Japon" de produire un roux et "venu de Suède" de produire un noir de jais. Tout le pipeline se résume à un seul appel : setCountry(country) ou randomizeFromCountry(country, gender?).

Un petit moteur de génétique

La fonction sur laquelle je me suis le plus amusé : projectChild. Deux factories peuvent produire un enfant dont les traits sont hérités avec une dominance biologique approximative :

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Sous le capot, c'est un modèle volontairement minuscule. Chaque parent porte un génotype à 2 allèles, un tiré de chaque côté, combiné en dominant ou récessif :

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Les traits qui ont un vrai axe de dominance (peau, yeux, cheveux) sont résolus par une liste ordonnée explicite -- le foncé domine le clair, les yeux marron/noir dominent le bleu, le noir de jais domine le blond :

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // le plus récessif
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // le plus dominant
] as const;

resolveByRank trouve l'index de chaque parent, prend le plus élevé sur une combinaison d'allèles "dominante" et le plus bas sur "récessive". Les couleurs fantasy (rose pastel, lilas) ne sont pas dans l'ordre -- elles font un pile ou face 50/50, ce qui est le bon comportement : elles ne sont pas biologiques, donc la dominance n'a pas de sens.

Les taches de rousseur modélisent MC1R : 75 % si les deux parents en ont, 25 % si un seul en porte, 0 % si aucun. La barbe est liée au SRY : retirée si l'enfant est une femme, sinon héritée du parent qui en avait une. La coupe de cheveux n'a rien de biologique -- c'est un choix culturel, donc l'enfant pioche dans son propre pool de genre, en préservant la texture si possible.

Rien de tout ça n'est de la génétique digne d'une publication. C'est une couche de ressenti : les gamins ressemblent à un mélange plausible de leurs parents, pas à la moyenne de deux inconnus.

Les parties ingénierie moins glamour qui ont compté

Quelques trucs pas flashy mais qui ont mérité leur place dans le diff :

Un pick plus sûr. L'original renvoyait undefined casté en T sur un tableau vide. Avec strict + noUncheckedIndexedAccess en TypeScript, c'est un mensonge que le compilateur signe. La nouvelle version lance une RangeError -- attrapée immédiatement au site d'appel au lieu de produire des props undefined trois niveaux plus bas.

Un deepMerge qui ne corrompt pas les tableaux. L'ancienne récursion s'activait dès que la valeur source était un objet, même si la cible était null ou un tableau. merge({tags: ["a"]}, {tags: ["b"]}) produisait {tags: {0: "b"}}. La nouvelle version ne récure que quand les deux côtés sont des objets simples.

Rendu batch en parallèle. batchFactory rendait les PNG en boucle sérialisée -- une exportation de 1000 cartes prenait des plombes. C'est maintenant un pool de workers avec une concurrence configurable (4 par défaut), qui préserve l'ordre des résultats en écrivant dans un tableau pré-dimensionné :

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

Sur une exportation de 1000 personnages, ça a transformé une pause-café en un "c'est déjà fini ?"

Un message d'erreur sharp qui dit quelque chose. buildPng importe sharp en paresseux parce que c'est une dépendance optionnelle que tu ne veux pas imposer aux utilisateurs SVG-only. L'ancien catch avalait la vraie erreur et disait toujours "sharp is required." Si l'échec réel était un conflit de version ou un problème de bindings natifs, tu passais dix minutes à réinstaller un truc déjà installé. La nouvelle version te dit toujours de l'installer, mais inclut l'erreur sous-jacente.

La suite

Le module est en 1.1.1 sur le dépôt character-factory. Le moteur génétique est l'endroit idéal pour continuer d'itérer -- il n'y a pas encore de suite de tests, donc les invariants de cohérence ("un personnage brésilien d'ascendance est-asiatique n'aura jamais les yeux noirs de jais avec des cheveux platine") ne sont assurés que par les poids. Ajouter bun test ou vitest et écrire un test de cohérence qui lance dix mille randomizeFromCountry par pays, c'est la prochaine étape.

Kurekuta lui-même est privé pour l'instant, mais chaque carte que tu verras un jour dedans n'est qu'un blob CharacterConfig et un buildPng() d'exister.

✨ AI Generated Article

构建 character-factory:带遗传基因的头像生成器

基于 DiceBear 的 TypeScript 模块:按国家和地区合理生成一致性头像、用于预测后代的小型遗传引擎,以及使其在卡牌游戏中可用的工程细节。

构建 character-factory:带遗传基因的头像生成器

我需要为 Kurekuta 生成成千上万个可信、各不相同的头像----这是一个私有的卡牌游戏项目,每张卡牌都包含一个角色"DNA",渲染器将其转化为肖像。购买现成套图看起来千篇一律。按种子生成一次性 DiceBear 头像又感觉随机得不对劲:一张日式卡牌可能生成一个斯堪的纳维亚金发碧眼角色,而两个"兄弟姐妹"看起来像陌生人。

所以我写了 character-factory----一个基于 DiceBear 的 Lorelei 系列的 TypeScript 模块,在 DiceBear 本身的基础上增加了三样东西:合理的人口统计分布、一个小型遗传引擎和一个流畅的构建器,在游戏循环中非常易用。

它能做什么

最小的可用代码片段:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // 加权民族 → 协调的肤色/发色/发型/胡须
  .setMood(Mood.Happy)
  .buildSvg();

这一行链式调用根据日本的人口统计分布加权选择民族,抽取搭配的肤色和发色,从正确的性别子池中挑选发型,然后将眼睛/眉毛/嘴巴锁定为"开心"的组合。结果可以渲染为 SVG,如果安装了 sharp 库,也可以输出任意尺寸的 PNG。

一个角色就是一个 CharacterConfig 对象----面部、头发、配饰、展示方式。构建器在内部修改它,你可以将其导出为 JSON、base64 或文件,也可以用同样的方式重新加载。对 Kurekuta 来说这一点至关重要:卡牌存储的是配置而非渲染好的图像,因此图片总是可重现的,卡牌的文件大小也保持极小。

合理的人口统计分布,而非随机像素

DiceBear 的选项是均匀随机选择器。传递 ["#ffdbb4", "#2c1b18"] 作为肤色,两者被选中的概率是相等的----这对 Logo 来说没问题,但对于"给我一个来自巴西的角色"来说毫无用处。

character-factory 提供了一个 国家 → 民族 → 特征 的管道:

// 模块内部的实际情况:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* 主要是黑色/深棕色,没有金色 */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

每一层都是加权随机抽取。这些权重不是社会学论文----它们是启发式的,确保"来自日本"不会产生红头发,"来自瑞典"不会产生乌黑头发。整个管道一次调用就搞定:setCountry(country) 或 randomizeFromCountry(country, gender?)。

一个小型遗传引擎

我最喜欢的功能:projectChild。两个工厂可以产生一个后代,其特征通过粗略的生物显性继承:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

在底层这是一个刻意简化的小模型。每个父母被视为携带一个 2-等位基因的基因型,每个基因从另一方抽取一个,组合成显性或隐性:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

具有真实显性轴的特征(皮肤、眼睛、头发)根据一个明确的有序列表来解析----深色显性于浅色,棕/黑色眼睛显性于蓝色,乌黑头发显性于金色:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // 最隐性
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // 最显性
] as const;

resolveByRank 找到每个父母的索引,在"显性"等位基因组合时取较高值,在"隐性"时取较低值。幻彩色(粉红、淡紫)不在列表中----它们回退到 50/50 的抛硬币结果,这是正确的行为:它们不是生物特征,所以显性没有意义。

雀斑模拟 MC1R 基因:如果父母双方都有,概率 75%;只有一方携带,概率 25%;双方都没有,概率 0%。胡须与 SRY 基因关联:如果后代为女性则去除,否则从有胡须的一方继承。发型完全不是生物特征----它是文化选择,所以后代从自己的性别池中选择,尽可能保留发质。

这些都不是发表级遗传学。它是一个感觉层:孩子看起来像是父母双方合理的混合体,而不是两个陌生人简单平均在一起。

那些不起眼但很重要的工程细节

有些事情虽然不炫酷,但在代码中占有一席之地:

更安全的 pick。 原来的版本在空数组上返回 undefined 并强制转换为 T 类型。在开启了 strict + noUncheckedIndexedAccess 的 TypeScript 中,这是编译器批准的一个谎言。新版本抛出 RangeError----在调用点立即捕获,而不是在三级之后产生 undefined 属性。

不损坏数组的 deepMerge。 原来的递归在源值是对象时就会触发,即使目标槽是 null 或数组。merge({tags: ["a"]}, {tags: ["b"]}) 会产生 {tags: {0: "b"}}。新版本只在双方都是普通对象时递归。

并行批处理渲染。 batchFactory 原来使用串行循环渲染 PNG----导出 1000 张卡牌耗时数分钟。现在它是一个工作池,可配置并发数(默认为 4),通过写入预大小数组来保持结果顺序:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

在导出 1000 个角色时,这使原本够喝杯咖啡的时间变成了"已经搞定了?"的瞬间。

有意义的 sharp 错误信息。 buildPng 使用惰性导入 sharp,因为它是类似 peer 的依赖项,你不想强加给仅需 SVG 的用户。原来的 catch 吞掉了真正的错误,总是显示"需要 sharp"。如果真正的失败是版本不匹配或原生绑定问题,你会花十分钟安装一个已经装好的东西。新版本仍然提示你安装它,但会包含底层错误信息。

下一步计划

该模块目前是 1.1.1 版本,在 character-factory 仓库中。遗传引擎显然是继续迭代的方向----目前还没有测试套件,所以像"一个巴西东亚混血角色永远不会拥有乌黑眼睛配铂金色头发"这样的连贯不变量只能靠权重来保证。添加 bun test 或 vitest,编写一个对每个国家运行一万次 randomizeFromCountry 调用的一致性测试,是下一步的计划。

Kurekuta 本身目前还是私有的,但你最终看到的每张卡牌都是一个 CharacterConfig 数据块,离实际展现就差一次 buildPng() 调用。

✨ AI Generated Article

character-factoryの構築:遺伝子を持つアバター

DiceBear上に構築したTypeScriptモジュール:国・民族に基づいた一貫性のある生成、子供を投影する小さな遺伝子エンジン、そしてカードゲームで使えるようにしたエンジニアリングの詳細。

character-factoryの構築:遺伝子を持つアバター

Kurekutaのために、何千もの信憑性のある個性的なアバターが必要だった----これは非公開のカードゲームプロジェクトで、各カードにキャラクターの「DNA」が入っていて、レンダラーがそれを肖像画に変換する仕組みだ。ストックパックを買うと、どうしてもありきたりに見えてしまう。DiceBearのシードごとのアバターを1回限り生成すると、変な方向にランダムすぎる:日本風のカードなのにスカンジナビアのブロンドが出てきたり、「兄弟」なのにまったくの他人に見えたりする。

そこでcharacter-factoryを書いた----DiceBearのLoreleiコレクションの上に乗せるTypeScriptモジュールで、DiceBear単体ではできない3つのことを追加している:首尾一貫した人口統計、小さな遺伝子エンジン、そしてゲームループで使いやすい流暢なビルダーだ。

何ができるか

最小の実用的なコード:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // 重み付けされた民族 → 一貫した肌/髪/髪型/髭
  .setMood(Mood.Happy)
  .buildSvg();

この1連のチェーンで、日本の人口構成で重み付けされた民族を選び、調和のとれた肌の色と髪の色を引き出し、適切な性別のサブプールから髪型を選び、目/眉毛/口を「ハッピー」な組み合わせに固定する。結果はSVGとして、またはsharpがインストールされていれば任意のサイズのPNGとしてレンダリングされる。

キャラクターは単なるCharacterConfigオブジェクト----顔、髪、アクセサリー、プレゼンテーション。ビルダーが内部でそれを変更し、JSON、base64、またはファイルとして取り出して、同じ方法で再読み込みできる。Kurekutaではこれが重要だ:カードはレンダリング画像ではなく設定を保存するので、アートは常に再現可能で、カードのファイルサイズは超小さく保たれる。

ただのランダムピクセルじゃなく、首尾一貫した人口統計

DiceBearのオプションは均一なピッカーだ。["#ffdbb4", "#2c1b18"]を肌の色に渡すと、どちらかが等確率で出る----ロゴには問題ないが、「ブラジルのキャラクターをくれ」には使えない。

character-factoryは国→民族→特徴のパイプラインを搭載している:

// モジュール内の実際のデータ:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* ほとんど黒/ダークブラウン、ブロンドなし */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

各レイヤーは重み付けされた抽選だ。重みは社会学の論文じゃない----「日本出身」から赤毛が出たり、「スウェーデン出身」から漆黒の髪が出たりしないようにするヒューリスティックだ。パイプライン全体が1回の呼び出しに集約される:setCountry(country)またはrandomizeFromCountry(country, gender?)。

小さな遺伝子エンジン

一番楽しんで作った機能:projectChild。2つのファクトリーが子供を生成でき、その特徴は大まかな生物学的優性で継承される:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

内部では意図的に小さなモデルになっている。各親は2アレル遺伝子型を持っていると見なされ、それぞれ片方の側から引かれ、優性か劣性かに組み合わされる:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

実際の優性軸を持つ特徴(肌、目、髪)は、明示的な順序付きリストに対して解決される----暗い方が明るい方より優性、茶色/黒の目が青より優性、漆黒の髪がブロンドより優性:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // 最も劣性
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // 最も優性
] as const;

resolveByRankは各親のインデックスを見つけ、「優性」のアレル組み合わせでは高い方を、「劣性」では低い方を選ぶ。ファンタジー色(パステルピンク、ライラック)は順序に含まれていない----50/50のコイントスにフォールバックする。これが正しい動作だ:生物学的じゃないから、優性は意味を持たない。

そばかすはMC1Rをモデル化:両親が持っていれば75%、片方だけなら25%、どちらもなければ0%。髭はSRY連鎖:子供が女性なら削除、そうでなければ髭がある方の親から継承。髪型は全く生物学的ではない----文化的選択なので、子供は自分の性別プールから選び、可能ならテクスチャを保持する。

どれも論文レベルの遺伝学じゃない。感覚レイヤーだ:子供が見知らぬ二人を平均したようには見えず、両親の妥当なミックスに見える。

地味だけど重要だったエンジニアリング部分

派手じゃないけど、差分に含める価値があったいくつかのこと:

より安全なpick。 元の実装は空の配列でundefinedをTとしてキャストして返していた。TypeScriptでstrict + noUncheckedIndexedAccessを使うと、それはコンパイラが承認する嘘になる。新しいバージョンはRangeErrorをスローする----3階層下でundefinedのプロパティを生成する代わりに、呼び出し元で即座に捕捉される。

配列を壊さないdeepMerge。 以前の再帰は、ターゲットがnullや配列でも、ソース値がオブジェクトなら常に発火していた。merge({tags: ["a"]}, {tags: ["b"]})が{tags: {0: "b"}}を生成していた。新しいバージョンは両方がプレーンオブジェクトの場合のみ再帰する。

並列バッチレンダリング。 batchFactoryはPNGをシリアルループでレンダリングしていて、1000枚のカードエクスポートに数分かかっていた。今は設定可能な並行数(デフォルト4)のワーカープールで、結果の順序を維持するために事前確保された配列に書き込む:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // レンダリングして保存
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

1000キャラクターのエクスポートで、コーヒーブレイクが「もう終わったの?」になる。

意味のあるエラーメッセージを出すsharp。 buildPngはsharpを遅延インポートする。なぜなら、SVGのみのユーザーに強制したくないピア的依存関係だからだ。以前のcatchは実際のエラーを飲み込んで、常に「sharpが必要です」とだけ言っていた。実際の失敗がバージョンの不一致やネイティブバインディングの問題でも、すでにインストールされているものを10分かけてインストールすることになる。新しいバージョンはインストールを促すが、根本的なエラーも含める。

今後の予定

モジュールはcharacter-factoryリポジトリでv1.1.1。遺伝子エンジンは明らかに改良を続ける場所だ----まだテストスイートがないので、「ブラジルの東アジア寄りのキャラクターが漆黒の目とプラチナブロンドの髪の組み合わせにならない」といった一貫性の不変条件は重みによってのみ強制されている。bun testやvitestを追加して、国ごとにrandomizeFromCountryを1万回実行する一貫性テストを書くのが次のステップだ。

Kurekuta自体は今のところ非公開だが、いずれ表示されるすべてのカードはCharacterConfigブロブと1回のbuildPng()呼び出しで存在できるようになる。

✨ AI Generated Article

character-factory 만들기: 유전학을 가진 아바타

DiceBear 기반 TypeScript 모듈: 국가/민족별 일관된 생성, 자식 투영을 위한 작은 유전학 엔진, 카드

character-factory 만들기: 유전학을 가진 아바타

Kurekuta를 위해 수천 개의 그럴듯하고 구별되는 아바타가 필요했어 -- 모든 카드가 캐릭터 "DNA"를 가지고 있고 렌더러가 그걸 초상화로 바꿔주는 개인 카드 게임 프로젝트야. 스톡 팩을 사면 평범해 보였을 거야. 시드별로 일회성 DiceBear 아바타를 생성하는 건 잘못된 방식으로 랜덤하다고 느껴졌어: 일본풍 카드가 스칸디나비아 금발이 나올 수 있고, 두 "형제자매"가 전혀 낯선 사람처럼 보였거든.

그래서 character-factory를 만들었어 -- DiceBear의 Lorelei 컬렉션 위에 TypeScript 모듈로, DiceBear만으로는 안 되는 세 가지를 추가했어: 일관된 인구통계, 작은 유전학 엔진, 그리고 게임 루프에서 사용하기 좋은 fluent builder.

하는 일

가장 작은 유용한 코드 조각:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // 가중치 기반 민족 → 일관된 피부/머리/컷/수염
  .setMood(Mood.Happy)
  .buildSvg();

저 단일 체인은 일본의 인구 구성을 반영한 민족을 선택하고, 어울리는 피부톤과 머리색을 뽑고, 올바른 성별 하위 풀에서 헤어스타일을 고른 다음, 눈/눈썹/입을 "행복한" 조합으로 고정해. 결과는 SVG로 렌더링되거나, sharp가 설치되어 있으면 원하는 크기의 PNG로 렌더링돼.

캐릭터는 그냥 CharacterConfig 객체야 -- 얼굴, 머리, 액세서리, 프레젠테이션. 빌더가 내부에서 하나를 변형하고, JSON, base64, 파일로 꺼내거나 같은 방식으로 다시 불러올 수 있어. Kurekuta에서 이게 중요한 이유: 카드는 렌더링된 이미지가 아니라 설정을 저장하므로, 아트는 항상 재현 가능하고 카드 파일 크기는 아주 작게 유지돼.

일관된 인구통계, 단순한 랜덤 픽셀이 아님

DiceBear의 옵션은 균일한 선택기야. ["#ffdbb4", "#2c1b18"]를 피부색으로 넘기면 둘 중 하나가 동일한 확률로 나와 -- 로고에는 괜찮지만 "브라질 출신 캐릭터를 줘"에는 쓸모없어.

character-factory는 국가 → 민족 → 특성 파이프라인을 제공해:

// 모듈 안의 실제 내용:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* mostly black/dark brown, no blonde */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

각 레이어는 가중치 기반 선택이야. 가중치는 사회학 논문이 아니야 -- "일본에서"가 빨간 머리를 만들지 않고 "스웨덴에서"가 새까만 머리를 만들지 않게 하는 휴리스틱일 뿐이야. 전체 파이프라인은 한 번의 호출로 줄어들어: setCountry(country) 또는 randomizeFromCountry(country, gender?).

작은 유전학 엔진

내가 가장 재미있게 만든 기능: projectChild. 두 팩토리가 아이를 만들 수 있고, 그 아이의 특성은 대략적인 생물학적 우성으로 유전돼:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

내부는 의도적으로 아주 작은 모델이야. 각 부모는 2-대립유전자 유전자형을 가진 것으로 처리되고, 각 부모에게서 하나씩 뽑아 우성 또는 열성으로 결합해:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

실제 우성 축이 있는 특성(피부, 눈, 머리)은 명시적인 순서 목록에 따라 해결돼 -- 더 어두운 것이 밝은 것보다 우성, 갈색/검은 눈이 파란 눈보다 우성, 새까만 머리가 금발보다 우성:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // 가장 열성
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // 가장 우성
] as const;

resolveByRank는 각 부모의 인덱스를 찾아, "우성" 대립유전자 조합에서는 더 높은 인덱스를 선택하고 "열성"에서는 더 낮은 인덱스를 선택해. 판타지 색상(파스텔 핑크, 라일락)은 순서에 없어 -- 50/50 동전 던지기로 폴백되는데, 이게 올바른 동작이야: 생물학적이지 않으니까 우성이 의미가 없거든.

주근깨는 MC1R을 모델링해: 양쪽 부모가 있으면 75%, 한쪽만 가지고 있으면 25%, 아무도 없으면 0%. 수염은 SRY 연결: 아이가 여성이면 제거되고, 그렇지 않으면 수염이 있는 부모에게서 상속받아. 헤어스타일은 생물학적이지 않아 -- 문화적 선택이니까, 아이는 자신의 성별 풀에서 고르고 가능하면 질감을 유지해.

이 중 어느 것도 출판할 수준의 유전학은 아니야. 그냥 느낌 레이어야: 아이들이 평균낸 두 낯선 사람처럼 보이기보다는 부모의 그럴듯한 혼합처럼 보이게 하는 거야.

중요했던 지루한 엔지니어링 부분

화려하지는 않지만 diff에서 자리를 차지한 몇 가지:

더 안전한 pick. 원래는 빈 배열에서 undefined를 T로 캐스팅해서 반환했어. TypeScript에서 strict + noUncheckedIndexedAccess를 사용하면, 컴파일러가 승인하는 거짓말이지. 새 버전은 RangeError를 던져 -- 3단계 아래에서 undefined prop이 생기는 대신 호출 지점에서 즉시 잡혀.

배열을 망가뜨리지 않는 deepMerge. 예전 재귀는 대상 값이 객체일 때마다 발동했는데, 대상 슬롯이 null이거나 배열이어도 마찬가지였어. merge({tags: ["a"]}, {tags: ["b"]})가 {tags: {0: "b"}}를 만들어냈어. 새 버전은 양쪽이 모두 일반 객체일 때만 재귀해.

병렬 배치 렌더링. batchFactory가 PNG를 직렬 루프로 렌더링하곤 했어 -- 1000장 카드 내보내기에 몇 분이 걸렸지. 이제는 설정 가능한 동시성(기본 4)의 워커 풀이고, 미리 크기가 지정된 배열에 써서 결과 순서를 유지해:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

1000개 캐릭터 내보내기에서 커피 한 잔 마실 시간이 "벌써 끝났어?"로 바뀌었어.

뭔가 말해주는 sharp 에러 메시지. buildPng는 sharp를 lazy-import하는데, SVG-only 사용자에게 강제하고 싶지 않은 peer-ish 의존성이기 때문이야. 예전 catch는 실제 에러를 삼키고 항상 "sharp is required."라고 말했어. 실제 실패가 버전 불일치나 네이티브 바인딩 문제였다면, 이미 설치된 걸 다시 설치하는 데 10분을 허비했겠지. 새 버전은 여전히 설치하라고 말하지만, 기본 에러도 포함시켜.

다음은 무엇인가

모듈은 현재 character-factory 저장소에서 1.1.1 버전이야. 유전학 엔진이 계속 반복할 분명한 장소야 -- 아직 테스트 스위트가 없어서, 일관성 불변식("브라질 동아시아-중심 캐릭터가 절대 새까만 눈과 백금발을 가지면 안 됨")은 가중치로만 강제되고 있어. bun test나 vitest를 추가하고 국가별로 randomizeFromCountry를 만 번 실행하는 일관성 테스트를 작성하는 게 다음 단계야.

Kurekuta 자체는 지금은 비공개지만, 언젠가 보게 될 모든 카드는 CharacterConfig blob 하나와 buildPng() 호출 하나만 있으면 존재할 수 있어.

✨ AI Generated Article

character-factory'yi İnşa Etmek: Genetikle Avatar Oluşturma

DiceBear üzerine bir TypeScript modülü: tutarlı ülke/etnisite

character-factory'yi İnşa Etmek: Genetikle Avatar Oluşturma

Kurekuta için binlerce inandırıcı, farklı avatara ihtiyacım vardı -- her kartın bir karakter "DNA"sı taşıdığı ve renderleyicinin bunu bir portreye dönüştürdüğü özel bir kart oyunu projesi. Hazır bir paket satın almak stok gibi görünürdü. Tohum başına tek seferlik DiceBear avatarları üretmek yanlış şekilde rastgele hissettiriyordu: Japon temalı bir kart İskandinav sarışınına düşebiliyordu ve iki "kardeş" birbirine yabancı gibi görünüyordu.

Bu yüzden character-factory'i yazdım -- DiceBear'ın Lorelei koleksiyonunun üzerine, DiceBear'ın tek başına vermediği üç şeyi ekleyen bir TypeScript modülü: tutarlı demografiler, küçük bir genetik motoru ve bir oyun döngüsünden kullanması keyifli akıcı bir builder.

Ne işe yarıyor

Kullanışlı en küçük parça:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // ağırlıklı etnisite → tutarlı ten/saç/sakal
  .setMood(Mood.Happy)
  .buildSvg();

Bu tek zincir, Japonya'nın demografik karışımına göre ağırlıklı bir etnisite seçer, birbiriyle uyumlu bir ten rengi ve saç rengi çeker, doğru cinsiyet alt havuzundan bir saç stili seçer ve ardından gözleri/kaşları/ağzı "mutlu" bir kombinasyona kilitler. Sonuç SVG olarak işlenir veya sharp kuruluysa herhangi bir boyutta PNG olarak.

Bir karakter sadece bir CharacterConfig nesnesidir -- yüz, saç, aksesuarlar, sunum. Builder bunu dahili olarak değiştirir ve sen onu JSON, base64 veya dosya olarak çekip aynı şekilde tekrar yükleyebilirsin. Kurekuta için bu önemlidir: bir kart, işlenmiş görüntüyü değil, yapılandırmayı saklar, böylece sanat her zaman yeniden üretilebilir ve kartın dosya boyutu küçük kalır.

Rastgele pikseller değil, tutarlı demografiler

DiceBear'ın seçenekleri tek tip seçicilerdir. Ten rengi için ["#ffdbb4", "#2c1b18"] geçirirsen ikisinden birini eşit olasılıkla alırsın -- bir logo için iyi, "bana Brezilya'dan bir karakter ver" için işe yaramaz.

character-factory bir ülke → etnisite → özellik pipeline'ı sunar:

// Modülün içinde gerçekte olan:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* çoğunlukla siyah/koyu kahve, sarışın yok */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Her katman ağırlıklı bir çekiliştir. Ağırlıklar bir sosyoloji makalesi değil -- "Japonya'dan" bir kızıl saçlı, "İsveç'ten" ise simsiyah saçlı üretmesini engelleyen bir buluşsal yöntem. Tüm pipeline tek bir çağrıya indirgenir: setCountry(country) veya randomizeFromCountry(country, gender?).

Küçük bir genetik motoru

En eğlendiğim özellik: projectChild. İki fabrika, özellikleri yaklaşık biyolojik baskınlıkla miras alan bir çocuk üretebilir:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Perde arkasında kasıtlı olarak küçük bir model var. Her ebeveyn, her iki taraftan çekilen 2-allel bir genotip taşıyor gibi kabul edilir ve baskın veya çekinik olarak birleştirilir:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Gerçek bir baskınlık ekseni olan özellikler (ten, göz, saç) açık bir sıralı listeye göre çözümlenir -- koyu ten açık ten üzerinde baskın, kahverengi/siyah gözler mavi göz üzerinde baskın, simsiyah saç sarışın üzerinde baskın:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // en çekinik
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // en baskın
] as const;

resolveByRank her ebeveynin indeksini bulur, "baskın" allel kombinasyonunda yüksek olanı, "çekinik"te ise düşük olanı seçer. Fantazi renkleri (pastel pembe, leylak) sıralamada yoktur -- yazı tura atar gibi %50/%50'ye düşerler, ki bu doğru davranıştır: biyolojik değillerdir, bu yüzden baskınlık bir anlam ifade edemez.

Çiller MC1R'yi modeller: her iki ebeveynde varsa %75, sadece birinde taşınıyorsa %25, hiçbirinde yoksa %0. Sakal SRY bağlantılıdır: çocuk kadınsa kaldırılır, aksi halde sakalı olan ebeveynden miras alınır. Saç stili biyolojik değildir -- kültürel bir seçimdir, bu yüzden çocuk kendi cinsiyet havuzundan seçer, mümkünse doku korunur.

Bunların hiçbiri yayın kalitesinde genetik değil. Bu bir his katmanı: çocuklar, iki yabancının ortalaması alınmış gibi değil, ebeveynlerinin makul bir karışımı gibi görünür.

Önemli Olan Sıkıcı Mühendislik Kısımları

Gösterişli olmayan ama diff'te yerini hak eden birkaç şey:

Daha güvenli bir pick. Orijinali, boş bir dizide undefined'ı T olarak cast edip döndürüyordu. TypeScript'te strict + noUncheckedIndexedAccess ile bu, derleyicinin onayladığı bir yalan. Yeni sürüm bir RangeError fırlatıyor -- üç seviye aşağıda undefined prop'lar üretmek yerine çağrı noktasında hemen yakalanıyor.

Dizileri bozmayan bir deepMerge. Eski özyineleme, kaynak değer bir nesne olduğunda, hedef alan null veya bir dizi olsa bile ateşleniyordu. merge({tags: ["a"]}, {tags: ["b"]}), {tags: {0: "b"}} üretiyordu. Yeni sürüm sadece her iki taraf da düz nesne olduğunda özyineleme yapıyor.

Paralel toplu işleme. batchFactory eskiden PNG'leri seri bir döngüde render ediyordu -- 1000 kartlık bir dışa aktarma dakikalarca sürüyordu. Artık yapılandırılabilir eşzamanlılığa (varsayılan 4) sahip bir worker havuzu ve sonuçları önceden boyutlandırılmış bir diziye yazarak sırayı koruyor:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render et ve kaydet
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

1000 karakterlik bir dışa aktarmada bu, kahve molasını "çoktan bitti mi?" anına dönüştürdü.

Bir şey söyleyen sharp hata mesajı. buildPng, sharp'ı tembelce import eder çünkü bu, sadece SVG kullanan kullanıcılara zorlamak istemediğin akran bağımlılığı gibidir. Eski catch gerçek hatayı yutardı ve her zaman "sharp gerekli" derdi. Gerçek hata bir sürüm uyuşmazlığı veya yerel bağlama sorunuysa, zaten kurulu olan bir şeyi kurmak için on dakika harcardın. Yeni sürüm hala kurmanı söyler, ama altta yatan hatayı da içerir.

Sırada ne var

Modül character-factory reposunda 1.1.1 sürümünde. Genetik motoru, üzerinde yinelemeye devam etmek için bariz yer -- henüz bir test paketi yok, bu yüzden tutarlı değişmezler ("Brezilyalı bir Doğu Asya eğilimli karakterin hiçbir zaman simsiyah gözlerle platin saçı eşleştirmemesi") sadece ağırlıklar tarafından zorlanıyor. bun test veya vitest eklemek ve ülke başına on bin randomizeFromCountry çağrısı çalıştıran bir tutarlılık testi yazmak bir sonraki adım.

Kurekuta'nın kendisi şimdilik özel, ama sonunda göreceğin her kart bir CharacterConfig blob'u ve bir buildPng() çağrısı uzaklığında.

✨ AI Generated Article

Costruire character-factory: avatar con la genetica

Un modulo TypeScript basato su DiceBear: generazione coerente

Costruire character-factory: avatar con la genetica

Mi servivano migliaia di avatar credibili e distinti per Kurekuta -- un progetto di gioco di carte privato dove ogni carta contiene un "DNA" del personaggio che il renderer trasforma in un ritratto. Comprare un pacchetto stock sarebbe stato troppo banale. Generare avatar DiceBear casuali per seed sembrava casuale nel modo sbagliato: una carta in stile giapponese poteva capitare su una bionda scandinava, e due "fratelli" sembravano estranei.

Così ho scritto character-factory -- un modulo TypeScript basato sulla collezione Lorelei di DiceBear che aggiunge tre cose che DiceBear da solo non offre: demografie coerenti, un piccolo motore genetico, e un builder fluido che è piacevole da usare in un loop di gioco.

Cosa fa

Il frammento più piccolo utile:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // etnia pesata → carnagione/capelli/taglio/barba coerenti
  .setMood(Mood.Happy)
  .buildSvg();

Quella singola catena sceglie un'etnia pesata in base alla composizione demografica del Giappone, abbina un tono della pelle e un colore di capelli che stanno bene insieme, sceglie un taglio di capelli dal sotto-gruppo di genere corretto, e blocca occhi/sopracciglia/bocca in una combinazione "felice". Il risultato viene renderizzato come SVG o, con sharp installato, come PNG di qualsiasi dimensione.

Un personaggio è semplicemente un oggetto CharacterConfig -- volto, capelli, accessori, presentazione. Il builder lo modifica internamente, e puoi estrarlo come JSON, base64 o file, e ricaricarlo allo stesso modo. Per Kurekuta questo è importante: una carta memorizza la configurazione, non l'immagine renderizzata, quindi l'arte è sempre riproducibile e la dimensione del file della carta rimane minima.

Demografie coerenti, non pixel casuali

Le opzioni di DiceBear sono selettori uniformi. Passa ["#ffdbb4", "#2c1b18"] per il colore della pelle e otterrai l'uno o l'altro con pari probabilità -- va bene per un logo, inutile per "dammi un personaggio dal Brasile."

character-factory fornisce una pipeline paese → etnia → tratti:

// Cosa c'è effettivamente nel modulo:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* mostly black/dark brown, no blonde */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Ogni livello è un'estrazione pesata. I pesi non sono un trattato di sociologia -- sono un'euristica che impedisce a "dal Giappone" di produrre una rossa e a "dalla Svezia" di produrre capelli nero corvino. L'intera pipeline si riduce a una singola chiamata: setCountry(country) o randomizeFromCountry(country, gender?).

Un piccolo motore genetico

La funzionalità con cui mi sono divertito di più: projectChild. Due factory possono produrre un figlio i cui tratti vengono ereditati con una grezza dominanza biologica:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Sotto il cofano è un modello volutamente minuscolo. Ogni genitore viene trattato come portatore di un genotipo a 2 alleli, uno da ciascun lato, combinati in dominante o recessivo:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

I tratti che hanno un vero asse di dominanza (pelle, occhi, capelli) vengono risolti rispetto a una lista ordinata esplicita -- scuro dominante su chiaro, occhi marroni/neri dominanti su azzurri, capelli nero corvino dominanti su biondi:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // most recessive
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // most dominant
] as const;

resolveByRank trova l'indice di ogni genitore, sceglie quello più alto su una combinazione di alleli "dominante" e quello più basso su "recessivo." I colori fantasy (rosa pastello, lilla) non sono nell'ordine -- ripiegano su un lancio della moneta 50/50, che è il comportamento giusto: non sono biologici, quindi la dominanza non può significare nulla.

Le lentiggini modellano MC1R: 75% se entrambi i genitori le hanno, 25% se solo uno le porta, 0% se nessuno. La barba è legata a SRY: rimossa se il figlio è femmina, altrimenti ereditata dal genitore che ne aveva una. Il taglio di capelli non è affatto biologico -- è una scelta culturale, quindi il figlio sceglie dal proprio pool di genere, preservando la consistenza se possibile.

Niente di tutto questo è genetica da pubblicazione. È un livello di sensazione: i figli sembrano una miscela plausibile dei loro genitori invece di due estranei messi insieme.

Le parti ingegneristiche noiose che contavano

Un paio di cose che non sono appariscenti ma si sono guadagnate il loro posto nel diff:

Un pick più sicuro. L'originale restituiva undefined castato come T su un array vuoto. Con strict + noUncheckedIndexedAccess in TypeScript, è una bugia che il compilatore approva. La nuova versione lancia un RangeError -- catturato immediatamente nel sito di chiamata invece di produrre proprietà undefined tre livelli più in profondità.

Un deepMerge che non corrompe gli array. La vecchia ricorsione si attivava ogni volta che il valore sorgente era un oggetto, anche se la destinazione era null o un array. merge({tags: ["a"]}, {tags: ["b"]}) produceva {tags: {0: "b"}}. La nuova versione ricorre solo quando entrambi i lati sono oggetti semplici.

Rendering batch parallelo. batchFactory renderizzava i PNG in un loop seriale -- un'esportazione di 1000 carte richiedeva minuti. Ora è un pool di worker con una concorrenza configurabile (default 4), preservando l'ordine dei risultati scrivendo in un array pre-dimensionato:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

Con un'esportazione di 1000 personaggi, ha trasformato una pausa caffè in un momento "ha già finito?"

Un messaggio di errore di sharp che dice qualcosa. buildPng importa sharp in modo lazy perché è una dipendenza peer-like che non vuoi imporre a chi usa solo SVG. Il vecchio catch ingoiava l'errore reale e diceva sempre "sharp is required." Se il vero problema era un mismatch di versione o un problema di binding nativo, perdevi dieci minuti a installare qualcosa che era già installato. La nuova versione ti dice ancora di installarlo, ma include l'errore sottostante.

Prossimi passi

Il modulo è alla versione 1.1.1 sul repository character-factory. Il motore genetico è il posto ovvio dove continuare a iterare -- non c'è ancora una suite di test, quindi invarianti coerenti ("un personaggio brasiliano con ascendenza est-asiatica non ha mai occhi nero corvino abbinati a capelli platino") sono imposti solo dai pesi. Aggiungere bun test o vitest e scrivere un test di coerenza che esegua diecimila chiamate randomizeFromCountry per paese è il prossimo passo.

Kurekuta stesso è privato per ora, ma ogni carta che alla fine vedrai è a un blob CharacterConfig e una chiamata buildPng() di distanza dall'esistere.

✨ AI Generated Article

Character-Factory bauen: Avatare mit Genetik

Ein TypeScript-Modul auf Basis von DiceBear: kohärente

character-factory bauen: Avatare mit Genetik

Ich brauchte tausende glaubwürdige, unverwechselbare Avatare für Kurekuta -- ein privates Kartenspiel-Projekt, bei dem jede Karte eine Charakter-„DNA“ enthält, die der Renderer in ein Porträt verwandelt. Ein Stock-Pack zu kaufen, hätte nach Stock ausgesehen. Einmalige DiceBear-Avatare pro Seed zu generieren, fühlte sich auf die falsche Art zufällig an: Eine japanisch angehauchte Karte könnte auf eine skandinavische Blondine landen, und zwei „Geschwister“ sahen aus wie Fremde.

Also habe ich character-factory geschrieben -- ein TypeScript-Modul auf Basis von DiceBears Lorelei-Sammlung, das drei Dinge hinzufügt, die DiceBear allein nicht bietet: kohärente Demografien, eine kleine Genetik-Engine und einen flüssigen Builder, der sich in einer Game-Schleife gut benutzen lässt.

Was es macht

Das kleinste nützliche Schnipsel:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // gewichtete Ethnie → kohärente Haut/Haar/Schnitt/Bart
  .setMood(Mood.Happy)
  .buildSvg();

Diese einzelne Kette wählt eine nach Japans demografischer Mischung gewichtete Ethnie aus, wählt einen zusammengehörigen Hautton und eine Haarfarbe, sucht eine Frisur aus dem richtigen Geschlechter-Pool und stellt dann Augen/Augenbrauen/Mund zu einer „fröhlichen“ Kombination zusammen. Das Ergebnis wird als SVG oder, mit installiertem sharp, als PNG in beliebiger Größe gerendert.

Ein Charakter ist einfach ein CharacterConfig-Objekt -- Gesicht, Haare, Accessoires, Präsentation. Der Builder mutiert eines intern, und du kannst es als JSON, Base64 oder Datei extrahieren und auf demselben Weg wieder laden. Für Kurekuta ist das wichtig: Eine Karte speichert die Konfiguration, nicht das gerenderte Bild, sodass die Kunst immer reproduzierbar ist und die Dateigröße einer Karte winzig bleibt.

Kohärente Demografien, nicht nur zufällige Pixel

DiceBears Optionen sind einheitliche Picker. Übergib ["#ffdbb4", "#2c1b18"] für die Hautfarbe und du bekommst eine von beiden mit gleicher Wahrscheinlichkeit -- gut für ein Logo, nutzlos für „gib mir einen Charakter aus Brasilien.“

character-factory liefert eine Land → Ethnie → Eigenschaften-Pipeline:

// Was tatsächlich im Modul ist:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* hauptsächlich schwarz/dunkelbraun, kein blond */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Jede Schicht ist eine gewichtete Ziehung. Die Gewichte sind keine soziologische Abhandlung -- sie sind eine Heuristik, die verhindert, dass „aus Japan“ eine Rothaarige produziert und „aus Schweden" pechschwarze Haare. Die gesamte Pipeline bricht auf einen einzigen Aufruf zusammen: setCountry(country) oder randomizeFromCountry(country, gender?).

Eine kleine Genetik-Engine

Das Feature, mit dem ich am meisten Spaß hatte: projectChild. Zwei Factories können ein Kind produzieren, dessen Eigenschaften mit grober biologischer Dominanz vererbt werden:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Unter der Haube steckt ein bewusst winziges Modell. Jeder Elternteil trägt einen 2-Allel-Genotyp, einen von jeder Seite gezogen, kombiniert zu dominant oder rezessiv:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Eigenschaften mit einer echten Dominanzachse (Haut, Augen, Haare) werden gegen eine explizite geordnete Liste aufgelöst -- dunkler dominant über heller, braune/schwarze Augen dominant über blau, pechschwarze Haare dominant über blond:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // am rezessivsten
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // am dominantesten
] as const;

resolveByRank findet den Index jedes Elternteils, wählt den höheren bei einer „dominanten“ Allelkombination und den niedrigeren bei „rezessiv.“ Fantasiefarben (Pastellpink, Lila) sind nicht in der Reihenfolge -- sie fallen auf einen 50/50-Münzwurf zurück, was das richtige Verhalten ist: Sie sind nicht biologisch, also kann Dominanz nichts bedeuten.

Sommersprossen modellieren MC1R: 75%, wenn beide Elternteile sie haben, 25%, wenn nur einer sie trägt, 0%, wenn keiner. Bart ist SRY-gebunden: entfernt, wenn das Kind weiblich ist, ansonsten von dem Elternteil geerbt, der einen hatte. Die Frisur ist überhaupt nicht biologisch -- sie ist eine kulturelle Wahl, also wählt das Kind aus seinem eigenen Geschlechter-Pool und bewahrt, wenn möglich, die Textur.

Nichts davon ist publikationsreife Genetik. Es ist eine Gefühlsebene: Kinder sehen aus wie eine plausible Mischung ihrer Eltern, anstatt wie zwei Fremde, die gemittelt wurden.

Die langweiligen Engineering-Teile, die wichtig waren

Ein paar Dinge, die nicht spektakulär sind, aber ihren Platz im Diff verdient haben:

Ein sichereres pick. Das Original gab undefined zurück, gecastet als T bei einem leeren Array. Mit strict + noUncheckedIndexedAccess in TypeScript ist das eine Lüge, die der Compiler absegnet. Die neue Version wirft einen RangeError -- sofort an der Aufrufstelle abgefangen, anstatt undefined-Props drei Ebenen tiefer zu produzieren.

Ein deepMerge, das Arrays nicht korrumpiert. Die alte Rekursion feuert, wenn der Quellwert ein Objekt ist, selbst wenn der Zielslot null oder ein Array ist. merge({tags: ["a"]}, {tags: ["b"]}) produzierte {tags: {0: "b"}}. Die neue Version rekursiert nur, wenn beide Seiten einfache Objekte sind.

Paralleles Batch-Rendering. batchFactory renderte früher PNGs in einer seriellen Schleife – ein 1000-Karten-Export lief Minuten. Jetzt ist es ein Worker-Pool mit konfigurierbarem Parallelitätsgrad (Standard 4), der die Ergebnisreihenfolge durch Schreiben in ein vorab dimensioniertes Array beibehält:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

Bei einem 1000-Charakter-Export verwandelte sich eine Kaffeepause in einen „Ist das schon fertig?“-Moment.

Eine sharp-Fehlermeldung, die etwas aussagt. buildPng importiert sharp lazy, weil es eine Peer-ähnliche Abhängigkeit ist, die man SVG-only-Benutzern nicht aufzwingen will. Der alte Catch verschluckte den eigentlichen Fehler und gab immer „sharp wird benötigt“ aus. Wenn der eigentliche Fehler ein Versionskonflikt oder ein natives Binding-Problem war, verbrachte man zehn Minuten damit, etwas zu installieren, das bereits installiert war. Die neue Version sagt dir immer noch, dass du es installieren sollst, enthält aber den zugrunde liegenden Fehler.

Was als Nächstes kommt

Das Modul ist auf Version 1.1.1 im character-factory-Repository. Die Genetik-Engine ist der offensichtliche Ort, um weiterzuentwickeln -- es gibt noch keine Testsuite, also werden kohärente Invarianten („ein brasilianischer Ostasien-lastiger Charakter hat niemals pechschwarze Augen mit platinblonden Haaren“) nur durch die Gewichte erzwungen. bun test oder vitest hinzuzufügen und einen Kohärenztest zu schreiben, der zehntausend randomizeFromCountry-Aufrufe pro Land ausführt, ist der nächste Schritt.

Kurekuta selbst ist vorerst privat, aber jede Karte, die du irgendwann darin sehen wirst, ist ein CharacterConfig-Blob und einen buildPng()-Aufruf davon entfernt zu existieren.

✨ AI Generated Article

Создание character-factory: аватары с генетикой

TypeScript-модуль на основе DiceBear: согласованная генерация на

Создание character-factory: аватары с генетикой

Мне понадобились тысячи правдоподобных, различимых аватаров для Kurekuta -- приватного проекта карточной игры, где у каждой карты есть «ДНК» персонажа, которую рендерер превращает в портрет. Покупка готового набора выглядела бы шаблонно. Генерация отдельных аватаров DiceBear по сиду была случайной не в том смысле: карта в японском стиле могла выдать скандинавскую блондинку, а два «родственника» выглядели как чужие.

Поэтому я написал character-factory -- TypeScript-модуль на основе коллекции DiceBear Lorelei, который добавляет три вещи, которых нет в чистом DiceBear: согласованную демографию, небольшой движок генетики и удобный строитель, приятный в использовании из игрового цикла.

Что он делает

Минимальный полезный пример:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // weighted ethnicity → coherent skin/hair/cut/beard
  .setMood(Mood.Happy)
  .buildSvg();

Эта цепочка выбирает этничность с учётом демографического распределения Японии, подбирает сочетающиеся тон кожи и цвет волос, выбирает причёску из соответствующего гендерного пула, а затем фиксирует глаза/брови/рот в «весёлую» комбинацию. Результат рендерится в SVG или, с установленным sharp, в PNG любого размера.

Персонаж -- это просто объект CharacterConfig: лицо, волосы, аксессуары, представление. Строитель изменяет его внутри, и ты можешь извлечь его как JSON, base64 или файл и загрузить таким же образом. Для Kurekuta это важно: карта хранит конфиг, а не готовое изображение, поэтому арт всегда воспроизводим, а размер карты остаётся крошечным.

Согласованная демография, а не просто случайные пиксели

Варианты DiceBear -- это равномерные выборки. Передай ["#ffdbb4", "#2c1b18"] для цвета кожи, и получишь либо то, либо другое с равными шансами -- нормально для логотипа, бесполезно для «дай мне персонажа из Бразилии».

character-factory поставляет конвейер страна → этничность → черты:

// What's actually in the module:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* mostly black/dark brown, no blonde */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Каждый слой -- это взвешенный выбор. Веса -- не научная работа по социологии; это эвристика, которая не даёт персонажу «из Японии» появиться с рыжими волосами, а персонажу «из Швеции» -- с иссиня-чёрными. Весь конвейер сворачивается в один вызов: setCountry(country) или randomizeFromCountry(country, gender?).

Небольшой движок генетики

Фича, которая доставила мне больше всего удовольствия: projectChild. Две фабрики могут создать ребёнка, чьи черты наследуются с примерным биологическим доминированием:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Под капотом -- намеренно простая модель. Каждый родитель рассматривается как носитель генотипа из 2 аллелей, по одной от каждой стороны, комбинируемых в доминантную или рецессивную:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Черты, имеющие реальную ось доминантности (кожа, глаза, волосы), разрешаются через явный упорядоченный список -- тёмное доминирует над светлым, карие/чёрные глаза доминируют над голубыми, иссиня-чёрные волосы доминируют над блондинистыми:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // most recessive
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // most dominant
] as const;

resolveByRank находит индекс каждого родителя, выбирает более высокий при комбинации «доминантных» аллелей и более низкий при «рецессивной». Фэнтезийные цвета (пастельно-розовый, сиреневый) отсутствуют в порядке -- они переходят к подбрасыванию монетки 50/50, что правильно: они не биологические, поэтому доминантность не имеет смысла.

Веснушки моделируются по принципу MC1R: 75%, если есть у обоих родителей, 25%, если только у одного, 0%, если ни у кого. Борода привязана к SRY: убирается, если ребёнок женского пола, иначе наследуется от родителя, у которого она была. Причёска не биологична -- это культурный выбор, поэтому ребёнок выбирает из своего гендерного пула, по возможности сохраняя текстуру.

Ничто из этого не является генетикой публикационного уровня. Это слой ощущений: дети выглядят как правдоподобная смесь родителей, а не как усреднение двух незнакомцев.

Скучные инженерные детали, которые оказались важны

Несколько вещей, которые не блещут, но заслужили своё место в диффе:

Более безопасный pick. Оригинал возвращал undefined, приведённый к T, для пустого массива. С strict + noUncheckedIndexedAccess в TypeScript это ложь, которую компилятор подписывает. Новая версия выбрасывает RangeError -- перехватывается сразу на месте вызова, вместо того чтобы порождать undefined-свойства тремя уровнями ниже.

deepMerge, который не портит массивы. Старая рекурсия срабатывала, когда исходное значение было объектом, даже если целевой слот был null или массивом. merge({tags: ["a"]}, {tags: ["b"]}) давало {tags: {0: "b"}}. Новая версия рекурсирует, только когда обе стороны -- простые объекты.

Параллельная пакетная отрисовка. batchFactory раньше рендерил PNG в последовательном цикле -- экспорт 1000 карт занимал минуты. Теперь это пул воркеров с настраиваемой конкурентностью (по умолчанию 4), сохраняющий порядок результатов путём записи в предварительно выделенный массив:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

На экспорте 1000 персонажей это превратило перерыв на кофе в момент «уже закончилось?».

Сообщение об ошибке sharp, которое хоть что-то говорит. buildPng лениво импортирует sharp, потому что это «полу-зависимость», которую не хочется навязывать пользователям, использующим только SVG. Старый catch проглатывал реальную ошибку и всегда говорил «sharp is required». Если реальной проблемой было несоответствие версий или проблема с нативными биндингами, ты тратил десять минут на установку того, что уже было установлено. Новая версия всё ещё говорит установить его, но включает в себя нижележащую ошибку.

Что дальше

Модуль на версии 1.1.1 в репозитории character-factory. Движок генетики -- очевидное место для продолжения итераций: пока нет набора тестов, поэтому инварианты согласованности («бразильский персонаж с восточноазиатскими чертами никогда не имеет иссиня-чёрных глаз в паре с платиновыми волосами») обеспечиваются только весами. Добавление bun test или vitest и написание теста на согласованность, который запускает десять тысяч randomizeFromCountry на каждую страну, -- следующий шаг.

Kurekuta пока приватный, но каждая карта, которую ты когда-нибудь в нём увидишь, -- это blob CharacterConfig и один вызов buildPng() от существования.

✨ AI Generated Article

Construyendo character-factory: avatares con genética

Un módulo de TypeScript sobre DiceBear: generación coherente

Construyendo character-factory: avatares con genética

Necesitaba miles de avatares creíbles y distintos para Kurekuta -- un proyecto privado de juego de cartas donde cada carta contiene un «ADN» de personaje que el renderizador convierte en un retrato. Comprar un paquete de stock se habría visto genérico. Generar avatares únicos de DiceBear por semilla se sentía aleatorio de la manera incorrecta: una carta de temática japonesa podía dar con una escandinava rubia, y dos «hermanos» parecían desconocidos.

Así que escribí character-factory -- un módulo de TypeScript sobre la colección Lorelei de DiceBear que añade tres cosas que DiceBear solo no te da: demografía coherente, un pequeño motor de genética y un builder fluido que es agradable de usar desde un bucle de juego.

Lo que hace

El fragmento útil más pequeño:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // etnia ponderada → piel/cabello/corte/barba coherentes
  .setMood(Mood.Happy)
  .buildSvg();

Esa sola cadena elige una etnia ponderada por la mezcla demográfica de Japón, selecciona un tono de piel y color de cabello que combinen, elige un peinado del subconjunto de género correcto, y luego fija los ojos/cejas/boca en una combinación «feliz». El resultado se renderiza como SVG o, con sharp instalado, como PNG de cualquier tamaño.

Un personaje es solo un objeto CharacterConfig -- rostro, cabello, accesorios, presentación. El builder lo muta internamente, y puedes extraerlo como JSON, base64 o un archivo, y recargarlo de la misma manera. Para Kurekuta esto importa: una carta almacena la configuración, no la imagen renderizada, así que el arte siempre es reproducible y el tamaño de archivo de una carta se mantiene mínimo.

Demografía coherente, no solo píxeles aleatorios

Las opciones de DiceBear son selectores uniformes. Pasa ["#ffdbb4", "#2c1b18"] para el color de piel y obtendrás cualquiera con las mismas probabilidades -- bien para un logo, inútil para «dame un personaje de Brasil».

character-factory trae un pipeline país → etnia → rasgos:

// Lo que realmente está en el módulo:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* mayormente negro/marrón oscuro, sin rubio */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Cada capa es una selección ponderada. Los pesos no son un artículo de sociología -- son una heurística que evita que «de Japón» produzca una pelirroja y «de Suecia» produzca cabello negro azabache. Todo el pipeline se reduce a una llamada: setCountry(country) o randomizeFromCountry(country, gender?).

Un pequeño motor de genética

La funcionalidad con la que más me divertí: projectChild. Dos fábricas pueden producir un hijo cuyos rasgos se heredan con dominancia biológica aproximada:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Bajo el capó es un modelo deliberadamente pequeño. Cada progenitor se trata como portador de un genotipo de 2 alelos, uno extraído de cada lado, combinado en dominante o recesivo:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Los rasgos que tienen un eje de dominancia real (piel, ojos, cabello) se resuelven contra una lista ordenada explícita -- más oscuro dominante sobre más claro, ojos marrones/negros dominantes sobre azules, cabello negro azabache dominante sobre rubio:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // más recesivo
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // más dominante
] as const;

resolveByRank encuentra el índice de cada progenitor, elige el más alto en una combinación de alelos «dominante» y el más bajo en «recesivo». Los colores fantásticos (rosa pastel, lila) no están en el orden -- recurren a un volado 50/50, que es el comportamiento correcto: no son biológicos, así que la dominancia no puede significar nada.

Las pecas modelan MC1R: 75% si ambos padres las tienen, 25% si solo uno porta, 0% si ninguno. La barba está ligada a SRY: se elimina si el hijo es mujer, de lo contrario se hereda del progenitor que tuviera barba. El peinado no es biológico en absoluto -- es una elección cultural, así que el hijo elige de su propio conjunto de género, preservando la textura si es posible.

Nada de esto es genética de publicación académica. Es una capa de sensación: los hijos se ven como una mezcla plausible de sus padres en lugar de dos desconocidos promediados.

Las partes de ingeniería aburridas que importaron

Algunas cosas que no son llamativas pero se ganaron su espacio en el diff:

Un pick más seguro. El original devolvía undefined casteado como T en un array vacío. Con strict + noUncheckedIndexedAccess en TypeScript, eso es una mentira que el compilador aprueba. La nueva versión lanza un RangeError -- atrapado inmediatamente en el lugar de la llamada en lugar de producir props undefined tres niveles más abajo.

Un deepMerge que no corrompe arrays. La recursión antigua se disparaba cuando el valor fuente era un objeto, incluso si el destino era null o un array. merge({tags: ["a"]}, {tags: ["b"]}) producía {tags: {0: "b"}}. La nueva versión solo recurre cuando ambos lados son objetos planos.

Renderizado por lotes en paralelo. batchFactory solía renderizar PNGs en un bucle serial -- una exportación de 1000 cartas se ejecutaba durante minutos. Ahora es un pool de workers con concurrencia configurable (por defecto 4), preservando el orden de los resultados escribiendo en un array pre-dimensionado:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

En una exportación de 1000 personajes, esto convirtió una pausa para el café en un momento de «¿ya terminó?».

Un mensaje de error de sharp que dice algo. buildPng importa sharp de forma diferida porque es una dependencia «peer-ish» que no quieres forzar a usuarios solo de SVG. El antiguo catch se tragaba el error real y siempre decía «sharp es necesario». Si el fallo real era una incompatibilidad de versión o un problema de enlace nativo, pasabas diez minutos instalando algo que ya estaba instalado. La nueva versión aún te dice que lo instales, pero incluye el error subyacente.

Lo que sigue

El módulo está en la versión 1.1.1 en el repositorio de character-factory. El motor de genética es el lugar obvio para seguir iterando -- todavía no hay suite de pruebas, así que los invariantes coherentes («un personaje brasileño de ascendencia asiática nunca tiene ojos negro azabache con cabello platino») solo se aplican mediante los pesos. Añadir bun test o vitest y escribir una prueba de coherencia que ejecute diez mil llamadas randomizeFromCountry por país es el siguiente paso.

Kurekuta en sí mismo es privado por ahora, pero cada carta que eventualmente veas en él es un blob CharacterConfig a una llamada buildPng() de distancia de existir.

✨ AI Generated Article

Construindo character-factory: avatares com genética

Um módulo TypeScript sobre o DiceBear: geração consistente

Construindo character-factory: avatares com um sistema genético

Eu precisava de milhares de avatares críveis e distintos para o Kurekuta -- um projeto de jogo de cartas privado onde cada carta contém um "DNA" de personagem que o motor de renderização transforma em retrato. Comprar um pacote pronto, isso se notaria. Gerar avatares DiceBear com seed por personagem, dava em qualquer coisa: uma carta no universo japonês podia cair numa loira escandinava, e dois "irmãos" pareciam estranhos.

Então escrevi o character-factory -- um módulo TypeScript sobre a coleção Lorelei do DiceBear que traz três coisas que o DiceBear sozinho não dá: perfis demográficos consistentes, um pequeno motor de genética, e um builder fluido agradável de usar desde um loop de jogo.

O que faz

O menor snippet útil:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // etnia ponderada → pele/cabelo/corte/barba consistentes
  .setMood(Mood.Happy)
  .buildSvg();

Essa simples cadeia escolhe uma etnia ponderada pela demografia japonesa, sorteia um tom de pele e cor de cabelo que combinam, seleciona um corte no subgrupo de gênero correto, e então trava os olhos/sobrancelhas/boca no modo "feliz". O resultado sai em SVG ou, com sharp instalado, em PNG em qualquer tamanho.

Um personagem é apenas um objeto CharacterConfig -- rosto, cabelo, acessórios, apresentação. O builder o modifica internamente, e você pode exportá-lo como JSON, base64 ou arquivo, e então recarregá-lo identicamente. Para o Kurekuta isso é crucial: uma carta armazena a config, não a imagem renderizada. Assim a arte é sempre reproduzível e o tamanho de uma carta permanece minúsculo.

Perfis demográficos consistentes, não pixel aleatório

As opções do DiceBear são seletores uniformes. Passe ["#ffdbb4", "#2c1b18"] para a cor da pele e você terá um ou outro com a mesma probabilidade -- OK para um logotipo, inútil para "me dê um personagem do Brasil."

O character-factory embarca um pipeline país → etnia → traços:

// O que está no módulo:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* sobretudo preto/castanho escuro, sem loiro */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Cada camada é um sorteio ponderado. Os pesos não são uma tese de sociologia -- é uma heurística que impede "vindo do Japão" de produzir um ruivo e "vindo da Suécia" de produzir um preto retinto. Todo o pipeline se resume a uma única chamada: setCountry(country) ou randomizeFromCountry(country, gender?).

Um pequeno motor de genética

A função que mais me divertiu: projectChild. Duas factories podem produzir uma criança cujos traços são herdados com dominância biológica aproximada:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Sob o capô, é um modelo voluntariamente minúsculo. Cada parental carrega um genótipo de 2 alelos, um puxado de cada lado, combinado em dominante ou recessivo:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Os traços que têm um eixo real de dominância (pele, olhos, cabelo) são resolvidos por uma lista ordenada explícita -- o escuro domina o claro, os olhos castanhos/pretos dominam o azul, o preto retinto domina o loiro:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // o mais recessivo
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // o mais dominante
] as const;

O resolveByRank encontra o índice de cada parental, pega o mais alto numa combinação de alelos "dominante" e o mais baixo em "recessiva". As cores fantasia (rosa pastel, lilás) não estão na ordem -- elas fazem um cara ou coroa 50/50, que é o comportamento correto: elas não são biológicas, então a dominância não faz sentido.

As sardas modelam MC1R: 75% se ambos os pais têm, 25% se apenas um carrega, 0% se nenhum. A barba está ligada ao SRY: removida se a criança for mulher, caso contrário herdada do parental que tinha uma. O corte de cabelo não tem nada de biológico -- é uma escolha cultural, então a criança escolhe no seu próprio pool de gênero, preservando a textura se possível.

Nada disso é genética digna de publicação. É uma camada de sensação: as crianças se parecem com uma mistura plausível de seus pais, não com a média de dois estranhos.

As partes de engenharia menos glamourosas que importaram

Algumas coisas não chamativas mas que mereceram seu lugar no diff:

Um pick mais seguro. O original retornava undefined castado para T num array vazio. Com strict + noUncheckedIndexedAccess no TypeScript, isso é uma mentira que o compilador assina. A nova versão lança um RangeError -- capturado imediatamente no local da chamada em vez de produzir props undefined três níveis abaixo.

Um deepMerge que não corrompe arrays. A antiga recursão ativava-se assim que o valor fonte era um objeto, mesmo se o alvo fosse null ou um array. merge({tags: ["a"]}, {tags: ["b"]}) produzia {tags: {0: "b"}}. A nova versão só recorre quando ambos os lados são objetos simples.

Renderização em lote paralela. O batchFactory renderizava os PNGs em loop serializado -- uma exportação de 1000 cartas levava uma eternidade. Agora é um pool de workers com concorrência configurável (4 por padrão), que preserva a ordem dos resultados escrevendo num array pré-dimensionado:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

Numa exportação de 1000 personagens, isso transformou uma pausa para café em um "já acabou?"

Uma mensagem de erro sharp que diz alguma coisa. O buildPng importa o sharp de forma lazy porque é uma dependência opcional que você não quer impor aos usuários SVG-only. O antigo catch engolia o erro real e sempre dizia "sharp is required." Se a falha real era um conflito de versão ou um problema de bindings nativos, você passava dez minutos reinstalando algo já instalado. A nova versão sempre diz para instalá-lo, mas inclui o erro subjacente.

O futuro

O módulo está na 1.1.1 no repositório character-factory. O motor genético é o lugar ideal para continuar iterando -- ainda não há suite de testes, então os invariantes de consistência ("um personagem brasileiro de ascendência asiática nunca terá olhos pretos retintos com cabelo platinado") só são garantidos pelos pesos. Adicionar bun test ou vitest e escrever um teste de consistência que rode dez mil randomizeFromCountry por país, é o próximo passo.

O Kurekuta em si é privado por enquanto, mas cada carta que você verá um dia nele é apenas um blob CharacterConfig e um buildPng() de existir.

✨ AI Generated Article

Membangun character-factory: avatar dengan genetika

Modul TypeScript di atas DiceBear: generasi konsisten per

Membangun character-factory: avatar dengan sistem genetika

Saya butuh ribuan avatar kredibel dan berbeda untuk Kurekuta -- sebuah proyek permainan kartu privat di mana setiap kartu berisi "DNA" karakter yang diubah mesin render menjadi potret. Membeli paket jadi, itu ketahuan. Membuat avatar DiceBear dengan seed per karakter, hasilnya kacau: kartu berlatar Jepang bisa mendapat wanita pirang Skandinavia, dan dua "saudara" tampak seperti orang asing.

Maka saya menulis character-factory -- modul TypeScript di atas koleksi Lorelei dari DiceBear yang memberikan tiga hal yang tidak bisa diberikan DiceBear sendiri: profil demografis yang konsisten, mesin genetika kecil, dan builder yang mulus enak digunakan dari dalam loop permainan.

Apa yang dilakukannya

Snippet paling kecil yang berguna:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // etnis berbobot → kulit/rambut/potongan/jenggot konsisten
  .setMood(Mood.Happy)
  .buildSvg();

Rantai sederhana ini memilih etnis berdasarkan bobot demografi Jepang, memilih warna kulit dan rambut yang cocok, memilih potongan rambut dari subgrup gender yang tepat, lalu mengunci mata/alis/mulut dalam mode "senang". Hasilnya keluar sebagai SVG atau, dengan sharp terinstal, PNG dalam ukuran berapa pun.

Karakter hanyalah objek CharacterConfig -- wajah, rambut, aksesori, presentasi. Builder memodifikasinya secara internal, dan kamu bisa mengekspornya sebagai JSON, base64, atau file, lalu memuatnya kembali secara identik. Untuk Kurekuta ini krusial: kartu menyimpan konfigurasi, bukan gambar yang sudah dirender. Jadi seni selalu dapat direproduksi dan ukuran kartu tetap kecil.

Profil demografis yang konsisten, bukan piksel acak

Opsi DiceBear adalah pemilih seragam. Berikan ["#ffdbb4", "#2c1b18"] untuk warna kulit dan kamu akan mendapat salah satu dengan probabilitas sama -- OK untuk logo, tidak berguna untuk "beri saya karakter dari Brasil."

character-factory memiliki pipeline negara → etnis → ciri:

// Yang ada di dalam modul:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* kebanyakan hitam/cokelat gelap, tanpa pirang */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Setiap lapisan adalah pengambilan berbobot. Bobotnya bukan tesis sosiologi -- ini heuristik yang mencegah "dari Jepang" menghasilkan rambut merah dan "dari Swedia" menghasilkan rambut hitam pekat. Seluruh pipeline cukup dengan satu panggilan: setCountry(country) atau randomizeFromCountry(country, gender?).

Mesin genetika kecil

Fungsi yang paling menyenangkan untuk dikerjakan: projectChild. Dua factory dapat menghasilkan anak yang ciri-cirinya diwariskan dengan dominasi biologis perkiraan:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Di balik layar, ini model yang sengaja dibuat sangat kecil. Setiap orang tua membawa genotipe 2 alel, satu dari masing-masing sisi, digabung menjadi dominan atau resesif:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Ciri-ciri yang memiliki sumbu dominasi nyata (kulit, mata, rambut) diselesaikan dengan daftar urut eksplisit -- gelap mendominasi terang, mata cokelat/hitam mendominasi biru, hitam pekat mendominasi pirang:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // paling resesif
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // paling dominan
] as const;

resolveByRank mencari indeks masing-masing orang tua, mengambil yang tertinggi pada kombinasi alel "dominan" dan yang terendah pada "resesif". Warna fantasi (merah muda pastel, ungu) tidak ada dalam urutan -- mereka memakai lempar koin 50/50, yang merupakan perilaku tepat: mereka tidak biologis, jadi dominasi tidak bermakna.

Bintik-bintik memodelkan MC1R: 75% jika kedua orang tua memilikinya, 25% jika hanya satu yang membawanya, 0% jika tidak ada. Jenggot terikat pada SRY: dihilangkan jika anak perempuan, sebaliknya diwarisi dari orang tua yang memilikinya. Potongan rambut tidak ada biologisnya -- itu pilihan kultural, jadi anak memilih dari kumpulan gender sendiri, mempertahankan tekstur jika memungkinkan.

Tidak ada satupun dari ini yang genetika layak publikasi. Ini adalah lapisan rasa: anak-anak terlihat seperti campuran masuk akal dari orang tua mereka, bukan rata-rata dua orang asing.

Bagian rekayasa yang kurang glamor tetapi berarti

Beberapa hal yang tidak mencolok tetapi layak tempat di diff:

pick yang lebih aman. Yang asli mengembalikan undefined yang di-cast ke T pada array kosong. Dengan strict + noUncheckedIndexedAccess di TypeScript, itu kebohongan yang ditandatangani kompilator. Versi baru melempar RangeError -- ditangkap segera di situs panggilan alih-alih menghasilkan props undefined tiga level di bawah.

deepMerge yang tidak merusak array. Rekursi lama aktif begitu nilai sumber adalah objek, bahkan jika target adalah null atau array. merge({tags: ["a"]}, {tags: ["b"]}) menghasilkan {tags: {0: "b"}}. Versi baru hanya merekurei ketika kedua sisi adalah objek biasa.

Render batch secara paralel. batchFactory merender PNG dalam loop serial -- ekspor 1000 kartu memakan waktu lama. Sekarang ini adalah pool pekerja dengan konkurensi yang dapat dikonfigurasi (4 secara default), yang mempertahankan urutan hasil dengan menulis ke array yang sudah dialokasikan:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

Pada ekspor 1000 karakter, ini mengubah waktu istirahat kopi menjadi "sudah selesai?"

Pesan error sharp yang berarti. buildPng mengimpor sharp secara lazy karena itu dependensi opsional yang tidak ingin kamu paksakan ke pengguna SVG-only. Catch lama menelan error asli dan selalu mengatakan "sharp is required." Jika kegagalan nyata adalah konflik versi atau masalah bindings asli, kamu menghabiskan sepuluh menit menginstal ulang sesuatu yang sudah terinstal. Versi baru tetap mengatakan untuk menginstalnya, tetapi menyertakan error yang mendasarinya.

Selanjutnya

Modul ini di versi 1.1.1 di repositori character-factory. Mesin genetika adalah tempat ideal untuk terus melakukan iterasi -- belum ada rangkaian pengujian, jadi invarian konsistensi ("karakter Brasil keturunan Asia Timur tidak akan pernah memiliki mata hitam pekat dengan rambut platinum") hanya dijamin oleh bobot. Menambahkan bun test atau vitest dan menulis tes konsistensi yang menjalankan sepuluh ribu randomizeFromCountry per negara, itu langkah selanjutnya.

Kurekuta sendiri masih privat untuk saat ini, tetapi setiap kartu yang suatu hari akan kamu lihat di dalamnya hanyalah sebuah blob CharacterConfig dan buildPng() untuk eksis.

✨ AI Generated Article

character-factory का निर्माण : आनुवंशिकी के साथ अवतार

DiceBear के ऊपर एक TypeScript मॉड्यूल : देश/जातीयता के अनुसार सुसंगत

character-factory का निर्माण : आनुवंशिक प्रणाली के साथ अवतार

मुझे Kurekuta के लिए हज़ारों विश्वसनीय और विशिष्ट अवतारों की ज़रूरत थी -- एक निजी कार्ड गेम प्रोजेक्ट जहाँ हर कार्ड में एक किरदार का "DNA" होता है जिसे रेंडर इंजन पोर्ट्रेट में बदलता है। तैयार पैक खरीदना, वह दिख जाता। DiceBear अवतारों को प्रति किरदार सीड से जनरेट करना, उससे बेतरतीब परिणाम मिलते थे : जापानी ब्रह्मांड वाला कार्ड कभी स्कैंडिनेवियाई गोरी पर गिर सकता था, और दो "भाई-बहन" अजनबियों जैसे लगते थे।

इसलिए मैंने character-factory लिखा -- DiceBear के Lorelei कलेक्शन के ऊपर एक TypeScript मॉड्यूल जो तीन चीज़ें लाता है जो अकेला DiceBear नहीं देता : सुसंगत जनसांख्यिकीय प्रोफ़ाइल, एक छोटा आनुवंशिकी इंजन, और एक सहज बिल्डर जो गेम लूप से उपयोग करने में सुविधाजनक है।

यह क्या करता है

सबसे छोटा उपयोगी स्निपेट :

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // भारित जातीयता → सुसंगत त्वचा/बाल/कट/दाढ़ी
  .setMood(Mood.Happy)
  .buildSvg();

यह सरल श्रृंखला जापानी जनसांख्यिकी द्वारा भारित एक जातीयता चुनती है, एक साथ फिट होने वाला त्वचा-रंग और बाल-रंग निकालती है, सही लिंग उपसमूह से एक कट चुनती है, फिर आँखों/भौहों/मुँह को "खुश" मोड में लॉक करती है। परिणाम SVG में या, sharp इंस्टॉल होने पर, किसी भी आकार में PNG में आता है।

एक किरदार सिर्फ़ एक CharacterConfig ऑब्जेक्ट है -- चेहरा, बाल, एक्सेसरीज़, प्रस्तुति। बिल्डर इसे आंतरिक रूप से संशोधित करता है, और आप इसे JSON, base64 या फ़ाइल में एक्सपोर्ट कर सकते हैं, फिर इसे वैसे ही पुनः लोड कर सकते हैं। Kurekuta के लिए यह महत्वपूर्ण है : एक कार्ड कॉन्फ़िग को स्टोर करता है, रेंडर की गई छवि को नहीं। इस तरह कला हमेशा पुनरुत्पादनीय होती है और कार्ड का आकार छोटा रहता है।

सुसंगत जनसांख्यिकीय प्रोफ़ाइल, बेतरतीब पिक्सेल नहीं

DiceBear विकल्प एकसमान सेलेक्टर हैं। ["#ffdbb4", "#2c1b18"] त्वचा के रंग के लिए पास करें और आपको दोनों में से कोई भी समान संभावना से मिलेगा -- लोगो के लिए ठीक है, "मुझे ब्राज़ील का एक किरदार दो" के लिए बेकार।

character-factory एक देश → जातीयता → लक्षण पाइपलाइन पैक करता है :

// मॉड्यूल में क्या है :
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* ज़्यादातर काला/गहरा भूरा, गोरा नहीं */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

हर परत एक भारित ड्रा है। वज़न कोई समाजशास्त्रीय थीसिस नहीं है -- यह एक अनुमान है जो "जापान से आए" को लाल बाल और "स्वीडन से आए" को गहरे काले बाल उत्पन्न करने से रोकता है। पूरी पाइपलाइन सिर्फ़ एक कॉल में समाती है : setCountry(country) या randomizeFromCountry(country, gender?)।

एक छोटा आनुवंशिकी इंजन

सबसे मज़ेदार फ़ंक्शन : projectChild। दो फ़ैक्टरियाँ एक बच्चा उत्पन्न कर सकती हैं जिसके लक्षण अनुमानित जैविक प्रभुत्व के साथ विरासत में मिलते हैं :

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

हुड के नीचे, यह जानबूझकर छोटा मॉडल है। हर माता-पिता में 2 एलील का जीनोटाइप होता है, एक प्रत्येक तरफ से खींचा जाता है, जिसे प्रभावी या अप्रभावी में जोड़ा जाता है :

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

जिन लक्षणों का वास्तविक प्रभुत्व अक्ष है (त्वचा, आँखें, बाल) उन्हें एक स्पष्ट क्रमबद्ध सूची द्वारा हल किया जाता है -- गहरा हल्के पर हावी होता है, भूरी/काली आँखें नीली पर हावी होती हैं, गहरा काला गोरे पर हावी होता है :

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // सबसे अप्रभावी
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // सबसे प्रभावी
] as const;

resolveByRank प्रत्येक माता-पिता का इंडेक्स ढूँढता है, "प्रभावी" एलील संयोजन पर सबसे अधिक लेता है और "अप्रभावी" पर सबसे कम। फंतासी रंग (पेस्टल गुलाबी, बैंगनी) क्रम में नहीं हैं -- वे 50/50 टॉस करते हैं, जो सही व्यवहार है : वे जैविक नहीं हैं, इसलिए प्रभुत्व का कोई मतलब नहीं है।

झाइयाँ MC1R मॉडल करती हैं : 75% अगर दोनों माता-पिता में हैं, 25% अगर सिर्फ़ एक में, 0% अगर किसी में नहीं। दाढ़ी SRY से जुड़ी है : अगर बच्चा महिला है तो हटा दी जाती है, अन्यथा उस माता-पिता से विरासत में मिलती है जिसके पास थी। बाल कटवाने का जैविक आधार नहीं है -- यह एक सांस्कृतिक विकल्प है, इसलिए बच्चा अपने स्वयं के लिंग पूल से चुनता है, यदि संभव हो तो बनावट को संरक्षित करता है।

इनमें से कुछ भी प्रकाशन योग्य आनुवंशिकी नहीं है। यह एक अनुभव परत है : बच्चे अपने माता-पिता का एक प्रशंसनीय मिश्रण दिखते हैं, दो अजनबियों का औसत नहीं।

कम ग्लैमरस इंजीनियरिंग भाग जो मायने रखते थे

कुछ फ्लैशी नहीं बल्कि ध्यान देने योग्य चीज़ें :

एक सुरक्षित pick। मूल खाली ऐरे पर undefined को T के रूप में कास्ट करके लौटाता था। TypeScript में strict + noUncheckedIndexedAccess के साथ, यह एक झूठ है जिस पर कंपाइलर हस्ताक्षर करता है। नया संस्करण RangeError फेंकता है -- तीन स्तर नीचे undefined प्रॉप्स उत्पन्न करने के बजाय कॉल साइट पर तुरंत पकड़ा जाता है।

एक deepMerge जो ऐरे को भ्रष्ट नहीं करता। पुराना रिकर्सन सक्रिय हो जाता था जब स्रोत मान एक ऑब्जेक्ट होता था, भले ही लक्ष्य null या एक ऐरे हो। merge({tags: ["a"]}, {tags: ["b"]}) से {tags: {0: "b"}} बनता था। नया संस्करण तभी रिकर्स करता है जब दोनों पक्ष सादे ऑब्जेक्ट हों।

समानांतर बैच रेंडरिंग। batchFactory PNG को क्रमिक लूप में रेंडर करता था -- 1000 कार्डों का निर्यात बहुत समय लेता था। अब यह कॉन्फ़िगरेबल कंकरेंसी (डिफ़ॉल्ट 4) के साथ वर्कर्स का एक पूल है, जो पूर्व-आकार की ऐरे में लिखकर परिणामों का क्रम बनाए रखता है :

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

1000 किरदारों के निर्यात पर, इसने कॉफ़ी ब्रेक को "पहले ही खत्म हो गया?" में बदल दिया।

एक sharp त्रुटि संदेश जो कुछ कहता है। buildPng sharp को आलसी आयात करता है क्योंकि यह एक वैकल्पिक निर्भरता है जिसे आप SVG-only उपयोगकर्ताओं पर थोपना नहीं चाहते। पुराना catch वास्तविक त्रुटि को निगल जाता था और हमेशा "sharp is required." कहता था। यदि वास्तविक विफलता संस्करण संघर्ष या नेटिव बाइंडिंग समस्या थी, तो आप पहले से इंस्टॉल चीज़ को पुनः इंस्टॉल करने में दस मिनट बिताते थे। नया संस्करण हमेशा इसे इंस्टॉल करने के लिए कहता है, लेकिन अंतर्निहित त्रुटि भी शामिल करता है।

आगे क्या

मॉड्यूल character-factory रिपॉजिटरी पर 1.1.1 है। आनुवंशिक इंजन पुनरावृति जारी रखने के लिए आदर्श स्थान है -- अभी तक कोई परीक्षण सूट नहीं है, इसलिए स्थिरता अपरिवर्तनीयताएँ ("ब्राज़ीलियाई किरदार पूर्वी एशियाई वंश के साथ कभी भी गहरी काली आँखों के साथ प्लैटिनम बाल नहीं होगा") केवल वज़न द्वारा सुनिश्चित की जाती हैं। bun test या vitest जोड़ना और एक स्थिरता परीक्षण लिखना जो प्रति देश दस हज़ार randomizeFromCountry चलाता है, अगला कदम है।

Kurekuta स्वयं अभी के लिए निजी है, लेकिन जो भी कार्ड आप कभी उसमें देखेंगे, वह सिर्फ़ एक CharacterConfig ब्लॉब और एक buildPng() का अस्तित्व है।

✨ AI Generated Article

بناء character-factory: صور رمزية بعلم الوراثة

وحدة TypeScript فوق DiceBear: توليد متسق حسب البلد/العرق،

بناء character-factory: صور رمزية بنظام وراثي

كنت بحاجة لآلاف الصور الرمزية المعقولة والمتميزة من أجل Kurekuta -- مشروع لعبة ورق خاص حيث كل بطاقة تحتوي على "حمض نووي" للشخصية يحوله محرك التصيير إلى صورة شخصية. شراء حزمة جاهزة كان سيكون مكشوفًا. توليد صور DiceBear عشوائيًا لكل شخصية كان ينتج فوضى: بطاقة بطابع ياباني قد تظهر بشخصية شقراء إسكندنافية، وشخصيتان "أخ وأخت" كانتا تبدوان كغرباء.

لذا كتبت character-factory -- وحدة TypeScript فوق مجموعة Lorelei من DiceBear توفر ثلاث ميزات لا يوفرها DiceBear وحده: ملفات ديموغرافية متسقة، محرك وراثي صغير، وسيط بناء سلس سهل الاستخدام من حلقة اللعبة.

ما الذي يفعله

أصغر مقطع مفيد:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // عرق مرجح → بشرة/شعر/قصة/لحية متسقة
  .setMood(Mood.Happy)
  .buildSvg();

هذه السلسلة البسيطة تختار عرقًا مرجحًا حسب الديموغرافيا اليابانية، تسحب لون بشرة ولون شعر متناسبين، تختار قصة شعر من المجموعة الفرعية الصحيحة حسب الجنس، ثم تثبت العيون/الحواجب/الفم على وضع "سعيد". النتيجة تخرج بصيغة SVG أو، مع تثبيت sharp، بصيغة PNG بأي حجم.

الشخصية هي مجرد كائن CharacterConfig -- الوجه، الشعر، الإكسسوارات، المظهر. يعدله وسيط البناء داخليًا، ويمكنك تصديره بصيغة JSON أو base64 أو ملف، ثم إعادة تحميله كما هو. بالنسبة لـ Kurekuta هذا أمر حاسم: البطاقة تخزن الإعدادات، وليس الصورة المصيرة. وبالتالي يكون الفن دائمًا قابلًا لإعادة الإنتاج ويظل حجم البطاقة صغيرًا جدًا.

ملفات ديموغرافية متسقة، لا بكسلات عشوائية

خيارات DiceBear هي محددات موحدة. مرر ["#ffdbb4", "#2c1b18"] للون البشرة وستحصل على أحدهما بنفس الاحتمال -- جيد لشعار، لكنه عديم الفائدة لعبارة "أعطني شخصية من البرازيل."

يحتوي character-factory على أنبوب بلد → عرق → سمات:

// ما يوجد في الوحدة:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [// خاصة أسود/بني غامق، لا أشقر],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

كل طبقة هي سحب مرجح. الأوزان ليست أطروحة اجتماعية -- إنها استدلال يمنع "قادم من اليابان" من إنتاج شخص أحمر الشعر و"قادم من السويد" من إنتاج شخص أسود حالك. أنبوب العمل بأكمله يختزل في استدعاء واحد: setCountry(country) أو randomizeFromCountry(country, gender?).

محرك وراثي صغير

الوظيفة الأكثر متعة في الكتابة: projectChild. يمكن لوسيطي بناء إنتاج طفلة ترث الصفات مع هيمنة بيولوجية تقريبية:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

تحت الغطاء، هو نموذج صغير عمدًا. كل والد يحمل نمطًا جينيًا من أليلين، واحد من كل جانب، يجمع إلى سائد أو متنحٍ:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

الصفات التي لها محور هيمنة حقيقي (البشرة، العيون، الشعر) تُحل بقائمة مرتبة صريحة -- الداكن يهيمن على الفاتح، العيون البنية/السوداء تهيمن على الزرقاء، الأسود حالك يهيمن على الأشقر:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // الأكثر تنحيًا
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // الأكثر هيمنة
] as const;

resolveByRank يجد ترتيب كل والد، ويأخذ الأعلى على تركيبة أليلات "سائدة" والأدنى على "متنحية". الألوان الخيالية (وردي باستيل، أرجواني) ليست في الترتيب -- تخضع للقرعة 50/50، وهو السلوك الصحيح: ليست بيولوجية، لذا الهيمنة لا معنى لها.

النمش يمثل MC1R: 75٪ إذا كان كلا الوالدين لديه نمش، 25٪ إذا كان واحد فقط يحمله، 0٪ إذا لم يكن لأي منهما. اللحية مرتبطة بـ SRY: تُزال إذا كانت الطفلة أنثى، وإلا تُورث من الوالد الذي كان لديه لحية. قصة الشعر ليست بيولوجية -- إنها اختيار ثقافي، لذا تختار الطفلة من مجموعتها حسب الجنس، مع الحفاظ على الملمس إن أمكن.

لا شيء من هذا هو علم وراثة يستحق النشر. إنها طبقة إحساس: يشبه الأطفال مزيجًا معقولًا من والديهم، لا متوسط شخصين غريبين.

الجوانب الهندسية الأقل بريقًا التي كانت مهمة

بعض الأشياء غير المبهرجة ولكنها استحقت مكانها في التعديل:

pick أكثر أمانًا. الأصلية كانت تُرجع undefined م coerced إلى T على مصفوفة فارغة. مع strict + noUncheckedIndexedAccess في TypeScript، هذه كذبة يوقعها المترجم. النسخة الجديدة ترمي RangeError -- تُلتقط فورًا في موقع الاستدعاء بدل إنتاج خصائص undefined على عمق ثلاثة مستويات.

deepMerge لا يفسد المصفوفات. التكرار القديم كان ينشط بمجرد أن تكون القيمة المصدر كائنًا، حتى لو كان الهدف null أو مصفوفة. merge({tags: ["a"]}, {tags: ["b"]}) كان يُنتج {tags: {0: "b"}}. النسخة الجديدة لا تتكرر إلا عندما يكون كلا الجانبين كائنات بسيطة.

تصيير دفعي متوازي. batchFactory كان يصير PNG في حلقة تسلسلية -- تصدير 1000 بطاقة كان يستغرق وقتًا طويلاً. الآن هو تجمع عمال مع توازٍ قابل للتكوين (4 افتراضيًا)، يحافظ على ترتيب النتائج بالكتابة في مصفوفة مسبقة الأبعاد:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

على تصدير 1000 شخصية، حوّل هذا استراحة القهوة إلى "هل انتهى بالفعل؟"

رسالة خطأ sharp ذات معنى. buildPng يستورد sharp بشكل كسول لأنه تابع اختياري لا تريد فرضه على مستخدمي SVG-only. القديم كان يبتلع الخطأ الحقيقي ويقول دائمًا "sharp is required." إذا كان الفشل الحقيقي تعارض إصدار أو مشكلة ربط أصلي، كنت تقضي عشر دقائق تعيد تثبيت شيء مثبت بالفعل. النسخة الجديدة تخبرك دائمًا بتثبيته، لكنها تتضمن الخطأ الأساسي.

الخطوات القادمة

الوحدة في الإصدار 1.1.1 على مستودع character-factory. المحرك الوراثي هو المكان المثالي لمواصلة التطوير -- لا توجد مجموعة اختبارات بعد، لذا فإن ثوابت الاتساق ("شخصية برازيلية من أصل شرق آسيوي لن يكون لها أبدًا عيون سوداء حالكة مع شعر بلاتيني") مضمونة فقط بالأوزان. إضافة bun test أو vitest وكتابة اختبار اتساق يشغل عشرة آلاف randomizeFromCountry لكل بلد، هي الخطوة التالية.

Kurekuta نفسه خاص حاليًا، لكن كل بطاقة ستراها فيه يومًا ما ليست سوى blob CharacterConfig واحدة وbuildPng() واحدة لتصبح موجودة.

✨ AI Generated Article

Xây dựng character-factory: avatar với hệ thống di truyền

Một module TypeScript xây trên DiceBear: tạo nhất quán theo quốc gia/sắc tộc,

Xây dựng character-factory: avatar với hệ thống di truyền

Tôi cần hàng ngàn avatar đáng tin và riêng biệt cho Kurekuta -- một dự án game bài cá nhân nơi mỗi lá bài chứa một "ADN" nhân vật mà engine render biến thành chân dung. Mua một gói có sẵn thì sẽ bị phát hiện ngay. Tạo avatar DiceBear theo seed từng nhân vật thì ra toàn thứ vớ vẩn: một lá bài theo chủ đề Nhật Bản có thể ra một cô gái tóc vàng Bắc Âu, và hai "anh chị em" trông chẳng giống nhau.

Vì thế tôi viết character-factory -- một module TypeScript xây trên bộ Lorelei của DiceBear, bổ sung ba thứ mà DiceBear đơn lẻ không có: hồ sơ nhân khẩu nhất quán, một engine di truyền nhỏ, và một builder mượt dễ dùng từ vòng lặp game.

Nó làm gì

Đoạn code ngắn nhất có ích:

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // dân tộc có trọng số → da/tóc/kiểu tóc/râu nhất quán
  .setMood(Mood.Happy)
  .buildSvg();

Chuỗi đơn giản này chọn một dân tộc theo trọng số nhân khẩu Nhật Bản, chọn màu da và màu tóc hợp nhau, chọn kiểu tóc trong phân nhóm giới tính phù hợp, rồi khóa mắt/lông mày/miệng ở chế độ "vui vẻ". Kết quả xuất ra SVG hoặc, nếu cài sharp, ra PNG ở bất kỳ kích thước nào.

Một nhân vật chỉ là một object CharacterConfig -- khuôn mặt, tóc, phụ kiện, cách trình bày. Builder sửa nó nội bộ, và bạn có thể xuất nó ra JSON, base64 hoặc file, rồi nạp lại y hệt. Với Kurekuta điều này rất quan trọng: một lá bài lưu config, không phải ảnh đã render. Nhờ đó hình ảnh luôn tái tạo được và kích thước lá bài vẫn rất nhỏ.

Hồ sơ nhân khẩu nhất quán, không phải pixel ngẫu nhiên

Các tùy chọn của DiceBear là bộ chọn đồng đều. Truyền ["#ffdbb4", "#2c1b18"] cho màu da và bạn sẽ nhận được cái này hay cái kia với xác suất như nhau -- OK cho logo, vô dụng cho "hãy cho tôi một nhân vật từ Brazil."

character-factory có một pipeline quốc gia → sắc tộc → đặc điểm:

// Nội dung bên trong module:
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* chủ yếu đen/nâu sẫm, không có vàng */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

Mỗi tầng là một lần rút có trọng số. Các trọng số không phải là luận văn xã hội học -- đó là heuristic ngăn "đến từ Nhật Bản" sinh ra tóc đỏ và "đến từ Thụy Điển" sinh ra tóc đen tuyền. Toàn bộ pipeline chỉ gói gọn trong một lần gọi: setCountry(country) hoặc randomizeFromCountry(country, gender?).

Một engine di truyền nhỏ

Chức năng tôi thích nhất: projectChild. Hai factory có thể sinh ra một đứa con có các đặc điểm thừa hưởng với tính trội sinh học gần đúng:

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

Bên dưới, đó là một mô hình cố tình rất nhỏ. Mỗi cha mẹ mang kiểu gen 2 alen, một từ mỗi bên, kết hợp thành trội hoặc lặn:

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

Các đặc điểm có trục trội thực sự (da, mắt, tóc) được giải quyết bằng một danh sách thứ tự rõ ràng -- tối trội sáng, mắt nâu/đen trội xanh, đen tuyền trội vàng:

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // lặn nhất
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // trội nhất
] as const;

resolveByRank tìm chỉ số của mỗi cha mẹ, lấy chỉ số cao nhất trên tổ hợp alen "trội" và thấp nhất trên "lặn". Các màu tóc giả tưởng (hồng pastel, tím hoa cà) không nằm trong thứ tự -- chúng random 50/50, đó là hành vi đúng: chúng không mang tính sinh học, nên trội/lặn vô nghĩa.

Tàn nhang mô phỏng MC1R: 75% nếu cả cha và mẹ đều có, 25% nếu chỉ một người mang, 0% nếu không ai có. Râu liên kết với SRY: bỏ nếu đứa trẻ là nữ, nếu không thì thừa hưởng từ cha/mẹ có râu. Kiểu tóc không liên quan gì đến sinh học -- đó là lựa chọn văn hóa, nên đứa trẻ chọn từ pool giới tính của chính nó, giữ kết cấu nếu có thể.

Không thứ nào trong số này là di truyền đáng để công bố. Đó là một tầng cảm giác: đứa trẻ trông giống sự pha trộn hợp lý của cha mẹ, chứ không phải trung bình của hai người lạ.

Những phần kỹ thuật kém hào nhoáng nhưng quan trọng

Vài thứ không hào nhoáng nhưng xứng đáng có mặt trong diff:

Một pick an toàn hơn. Bản gốc trả về undefined cast thành T trên mảng rỗng. Với strict + noUncheckedIndexedAccess trong TypeScript, đó là lời nói dối mà compiler ký vào. Phiên bản mới ném RangeError -- bắt ngay tại điểm gọi thay vì sinh ra các prop undefined ở ba tầng sâu hơn.

Một deepMerge không làm hỏng mảng. Đệ quy cũ kích hoạt ngay khi giá trị nguồn là object, kể cả khi đích là null hay một mảng. merge({tags: ["a"]}, {tags: ["b"]}) cho ra {tags: {0: "b"}}. Phiên bản mới chỉ đệ quy khi cả hai vế đều là object thường.

Render batch song song. batchFactory render PNG theo vòng lặp tuần tự -- xuất 1000 lá bài mất cả thế kỷ. Giờ nó là một pool worker với độ đồng thời có thể cấu hình (mặc định 4), giữ thứ tự kết quả bằng cách ghi vào một mảng được cấp phát trước:

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

Khi xuất 1000 nhân vật, nó biến một khoảng nghỉ giải lao thành "xong rồi à?"

Thông báo lỗi sharp có ý nghĩa. buildPng import sharp dạng lazy vì nó là dependency tùy chọn mà bạn không muốn áp đặt lên người dùng SVG-only. Catch cũ nuốt mất lỗi thật và luôn báo "sharp is required." Nếu lỗi thực sự là xung đột phiên bản hoặc vấn đề bindings native, bạn mất mười phút cài lại thứ đã cài. Phiên bản mới vẫn bảo bạn cài nó, nhưng kèm lỗi gốc bên dưới.

Tiếp theo

Module đang ở phiên bản 1.1.1 trên kho character-factory. Engine di truyền là nơi lý tưởng để tiếp tục cải tiến -- chưa có bộ kiểm thử nào, nên các bất biến nhất quán ("một nhân vật Brazil gốc Đông Á sẽ không bao giờ có mắt đen tuyền với tóc bạch kim") chỉ được đảm bảo bởi các trọng số. Thêm bun test hoặc vitest và viết một kiểm thử nhất quán chạy mười ngàn lần randomizeFromCountry mỗi quốc gia là bước tiếp theo.

Kurekuta hiện vẫn là dự án riêng, nhưng mỗi lá bài bạn sẽ thấy trong đó chẳng qua là một blob CharacterConfig và một buildPng() để tồn tại.

✨ AI Generated Article

สร้าง character-factory : อวาตาร์ที่มีพันธุกรรม

โมดูล TypeScript ที่ทำงานบน DiceBear: สร้างอวาตาร์ที่สอดคล้อง

สร้าง character-factory : อวาตาร์ที่มีระบบพันธุกรรม

ผมต้องการอวาตาร์ที่ดูสมจริงและแตกต่างกันเป็นพัน ๆ ตัวสำหรับ Kurekuta -- โปรเจกต์เกมการ์ดส่วนตัวที่การ์ดแต่ละใบมี "DNA" ของตัวละครที่เอนจินเรนเดอร์เปลี่ยนเป็นภาพพอร์ตเรต การซื้อแพ็คสำเร็จรูปก็ดูออก การสร้างอวาตาร์ DiceBear ด้วย seed ต่อตัวละครก็ให้ผลลัพธ์ที่ไม่แน่นอน : การ์ดแนวญี่ปุ่นอาจได้ตัวละครสาวผมบลอนด์สแกนดิเนเวีย และ "พี่น้อง" สองคนกลับดูไม่ใช่คนรู้จักกันเลย

ผมเลยเขียน character-factory -- โมดูล TypeScript ที่ทำงานบนชุด Lorelei ของ DiceBear ซึ่งเพิ่มสามสิ่งที่ DiceBear อย่างเดียวทำไม่ได้ : โปรไฟล์ประชากรที่สอดคล้องกัน, เอนจินพันธุกรรมขนาดเล็ก, และ builder ที่ใช้งานง่าย สะดวกต่อการเรียกใช้จากลูปเกม

มันทำอะไรได้บ้าง

ตัวอย่างสั้นที่สุดที่ใช้งานได้ :

import { CharacterFactory, Country, Mood } from "character-factory";

const svg = new CharacterFactory()
  .setCountry(Country.Japan)   // ถ่วงน้ำหนักเชื้อชาติ → ผิว/ผม/ทรงผม/หนวดเครา ที่สอดคล้องกัน
  .setMood(Mood.Happy)
  .buildSvg();

การเรียกใช้เพียงเท่านี้จะสุ่มเชื้อชาติโดยถ่วงน้ำหนักตามประชากรญี่ปุ่น, จับคู่สีผิวและสีผมที่เข้ากัน, เลือกทรงผมในกลุ่มย่อยตามเพศที่เหมาะสม, จากนั้นล็อคดวงตา/คิ้ว/ปากเป็นโหมด "มีความสุข" ผลลัพธ์ที่ได้คือ SVG หรือหากติดตั้ง sharp ก็จะได้ PNG ที่ขนาดตามต้องการ

ตัวละครแต่ละตัวเป็นแค่อ็อบเจกต์ CharacterConfig -- ใบหน้า, ผม, อุปกรณ์เสริม, การนำเสนอ builder จะปรับเปลี่ยนภายใน และคุณสามารถส่งออกเป็น JSON, base64 หรือไฟล์ แล้วโหลดกลับมาเหมือนเดิม สำหรับ Kurekuta นี่เป็นสิ่งสำคัญ : การ์ดเก็บ config ไม่ใช่ภาพที่เรนเดอร์แล้ว ดังนั้นอาร์ตเวิร์กจึงสามารถสร้างซ้ำได้เสมอ และขนาดการ์ดก็เล็กมาก

โปรไฟล์ประชากรที่สอดคล้องกัน ไม่ใช่พิกเซลสุ่ม

ตัวเลือกของ DiceBear เป็นตัวเลือกแบบสม่ำเสมอ ใส่ ["#ffdbb4", "#2c1b18"] สำหรับสีผิว แล้วคุณจะได้สีใดสีหนึ่งด้วยความน่าจะเป็นเท่ากัน -- ใช้ได้กับโลโก้ แต่ไร้ประโยชน์สำหรับ "ขอตัวละครจากบราซิลหน่อย"

character-factory มาพร้อมกับไปป์ไลน์ ประเทศ → เชื้อชาติ → ลักษณะ :

// สิ่งที่อยู่ในโมดูล :
ethnicitiesByCountry[Country.Brazil] = [
  { ethnicity: Ethnicity.WestEuropean,  weight: 35 },
  { ethnicity: Ethnicity.BlackAfrican,  weight: 25 },
  { ethnicity: Ethnicity.Latino,        weight: 30 },
  // ...
];

ETHNICITY_PROFILES[Ethnicity.EastAsian] = {
  skinColors: [
    { color: SkinColor.Light,  weight: 35 },
    { color: SkinColor.Warm,   weight: 40 },
    { color: SkinColor.Medium, weight: 20 },
    // ...
  ],
  hairColors: [/* ส่วนใหญ่เป็นสีดำ/น้ำตาลเข้ม ไม่มีบลอนด์ */],
  hairCuts:   { male: [...], female: [...] },
  beardProbability: 0.15,
};

แต่ละชั้นเป็นการสุ่มแบบถ่วงน้ำหนัก ค่าน้ำหนักไม่ใช่วิทยานิพนธ์ทางสังคม -- เป็นฮิวริสติกที่ป้องกันไม่ให้ "มาจากญี่ปุ่น" ได้คนผมแดง หรือ "มาจากสวีเดน" ได้คนผมดำสนิท ทั้งไปป์ไลน์ทำงานด้วยการเรียกเพียงครั้งเดียว : setCountry(country) หรือ randomizeFromCountry(country, gender?)

เอนจินพันธุกรรมขนาดเล็ก

ฟังก์ชันที่สนุกที่สุดในการเขียน : projectChild สอง factories สามารถสร้างลูกที่มีลักษณะสืบทอดแบบเด่นทางชีวภาพโดยประมาณ :

const parentA = new CharacterFactory().setCountry(Country.Sweden);
const parentB = new CharacterFactory().setCountry(Country.Japan);
const kid     = parentA.projectChild(parentB.getConfig());

เบื้องหลังเป็นโมเดลที่ตั้งใจให้เล็กมาก พ่อแม่แต่ละคนมีจีโนไทป์แบบ 2 อัลลีล สุ่มจากแต่ละฝ่าย รวมกันเป็นเด่นหรือด้อย :

function combine(a: Allele, b: Allele): "dominant" | "recessive" {
  return a === "D" || b === "D" ? "dominant" : "recessive";
}

ลักษณะที่มีแกนความเด่นจริง (ผิว, ดวงตา, ผม) ถูกกำหนดโดยลิสต์ลำดับที่ชัดเจน -- สีเข้มเด่นกว่าสีอ่อน, ตาสีน้ำตาล/ดำเด่นกว่าสีฟ้า, ผมดำสนิทเด่นกว่าผมบลอนด์ :

const HAIR_DOMINANCE_ORDER = [
  HairColor.LightBlonde,   // ด้อยที่สุด
  HairColor.GoldenBlonde,
  HairColor.HoneyBlonde,
  HairColor.Auburn,
  HairColor.Red,
  HairColor.Copper,
  HairColor.LightBrown,
  HairColor.Brown,
  HairColor.DarkBrown,
  HairColor.SoftBlack,
  HairColor.JetBlack,      // เด่นที่สุด
] as const;

resolveByRank หา index ของพ่อแม่แต่ละคน เลือกค่าสูงสุดเมื่อรวมอัลลีลแบบ "เด่น" และค่าต่ำสุดเมื่อเป็น "ด้อย" สีแฟนตาซี (ชมพูพาสเทล, ม่วง) ไม่ได้อยู่ในลำดับ -- มันสุ่ม 50/50 ซึ่งเป็นพฤติกรรมที่ถูกต้อง : มันไม่ใช่ลักษณะทางชีวภาพ ดังนั้นความเด่นจึงไม่มีความหมาย

กระจุดจำลอง MC1R : 75% ถ้าพ่อแม่ทั้งสองมี, 25% ถ้ามีเพียงคนเดียว, 0% ถ้าไม่มี เคราขึ้นอยู่กับ SRY : ถูกลบออกถ้าเด็กเป็นผู้หญิง ไม่เช่นนั้นก็สืบทอดจากพ่อแม่ที่มีเครา ทรงผมไม่ใช่เรื่องชีวภาพ -- เป็นเรื่องวัฒนธรรม ดังนั้นเด็กจึงสุ่มจาก pool ของเพศตนเอง โดยคง texture ไว้ถ้าเป็นไปได้

ไม่มีอะไรที่เป็นพันธุศาสตร์ระดับตีพิมพ์ มันเป็นชั้นของความรู้สึก : เด็กดูเหมือนการผสมผสานที่ plausible ของพ่อแม่ ไม่ใช่ค่าเฉลี่ยของคนสองคนที่ไม่รู้จัก

ส่วนวิศวกรรมที่ไม่สวยหรูแต่สำคัญ

มีบางอย่างที่ไม่เด่นแต่คุ้มค่าที่อยู่ใน diff :

pick ที่ปลอดภัยขึ้น. ต้นฉบับคืนค่า undefined ที่ cast เป็น T เมื่อเจออาเรย์ว่าง ด้วย strict + noUncheckedIndexedAccess ใน TypeScript นั่นคือการโกหกที่คอมไพเลอร์ยอมรับ เวอร์ชันใหม่โยน RangeError -- ถูกจับทันทีที่จุดเรียกแทนที่จะสร้าง props undefined สามระดับลึกลงไป

deepMerge ที่ไม่ทำให้อาเรย์เสียหาย. การเรียกซ้ำแบบเก่าทำงานเมื่อค่าต้นทางเป็นอ็อบเจกต์ แม้ว่าเป้าหมายจะเป็น null หรืออาเรย์ merge({tags: ["a"]}, {tags: ["b"]}) ให้ผลลัพธ์เป็น {tags: {0: "b"}} เวอร์ชันใหม่จะเรียกซ้ำเฉพาะเมื่อทั้งสองฝั่งเป็นอ็อบเจกต์ธรรมดา

เรนเดอร์แบบ batch แบบขนาน. batchFactory เคยเรนเดอร์ PNG เป็นลูปต่อเนื่อง -- การส่งออก 1,000 ใบใช้เวลานานมาก ตอนนี้เป็น pool ของ workers ที่ปรับระดับความขนานได้ (ค่าเริ่มต้น 4) ซึ่งรักษาลำดับผลลัพธ์โดยเขียนลงในอาเรย์ที่จองพื้นที่ไว้แล้ว :

const worker = async () => {
  while (true) {
    const i = nextIndex++;
    if (i >= count) return;
    // render and save
    results[i] = { index: i + 1, filePath, config: clone.getConfig() };
    done++;
    onProgress?.(done, count);
  }
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));

ในการส่งออกตัวละคร 1,000 ตัว มันเปลี่ยนจาก "ไปชงกาแฟรอ" เป็น "เสร็จแล้วเหรอ?"

ข้อความข้อผิดพลาด sharp ที่มีความหมาย. buildPng import sharp แบบขี้เกียจเพราะมันเป็น dependency ตัวเลือกที่คุณไม่อยากบังคับผู้ใช้ที่ใช้แค่ SVG โค้ดจับข้อผิดพลาดแบบเก่ากลบข้อผิดพลาดจริงและบอกแค่ว่า "sharp is required." ถ้าความล้มเหลวจริงเป็นเรื่องเวอร์ชันขัดแย้งหรือปัญหา native bindings คุณจะเสียเวลาสิบนาทีลงใหม่ทั้งที่ติดตั้งไว้แล้ว เวอร์ชันใหม่ยังคงบอกให้ติดตั้ง แต่รวมข้อผิดพลาดที่แท้จริงไว้ด้วย

ต่อไป

โมดูลอยู่ที่เวอร์ชัน 1.1.1 บน repository character-factory เอนจินพันธุกรรมเป็นจุดที่เหมาะสำหรับการพัฒนาต่อ -- ยังไม่มีชุดทดสอบ ดังนั้น invariants ความสอดคล้อง ("ตัวละครบราซิลเชื้อสายเอเชียตะวันออกจะไม่มีวันมีตาสีดำสนิทกับผมสีพลาตินัม") จึงรับประกันด้วยน้ำหนักเท่านั้น การเพิ่ม bun test หรือ vitest และเขียนเทสต์ความสอดคล้องที่สุ่ม randomizeFromCountry หมื่นครั้งต่อประเทศ คือขั้นตอนถัดไป

Kurekuta เองยังเป็นโปรเจกต์ส่วนตัวในตอนนี้ แต่ทุกการ์ดที่คุณจะได้เห็นในอนาคตนั้นเป็นเพียง blob CharacterConfig และ buildPng() เท่านั้น

Related Articles