Skip to content
第01回 3Dダイスポーカー

4 / 4 役の判定 ​

このステップの目的

5個の整数からポーカーの役を判定する

ダイスポーカーの実装 ​

ダイスポーカーの初期画面ダイスポーカーの初期画面

前のステップでDiceFace.Read()を実装し、ダイスの出目を得ることができるようになりました。
このステップでは同時に5個のダイスを投げ、出目からポーカーの役を判定する形に発展させます。

Assets/UNSLOW/DicePoker/Scenes/DicePoker.unityを開いてください。
5個のダイスがシーンに配置されています。

ダイスは全てDice.prefabのインスタンスです。
前ステップと同じDiceFace.csを使用するため、実装した出目の判定をそのまま利用することができます。

HierarchyでDice Gameを選び、InspectorのDice配列に5個のダイスが割り当てられていることを確認します。
DiceGameは、この配列を使って全個体を操作します。

Playして投げると、停止後に5個の出目と「役なし」が表示されます。
ポーカーの役を判定する関数DiceHand.Evaluate()は、初期状態では判定の機能は実装されていません。
DiceFace.Read()同様に実装を進めていきます。

ダイスポーカーの役 ​

ダイスポーカーは、トランプを使ったポーカーの簡略版です。
以下がダイスポーカーの役一覧です。

役条件例
フラッシュ同じ数字が5個3・3・3・3・3
フォーカード同じ数字が4個2・2・2・2・6
フルハウス3個の組と2個の組1・1・1・4・4
スリーカード3個の組が1つ、残りは別々5・5・5・1・3
ツーペア2個の組が2つ2・2・4・4・6
ワンペア2個の組が1つ、残りは別々2・2・3・4・6
ストレート1〜5、または2〜6が1個ずつ2・3・4・5・6
役なし上のどれにも当てはまらない1・2・3・4・6

トランプでのフラッシュは同じスート(絵柄)のカードが5枚揃った役ですが、ダイスポーカーでは同じ数字が5個揃った役をフラッシュとしています。
他はトランプのポーカーと同じように、数字の組み合わせで役が決まります。

役は、出目の数字の組み合わせによって決まります。
やや複雑ですが明確なルールが定義されており、出目の個数を数えることで役を判定することができます。

DiceGameの状態と停止待ち ​

ダイスの投擲と停止までの待機処理はDiceGameクラス内で行われます。
処理の内容はDiceFaceTestと同様ですが、5個投げられるダイス全個体の停止を確認する点が異なります。

DiceGame.AllDiceStopped()は、全てのダイスが停止しているかを確認するメソッドです。
AllはSystem.Linqの機能で、配列の全要素が条件を満たすとtrueになります。
ここではdices配列の各ダイスのIsSleepingを通じて、Rigidbodyが全てスリープしているかを調べています。
ダイスが1個でも動いていれば待機を続けます。

DiceGame.cscsharp
private bool AllDiceStopped()
{
    return dices.All(die => die.IsSleeping);
}

役を表す列挙型 ​

役の種類は、DiceHandクラス内に定義された列挙型(enum)のHandTypeで表します。
列挙型は、ワンペアやフルハウスなど、決まった種類の値に名前を付ける仕組みです。
DiceHandの外から参照するときは、DiceHand.HandType.OnePairのように書きます。

DiceHand.cscsharp
public enum HandType
{
    None = 0,         // 役なし
    OnePair,          // ワンペア
    TwoPair,          // ツーペア
    ThreeOfAKind,     // スリーカード
    Straight,         // ストレート
    FullHouse,        // フルハウス
    FourOfAKind,      // フォーカード
    Flush             // 同じ数字が5個(この教材のルール)
}

Noneは「役なし」を表します。
開始版のEvaluateは仮の戻り値として常にHandType.Noneを返すため、役が成立する出目を渡しても「役なし」と表示されます。

役を表すHandTypeの定義と、役を表示名に変換するGetDisplayNameは初期状態で実装済みです。
演習ではEvaluateの中身を実装します。

出目から役を求める ​

DiceGame.ShowResult()は、結果表示用のテキストを設定する関数です。

DiceFace.Read()を各ダイスのインスタンスに対して呼んでダイスの目を取得し、DiceHand.Evaluate()で役の判定を行います。
DiceHand.Evaluate()は整数配列を受け取るstaticメソッドなので、クラス名から呼び出しています。

DiceGame.cscsharp
private void ShowResult()
{
    var values = new int[dices.Length];

    for (var i = 0; i < dices.Length; i++)
        values[i] = dices[i].Read();

    _faces = string.Join("   ", values);
    var hand = DiceHand.Evaluate(values);
    _hand = DiceHand.GetDisplayName(hand);

    if (hand == DiceHand.HandType.Flush)
        _hand = $"{values[0]}の{_hand}";
}

DiceHand.Evaluate()に出目の一覧を渡すため、valuesに1個ずつ出目を入れています。
これは役判定に合わせて出目の表示にも使用しています。

Evaluateが返すhandは、DiceHand.HandType型の値です。
ここでは得られたhandの値をGetDisplayNameで表示用の文字列へ変換し、_handに保存しています。
_facesと_handに保存した文字列は、最終的に画面に表示されます。

例外的な処理として、各ダイスの目がすべて同じだった時に成立するフラッシュだけは、表示の際に目の数字を添えています。
ここでは表示側でvalues[0]を添えて「3のフラッシュ」のように表示します。

出目の集計 ​

Assets/UNSLOW/DicePoker/Scripts/DiceHand.csを開き、DiceHand.Evaluate()を実装します。
DiceHand.Evaluate()の中にあるreturn HandType.None;と課題用コメントを、個数の集計と役を返す処理へ置き換えます。

DiceHand.Evaluate()は、出目の配列を受け取り、役の種類をHandTypeで返すメソッドです。
以下のようなコードになるはずです。

DiceHand.cscsharp
public static HandType Evaluate(int[] values)
{
    if (values is not { Length: 5 })
        throw new ArgumentException("Five dice values are required.");

    var counts = new int[7];
    foreach (var value in values)
    {
        if (value is < 1 or > 6)
            throw new ArgumentOutOfRangeException(nameof(values));

        counts[value]++;
    }

    var pairs = 0;
    var three = false;

    for (var value = 1; value <= 6; value++)
    {
        if (counts[value] == 5) return HandType.Flush;
        if (counts[value] == 4) return HandType.FourOfAKind;
        if (counts[value] == 3) three = true;
        if (counts[value] == 2) pairs++;
    }

    if (three && pairs == 1) return HandType.FullHouse;
    if (three) return HandType.ThreeOfAKind;
    if (pairs == 2) return HandType.TwoPair;
    if (pairs == 1) return HandType.OnePair;

    var middle = counts[2] == 1 &&
                 counts[3] == 1 &&
                 counts[4] == 1 &&
                 counts[5] == 1;

    if (middle && (counts[1] == 1 || counts[6] == 1)) return HandType.Straight;

    return HandType.None;
}

やや複雑な判定になりますので、順を追って解説します。

出目の集計方法 ​

まず、出目を添字として個数を保存する配列を用意します。
添字0と1〜6の出目を対応させた7要素の配列を用意します。

csharp
int[] counts = new int[7];

続いてforeach (int value in values)で出目を1個ずつ取り出し、ループ内でcounts[value]++;を実行します。
例えば出目が{ 2, 2, 3, 4, 6 }なら、counts[2]だけが2になります。
つまり2の目が2個あることがわかります。

集計用の配列を用意することで、数字が出てきた順番は関係なく各数字の個数を把握することができます。

役を判定する ​

ここまでで、出目に対応したダイスの個数がcounts配列に集計されました。
得られた結果をもとに、役を判定していきます。

トランプでのフラッシュは同じスート(絵柄)のカードが5枚揃った役ですが、ダイスポーカーでは同じ数字が5個揃った役をフラッシュとしています。
他はトランプのポーカーと同じように、数字の組み合わせで役が決まります。

判定関数では、集計のループの後に各出目の個数を数えるループを回します。

DiceHand.cscsharp
    for (var value = 1; value <= 6; value++)
    {
        if (counts[value] == 5) return HandType.Flush;
        if (counts[value] == 4) return HandType.FourOfAKind;
        if (counts[value] == 3) three = true;
        if (counts[value] == 2) pairs++;
    }

得られた数が5個ならフラッシュ、4個ならフォーカードが確定しますので、その場で役を返します。

3個の場合は、スリーカードかフルハウスのどちらかになります。
一旦threeをtrueにして、ダイスが3個そろっていることを記録しておきます。

2個ならpairsを1増やして、他の数字も調べます。

以上の判定ループの後で、3個と2個の組み合わせからフルハウスなどを判定します。
フルハウスはスリーカードより先に判定しなければなりません。

ここまでで、同じ数字の組がひとつもなければストレートの可能性があります。
1〜5または2〜6が、それぞれ1個ずつあるかを確認します。

これらの判定に最後まで当てはまるものがなければ、役なしとなります。
HandType.Noneを返して役なしを示し、終了します。

役の静的な判定テスト ​

ゲームでは、アクションが伴う処理と、静的なロジックで行われる判定処理とが混在することがままあります。
静的な判定の実装をチェックするために、都度アクション行うのは非効率です。

ここでは各役の判定が確実に実装されたかを確認するため、一時的なテストコードを用意します。
値を決めた整数配列をDiceHand.Evaluate()に渡して動作確認してみましょう。

DiceGame.csの既存のprivate void Start() => RefreshUI();を、次の内容に置き換えてください。

DiceGame.cscsharp
private void Start()
{
    RefreshUI();
    var hand = DiceHand.Evaluate(new[] { 2, 2, 3, 4, 6 });
    Debug.Log(DiceHand.GetDisplayName(hand));
}

Startは、このコンポーネントの実行開始時に1回呼ばれます。
画面を初期化するRefreshUI()を残し、役の判定結果をGetDisplayNameで文字列に変換して、Debug.LogでUnityのConsoleへ出力します。

保存してUnityへ戻り、Consoleのコンパイルエラーがないかを確認してからDicePoker.unityシーンをPlayしてください。
Consoleに「ワンペア」と出るはずです。
正常に表示されたら、以下の手順で他の役も確認してみましょう。

  1. Playを止め、new[] { ... }の5個の数字を上の役一覧の例の数字に変更します。
    保存し、再びConsoleをClearしてPlayします。
    フラッシュは「フラッシュ」または「3のフラッシュ」と表示されれば正しく判定されています。
  2. 全7種類の役と役なしを確かめたら、ストレートのもうひとつのパターン{ 1, 2, 3, 4, 5 }も試します。
  3. 最後に{ 2, 2, 3, 3, 3 }と、並べ替えた{ 3, 2, 3, 2, 3 }を試し、両方がフルハウスになることを確認します。

フルハウスがスリーカードになった場合は、条件を調べる順序を見直してみましょう。
数値を並べ替えると結果が変わる場合は、出目の隣同士を比較していないか、countsが正しく数字ごとの個数を集計できているかを確認してください。

「テスト」 ​

静的な判定ロジックが正しく動作するかを確認するため、専用のプログラムを用意することがあります。
実際のゲームプレイを伴わず、固定の入力に対して期待される出力が得られるかをチェックするためのものです。

こうしたプログラム動作の確認プロセスをテストと呼びます。

テストを行うことで、個々のロジックの正しさを個別に検証できます。
なんらかの事情でプログラムの動作がおかしくなっていないかを定期的に自動点検すれば、いつの間にか動作がおかしくなっていたが原因がわからない、といった事態を減らすことも可能です。

ゲームはロジックが複雑に絡み合って動作するものですが、実際のゲームを動かしてみないと不具合がわからないようでは確認に手間がかかりすぎ、品質の維持が難しくなります。
テストはプログラムの品質を保つための重要なプロセスで、ゲームプログラミングの世界でも広く活用されています。

テストを通過したからといって、プログラムにバグの存在しないことが保証されるわけではありません。
ですがテストとして部品単位の品質検証を行うことで、ゲームプログラム全体の品質を維持しやすくなるのは間違いありません。

今回は、Startメソッド内で固定配列を使って役の判定を確認していますが、これは本来の意味でのテストとは異なります。
Unityには体系的にテストを行う専用のテストフレームワークが用意されています。
是非 Unity Test Framework で検索して、公式のドキュメントやチュートリアルを参照してください。

実際の投擲による確認 ​

Playを止め、確認用に置き換えたStartを元のprivate void Start() => RefreshUI();に戻して保存します。
これで固定配列の確認を終え、ダイスから取得した値を使う状態へ戻ります。

DicePoker.unityでPlayし、Spaceキーまたはボタンで投げます。
全ダイスが停止した後に、5個の上面の模様・出目の表示・役の名前を見比べてください。

出目と役が表示されれば基本部分は完成です。

チェックリスト
  • ReadyとRollingの切り替えと、全個体の停止を待つ理由を説明できる
  • 5個の出目を正しく集計できる
  • 役の判定結果をenumで扱い、表示名へ変換できる
  • 固定配列で7種類の役と役なし、2種類のストレートを確認した
  • 出目の並び順を変えても同じ役になる
  • Startを元に戻し、実際の投擲で出目と役を確認した
  • 判定後に結果が固定される

実装例を確認する ​

自分のコードを動かした後、講師の案内に合わせて実装例を確認しましょう。

3Dダイスポーカーの完成例をダウンロード

開始用プロジェクトとは別のフォルダーに展開し、Unity 6000.5.5f1でDicePokerを開きます。
Assets/UNSLOW/DicePoker/Scenes/DicePoker.unityを開いてPlayしてください。

出目だけを調べたいときは、Assets/UNSLOW/DicePoker/Scenes/DiceFaceTest.unityで投擲後の上面・6面の内積・Readの結果を比較できます。
自分の作業とは別の場所に展開し、完成例のコードと結果を比較してください。

次の2つは、完成例のスクリプトの全文です。自分の実装と、入力・比較条件・戻り値を対応させて読んでください。

出目判定:DiceFace.cs
csharp
using UnityEngine;

namespace UNSLOW.DicePoker
{
    [RequireComponent(typeof(Rigidbody))]
    public sealed class DiceFace : MonoBehaviour
    {
        /// <summary>
        ///     ダイスの各面の法線方向
        /// </summary>
        public static readonly Vector3[] Normals =
        {
            Vector3.right,  // 1
            Vector3.up,     // 2
            Vector3.forward, // 3
            Vector3.back,   // 4
            Vector3.down,   // 5
            Vector3.left    // 6
        };

        /// <summary>
        /// ダイスの上面の出目を読み取ります
        /// </summary>
        /// <returns>ダイスの上面の出目(1〜6)</returns>
        public int Read()
        {
            var value = 1;
            var best = -1f;

            for (int i = 0; i < Normals.Length; i++)
            {
                var direction = transform.TransformDirection(Normals[i]);
                var dot = Vector3.Dot(direction, Vector3.up);

                if (dot <= best)
                    continue;

                best = dot;
                value = i + 1;
            }

            return value;
        }
    }
}
役判定:DiceHand.cs
csharp
using System;

namespace UNSLOW.DicePoker
{
    public static class DiceHand
    {
        /// <summary>ダイスの役の種類</summary>
        public enum HandType
        {
            None = 0,         // 役なし
            OnePair,          // ワンペア
            TwoPair,          // ツーペア
            ThreeOfAKind,     // スリーカード
            Straight,         // ストレート
            FullHouse,        // フルハウス
            FourOfAKind,      // フォーカード
            Flush             // 同じ数字が5個(この教材のルール)
        }

        /// <summary>
        ///     ダイスの役を判定する
        /// </summary>
        /// <param name="values">ダイスの値の配列</param>
        /// <returns>ダイスの役の種類</returns>
        /// <exception cref="ArgumentException">ダイスの数が5でない場合にスローされる</exception>
        /// <exception cref="ArgumentOutOfRangeException">ダイスの値が1から6の範囲外の場合にスローされる</exception>
        public static HandType Evaluate(int[] values)
        {
            if (values is not { Length: 5 })
                throw new ArgumentException("Five dice values are required.");

            var counts = new int[7];
            foreach (var value in values)
            {
                if (value is < 1 or > 6)
                    throw new ArgumentOutOfRangeException(nameof(values));

                counts[value]++;
            }

            var pairs = 0;
            var three = false;

            for (var value = 1; value <= 6; value++)
            {
                if (counts[value] == 5) return HandType.Flush;
                if (counts[value] == 4) return HandType.FourOfAKind;
                if (counts[value] == 3) three = true;
                if (counts[value] == 2) pairs++;
            }

            if (three && pairs == 1) return HandType.FullHouse;
            if (three) return HandType.ThreeOfAKind;
            if (pairs == 2) return HandType.TwoPair;
            if (pairs == 1) return HandType.OnePair;

            var middle = counts[2] == 1 &&
                         counts[3] == 1 &&
                         counts[4] == 1 &&
                         counts[5] == 1;

            if (middle && (counts[1] == 1 || counts[6] == 1)) return HandType.Straight;

            return HandType.None;
        }

        /// <summary>役の種類を画面表示用の名前へ変換する</summary>
        public static string GetDisplayName(HandType hand)
        {
            return hand switch
            {
                HandType.None => "役なし",
                HandType.OnePair => "ワンペア",
                HandType.TwoPair => "ツーペア",
                HandType.ThreeOfAKind => "スリーカード",
                HandType.Straight => "ストレート",
                HandType.FullHouse => "フルハウス",
                HandType.FourOfAKind => "フォーカード",
                HandType.Flush => "フラッシュ",
                _ => throw new ArgumentOutOfRangeException(nameof(hand), hand, "Unknown dice hand.")
            };
        }
    }
}

上の完成例では、入力が5個であることと、各値が1〜6であることも確認しています。
条件を満たさない入力は、throwで例外として知らせています。

発展:役の確率と仮想コインの配当

役の確率を読む ​

公平な6面ダイスを5個、独立に1回ずつ振ると仮定します。ダイスを区別した出方は6 × 6 × 6 × 6 × 6 = 7,776通りです。

「その役になる出方の数 ÷ 7,776」で確率を求められます。例えばフラッシュは、1を5個から6を5個までの6通りです。ストレートは2種類あり、それぞれの数字の並び方が5! = 120通りなので、合計240通りです。

役出方の数確率(約)
ワンペア3,60046.2963%
ツーペア1,80023.1481%
スリーカード1,20015.4321%
フォーカード1501.9290%
フルハウス3003.8580%
ストレート2403.0864%
フラッシュ60.0772%
役なし4806.1728%

各行は重複しないように分類しています。「1のフラッシュ」だけなら1通りなので、確率は1 / 7,776です。
この表は公平で独立なダイスの理論値であり、今回の物理シミュレーションが同じ分布になることを保証するものではありません。

出方の数をもう少し詳しく求める

C(n, r)は、n種類から順番を区別せずr種類を選ぶ組合せの数です。

コンバインは組み合わせの数を求めるときに使います。例えば、C(5, 3)は5種類から順番を区別せずに3種類を選ぶ組合せの数です。
C(n, r) = n! / (r! (n-r)!)です。

役数え方
ワンペアペアの数字6 × 残りの数字C(5,3) × 並べ方5!/2! = 3,600
ツーペアペアの数字C(6,2) × 残りの数字4 × 並べ方5!/(2!2!) = 1,800
スリーカード3個の数字6 × 残りの数字C(5,2) × 並べ方5!/3! = 1,200
フォーカード4個の数字6 × 残りの数字5 × 並べ方5!/4! = 150
フルハウス3個の数字6 × 2個の数字5 × 並べ方5!/(3!2!) = 300
ストレート数字の組2 × 並べ方5! = 240
フラッシュ数字6 = 6
役なし全体7,776から他の役の出方を引く = 480

発展:仮想コインの配当を設計する ​

1回10コインで遊ぶとしたら、どの役に何コインを払い戻すと面白くなるでしょうか。
表を見て、「よく当たるが配当は小さい」「めったに当たらないがリターンは大きい」といった体験の違いを考えてみましょう。

平均見て払い戻しは、各役について確率 × 払い戻すコイン数を求めて足すと計算できます。
そこから参加費を引いたものが、1回あたりの平均的な増減です。
確率は百分率ではなく、3,600 / 7,776のような割合で計算します。

ポーカーとして完成させる

さらに、残すダイスを選ぶホールドと振り直しを加えると、プレイヤーの選択が生まれます。
ホールドのためのUIが必要になりますが、より戦略的なゲーム体験が可能になります。

ただし、何を残すかによって次の役の確率は変わります。
上の「5個を1回だけ振る」表を、そのまま配当設計に使うことはできません。
配当表をどのように設計するべきか、考えてみてください。