見出し画像

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本の文字列につなぐ

  2. その文字列を1文字ずつなめてハッシュ値を更新

  3. 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つのレイヤーの組み合わせで作る設計にしました。

  1. ボディシェイプ(体の形):8種類

  2. 目のパターン:8種類

  3. 口のパターン:8種類

  4. カラーパレット: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部からの通読、または記事単体でも参考になればうれしいです。
アプリも、よければ実際に触ってみてください。


もしよければ私のポートフォリオのぞいてみてください
ニッチな市場で、誰かの「ちょっと便利」や「ちょっと楽しい」を
目指して開発しています。



いいなと思ったら応援しよう!

lab.aiueo 記事が役に立ったら、コーヒー1杯分でも応援いただけると嬉しいです☕️ エラーと戦う開発者のガソリンになります!