20問が999の個性に枝分かれする ──決定論的ハッシュとピクセルアートの設計

前回は『わたし型番』を作ろうと思ったきっかけと、
構想の輪郭を書きました。
今回は実装編です。
「同じ入力なら必ず同じ結果が出る」アプリを、どう作ったのか。
コードの中身と、その裏にある設計判断の両方を、
できるだけ平易に書いていきます。
サーバーレスで、ブラウザだけで動く。
それでいて、999通りのキャラと取扱説明書が一意に生成される。
この裏側を一気にお見せします。
▶ アプリはこちら:
※拡散いただけると泣いて喜びます。

全体の処理フロー
まず、全体像を1枚にまとめるとこんな感じです。
[名前 + 20問の回答]
↓
ハッシュ計算
↓
[型番 001〜999]
↓
┌─────┴─────┐
レアリティ キャラ生成
↓
体・目・口・色 + 名前
↓
[取扱説明書 完成]ポイントは、サーバーが要らないこと。
すべての変換が、ブラウザで動く JavaScript の中で完結します。
これは「個人情報を一切預からなくて済む」点でも、運用上ものすごく楽でした。データベースもバックエンドも要らないので、月額コストはゼロ。
「同じ入力 → 必ず同じ結果」の作り方
このアプリの最も大事な性質が「決定論的」であることです。
同じ名前と同じ回答なら、何度やっても必ず同じ型番が出る。
これがないと、ユーザーが「もう一度やったら別の結果になった」と不信感を持ってしまいます。占いっぽく見えてしまうのも嫌でした。
そこで使ったのが FNV-1a という軽量なハッシュ関数です。
実際のコードはこれだけです。
export function computeModelNumber(userName: string, answers: number[]): number {
const seed = `${userName.trim().toLowerCase()}|${answers.join(",")}`;
let hash = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) {
hash ^= seed.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
const positive = hash >>> 0;
return (positive % 999) + 1;
}やっていることは:
名前と回答を1本の文字列につなぐ
その文字列を1文字ずつなめてハッシュ値を更新
32ビット整数になったハッシュを 999 で割って余りを取り、+1 する
これで 1〜999 の整数が出ます。
同じ入力からは数学的に必ず同じ値が出るので、再現性が保証されます。
FNV-1a を選んだ理由は次の通り。
とにかく軽い(数十行で書ける)
分布が均等(特定の数字に偏らない)
暗号用途ではないので高速
ブラウザで一瞬で計算が終わるので、UI に違和感を残しません。
レアリティ設計 ──ガチャの黄金比を真似る
型番 001〜999 を5段階に分けて、希少性を演出します。
| レアリティ | 範囲 | 出現率 |
|---|---|---|
| Legendary(伝説級)| 001〜009 | 0.9% |
| Epic(超希少)| 010〜049 | 4.0% |
| Rare(希少)| 050〜149 | 10.0% |
| Uncommon(やや希少)| 150〜399 | 25.0% |
| Common(標準)| 400〜999 | 60.1% |
合計はぴったり 100%。これはユニットテストでも検証しています。
配分のコツは、「Common が6割」「Legendary が1%以下」にすること。
これはガチャ系のソーシャルゲームで長年磨かれてきた感覚値で、
多くの人にとっては「だいたい何か出る」体験
一部の人にとっては「うわ伝説級引いた!」体験
の両方が成立する黄金比です。
Legendary 個体を引いた人だけ、結果画面に追加で「✦ LEGENDARY 個体を引き当てました ✦」というアニメ付きの表示が出るようにしました。
これが SNS にスクショを上げたくなる小さなトリガーになります。

ピクセルキャラを「組み立てる」
ここが個人的にいちばん遊んだ部分です。
999通りの型番に対して、それぞれ別のキャラを 手で描かずに 自動生成したい。
そこで、キャラを次の4つのレイヤーの組み合わせで作る設計にしました。
ボディシェイプ(体の形):8種類
目のパターン:8種類
口のパターン:8種類
カラーパレット:12種類
掛け合わせると 約 6,000パターン。型番が999通りなので、どの個体も別の見た目になります。
ボディシェイプは、14×14グリッドの文字配列でこんなふうに定義しています。
"..............",
"....BBBBBB....",
"...BBBBBBBB...",
"..BBBBBBBBBB..",
".BBBBBBBBBBBB.",
".BBBBBBBBBBBB.",
...`B` が本体カラー、`A` がアクセントカラー、`.` が透明。
これを SVG の `<rect>` 要素に変換すれば、ドット絵の完成です。
外部画像を一切使っていないので、画像のダウンロード待ちが発生しません。html-to-image で PNG 保存するときも、欠けや遅延が起きないメリットがあります。
自動アウトラインで「プロっぽさ」を底上げする
最初に作ったキャラは、なんだかのっぺりして見えました。
プロのドット絵師さんの作品と比べると、「縁取り」がないせいで弱く感じる。
そこで、コード側で 自動アウトライン をつけることにしました。
function addOutline(grid: string[][]): string[][] {
const out = grid.map((row) => [...row]);
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[y].length; x++) {
if (grid[y][x] !== ".") continue;
// 透明セルの上下左右に体があれば、暗色 D に置き換える
const top = grid[y - 1]?.[x];
const bot = grid[y + 1]?.[x];
const left = grid[y][x - 1];
const right = grid[y][x + 1];
if (isBodyPixel(top) || isBodyPixel(bot) ||
isBodyPixel(left) || isBodyPixel(right)) {
out[y][x] = "D";
}
}
}
return out;
}やっていることは単純で、透明なマス目を1つずつ見て、上下左右のどれかに体ピクセルがあれば、自分を暗色(パレットの D 色)に変えるだけ。
これだけでキャラのシルエットがきりっとして、グッと「ちゃんとしてる感」が出ました。
手で1個ずつ縁取りを描くのに比べて、1関数で全キャラに同じ品質を保証できます。
取扱説明書の文章は「効果文」の集合
20問の各選択肢には、それぞれ短い「効果文」が紐づいています。
{
label: "歯ブラシ",
effect: "早朝から正常起動できる優良モデル。周囲の未起動者への配慮を推奨します。",
}ユーザーが選んだ選択肢の `effect` を、対応するセクション(警告 / 推奨動作環境 / 使用上の注意 / 推奨される使い方 / 特殊機能)に並べるだけで、取扱説明書が組み上がります。
設問は5セクションに均等配分して、各セクション4行ずつ。
1人のユーザーが受け取る効果文は20本。これがすべて、その人の選択の結果として組み上がります。
シンプルな仕組みですが、20問×4選択肢で 80通りの効果文 を用意しているので、組み合わせの妙でかなりバリエーション豊かに見えます。
文体も、できるだけ家電取説の語感に寄せました。
⚠ 起動直後30分はSNSループに陥る恐れがあります。通知の遮断を推奨します。
【オーバードライブ】食事を忘れるレベルの過集中機能を搭載しています。
燃料は外部調達型。手軽さを優先した運用設計です。
このトーンの揺らぎが、読んでいて楽しい結果ページを作っていると思います。
テストで「決定論」を保証する
決定論的アルゴリズムは、自動テストとの相性が抜群です。
壊れたらすぐ気付ける構造を、テストで作り込みました。
特に効いているのは次の3つ。
1. 1〜999 全数チェック
it("各インデックスが配列範囲内に収まる", () => {
for (let n = 1; n <= 999; n++) {
const spec = getCharacterSpec(n);
expect(spec.body).toBeLessThan(BODY_SHAPES.length);
// ...
}
});全999個の型番を実際に回して、キャラ生成が壊れないかチェックします。配列を1つでも消すと即落ちる、頼れるテストです。
2. レアリティ境界 10ペア
const boundaries: Array<[number, string]> = [
[1, "Legendary"],
[9, "Legendary"],
[10, "Epic"],
[49, "Epic"],
...
];
it.each(boundaries)("modelNumber=%i は %s", (n, rarity) => {
expect(getRarityInfo(n).rarity).toBe(rarity);
});レアリティ表の境界を全部チェック。範囲を1つでも書き換えたら落ちます。
3. 同じ入力 → 同じ出力
it("同じ入力なら全フィールドが完全一致", () => {
const a = buildManual("repro", [0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3]);
const b = buildManual("repro", [0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3]);
expect(a).toEqual(b);
});ハッシュ関数を1文字でもいじったら落ちます。決定論性を守る最後の砦。
結果は全38ケース、2秒で完走。
✓ Test Files 3 passed (3)
✓ Tests 38 passed (38)
✓ Duration 2.13s個人開発でテストを書くかは賛否ありますが、「アルゴリズムが核」のアプリではテストがあった方が安心して触れる、と実感しました。
まとめ / 次回予告
今回は実装の中身を一気に駆け抜けました。
決定論的ハッシュ で「同じ入力には必ず同じ結果」を保証
レアリティ配分 で希少性とシェア欲を設計
レイヤー合成 でドット絵を6,000パターン量産
自動アウトライン でプロっぽさを底上げ
効果文の組み合わせ で 20問から豊かな取説を組み上げる
テストで決定論を守る
次回は公開と拡散編。
コードができてからの「動くものを世に出す」プロセスを、GitHub・Vercel・OGP・X 戦略まで、スクショ付きで全部見せます。
第2部、読んでくださってありがとうございました。
第1部からの通読、または記事単体でも参考になればうれしいです。
アプリも、よければ実際に触ってみてください。
もしよければ私のポートフォリオのぞいてみてください
ニッチな市場で、誰かの「ちょっと便利」や「ちょっと楽しい」を
目指して開発しています。
いいなと思ったら応援しよう!
記事が役に立ったら、コーヒー1杯分でも応援いただけると嬉しいです☕️ エラーと戦う開発者のガソリンになります!