Subterranean Flower

JavaScriptでグラブルのポーカーみたいなのを作る

Author
古都こと
ふよんとえんよぷよぐやま!!!

経緯や理由はわかりませんが、日本のビデオゲームとポーカーミニゲームは切っても切り離せない関係にあります。RPGのカジノコーナーに行けば、多くの場合はポーカーと出会うことができるでしょう。

さてそんなポーカーですが、自分で作ってみたくなったことはありませんか?今回は、JavaScriptを使って、簡単なポーカーを作ってみましょう。

ビデオゲームポーカー

日本におけるポーカーは、一般的に古典的で素朴なポーカーが採用されることがほとんどです。テキサス・ホールデムも有名ですが、特にビデオゲーム内のミニゲームにおいては、多くの場合、最もシンプルなものが採用され、対戦相手はおらず、1人で役を作って遊ぶものに仕上がっています。

ルールとしては、プレイヤーにカードが5枚配られ、プレイヤーは保持したいカードを複数選びます。1度きりの交換後、手札の役を見て、強い役ほど報酬が多くもらえる、という作りになっています。

例えばスマートフォンゲームのグランブルーファンタジーにおいても同様のミニゲームが実装されており、気軽にポーカーが楽しめるようになっています。

私がグラブルを始めた当時、最強クラスの召喚石「アナト」をとるために、必死でカジノ通いをした記憶があります。

ゲームのポーカーは、現実のポーカーとはまた違った特徴を持っていますが、それでもなかなか楽しいものです。今回はこれを作ってみましょう。

ポーカーを作る

それではポーカーを作っていきましょう。今回作るのはポーカー部分だけで、ベットや報酬部分、ダブルアップについては作成しません。

完成済みのソースコードは以下に置いてあります:

https://github.com/subterraneanflowerblog/gbf-poker-modoki

カードの準備

まずはHTMLファイルを用意します。以下の内容を適当な名前で保存します。内容としては、CSSファイル「style.css」とJavaScriptファイル「poker.js」「main.js」を読み込んでいるだけです。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>ポーカー</title>
    <link rel="stylesheet" href="style.css">
    <script src="poker.js" defer></script>
    <script src="main.js" defer></script>
  </head>
  <body>
  </body>
</html>

HTMLファイルが用意できたら、次はHTMLファイルと同じフォルダに「poker.js」を作り、編集していきます。ポーカーに必要なカードデータを入力していきます。

const Suit = {
  SPADE: '♠️',
  CLUB: '♣️',
  DIAMOND: '♦️',
  HEART: '♥️'
};

const suitList = [
  Suit.SPADE,
  Suit.CLUB,
  Suit.DIAMOND,
  Suit.HEART
];

const cardList = [
  {rank: 2, label: '2' },
  {rank: 3, label: '3' },
  {rank: 4, label: '4' },
  {rank: 5, label: '5' },
  {rank: 6, label: '6' },
  {rank: 7, label: '7' },
  {rank: 8, label: '8' },
  {rank: 9, label: '9' },
  {rank: 10, label: '10' },
  {rank: 11, label: 'J' },
  {rank: 12, label: 'Q' },
  {rank: 13, label: 'K' },
  {rank: 14, label: 'A' }
];

// 各スートごとに2からAまで作成する
const deckBase = 
    suitList
        .map((suit) => cardList.map((card) => ({suit, ...card})))
        .flat()
        .map(Object.freeze);

// ジョーカー
const joker = Object.freeze({ isWildcard: true, label: 'Joker' });

// 山札クラス
class Deck {
  constructor(options = {}) {
    this._deck = [...deckBase]; // deckBaseをコピー
    if(options.includesJoker) { this._deck.push(joker); }

    // シャッフル
    this._deck.sort((a, b) => Math.random() - 0.5);
  }

  // 山札からカードを取り出すメソッド
  deal(num) {
    return this._deck.splice(0, num);
  }
}

これで、カードデータと山札クラスが出来上がりました。カードデータは、単純にスートと数字の組み合わせを生成しているだけです。

山札クラスは、シャッフルされた山札を生成します。加えて、山札からカードを指定枚数引く、dealメソッドを実装しています。

試しに表示してみる

次に「main.js」をHTMLファイルと同じフォルダに作成し、編集します。main.jsでは、poker.jsで定義した変数やクラスを用いて、実際のゲーム進行処理を行います。

ゲームを作る前に、まずはデータの表示ができるかどうかだけを確認します。main.jsに、以下の内容を書き込んでください。

// カードを表す要素を作成する関数
const createCardElement = (card) => {
  const elem = document.createElement('div');
  elem.classList.add('card');

  // 「♣️K」のような表示を作る
  const cardLabel = document.createElement('div');
  cardLabel.innerText = `${card.suit || ''}${card.label}`;
  elem.appendChild(cardLabel);

  return elem;
};

//
// メイン処理
//

(function startGame() {
  // カード情報作成
  const deck = new Deck({includesJoker: true});
  const cards = deck.deal(5);

  // カードを描画する
  // renderTargetは描画対象(ここではdocument.bodyにしておきます)
  // stateは現在の状態(手札のリスト)です
  (function render(renderTarget, state) {
    renderTarget.innerText = ''; // 描画内容をクリア

    // カードの組を表示するコンテナを作成
    const container = document.createElement('div');
    container.classList.add('card-group');
    renderTarget.appendChild(container);

    // 各カードの内容をコンテナに詰め込む
    for(const card of state.cardList) {
      const cardElem = createCardElement(card);
      container.appendChild(cardElem);
    }
  })(document.body, {cardList: cards});
})();

startGame関数は、ゲームをスタートします。山札からカードを5枚引き、そのデータを表示しています。

startGame関数の中にあるrender関数は、いままでの表示内容を消去し、データを元にまた新たに表示しなおします。render関数は、初めの1回と、データに変更があるたびに呼び出すようにします。引数としては、表示対象の要素と、現在の状態(state)を受け取ります。

また、各要素にクラス名(card、card-groupなど)を振ってあります。これは後ほどCSSでのスタイリングをするためです。

これで表示の準備はできましたので、HTMLファイルをダブルクリックで開いてみましょう。ダブルクリックでブラウザが開かない場合、ブラウザのウィンドウ上にHTMLファイルをドロップすることでも、開くことができます。

以下の画像のように表示されたでしょうか?(絵文字を使用しているので、環境によってスートの表示は違います)

表示されたら、何度かリロードして、毎回ランダムに表示関わることを確認してください。

これでポーカーの最初の部分は実装できました。

見た目を整える

これでも表示はできるのですが、見た目が貧相です。少し見た目を整えてみましょう。

次は「style.css」ファイルを作成します。クラスはすでにJavaScriptファイル内で振ってあるので、そこにスタイルを適用していくだけです。

body {
  font-family: sans-serif;
}

.card-group {
  display: flex;
  font-family: sans-serif;
}

.card {
  margin: 0 1em;
  border-radius: 3px;
  box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.2);
  padding: 1em;
  width: 5em;
}

.card > div {
  text-align: center;
}

これで多少は見た目が良くなるはずです。確認してみましょう。

少しカードっぽい見た目になりました!よりセンスのある方は、自分でスタイルをいじってみてもいいでしょう。

スタイルが適用されない場合、HTMLファイル内でのCSSファイルの指定を間違えてないか、style.cssファイルの名前を間違えていないか、置き場所を間違えていないか等を確認してみてください。

カード保持/交換の切り替え

次にカードを保持するか交換するかのデータを追加し、切り替えができるようにします。

まずはカードを引く部分で、各カードに保持フラグを追加します。main.jsのstartGame内部の冒頭付近を編集します。

  // カード情報作成
  const deck = new Deck({includesJoker: true});
  const cards = deck.deal(5).map((c) => ({isHold: false, ...c}));

各カードにisHoldフラグを追加しています。

そして、そのデータに合わせ表示を変更します。main.jsの冒頭にあるcreateCardElement関数を編集します。

// カードを表す要素を作成する関数
const createCardElement = (card) => {
  const elem = document.createElement('div');
  elem.classList.add('card');

  // 「♣️K」のような表示を作る
  const cardLabel = document.createElement('div');
  cardLabel.innerText = `${card.suit || ''}${card.label}`;
  elem.appendChild(cardLabel);

  // isHoldフラグがあれば、「HOLD」表示を追加し、
  // 要素にholdクラスを追加する
  if(card.isHold) {
    const holdIndicator = document.createElement('div');
    holdIndicator.innerText = 'HOLD';
    elem.appendChild(holdIndicator);
    elem.classList.add('hold');
  }

  return elem;
};

これで、保持状態が表示に反映されるようになったはずです。しかし、まだ保持状態の切り替え機能を実装していないため、その確認はできません。

なので、次は切り替え機能を作りましょう。カードをクリックすると切り替えられるようにします。render関数の処理を変更しましょう。

  // カードを描画する
  // renderTargetは描画対象(ここではdocument.bodyにしておきます)
  // stateは現在の状態(手札のリスト)です
  (function render(renderTarget, state) {
    renderTarget.innerText = ''; // 描画内容をクリア

    // カードの組を表示するコンテナを作成
    const container = document.createElement('div');
    container.classList.add('card-group');
    renderTarget.appendChild(container);

    // 各カードの内容をコンテナに詰め込む
    for(const card of state.cardList) {
      const cardElem = createCardElement(card);

      // カードをクリックすると保持状態を切り替え
      // 全体を再描画する
      cardElem.addEventListener('click', () => {
        card.isHold = !card.isHold;
        render(renderTarget, state);
      });

      container.appendChild(cardElem);
    }
  })(document.body, {cardList: cards});

カードをクリックしたときの処理を追加しました。カードをクリックすると保持状態を切り替え、再描画します。

また、状態が分かりやすいように、style.cssに保持状態時のスタイルを追加しておきましょう。追記場所はファイルの一番下で大丈夫です。

.card.hold {
  background-color: rgb(177, 206, 177);
}

これで動作を確認してみましょう。表示されたカードをクリックすると、状態が切り替わるはずです。

カードの交換

カードの交換の実装は簡単で、isHoldフラグのついていないカードを入れ替えるだけです。render関数に、カード交換のボタンを追加してみましょう。

  // カードを描画する
  // renderTargetは描画対象(ここではdocument.bodyにしておきます)
  // stateは現在の状態(手札のリストとゲームフェーズ)です
  (function render(renderTarget, state) {
    renderTarget.innerText = ''; // 描画内容をクリア

    // カードの組を表示するコンテナを作成
    const container = document.createElement('div');
    container.classList.add('card-group');
    renderTarget.appendChild(container);

    // 各カードの内容をコンテナに詰め込む
    for(const card of state.cardList) {
      const cardElem = createCardElement(card);

      // カードをクリックすると保持状態を切り替え
      // 全体を再描画する
      cardElem.addEventListener('click', () => {
        card.isHold = !card.isHold;
        render(renderTarget, state);
      });

      container.appendChild(cardElem);
    }

    // カード交換ボタン
    // クリックすると保持フラグのついていないカードを交換し
    // 再描画する
    const changeButton = document.createElement('button');
    changeButton.innerText = '交換する';
    changeButton.addEventListener('click', () => {
      const newCardList = state.cardList
          .map((c) => c.isHold ? c : deck.deal(1)[0]);
    
      render(renderTarget, {
        cardList: newCardList,
        phase: 'done'
      });
    });
    renderTarget.appendChild(changeButton);
  })(document.body, {cardList: cards});

ここで追加したカード交換ボタンは、クリック時に各カードを、isHoldフラグがtrueならばそのままに、falsyならば山札から新しく1枚カードを引きます。

そして画面を再描画するのですが、このときstateに「phase」というキーを追加しています。これはゲームのフェーズ(交換フェーズ、完了フェーズ、など)を表します。phaseは後ほど使います。

さて、この状態で動作させてみると、正常に動作することがわかると思います。つまりHOLD状態のカードは保持され、その他のカードは交換されます。

また、それと同時にいくつかの不具合も見つかります。例えば何度も交換ボタンを押すと山札が尽きてエラーになる不具合です。

先ほど追加したphaseの値を見て、処理を変えてみましょう。phaseが’done’のとき、カードのクリックをできなくする処理と、交換ボタンを「次のゲームへ」ボタンに変更する処理を追加します。

render関数は以下のようになります:

  (function render(renderTarget, state) {
    renderTarget.innerText = ''; // 描画内容をクリア

    // カードの組を表示するコンテナを作成
    const container = document.createElement('div');
    container.classList.add('card-group');
    renderTarget.appendChild(container);

    // 各カードの内容をコンテナに詰め込む
    for(const card of state.cardList) {
      const cardElem = createCardElement(card);

      // カードをクリックすると保持状態を切り替え
      // 全体を再描画する
      // ゲームフェーズがdoneのときは押せない
      if(state.phase !== 'done') {
        cardElem.addEventListener('click', () => {
          card.isHold = !card.isHold;
          render(renderTarget, state);
        });
      }

      container.appendChild(cardElem);
    }

    // 現在のゲームフェーズを見て処理を変える
    if(state.phase === 'done') {
      // 次のゲームを開始するボタン
      const nextGameButton = document.createElement('button');
      nextGameButton.innerText = '次のゲームへ';
      nextGameButton.addEventListener('click', () => {
        startGame();
      });
      renderTarget.appendChild(nextGameButton);
    } else {
      // カード交換ボタン
      // クリックすると保持フラグのついていないカードを交換し
      // 再描画する
      const changeButton = document.createElement('button');
      changeButton.innerText = '交換する';
      changeButton.addEventListener('click', () => {
        const newCardList = state.cardList
            .map((c) => c.isHold ? c : deck.deal(1)[0]);
        
        render(renderTarget, {
          cardList: newCardList,
          phase: 'done'
        });
      });
      renderTarget.appendChild(changeButton);
    }
  })(document.body, {cardList: cards});

まずはphaseがdoneのとき、カードにイベントリスナを登録しないようにします。これはphase !== ‘done’のときだけイベントリスナを登録することで実現できます。該当部分だけを抜き出すと、以下のようになります:

      // カードをクリックすると保持状態を切り替え
      // 全体を再描画する
      // ゲームフェーズがdoneのときは押せない
      if(state.phase !== 'done') {
        cardElem.addEventListener('click', () => {
          card.isHold = !card.isHold;
          render(renderTarget, state);
        });
      }

次に、表示するボタンも変更します。phaseがdoneのときは「次のゲームへ」ボタンを、そうでないときは「交換する」ボタンを表示します。該当部分だけを抜き出すと、以下のようになります:

    // 現在のゲームフェーズを見て処理を変える
    if(state.phase === 'done') {
      // 次のゲームを開始するボタン
      const nextGameButton = document.createElement('button');
      nextGameButton.innerText = '次のゲームへ';
      nextGameButton.addEventListener('click', () => {
        startGame();
      });
      renderTarget.appendChild(nextGameButton);
    } else {
      // カード交換ボタン
      // クリックすると保持フラグのついていないカードを交換し
      // 再描画する
      const changeButton = document.createElement('button');
      changeButton.innerText = '交換する';
      changeButton.addEventListener('click', () => {
        const newCardList = state.cardList
            .map((c) => c.isHold ? c : deck.deal(1)[0]);
        
        render(renderTarget, {
          cardList: newCardList,
          phase: 'done'
        });
      });
      renderTarget.appendChild(changeButton);
    }

「次のゲームへ」ボタンは、ゲームを再スタートします。実際の処理としては、ゲーム全体を囲っているstartGame関数を呼び出しているだけです。

「交換する」ボタンには変更はありません。

ここまでのコード

poker.js

const Suit = {
  SPADE: '♠️',
  CLUB: '♣️',
  DIAMOND: '♦️',
  HEART: '♥️'
};

const suitList = [
  Suit.SPADE,
  Suit.CLUB,
  Suit.DIAMOND,
  Suit.HEART
];

const cardList = [
  {rank: 2, label: '2' },
  {rank: 3, label: '3' },
  {rank: 4, label: '4' },
  {rank: 5, label: '5' },
  {rank: 6, label: '6' },
  {rank: 7, label: '7' },
  {rank: 8, label: '8' },
  {rank: 9, label: '9' },
  {rank: 10, label: '10' },
  {rank: 11, label: 'J' },
  {rank: 12, label: 'Q' },
  {rank: 13, label: 'K' },
  {rank: 14, label: 'A' }
];

// 各スートごとに2からAまで作成する
const deckBase = 
    suitList
        .map((suit) => cardList.map((card) => ({suit, ...card})))
        .flat()
        .map(Object.freeze);

// ジョーカー
const joker = Object.freeze({ isWildcard: true, label: 'Joker' });

// 山札クラス
class Deck {
  constructor(options = {}) {
    this._deck = [...deckBase]; // deckBaseをコピー
    if(options.includesJoker) { this._deck.push(joker); }

    // シャッフル
    this._deck.sort((a, b) => Math.random() - 0.5);
  }

  // 山札からカードを取り出すメソッド
  deal(num) {
    return this._deck.splice(0, num);
  }
}

main.js

// カードを表す要素を作成する関数
const createCardElement = (card) => {
  const elem = document.createElement('div');
  elem.classList.add('card');

  // 「♣️K」のような表示を作る
  const cardLabel = document.createElement('div');
  cardLabel.innerText = `${card.suit || ''}${card.label}`;
  elem.appendChild(cardLabel);

  // isHoldフラグがあれば、「HOLD」表示を追加し、
  // 要素にholdクラスを追加する
  if(card.isHold) {
    const holdIndicator = document.createElement('div');
    holdIndicator.innerText = 'HOLD';
    elem.appendChild(holdIndicator);
    elem.classList.add('hold');
  }

  return elem;
};

//
// メイン処理
//

(function startGame() {
  // カード情報作成
  const deck = new Deck({includesJoker: true});
  const cards = deck.deal(5).map((c) => ({isHold: false, ...c}));

  // カードを描画する
  // renderTargetは描画対象(ここではdocument.bodyにしておきます)
  // stateは現在の状態(手札のリストとゲームフェーズ)です
  (function render(renderTarget, state) {
    renderTarget.innerText = ''; // 描画内容をクリア

    // カードの組を表示するコンテナを作成
    const container = document.createElement('div');
    container.classList.add('card-group');
    renderTarget.appendChild(container);

    // 各カードの内容をコンテナに詰め込む
    for(const card of state.cardList) {
      const cardElem = createCardElement(card);

      // カードをクリックすると保持状態を切り替え
      // 全体を再描画する
      // ゲームフェーズがdoneのときは押せない
      if(state.phase !== 'done') {
        cardElem.addEventListener('click', () => {
          card.isHold = !card.isHold;
          render(renderTarget, state);
        });
      }

      container.appendChild(cardElem);
    }

    // 現在のゲームフェーズを見て処理を変える
    if(state.phase === 'done') {
      // 次のゲームを開始するボタン
      const nextGameButton = document.createElement('button');
      nextGameButton.innerText = '次のゲームへ';
      nextGameButton.addEventListener('click', () => {
        startGame();
      });
      renderTarget.appendChild(nextGameButton);
    } else {
      // カード交換ボタン
      // クリックすると保持フラグのついていないカードを交換し
      // 再描画する
      const changeButton = document.createElement('button');
      changeButton.innerText = '交換する';
      changeButton.addEventListener('click', () => {
        const newCardList = state.cardList
            .map((c) => c.isHold ? c : deck.deal(1)[0]);
        
        render(renderTarget, {
          cardList: newCardList,
          phase: 'done'
        });
      });
      renderTarget.appendChild(changeButton);
    }
  })(document.body, {cardList: cards});
})();

役を判別する

ここまでで、ゲームの流れはできたように思います。ですが、まだ役についての機能が存在しません。次は役の判別機能を作りましょう。

今度は「poker.js」のほうを編集します。poker.jsの下の方にどんどん追加していきましょう。

役の判定については様々な方法があるのですが、今回はわかりやすさを重視して、素直に条件を調べていく方法を取ります。速度は出ませんが、実装が簡単です。

まず、poker.jsに便利関数を追加していきましょう。役を判定するにあたり、ストの枚数や、各ランクの枚数をカウントする関数が欲しくなってきます。以下のような関数を、poker.jsの一番下に追加します:

// スートごとの枚数をカウントする関数
const countSuit = (cardList) => {
  let wildcard = 0;
  const count = {
    [Suit.SPADE]: 0,
    [Suit.CLUB]: 0,
    [Suit.DIAMOND]: 0,
    [Suit.HEART] : 0
  };

  for(const card of cardList) {
    if(card.isWildcard) { wildcard++; }
    else { count[card.suit]++; }
  }

  return {
    wildcard,
    ...count
  }
};

// ランクごとの枚数をカウントする関数
const countRank = (cardList) => {
  // インデックスが0から14までの15要素の配列
  // 0,1は未使用で2から14はそれぞれのカード
  // 全て0で初期化しておく
  let wildcardCount = 0;
  const rankCount = new Array(15).fill(0);

  // カウントする
  for(const card of cardList) {
    if(card.isWildcard) {
      wildcardCount++;
    } else {
      rankCount[card.rank]++;
    }
  }

  return {
    wildcard: wildcardCount,
    rank: rankCount
  };
};

countSuit関数は、スートごとの枚数を算出します。countRank関数は、ランクごとの枚数を算出します。これらは後ほど使います。

次に、ジョーカーを取り除く関数と、カードリストをランクでソートする関数を作ります:

// ジョーカーを取り除く関数
const removeJoker = (cardList) => {
  return cardList.filter((c) => !c.isWildcard);
};

// カードのランクでソートする関数
const sortByRank = (cardList) => {
  return [...cardList].sort((a, b) => a.rank - b.rank);
};

ジョーカーを取り除く関数は、役の判定の際に、ジョーカーが邪魔な場合に使います。ランクでソートする関数は、ストレートなどの役の判定に使います。

フラッシュ/ストレート/ストレートフラッシュ

判定処理の都合上、強い役から順番に処理していきたいところです。ただ、ロイヤルストレートフラッシュを判定するにはストレートフラッシュの判定が必要で、ストレートフラッシュの判定にはフラッシュの判定と、ストレートの判定が必要です。

なので、まずはフラッシュとストレートの判定を作ります。poker.jsに追加していきます。まずはフラッシュから:

// フラッシュ(5枚全てが同じスート)
// ジョーカーも考慮する
const isFlush = (cardList) => {
  const count = countSuit(cardList);
  return suitList.some(
    (s) => count[s] + count.wildcard === 5
  );
};

フラッシュの判定は簡単です。スートの枚数をカウントして、ジョーカーの枚数と足して5になるスートがあれば、フラッシュです。

次にストレートの判定を作ります。これはなかなかに難しく、特にジョーカーが混ざるとかなり複雑になります。以下のようにします:

// ストレート(5枚のランクが連続している)
// ジョーカーも考慮する
const isStraight = (cardList) => {
  const count = countSuit(cardList);

  // ランクでソートしておく
  const canonical = sortByRank(removeJoker(cardList));

  let straight = true;
  let remainWildcard = count.wildcard;
  for(let index = 0; index < canonical.length - 1; index++) {
    const current = canonical[index];
    const next = canonical[index+1];

    // 隣り合うカード間のランク差
    // ランクでソート済みなので、差を調べるだけでわかる
    // 隣同士の差が1ならストレート
    let gap = next.rank - current.rank;

    // A(14), 2, 3, 4, 5はストレート
    // (余談)K, A, 2, 3, 4などAを挟むのはストレートではないらしい
    const isLast = index === canonical.length - 2;
    const aceStart = isLast && current.label == '5' && next.label === 'A';
    if(aceStart) { gap = 1; }

    if(isLast && current.label == '4' && next.label === 'A') {
      gap = 2;
    }

    // gapが2でジョーカーが残っていれば埋める
    // 5, Joker, 7, 8, 9、みたいなやつ
    const useJoker = gap == 2 && remainWildcard > 0;
    if(useJoker) {
      gap = 1;
      remainWildcard--;
    }

    // 差が1でなければストレートではない
    if(gap !== 1) {
      straight = false;
      break;
    }
  }

  return straight;
};

カードをランクでソートし、隣り合うカード同士のランク差がすべて1ならば、ストレートです。隣り合うカードの差が2のときはジョーカーで埋めます。

なお、「A, 2, 3, 4, 5」はストレートとして認められています。

ここまできたら、ストレートフラッシュの判定は簡単です。ストレートかつフラッシュであればいいのですから。

// ストレートフラッシュ
const isStraightFlush = (cardList) => {
  return isStraight(cardList) && isFlush(cardList);
};

これで、フラッシュ/ストレート/ストレートフラッシュの判定関数ができました。

ロイヤルストレートフラッシュ

ロイヤルストレートフラッシュは、ストレートフラッシュの特殊な場合です。10からAまでの連番のストレートフラッシュが成立したとき、ロイヤルストレートフラッシュとなります。

これも簡単に判定でき、以下のようになります:

// ロイヤルストレートフラッシュ
const isRoyalStraightFlush = (cardList) => {
  // ストレートフラッシュでないならfalse
  if(!isStraightFlush(cardList)) { return false; }

  // 10〜Aが全てあるかチェック
  // ストレートフラッシュは満たしているので、
  // 重複チェックは不要
  const royalCards = ['10', 'J', 'Q', 'K', 'A'];
  let score = 0;
  for(const card of cardList) {
    if(card.isWildcard || royalCards.includes(card.label)) {
      score++;
    }
  }

  return score === 5;
};

ロイヤルストレートフラッシュについては、これだけです。

フォーカード等

次いで、ファイブカード、フォーカード、スリーカードについて作っていきましょう。これらはカードの枚数をチェックするだけで実現できるので、簡単です。

// ファイブカード
const isFiveOfAKind = (cardList) => {
  const count = countRank(cardList);
  return count.rank.some((c) => c + count.wildcard === 5);
};

// フォーカード
const isFourOfAKind = (cardList) => {
  const count = countRank(cardList);
  return count.rank.some((c) => c + count.wildcard === 4);
}

// スリーカード
const isThreeOfAKind = (cardList) => {
  const count = countRank(cardList);
  return count.rank.some((c) => c + count.wildcard === 3);
};

ワンペア/ツーペア/フルハウス

最後にワンペア/ツーペア/フルハウスです。ツーペア/ワンペアについては難しいところはありません:

// ツーペア
const isTwoPair = (cardList) => {
  const count = countRank(cardList);

  // ジョーカーがあるとツーペアになりえない
  if(count.wildcard) { return false; }

  // 2枚が2つあればツーペア
  return count.rank.filter((c) => c === 2).length === 2;
};

// ワンペア
const isOnePair = (cardList) => {
  const count = countRank(cardList);
  return count.rank.some((c) => c + count.wildcard === 2);
};

気をつけるのは、ジョーカーがあるとツーペアが成立しない点です。ジョーカーがあると、スリーカードが優先されるからです。

また、これらの関数は完全にワンペア/ツーペアであることを保証しません。例えばフルハウスの手札に対してもisOnePairはtrueを返します。よって関数の呼び出し順序は重要になってきます。

フルハウスについては、2枚3枚パターンか、ツーペア+ジョーカーのパターンです:

// フルハウス
const isFullHouse = (cardList) => {
  const count = countRank(cardList);

  // ジョーカーがあれば、
  // ジョーカーを除いてツーペアを調べる
  if(count.wildcard) {
    return isTwoPair(removeJoker(cardList));
  }

  // ジョーカーがなければ、
  // 2枚と3枚のランクを探す
  const two = count.rank.find((c) => c === 2);
  const three = count.rank.find((c) => c === 3);

  return !!(two && three);
};

役名の取得

ここまでできたら、役名の取得関数を作りましょう。強い役から順番に判定していきます。

// 役名を取得する関数
const getHandName = (cardList) => {
  if(isRoyalStraightFlush(cardList)) {
    return 'ロイヤルストレートフラッシュ';
  } else if(isFiveOfAKind(cardList)) {
    return 'ファイブカード';
  } else if(isStraightFlush(cardList)) {
    return 'ストレートフラッシュ';
  } else if(isFourOfAKind(cardList)) {
    return 'フォーカード';
  } else if(isFullHouse(cardList)) {
    return 'フルハウス';
  } else if(isFlush(cardList)) {
    return 'フラッシュ';
  } else if(isStraight(cardList)) {
    return 'ストレート';
  } else if(isThreeOfAKind(cardList)){
    return 'スリーカード';
  } else if(isTwoPair(cardList)) {
    return 'ツーペア';
  } else if(isOnePair(cardList)) {
    return 'ワンペア';
  } else {
    return 'ノーペア';
  }
};

これで役名を判定することができるようになりました!

ゲームを仕上げる

役の判別までできたので、ゲームを仕上げましょう!「交換する」ボタンを押した後に、役名を取得して、表示する機能をつけます。

main.jsの60行目付近、phaseがdoneのときの処理に、役名表示処理を追加します:

    // 現在のゲームフェーズを見て処理を変える
    if(state.phase === 'done') {
      // 役の表示
      const hand = getHandName(state.cardList);
      const handLabel = document.createElement('div');
      handLabel.innerText = hand;
      renderTarget.appendChild(handLabel);

      // 次のゲームを開始するボタン
      const nextGameButton = document.createElement('button');
      nextGameButton.innerText = '次のゲームへ';
      nextGameButton.addEventListener('click', () => {
        startGame();
      });
      renderTarget.appendChild(nextGameButton);
    } else {

これで役名の表示ができるはずです。実際に動かしてみましょう。

できました!長い道のりでしたが、ようやく完成しました!

さいごに

今回はシンプルなポーカーゲームを作りましたが、より発展的な、ベットや報酬、ダブルアップ等が存在するポーカーゲームも作れるでしょう。

また、デザインやアニメーションに凝ってみても良いかもしれません。今回作ったのは、なかなか殺風景ですからね。

また、役の判定について最適化しても良いかもしれません。今回は愚直な方法をとっているので、処理効率は良くありません。

いろいろと工夫のしがいはあると思うので、何か思いついたらぜひ挑戦してみてください。