JavaScript
Node.js

ガチャプログラムの実装(中級者向け)

ガチャの実装はおそらくあなたが思っている以上に難しい

本稿では、最終的に以下の機能をもつガチャを作る。最初は簡単なものを作り、徐々に難しくしていく。言語は JavaScript。ES6もバリバリ使うので初心者には難しいかも。

ガチャ仕様.txt
- ピックアップ対応:       特定のキャラをあたりやすくさせる
- 10連特典対応:          10連の場合には、★4 一体以上を保証
- 天井対応:              99 回連続で★5 がでてない場合、★5 を保証
- メンテが容易
    - コードの変更が一切不要
    - 設定ミスしにくい設計

1. 超基本

基本からはじめよう。1% で大当たり, 10% で当たり、 89% ではずれのガチャはこんな感じで実装できる。乱数はガチャ関数の外に出して純粋性を保つことで、テストをしやすくするのが重要:

function gacha(rval) {
  if (rval < 0.01) return { id: '大あたり' };
  if (rval < 0.11) return { id: 'あたり' };
  return { id: 'はずれ' };
}

console.log(gacha(0.005)) // 大あたり
console.log(gacha(0.1)) // あたり
console.log(gacha(0.8)) // はずれ

2. 設定をくくりだす

前述の例では、ガチャ対象がハードコードされており、再利用性が全くない。これを改善する。設定をくくりだして、ガチャ内容変更のたびにガチャ関数を変更しなくてもよいようにする。設定はDBに格納されることを想定して、非同期関数で取得することにした:

function gacha(config, rval) {
  let accum = 0;
  for (const item of config) {
    accum += item.prob;
    if (rval < accum) return { id: item.id };
  }
  throw new Error('should not reach here');
}

async function getConfig() {
  return [
    { id: '大当たり', prob: 0.01 },
    { id: '当たり', prob: 0.1 },
    { id: 'はずれ', prob: 0.89 },
  ];
}

async function main() {
  const config = await getConfig();
  console.log(gacha(config, 0.003)) // 大当たり
  console.log(gacha(config, 0.03)) // あたり
  console.log(gacha(config, 0.7)) // はずれ
}

main();

3. より現実的な設定

最高レアリティのキャラは複数存在する(もちろん他のレアリティでも)。つまり、ガチャとは、ただ当たるだけでなく、どのキャラが当たったのか?というのが通常のユースケースになる。それに合わせてガチャ設定のデータ構造を変更するとともに、プログラムも変更しよう:

function gacha (config, rval) {
  let accum = 0;
  for (const entry of config) {
    for (const charID of entry.ids) {
      accum += entry.prob / entry.ids.length;
      if (rval < accum) return { id: charID };
    }
  }
  throw new Error('should not reach here');
}

async function getConfig() {
  return [
    {
      rarity: 5, // ★★★★★
      prob: 0.01,
      ids: [5001, 5002, 5003],
    },
    {
      rarity: 4, // ★★★★
      prob: 0.1,
      ids: [4001, 4002, 4003],
    },
    {
      rarity: 3, // ★★★
      prob: 0.89,
      ids: [3000, 3001, 3002],
    },
  ];
}

async function main() {
  const config = await getConfig();
  console.log(gacha(config, 0.001)); // 大当たり, キャラID 5001
  console.log(gacha(config, 0.004)); // 大当たり, キャラID 5002
  console.log(gacha(config, 0.04)); // あたり, キャラID 4001
  console.log(gacha(config, 0.7)); // はずれ
}

main();

4. ファイル分割

前述のガチャ関数はとりあえず満足のいくものである。しかし、設定のデータ構造、とくに ids フィールドが非正規化されているのを何とかする(ガチャ機能とはスコープが異なるがとりあげる)。このままだと、簡単に不整合なガチャ設定を作ってしまいがちになる。DBから取得できると想定されるのは、(ガチャゲットできる)キャラID一覧というマスタデータとガチャ情報である。この正規化された二者をくっつけて、先ほどのガチャ設定を動的に構築しよう。少しコードが大きくなるので、コードを二つに分割する。まずは getConfig() を提供する config モジュール:

config.js
async function getDataFromDB() {
  // data for gacha
  const info = {
    weights: [[5, 0.01], [4, 0.1], [3, 0.89]],
  };
  // data for character
  const master = [
    { id: '5001', rarity: 5 }, // other attributes are ommitted
    { id: '5002', rarity: 5 },
    { id: '5003', rarity: 5 },
    { id: '4001', rarity: 4 },
    { id: '4002', rarity: 4 },
    { id: '4003', rarity: 4 },
    { id: '3001', rarity: 3 },
    { id: '3002', rarity: 3 },
    { id: '3003', rarity: 3 },
  ];

  return [info, master];
}

export async function getConfig() {
  const config = [];
  const [info, master] = await getDataFromDB();
  info.weights.forEach(([rarity, prob]) => {
    const ids = master
      .filter(x => x.rarity === rarity)
      .map(x => x.id);
    config.push({ rarity, prob, ids });
  });
  return config;
}

ガチャ本体のコードは変わらず:

index.js
(, 変更なし)

5. ピックアップ対応

イベント時など、特定のキャラの出現確率が上がる場合がある。つまり、★5 があたる確率は変わらないが、もし★5が当たった場合、特定の ★5 キャラが選ばれる可能性があがるというものだ。ピックアップ対象は複数存在する場合もある。色々な設定の仕方があるだろうが、ここでは、条件付き確率を設定できるようにする。例えば、★5 が当選した場合に、ピックアップ対象(もちろんこれも★5) が当選する条件付き確率が 50% である場合、0.5 を設定で与えるようにする。

まずは config モジュール。ガチャ設定に pickup フィールドを追加する:

config.js
function rarityOf(master, id) {
  const me = master.find(x => x.id === id);
  if (me) return me.rarity;
  throw new Error(`no such ID is found: ${id}`);
}

async function getDataFromDB() {
  // data for gacha
  const info = {
    weights: [[5, 0.01], [4, 0.1], [3, 0.89]],
    pickup: [['5001', 0.4], ['5002', 0.4], ['4001', 0.1]],
  };
  // data for character
  const master = [
    { id: '5001', rarity: 5 }, // other attributes are ommitted
    { id: '5002', rarity: 5 },
    { id: '5003', rarity: 5 },
    { id: '4001', rarity: 4 },
    { id: '4002', rarity: 4 },
    { id: '4003', rarity: 4 },
    { id: '3001', rarity: 3 },
    { id: '3002', rarity: 3 },
    { id: '3003', rarity: 3 },
  ];

  return [info, master];
}

export async function getConfig() {
  const config = [];
  const [info, master] = await getDataFromDB();
  info.weights.forEach(([rarity, prob]) => {
    const ids = master
      .filter(x => x.rarity === rarity)
      .map(x => x.id);
    const pickups = info.pickup
      .filter(x => rarityOf(master, x[0]) === rarity);
    config.push({ rarity, prob, pickups, ids });
  });
  return config;
}

ピックアップの存在によって、同レアリティ内での他のキャラの当選確率はノントリビアルになる。これに対応するために、テーブルという概念を導入する。テーブルとは、各キャラごとの当選確率を保持するArrayである(注: ホントはArrayじゃなくてもよくて関数でもよいがわかりやすさを重視)。[[ID, prob]]。テーブルを作るために、まず、同レアリティ内の全てのピックアップ対象の当選確率の総和を求めることで、そのレアリティにおける非ピックアップ対象の確率が求まる。

index.js
import { getConfig } from './config';

function createTable(config) {
  const table = [];
  for (const entry of config) {
    const nonPickProb = entry.prob
      * entry.pickups.reduce((acc, x) => acc - x[1], 1)
      / (entry.ids.length - entry.pickups.length);
    for (const cid of entry.ids) {
      const searched = entry.pickups.find(x => x[0] === cid);
      const prob = (searched)
        ? entry.prob * searched[1]
        : nonPickProb;
      table.push([cid, prob]);
    }
  }
  return table;
}

function gacha(config, rval) {
  const table = createTable(config);

  let accum = 0;
  for (const [cid, prob] of table) {
    accum += prob;
    if (rval < accum) return cid;
  }
  throw new Error('should not reach here');
}

async function main() {
  const config = await getConfig();
  console.log(gacha(config, 0.001)); // 大当たり, キャラID 5001
  console.log(gacha(config, 0.004)); // 大当たり, キャラID 5002
  console.log(gacha(config, 0.04)); // あたり, キャラID 4001
  console.log(gacha(config, 0.7)); // はずれ
}

main();

6. 天井システム

直近の N-1回のガチャで最高レアリティが当選していなかった場合、必ず最高レアリティが当たるシステムを実装しよう。

さて、天井時にはテーブル作成ロジックが大きく変更になる。そしてそれ以外のロジックは変更はない。天井時には、作成した config から★5 以外のエントリをすべて削除し、確率のノーマライズを行った新しい config を使ってテーブルを作成すればよい。この実装であればピックアップ要件とも両立する。

なお、直近の履歴はガチャの設定ではなく、ユーザごとに固有な情報のため、ガチャ関数の別の引数として与えるのが適当だろう(main関数の gacha 呼び出しロジックをみよ)。

config.js
(, 変更なし)
index.js
import { getConfig } from './config';

function createTable(config) {
  const table = [];
  for (const entry of config) {
    const nonPickProb = entry.prob
      * entry.pickups.reduce((acc, x) => acc - x[1], 1)
      / (entry.ids.length - entry.pickups.length);
    for (const cid of entry.ids) {
      const searched = entry.pickups.find(x => x[0] === cid);
      const prob = (searched)
        ? entry.prob * searched[1]
        : nonPickProb;
      table.push([cid, prob]);
    }
  }
  return table;
}

function normalize(configLike) {
  const ret = [];
  const summed = configLike.reduce((acc, x) => acc + x.prob, 0);
  configLike.forEach(entry => ret.push({ ...entry, prob: entry.prob / summed }));
  return ret;
}

function createTableCeil(conf) {
  const filtered = normalize(conf.filter(x => x.rarity === 5));
  return createTable(filtered);
}

function gacha(config, user, rval) {
  const ceilCount = user.ceilCount || 0;
  const table = ceilCount === 99
    ? createTableCeil(config)
    : createTable(config);

  let accum = 0;
  for (const [cid, prob] of table) {
    accum += prob;
    if (rval < accum) return cid;
  }
  throw new Error('should not reach here');
}

async function main() {
  const config = await getConfig();
  console.log(gacha(config, { ceilCount: 40 }, 0.7)); // はずれ
  console.log(gacha(config, { ceilCount: 99 }, 0.7)); // 大当たり ID: 5002
}

main();

7. 十連特典

普通、ガチャは1回ずつ回すが、まとめて10回のガチャを回すこともできる(もちろん値段は10倍になる)。これを10連と呼ぶ。多くのソシャゲでは、10連ガチャの場合に特典がつく。ここでは、最高レアリティから一つ下のレアリティ以上のキャラが最低1体出ることを保証する(要は10連したら★4 が 1体以上確定)という特典を実装する。なお、もし10連の途中で天井を迎える場合は、最高レアリティ(★5)が保証代わりになる。

十連特典の実装方法としては、1..9 回目までに ★4以上が一度も出ていなければ、10回目に ★4 & ★5 のみで構成されたテーブルを作る、というロジックをガチャ関数が受け止められればよい。当然、ピックアップ、天井システムとも両立する。

config.js
(, 変更なし)
index.js
import { getConfig } from './config';

function createTable(config) {
  const table = [];
  for (const entry of config) {
    const nonPickProb = entry.prob
      * entry.pickups.reduce((acc, x) => acc - x[1], 1)
      / (entry.ids.length - entry.pickups.length);
    for (const cid of entry.ids) {
      const searched = entry.pickups.find(x => x[0] === cid);
      const prob = (searched)
        ? entry.prob * searched[1]
        : nonPickProb;
      table.push([cid, prob]);
    }
  }
  return table;
}

function normalize(configLike) {
  const ret = [];
  const summed = configLike.reduce((acc, x) => acc + x.prob, 0);
  configLike.forEach(entry => ret.push({ ...entry, prob: entry.prob / summed }));
  return ret;
}

function createTableCeil(conf) {
  const filtered = normalize(conf.filter(x => x.rarity === 5));
  return createTable(filtered);
}

function createTableRescue(conf) {
  const filtered = normalize(conf.filter(x => x.rarity > 3));
  return createTable(filtered);
}

function gacha(config, user, rval) {
  const ceilCount = user.ceilCount || 0;
  const table = ceilCount === 99
    ? createTableCeil(config)
    : user.rescue
      ? createTableRescue(config)
      : createTable(config);

  let accum = 0;
  for (const [cid, prob] of table) {
    accum += prob;
    if (rval < accum) return cid;
  }
  throw new Error('should not reach here');
}

async function main() {
  const config = await getConfig();
  console.log(gacha(config, { ceilCount: 40, rescue: false }, 0.7)); // はずれ 3002
  console.log(gacha(config, { ceilCount: 40, rescue: true }, 0.7)); // あたり 4003
  console.log(gacha(config, { ceilCount: 99, rescue: true }, 0.7)); // 大当たり 5002
}

main();

8. 完成

必要な素材がそろったので後は使いやすいようにまとめる。config モジュールと、 gacha モジュール、 index.js。

config.js
function rarityOf(master, id) {
  const me = master.find(x => x.id === id);
  if (me) return me.rarity;
  throw new Error(`no such ID is found: ${id}`);
}

async function getDataFromDB() {
  // data for gacha
  const info = {
    weights: [[5, 0.01], [4, 0.1], [3, 0.89]],
    pickup: [['5001', 0.4], ['5002', 0.4], ['4001', 0.1]],
  };
  // data for character
  const master = [
    { id: '5001', rarity: 5 }, // other attributes are ommitted
    { id: '5002', rarity: 5 },
    { id: '5003', rarity: 5 },
    { id: '4001', rarity: 4 },
    { id: '4002', rarity: 4 },
    { id: '4003', rarity: 4 },
    { id: '3001', rarity: 3 },
    { id: '3002', rarity: 3 },
    { id: '3003', rarity: 3 },
  ];

  return [info, master];
}

export async function getConfig() {
  const config = [];
  const [info, master] = await getDataFromDB();
  info.weights.forEach(([rarity, prob]) => {
    const ids = master
      .filter(x => x.rarity === rarity)
      .map(x => x.id);
    const pickups = info.pickup
      .filter(x => rarityOf(master, x[0]) === rarity);
    config.push({ rarity, prob, pickups, ids });
  });
  return config;
}
gacha.js
function createTable(config) {
  const table = [];
  for (const entry of config) {
    const nonPickProb = entry.prob
      * entry.pickups.reduce((acc, x) => acc - x[1], 1)
      / (entry.ids.length - entry.pickups.length);
    for (const cid of entry.ids) {
      const searched = entry.pickups.find(x => x[0] === cid);
      const prob = (searched)
        ? entry.prob * searched[1]
        : nonPickProb;
      table.push([cid, prob, entry.rarity]);
    }
  }
  return table;
}

function normalize(configLike) {
  const ret = [];
  const summed = configLike.reduce((acc, x) => acc + x.prob, 0);
  configLike.forEach(entry => ret.push({ ...entry, prob: entry.prob / summed }));
  return ret;
}

function createTableCeil(conf) {
  const filtered = normalize(conf.filter(x => x.rarity === 5));
  return createTable(filtered);
}

function createTableRescue(conf) {
  const filtered = normalize(conf.filter(x => x.rarity > 3));
  return createTable(filtered);
}

function gachaInternal(config, user, rval) {
  const ceilCount = user.ceilCount || 0;
  const table = ceilCount === 99
    ? createTableCeil(config)
    : user.rescue
      ? createTableRescue(config)
      : createTable(config);

  let accum = 0;
  for (const [cid, prob, rarity] of table) {
    accum += prob;
    if (rval < accum) return [cid, rarity];
  }
  throw new Error('should not reach here');
}

export function gacha(config, user, rval) {
  const [id, rarity] = gachaInternal(config, user, rval);
  const ceilCount = rarity === 5 ? 0 : user.ceilCount + 1;
  return { id, ceilCount };
}

export function gacha10(config, user, rvals) {
  const ids = [];
  let { ceilCount } = user;
  for (let i = 0, over4 = false; i < rvals.length; i += 1) {
    const rescue = i === rvals.length - 1 && over4 === false;
    const [id, rarity] = gachaInternal(config, { ceilCount, rescue }, rvals[i]);
    ceilCount = rarity === 5 ? 0 : ceilCount + 1;
    if (rarity > 3) over4 = true;
    ids.push(id);
  }
  return { ids, ceilCount };
}

モジュールの使い方は以下の index.js を見る

index.js
import { getConfig } from './config';
import { gacha, gacha10 } from './gacha';

async function main() {
  const config = await getConfig();
  // 単発ガチャの実行
  console.log(gacha(config, { ceilCount: 40 }, 0.005)); // -> { id: '5002', ceilCount: 0 }
  console.log(gacha(config, { ceilCount: 40 }, 0.04)); // -> { id: '4002', ceilCount: 41 }
  console.log(gacha(config, { ceilCount: 40 }, 0.7)); // -> { id: '3002', ceilCount: 41 }
  console.log(gacha(config, { ceilCount: 99 }, 0.7)); // -> { id: '5002', ceilCount: 0 }

  // 10連ガチャの実行
  console.log(gacha10(config, { ceilCount: 70 }, Array(10).fill(0.7)));
  // -> { ceilCount: 80, ids: [ '3002', '3002', '3002', '3002', '3002', '3002', '3002', '3002', '3002', '4003' ] }

  console.log(gacha10(config, { ceilCount: 92 }, Array(10).fill(0.7)));
  // -> { ceilCount: 2, ids: [ '3002', '3002', '3002', '3002', '3002', '3002', '3002', '5002', '3002', '3002' ] }
}

main();

9. ケーススタディ

自分がソシャゲ運営になったと仮定して、サービス開始からの動きを見てみよう。

9-1. サービス開始記念

★5を 3体, ★4を 3体、★3を 3体をガチャに実装する。サービス開始記念で ★5 の出現確率を通常の 1% から 2% に倍増させる。config.js を以下のように構成(単体テストの話、実際はDBの該当するレコードを更新)。

config.jsから抜粋.js
async function getDataFromDB() {
  const info = {
    weights: [[5, 0.02], [4, 0.1], [3, 0.88]],
    pickup: [],
  };
  const master = [
    { id: '5001', rarity: 5 }, // other attributes are ommitted
    { id: '5002', rarity: 5 },
    { id: '5003', rarity: 5 },
    { id: '4001', rarity: 4 },
    { id: '4002', rarity: 4 },
    { id: '4003', rarity: 4 },
    { id: '3001', rarity: 3 },
    { id: '3002', rarity: 3 },
    { id: '3003', rarity: 3 },
  ];
  return [info, master];
}

9-2. ガチャを通常モードに戻す

サービス開始記念が終わったので ★5 の出現確率をもとに戻そう。

config.jsから抜粋.js
async function getDataFromDB() {
  const info = {
    weights: [[5, 0.01], [4, 0.1], [3, 0.89]],
    pickup: [],
  };
  const master = [
    { id: '5001', rarity: 5 }, // other attributes are ommitted
    { id: '5002', rarity: 5 },
    { id: '5003', rarity: 5 },
    { id: '4001', rarity: 4 },
    { id: '4002', rarity: 4 },
    { id: '4003', rarity: 4 },
    { id: '3001', rarity: 3 },
    { id: '3002', rarity: 3 },
    { id: '3003', rarity: 3 },
  ];
  return [info, master];
}

9-3. 新キャラ実装キャンペーン

★5 を 1体、★4 を 2体、★3 を1体新規に実装すると同時に新キャラのピックアップガチャを開催

config.jsから抜粋.js
async function getDataFromDB() {
  const info = {
    weights: [[5, 0.01], [4, 0.1], [3, 0.89]],
    pickup: [['5004', 0.5], ['4004', 0.3], ['4005', 0.3], ['3004', 0.3]],
  };
  const master = [
    { id: '5001', rarity: 5 },
    { id: '5002', rarity: 5 },
    { id: '5003', rarity: 5 },
    { id: '5004', rarity: 5 }, // 新規★5
    { id: '4001', rarity: 4 },
    { id: '4002', rarity: 4 },
    { id: '4003', rarity: 4 },
    { id: '4004', rarity: 4 }, // 新規★4 
    { id: '4005', rarity: 4 }, // 新規★4
    { id: '3001', rarity: 3 },
    { id: '3002', rarity: 3 },
    { id: '3003', rarity: 3 },
    { id: '3004', rarity: 3 }, // 新規★3
  ];
  return [info, master];
}

10. やりのこし

商用で使うには以下の機能を用意した方がよいだろう

  • バリデーション
    • pickup 対象IDがマスタに含まれているかをチェック
    • pickup 対象の条件付き確率が1を超えないかをチェック
    • 各レアリティの当選確率の合計がちゃんと 1 になっているか
  • コールバック
    • ガチャ結果にログをとったりgacha 関数はコールバックをとらせた方が便利
  • 特殊モード
    • 特定ユーザに当たりが出やすいテーブルを作る機能 (生配信用・関係者様向け・SNS対応)