Reactでポーカー確率計算機を作ってみた

## はじめに
Advent Calendar参加ということで、趣味のPokerに触れつつ自身のReact学習も兼ねてPokerの確率計算アプリを作る。
ポーカー初心者向けのツールが、簡単に作れそうなモジュールを発見したので使ってみる。

## この記事でわかること
- コンポーネントの分け方
- UIコンポーネントの作り方

## 完成イメージ
![スクリーンショット 2024-12-09 8.47.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935090/a11870f9-ab81-c58d-8287-675ef9d4c265.png)

### 機能
- ハンド選択
    - Player1→Player2→Boardの順でカードを選択可能
- 計算
    - Player 2人分のハンドを選択時計算可能
    - Board 3枚、4枚、5枚選択時計算可能

→つまりpreflop → flop → turn → riverそれぞれでハンドの勝率を計算できる

基本的にハンドを比較するツールである点から、必要な入力項目を順番に入力させる（選択後の決定ボタンのアクションを減らしたかった。）

## 開発環境
OS: macOS
node: 20.11.1
react: 18.3.1
CSSフレームワーク: tailwind css(3.4.16)
確率計算モジュール: poker-odds-calc(0.0.14)

## おおまかな流れ
1. プロジェクト作成
2. モジュールのインストール
3. コンポーネント分け
4. 実装

## 1. プロジェクト作成

```
$ npm create vite@latest odds-calculator -- --template react-ts
$ cd odds-calculator
```

## 2. モジュールのインストール
### Tailwind CSS with Vite
[tailwind css公式](https://tailwindcss.com/docs/guides/vite)
```
$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
```

### poker-odds-calc
NLH以外にもShortdeckやOmahaも計算できるモジュールです。
[poker-odds-calc](https://github.com/siavashg87/poker-odds-calc?tab=readme-ov-file#readme)
```
$ npm install poker-odds-calc
```

### clsx
classNameを動的に変える際に可読性高くコードを書くことができるモジュール。
[clsx](https://www.npmjs.com/package/clsx)
```
$ npm install clsx
```

## tailwind cssの有効化
下記を追加してください。
```App.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

```tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
```

## 3. コンポーネント構成
![スクリーンショット 2024-12-09 8.47.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935090/08ff3bed-d9aa-7abe-dcf7-0f1b425522e8.png)

赤 > 橙 > 黄の順で親子関係のコンポーネントを組んでます。

## 4. 実装
長いのでコードを折りたたんでます。
<details><summary>App.tsx</summary>

```App.tsx
import React, {Suspense, lazy} from 'react';
import Loading from './ui/Loading';
import './App.css';

const PokerOddsCalculator = lazy(() => import('./components/PokerOddsCalculator'));

const App: React.FC = () => {
  return (
    <Suspense fallback={<Loading />}>
      <PokerOddsCalculator />
    </Suspense>
  );
}

export default App;
```
</details>
<details><summary>components/PokerOddsCalculator.tsx</summary>

```components/PokerOddsCalculator.tsx
import React, { useState } from "react";
import { TexasHoldem } from "poker-odds-calc";
import clsx from "clsx";

import PlayersSelection from "./PlayerSelection";
import BoardSelection from "./BoardSelection";
import CardSelector from "../ui/CardSelector";
import CalculateButton from "./CalculateButton";
import ResetButton from "./ResetButton";

import { Card, IBoardActionInterface, ResultsInterface } from "../types";

const PokerOddsCalculator: React.FC = () => {
  const [players, setPlayers] = useState<Card[][]>([[], []]);
  const [board, setBoard] = useState<Card[]>([]);
  const [results, setResults] = useState<ResultsInterface | null>(null);
  const [currentStage, setCurrentStage] = useState<
    "player1" | "player2" | "board"
  >("player1");

  const playerNum = 2;
  const playerHasCardNum = 2;
  const preFlopCardNum = 0;
  const flopCardNum = 3;
  const turnCardNum = 4;
  const riverCardNum = 5;
  const maxCardNum = playerNum * playerHasCardNum + riverCardNum;

  const getAllSelectedCards = () => {
    return [...players.flat(), ...board];
  };

  const updateHand = (card: Card) => {
    switch (currentStage) {
      case "player1":
        updatePlayerHand(0, card);
        break;
      case "player2":
        updatePlayerHand(1, card);
        break;
      case "board":
        updateBoard(card);
        break;
    }
  };

  const updatePlayerHand = (playerIndex: number, card: Card) => {
    const currentPlayerCards = players[playerIndex];

    // カードの重複確認
    if (currentPlayerCards.includes(card)) {
      const newPlayerCards = currentPlayerCards.filter((c) => c !== card);
      const newPlayers = [...players];
      newPlayers[playerIndex] = newPlayerCards;
      setPlayers(newPlayers);
      return;
    }

    // カードを追加
    const newPlayerCards = [...currentPlayerCards, card];
    const newPlayers = [...players];
    newPlayers[playerIndex] = newPlayerCards;
    setPlayers(newPlayers);

    // カード追加対象の変更
    if (playerIndex === 0 && newPlayerCards.length === 2) {
      setCurrentStage("player2");
    } else if (playerIndex === 1 && newPlayerCards.length === 2) {
      setCurrentStage("board");
    }
  };

  const updateBoard = (card: Card) => {
    // カードの重複確認
    if (board.includes(card)) {
      alert("このカードは既にボードに存在します");
      return;
    }

    // boardは最大5枚まで
    if (board.length < riverCardNum) {
      const newBoard = [...board, card];
      setBoard(newBoard);
    } else {
      alert("ボードには最大5枚のカードしか置けません");
    }
  };

  const calculateOdds = () => {
    if (!players.every((player) => player.length === playerNum)) {
      alert("各プレイヤーは2枚のカードを選択する必要があります");
      return;
    }

    const table = new TexasHoldem();

    players.forEach((playerCards) => {
      table.addPlayer(playerCards as [string, string, string, string]);
    });

    if (board.length > 0) {
      table.boardAction((boardAction: IBoardActionInterface) => {
        switch (board.length) {
          case flopCardNum:
            boardAction.setFlop(board);
            break;
          case turnCardNum:
            boardAction.setFlop(board.slice(0, 3));
            boardAction.setTurn(board[3]);
            break;
          case riverCardNum:
            boardAction.setFlop(board.slice(0, 3));
            boardAction.setTurn(board[3]);
            boardAction.setRiver(board[4]);
            break;
        }
      });
    }

    const result = table.calculate();

    setResults({
      players: result.getPlayers().map((player, index) => ({
        hand: players[index],
        wins: Math.round(player.getWinsPercentage()).toString(),
      })),
      board: result.getBoard(),
    });
  };

  const resetCalculator = () => {
    setPlayers([[], []]);
    setBoard([]);
    setResults(null);
    setCurrentStage("player1");
  };

  const isDisabled = () => {
    if (!players.every((player) => player.length === playerNum)) {
      return true;
    } else if (board.length > preFlopCardNum && board.length < flopCardNum) {
      return true;
    } else {
      return false;
    }
  };

  return (
    <div className="poker-odds-calculator">
      <h2 className="text-4xl font-bold mb-8">Poker Odds Calculator</h2>
      <PlayersSelection players={players} results={results} />
      <BoardSelection board={board} />
      <CardSelector
        onCardSelect={updateHand}
        selectedCards={getAllSelectedCards()}
        maxCards={maxCardNum}
        excludedCards={getAllSelectedCards()}
      />
      <div className="actions flex justify-center gap-4 my-8 mx-auto">
        <CalculateButton
          onClick={calculateOdds}
          ariaDisabled={isDisabled()}
          className={clsx(
            "w-60 text-white py-2 px-4 border-none hover:outline-red-500",
            {
              "bg-slate-500": isDisabled(),
              "bg-red-500": !isDisabled(),
            }
          )}
        />
        <ResetButton
          onClick={resetCalculator}
          className="w-60 bg-black text-white border-none py-2 px-4"
        />
      </div>
    </div>
  );
};

export default PokerOddsCalculator;

```
</details>
<details><summary>components/PlayerSelection.tsx</summary>

```components/PlayerSelection.tsx
import React from "react";
import Player from "./Player";
import { Card, ResultsInterface } from "../types";

interface PlayersSelectionProps {
  players: Card[][];
  results: ResultsInterface | null;
}

const PlayersSelection: React.FC<PlayersSelectionProps> = ({ players, results }) => {
  return (
    <div className="players-selection text-left mx-auto text-xl">
      <div className="flex justify-between gap-4 mt-2">
        {players.map((playerCards, playerIndex) => (
          <Player key={playerIndex} playerCards={playerCards} playerIndex={playerIndex} results={results} />
        ))}
      </div>
    </div>
  );
};

export default PlayersSelection;
```
</details>
<details><summary>components/Player.tsx</summary>

```components/Player.tsx
import React from "react";
import { Card, Suit, ResultsInterface } from "../types";
import { suitSymbols } from "../utils/suitSymbols";

interface PlayerProps {
  playerCards: Card[];
  playerIndex: number;
  results: ResultsInterface | null;
}

const Player: React.FC<PlayerProps> = ({ playerCards, playerIndex, results }) => {
  return (
    <div className="player-hand w-[calc(50%_-_1rem)] h-16 flex justify-between items-center mt-2">
      <div className="flex justify-start items-center gap-2">
        <p className="w-28 font-bold">Player {playerIndex + 1}:&emsp;</p>
        {playerCards.map((card, cardIndex) => (
          <p key={cardIndex} className="w-10 text-center text-base break-words h-16 py-2 px-2 border rounded-lg border-black/10">
            {`${card[0]}${suitSymbols[card[1] as Suit]}`}
          </p>
        ))}
      </div>
      <div className="flex items-end w-auto">
        {results && <p className="text-3xl font-bold">{results.players[playerIndex].wins}</p>}
        <p className="text-right text-sm ml-1">%</p>
      </div>
    </div>
  );
};

export default Player;
```
</details>
<details><summary>components/BoardSelection.tsx</summary>

```components/BoardSelection.tsx
import React from "react";
import { Card, Suit } from "../types";
import { suitSymbols } from "../utils/suitSymbols";

interface BoardSelectionProps {
  board: Card[];
}

const BoardSelection: React.FC<BoardSelectionProps> = ({ board }) => {
  return (
    <div className="board-selection text-left mx-auto my-8 text-xl">
      <div className="player-hand h-16 flex items-center mt-2">
        <p className="w-28 font-bold">Board:&emsp;</p>
        <div className="flex justify-start items-center gap-2">
          {board.map((card, index) => (
            <p key={index} className="inline w-10 text-center text-base break-words h-16 py-2 px-2 border rounded-lg border-black/10">
              {`${card[0]}${suitSymbols[card[1] as Suit]}`}
            </p>
          ))}
        </div>
      </div>
    </div>
  );
};

export default BoardSelection;
```
</details>
<details><summary>components/CalculateButton.tsx</summary>

```components/CalculateButton.tsx
import React from 'react';
import Button from '../ui/Button';

interface CalculateButtonProps {
  onClick: () => void;
  ariaDisabled?:boolean;
  className?: string;
}

const CalculateButton: React.FC<CalculateButtonProps> = ({ onClick, ariaDisabled, className }) => {
  return (
    <Button
      onClick={onClick}
      ariaDisabled={ariaDisabled}
      className={className}
    >
      Calculate Odds
    </Button>
  );
};

export default CalculateButton;
```
</details>
<details><summary>components/ResetButton.tsx</summary>

```components/ResetButton.tsx
import React from 'react';
import Button from '../ui/Button';

interface ResetButtonProps {
  onClick: () => void;
  className?: string;
}

const ResetButton: React.FC<ResetButtonProps> = ({ onClick, className }) => {
  return (
    <Button
      onClick={onClick}
      className={className}
    >
      Reset
    </Button>
  );
};

export default ResetButton;
```
</details>
<details><summary>ui/Button.tsx</summary>

```ui/Button.tsx
import React from 'react';

interface ButtonProps {
  onClick: () => void;
  ariaDisabled?: boolean;
  className?: string;
  children: React.ReactNode;
}

const Button: React.FC<ButtonProps> = ({ onClick, ariaDisabled, className, children }) => {
  return (
    <button
      onClick={onClick}
      aria-disabled={ariaDisabled}
      className={className}
    >
      {children}
    </button>
  );
};

export default Button;
```
</details>
<details><summary>ui/CardSelector.tsx</summary>

```ui/CardSelector.tsx
import React from "react";
import { Card, Suit, Rank } from "../types";
import { suitSymbols } from "../utils/suitSymbols";
import clsx from "clsx";

interface CardSelectorProps {
  onCardSelect: (card: Card) => void;
  selectedCards: Card[];
  maxCards?: number;
  excludedCards?: Card[];
}

const CardSelector: React.FC<CardSelectorProps> = ({
  onCardSelect,
  selectedCards,
  maxCards = 9,
  excludedCards = [],
}) => {
  const suits: Suit[] = ["s", "h", "c", "d"];
  const ranks: Rank[] = [
    "A",
    "K",
    "Q",
    "J",
    "T",
    "9",
    "8",
    "7",
    "6",
    "5",
    "4",
    "3",
    "2",
  ];

  const isCardSelected = (card: Card) => selectedCards.includes(card);
  const isCardExcluded = (card: Card) => excludedCards.includes(card);
  const isSelectionFull = selectedCards.length >= maxCards;

  return (
    <div className="card-selector">
      {suits.map((suit) => (
        <div key={suit} className="suit-column flex gap-2 mb-2">
          {ranks.map((rank) => {
            const card: Card = `${rank}${suit}`;
            const isSelected = isCardSelected(card);
            const isExcluded = isCardExcluded(card);

            return (
              <button
                key={card}
                onClick={() => {
                  onCardSelect(card);
                }}
                aria-Disabled={
                  (isSelectionFull && !isCardSelected(card)) ||
                  isCardExcluded(card)
                }
                className={clsx(
                  "card-button w-10 text-center break-words h-16 py-2 px-2 border-black/10 hover:border-black/40",
                  {
                    "font-bold": isSelected,
                    "opacity-50 cursor-not-allowed": isExcluded,
                  }
                )}
              >
                {rank}
                {suitSymbols[suit]}
              </button>
            );
          })}
        </div>
      ))}
    </div>
  );
};

export default CardSelector;
```
</details>
<details><summary>ui/Loading.tsx</summary>

```ui/Loading.tsx
import React from 'react';

const Loading: React.FC = () => {
  return (
    <div className="loading-spinner">
      Loading...
    </div>
  );
};

export default Loading;
```
</details>
<details><summary>utils/suitSymbols.tsx</summary>

```utils/suitSymbols.tsx
import { Suit } from '../types';

export const suitSymbols: { [key in Suit]: string } = {
  s: '♠️',
  h: '❤️',
  c: '♣️',
  d: '♦️'
};
```
</details>
<details><summary>types/index.ts</summary>

```types/index.ts
export type Suit = 'h' | 'd' | 'c' | 's';
export type Rank = '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'T' | 'J' | 'Q' | 'K' | 'A';
export type Card = `${Rank}${Suit}`;

export interface IBoardActionInterface {
  setFlop: (cards: Card[]) => void;
  setTurn: (card: Card) => void;
  setRiver: (card: Card) => void;
}

export interface PlayerResultInterface {
  hand: Card[];
  wins: string;
}

export interface ResultsInterface {
  players: PlayerResultInterface[];
  board: string;
}
```
</details>

## おわりに
今回のポーカーの確率計算アプリの実装を通じてReactの基本的な使い方を学ぶことができ、非常に楽しみながら開発を進めることができました。

今後は人数の増加や指定カードの削除機能を拡張することで、より高度なポーカーのシミュレーションや、他のポーカーゲーム（例：OmahaやShortdeck）のサポートを追加してみるのも面白いかもしれません。