2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

bravesoftAdvent Calendar 2024

Day 13

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

Last updated at Posted at 2024-12-12

はじめに

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

この記事でわかること

  • コンポーネントの分け方
  • UIコンポーネントの作り方

完成イメージ

スクリーンショット 2024-12-09 8.47.30.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公式

$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p

poker-odds-calc

NLH以外にもShortdeckやOmahaも計算できるモジュールです。
poker-odds-calc

$ npm install poker-odds-calc

clsx

classNameを動的に変える際に可読性高くコードを書くことができるモジュール。
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

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

4. 実装

長いのでコードを折りたたんでます。

App.tsx
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;
components/PokerOddsCalculator.tsx
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;

components/PlayerSelection.tsx
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;
components/Player.tsx
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;
components/BoardSelection.tsx
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;
components/CalculateButton.tsx
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;
components/ResetButton.tsx
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;
ui/Button.tsx
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;
ui/CardSelector.tsx
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;
ui/Loading.tsx
ui/Loading.tsx
import React from 'react';

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

export default Loading;
utils/suitSymbols.tsx
utils/suitSymbols.tsx
import { Suit } from '../types';

export const suitSymbols: { [key in Suit]: string } = {
  s: '♠️',
  h: '❤️',
  c: '♣️',
  d: '♦️'
};
types/index.ts
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;
}

おわりに

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

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?