はじめに
Advent Calendar参加ということで、趣味のPokerに触れつつ自身のReact学習も兼ねてPokerの確率計算アプリを作る。
ポーカー初心者向けのツールが、簡単に作れそうなモジュールを発見したので使ってみる。
この記事でわかること
- コンポーネントの分け方
- UIコンポーネントの作り方
完成イメージ
機能
- ハンド選択
- 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. プロジェクト作成
$ npm create vite@latest odds-calculator -- --template react-ts
$ cd odds-calculator
2. モジュールのインストール
Tailwind CSS with Vite
$ 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の有効化
下記を追加してください。
@tailwind base;
@tailwind components;
@tailwind utilities;
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
3. コンポーネント構成
赤 > 橙 > 黄の順で親子関係のコンポーネントを組んでます。
4. 実装
長いのでコードを折りたたんでます。
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
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
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
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}: </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
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: </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
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
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
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
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
import React from 'react';
const Loading: React.FC = () => {
return (
<div className="loading-spinner">
Loading...
</div>
);
};
export default Loading;
utils/suitSymbols.tsx
import { Suit } from '../types';
export const suitSymbols: { [key in Suit]: string } = {
s: '♠️',
h: '❤️',
c: '♣️',
d: '♦️'
};
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)のサポートを追加してみるのも面白いかもしれません。