現在開発中のコードクエストというゲーム内のカジノ機能に、オンライ対戦できるテキサスホールデムポーカーをChatGPTと格闘しながら実装しました!
一応動くのができましたが、まだ完璧ではありません。
エラーや実際のポーカーと違う挙動があればぜひご報告おねがいします。
以下ソースコード(htmlとnode.jsです。)
texasholdem.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Texas Hold'em Demo</title>
<style>
body {
background: #006400;
color: #fff;
font-family: sans-serif;
padding: 20px;
}
.container {
max-width: 800px;
margin: auto;
background: #228B22;
padding: 20px;
border-radius: 10px;
}
input,
button {
margin: 5px;
padding: 5px 10px;
border: none;
border-radius: 4px;
}
button {
cursor: pointer;
}
.player {
margin-bottom: 5px;
padding: 5px;
}
.current-turn {
background-color: yellow;
color: black;
font-weight: bold;
}
.card {
width: 100px;
margin: 2px;
}
</style>
<script>
function getCardImageHtml(card) {
if (card.rank === "??" && card.suit === "??") {
return `<img src="playingcard/back.svg" alt="back" class="card">`;
} else {
let suitLetter = "";
switch (card.suit) {
case "♣": suitLetter = "C"; break;
case "♦": suitLetter = "D"; break;
case "♥": suitLetter = "H"; break;
case "♠": suitLetter = "S"; break;
}
return `<img src="playingcard/${card.rank}${suitLetter}.svg"
alt="${card.rank}${suitLetter}" class="card">`;
}
}
function renderHand(hand) {
return hand.map(card => getCardImageHtml(card)).join("");
}
</script>
</head>
<body>
<div class="container">
<h1>Texas Hold'em Online</h1>
<div>
<input type="text" id="playerName" placeholder="名前">
<input type="number" id="roomId" placeholder="テーブル番号 (1~10)" min="1" max="10">
<button id="joinBtn">参加</button>
<button id="startBtn">ゲーム開始</button>
</div>
<hr>
<div>
<h3>フェーズ: <span id="phase"></span></h3>
<h3>ポット: <span id="pot"></span></h3>
<h3>テーブル最高ベット: <span id="currentBet"></span></h3>
<h3>現在アクション中: <span id="currentPlayerId"></span></h3>
</div>
<div id="communityCards"></div>
<div id="players"></div>
<div>
<h3>アクション</h3>
<button id="foldBtn" onclick="sendAction('fold')">フォールド</button>
<button id="callBtn" onclick="sendAction('call')">コール</button>
<button id="checkBtn" onclick="sendAction('check')" style="display:none;">チェック</button>
<input type="number" id="raiseAmount" placeholder="追加レイズ額 (例:10)" style="width:80px;">
<button id="raiseBtn" onclick="sendAction('raise')">レイズ</button>
</div>
<div id="messages" style="margin-top:10px; color:yellow;"></div>
<div id="result" style="margin-top:10px;"></div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
let myId = null;
socket.on('connect', () => {
myId = socket.id;
});
const joinBtn = document.getElementById('joinBtn');
const startBtn = document.getElementById('startBtn');
const foldBtn = document.getElementById('foldBtn');
const callBtn = document.getElementById('callBtn');
const checkBtn = document.getElementById('checkBtn');
const raiseBtn = document.getElementById('raiseBtn');
const playerNameInput = document.getElementById('playerName');
const roomIdInput = document.getElementById('roomId');
const phaseSpan = document.getElementById('phase');
const potSpan = document.getElementById('pot');
const currentBetSpan = document.getElementById('currentBet');
const currentPlayerSpan = document.getElementById('currentPlayerId');
const communityDiv = document.getElementById('communityCards');
const playersDiv = document.getElementById('players');
const messagesDiv = document.getElementById('messages');
const resultDiv = document.getElementById('result');
joinBtn.onclick = () => {
const name = playerNameInput.value.trim();
const room = roomIdInput.value.trim();
if (name && room) {
socket.emit("joinGame", { playerName: name, roomId: room });
}
};
startBtn.onclick = () => {
socket.emit("startGame");
};
function sendAction(actionType) {
const raiseAmount = parseInt(document.getElementById('raiseAmount').value, 10) || 0;
socket.emit("playerAction", { action: actionType, raiseAmount });
}
socket.on("gameUpdate", (data) => {
messagesDiv.innerHTML = "";
// ※ ショウダウンやWAITINGに変わっても、ここではresultをクリアしない
phaseSpan.textContent = data.phase;
potSpan.textContent = data.pot;
currentBetSpan.textContent = data.currentBet;
currentPlayerSpan.textContent = data.currentPlayerId || "";
// フェーズがWAITINGならアクション不可
if (data.phase === "WAITING") {
foldBtn.disabled = true;
callBtn.disabled = true;
checkBtn.disabled = true;
raiseBtn.disabled = true;
} else {
const isMyTurn = (data.currentPlayerId === myId);
foldBtn.disabled = !isMyTurn;
raiseBtn.disabled = !isMyTurn;
// call / check切り替え
const me = data.players.find(p => p.id === myId);
if (me) {
if (me.bet === data.currentBet) {
checkBtn.style.display = "inline-block";
callBtn.style.display = "none";
checkBtn.disabled = !isMyTurn;
} else {
checkBtn.style.display = "none";
callBtn.style.display = "inline-block";
callBtn.disabled = !isMyTurn;
}
}
}
// コミュニティカード
communityDiv.innerHTML = `<h3>コミュニティカード:</h3> ${renderHand(data.communityCards)}`;
// プレイヤー一覧
playersDiv.innerHTML = `<h3>プレイヤー:</h3>`;
data.players.forEach(p => {
const isCurrent = (p.id === data.currentPlayerId);
// p.rank には "Pair, Tens" などが格納されている(自分 or SHOWDOWN時)
// ただし、相手はショウダウン前には "N/A" or 空文字のはず
playersDiv.innerHTML += `
<div class="player ${isCurrent ? 'current-turn' : ''}">
<strong>Name:</strong> ${p.positionLabel} ${p.name} |
<strong>Chips:</strong> ${p.chips} |
<strong>Bet:</strong> ${p.bet} |
<strong>Fold:</strong> ${p.hasFolded} |
<strong>Hand:</strong> ${renderHand(p.hand)}
<strong>Rank:</strong> ${p.rank || ""}
</div>
`;
});
});
socket.on("gameMessage", (data) => {
messagesDiv.innerHTML = data.message;
});
socket.on("gameResult", (data) => {
// 結果を表示(複数行になる場合もあるので改行に注意)
resultDiv.innerHTML = `<h3 style="white-space:pre-line;">${data.message}</h3>`;
});
socket.on("winNotification", (data) => {
// 勝者には "Win" 表示を上書き or 追加
// 必要があれば、既存の表示に追記しても良い
resultDiv.innerHTML += `<h3>${data.message}</h3>`;
});
</script>
</body>
</html>
server.js
const express = require("express");
const http = require("http");
const socketIo = require("socket.io");
const { Hand } = require("pokersolver"); // 手役判定ライブラリ
// 定数
const SMALL_BLIND = 10;
const BIG_BLIND = 20;
const MAX_TABLES = 10;
const MAX_PLAYERS_PER_TABLE = 6;
const PHASES = {
WAITING: "WAITING",
PRE_FLOP: "PRE_FLOP",
FLOP: "FLOP",
TURN: "TURN",
RIVER: "RIVER",
SHOWDOWN: "SHOWDOWN",
};
//
// TexasHoldemGame クラス(オールイン・サイドポット対応版)
//
class TexasHoldemGame {
constructor() {
this.players = []; // { id, name, chips, bet, totalBet, hasFolded, isAllIn, hand: [], hasActed: false }
this.deck = [];
this.communityCards = [];
this.pot = 0;
this.currentBet = 0;
this.currentPlayerIndex = 0;
this.phase = PHASES.WAITING;
this.dealerButtonIndex = 0;
this.actionStartIndex = 0;
// ヘッズアップ用にSBとBBのインデックスを記録
this.smallBlindIndex = null;
this.bigBlindIndex = null;
this.waitingPlayers = [];
}
// [追加] ゲーム終了時に呼び出して、待機中プレイヤーを本参加に編入する
incorporateWaitingPlayers() {
console.log(
"[DEBUG incorporateWaitingPlayers] start. waitingPlayers:",
this.waitingPlayers.map((p) => ({
id: p.id,
name: p.name,
chips: p.chips,
disconnected: p.disconnected,
}))
);
// 0チップ or disconnected な人は合流させない
const validWaiters = this.waitingPlayers.filter(
(p) => p.chips > 0 && !p.disconnected
);
console.log(
`[DEBUG incorporateWaitingPlayers] from ${this.waitingPlayers.length} to ${validWaiters.length} valid players.`
);
// 合流
this.players.push(...validWaiters);
// waitingPlayers クリア
this.waitingPlayers = [];
console.log(
"[DEBUG incorporateWaitingPlayers] end. players:",
this.players.map((p) => ({
id: p.id,
name: p.name,
chips: p.chips,
disconnected: p.disconnected,
}))
);
}
// デッキ初期化・シャッフル
initializeDeck() {
const suits = ["♠", "♥", "♦", "♣"];
const ranks = [
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"J",
"Q",
"K",
"A",
];
this.deck = [];
for (let suit of suits) {
for (let rank of ranks) {
this.deck.push({ suit, rank });
}
}
for (let i = this.deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.deck[i], this.deck[j]] = [this.deck[j], this.deck[i]];
}
}
// プレイヤー追加
addPlayer({ id, name, chips = 1000 }) {
// もし現在フェーズが WAITING (次ゲーム待ち) ならすぐ参加可能
if (this.phase === PHASES.WAITING) {
this.players.push({
id,
name,
chips,
bet: 0,
totalBet: 0,
hasFolded: false,
isAllIn: false,
hand: [],
hasActed: false,
disconnected: false,
});
} else {
// それ以外のフェーズ(ゲーム進行中)なら次のゲームまで待機
this.waitingPlayers.push({
id,
name,
chips,
bet: 0,
totalBet: 0,
hasFolded: false,
isAllIn: false,
hand: [],
hasActed: false,
});
}
}
removePlayer(socketId) {
this.players = this.players.filter((p) => p.id !== socketId);
}
startRound() {
// すでに動いているテーブルでもディーラーボタンを進める(複数人時)など
if (this.phase !== PHASES.WAITING && this.players.length > 1) {
this.dealerButtonIndex =
(this.dealerButtonIndex + 1) % this.players.length;
}
this.phase = PHASES.PRE_FLOP;
this.initializeDeck();
this.communityCards = [];
this.pot = 0;
this.currentBet = 0;
for (let p of this.players) {
p.bet = 0;
p.totalBet = 0;
p.hasFolded = false;
p.isAllIn = false;
p.hand = [];
p.hasActed = false;
}
// 2枚配る
for (let p of this.players) {
p.hand.push(this.deck.pop(), this.deck.pop());
}
if (this.players.length >= 2) {
if (this.players.length === 2) {
// ヘッズアップ
// dealerButtonIndex が SB(ボタン)を担当
this.smallBlindIndex = this.dealerButtonIndex;
this.bigBlindIndex = (this.dealerButtonIndex + 1) % this.players.length;
// SBとBBのブラインドを支払う
this._postBet(this.players[this.smallBlindIndex], SMALL_BLIND);
this._postBet(this.players[this.bigBlindIndex], BIG_BLIND);
this.currentBet = BIG_BLIND;
// プリフロップはSBが先にアクション
this.currentPlayerIndex = this.smallBlindIndex;
this.actionStartIndex = this.smallBlindIndex;
} else {
// 3人以上
const sbIndex = (this.dealerButtonIndex + 1) % this.players.length;
const bbIndex = (this.dealerButtonIndex + 2) % this.players.length;
this._postBet(this.players[sbIndex], SMALL_BLIND);
this._postBet(this.players[bbIndex], BIG_BLIND);
this.currentBet = BIG_BLIND;
// 3人以上はBBの次(ディーラーから見て3つ先)が最初にアクション
this.currentPlayerIndex =
(this.dealerButtonIndex + 3) % this.players.length;
this.actionStartIndex = this.currentPlayerIndex;
// 必要に応じて smallBlindIndex, bigBlindIndex を記録
this.smallBlindIndex = sbIndex;
this.bigBlindIndex = bbIndex;
}
}
}
// プレイヤーが金額をベットする(オールイン含む)
_postBet(player, amount) {
let toPost = amount;
if (amount > player.chips) {
toPost = player.chips;
player.isAllIn = true;
player.hasActed = true;
}
player.chips -= toPost;
player.bet += toPost;
player.totalBet += toPost;
this.pot += toPost;
}
handleAction(playerId, action, raiseAmount = 0) {
console.log("====== handleAction START ======");
console.log(
`[DEBUG] Called by playerId=${playerId}, action=${action}, raiseAmount=${raiseAmount}`
);
console.log(
`[DEBUG] currentPlayerIndex=${this.currentPlayerIndex}, currentBet=${this.currentBet}`
);
// プレイヤー一覧をデバッグ出力
this.players.forEach((p, i) => {
console.log(
`[DEBUG] Player idx=${i}, id=${p.id}, name=${p.name}, ` +
`folded=${p.hasFolded}, allIn=${p.isAllIn}, hasActed=${p.hasActed}, ` +
`bet=${p.bet}, chips=${p.chips}`
);
});
// ---------- [1] ターンチェック ----------
const playerIndex = this.players.findIndex((p) => p.id === playerId);
if (playerIndex !== this.currentPlayerIndex) {
console.log("[DEBUG] -> Not the correct turn for this player.");
console.log("====== handleAction END (error) ======");
return { error: "現在のアクションはあなたの番ではありません" };
}
const player = this.players[playerIndex];
if (player.hasFolded || player.isAllIn) {
console.log("[DEBUG] -> Player is already folded or all-in.");
console.log("====== handleAction END (error) ======");
return { error: "すでにフォールドまたはオールインしています" };
}
// ---------- [2] アクション別処理 ----------
switch (action) {
case "fold": {
if (player.bet === this.currentBet) {
console.log(
"[DEBUG] -> Attempted to fold when check is possible. Disallowed by current logic."
);
console.log("====== handleAction END (error) ======");
return { error: "チェック可能な状況でフォールドはできません" };
}
player.hasFolded = true;
player.hasActed = true;
console.log(`[DEBUG] -> Player ${player.id} folded.`);
break;
}
case "check": {
// currentBetとの差があればチェック不可
if (player.bet !== this.currentBet) {
console.log("[DEBUG] -> Attempted to check, but bet mismatch.");
console.log("====== handleAction END (error) ======");
return {
error: "チェックは現在のベット額と一致している場合のみ可能です",
};
}
player.hasActed = true;
console.log(`[DEBUG] -> Player ${player.id} checked.`);
break;
}
case "call": {
// callに必要な額
const diff = this.currentBet - player.bet;
console.log(
`[DEBUG] -> call diff=${diff}, playerChips=${player.chips}`
);
if (diff > 0) {
if (diff >= player.chips) {
// 追いつくのに自分のチップ全部必要(=オールイン)
console.log("[DEBUG] -> Player goes all-in (call).");
this._postBet(player, player.chips);
player.isAllIn = true;
} else {
this._postBet(player, diff);
console.log(`[DEBUG] -> Player calls. Bet +${diff}.`);
}
} else {
console.log(
"[DEBUG] -> diff <= 0 => Already matched or behind. No additional bet needed."
);
}
player.hasActed = true;
break;
}
case "raise": {
// ここでは raiseAmount を「currentBet にいくら上乗せするか」と解釈
const additional = raiseAmount;
if (additional <= 0) {
console.log("[DEBUG] -> Invalid raiseAmount (<=0).");
console.log("====== handleAction END (error) ======");
return { error: "レイズ額は正の数を入力してください" };
}
const finalBet = this.currentBet + additional;
console.log(
`[DEBUG] -> finalBet=${finalBet}, currentBet(before)=${this.currentBet}, player.bet=${player.bet}`
);
if (finalBet <= this.currentBet) {
console.log("[DEBUG] -> finalBet <= currentBet => invalid raise.");
console.log("====== handleAction END (error) ======");
return {
error: "レイズ額は現在のベットより大きくなければなりません",
};
}
const diff = finalBet - player.bet;
if (diff <= 0) {
console.log("[DEBUG] -> diff <= 0 => already bet enough?");
console.log("====== handleAction END (error) ======");
return { error: "既にその額以上をベットしています。" };
}
if (diff >= player.chips) {
console.log("[DEBUG] -> Raise exceeds player's chips => all-in.");
this._postBet(player, player.chips);
player.isAllIn = true;
} else {
this._postBet(player, diff);
console.log(`[DEBUG] -> Player raised. Bet +${diff}.`);
}
// テーブルのcurrentBetを更新
console.log(
`[DEBUG] -> Updating currentBet from ${this.currentBet} to ${player.bet}`
);
this.currentBet = player.bet;
player.hasActed = true;
// レイズしたので他アクティブプレイヤーのhasActedをfalseに戻す
this.players.forEach((p, i) => {
if (!p.hasFolded && !p.isAllIn && i !== playerIndex) {
p.hasActed = false;
console.log(
`[DEBUG] -> Reset hasActed=false for idx=${i}, id=${p.id}`
);
}
});
// レイズしたプレイヤーをactionStartIndexに
this.actionStartIndex = playerIndex;
break;
}
default: {
console.log("[DEBUG] -> Unknown action.");
console.log("====== handleAction END (error) ======");
return { error: "不明なアクションです" };
}
}
// ---------- [3] アクション処理後の状態をデバッグ出力 ----------
console.log("[DEBUG] After action processing:");
console.log(
`[DEBUG] currentBet=${this.currentBet}, currentPlayerIndex=${this.currentPlayerIndex}`
);
this.players.forEach((p, i) => {
console.log(
`[DEBUG] idx=${i}, id=${p.id}, name=${p.name}, ` +
`folded=${p.hasFolded}, allIn=${p.isAllIn}, hasActed=${p.hasActed}, ` +
`bet=${p.bet}, chips=${p.chips}`
);
});
// ---------- [4] フォールドによる1人残りチェック ----------
const activePlayers = this.players.filter((p) => !p.hasFolded);
if (activePlayers.length === 1) {
console.log(
"[DEBUG] => Only one active player left => immediate termination (fold)."
);
console.log("====== handleAction END (termination) ======");
return { termination: true };
}
// ---------- [5] 次のプレイヤーへ ----------
console.log("[DEBUG] -> Calling nextPlayer()...");
const oldIndex = this.currentPlayerIndex;
this.nextPlayer(); // 内部で isAllIn や hasFolded のプレイヤーをスキップする実装が必要
console.log(`[DEBUG] -> nextPlayer() done.
oldIndex=${oldIndex}, newIndex=${this.currentPlayerIndex}`);
// ---------- [6] ベッティングラウンド終了判定 ----------
// - isBettingRoundComplete() は内部で「全員オールイン or 全員hasActed」をチェック
// - ここでtrueならthis.advancePhase() → フェーズ移行
const roundDone = this.isBettingRoundComplete();
console.log(`[DEBUG] isBettingRoundComplete() => ${roundDone}`);
if (roundDone || this.currentPlayerIndex === this.actionStartIndex) {
// 「1周したら終了」判定を残す場合は this.currentPlayerIndex === this.actionStartIndex も考慮
console.log("[DEBUG] -> Betting round ended => advancePhase().");
this.advancePhase();
}
console.log("====== handleAction END (success) ======");
return { success: true };
}
isBettingRoundComplete() {
// フォールドしていないプレイヤー
const activePlayers = this.players.filter((p) => !p.hasFolded);
// デバッグログ:呼び出しの都度、状況を確認
console.log(
"[DEBUG isBettingRoundComplete] activePlayers.length=",
activePlayers.length
);
// 1人以下なら既に終わり(フォールド総取りなど)
if (activePlayers.length <= 1) {
console.log(
"[DEBUG isBettingRoundComplete] => true (only one player remains)"
);
return true;
}
// 全員オールインなら残りのカードをすべて配ってショウダウンへ
const allAllIn = activePlayers.every((p) => p.isAllIn);
console.log("[DEBUG isBettingRoundComplete] allAllIn =", allAllIn);
if (allAllIn) {
console.log(
"[DEBUG isBettingRoundComplete] => All players all-in, deal remaining cards and go SHOWDOWN."
);
// フロップ→ターン→リバーを自動的にdealしてSHOWDOWNへ
while (this.phase !== PHASES.SHOWDOWN) {
this.advancePhase();
}
// ここでショウダウン処理(サイドポット計算など)が走る想定
return true;
}
// ここに来た時点で「複数のアクティブプレイヤーがいて、全員オールインではない」状態
// => 全員の hasActed が終わったか(またはフォールドorオールイン)をチェック
const everyoneActed = activePlayers.every((p) => p.hasActed || p.isAllIn);
console.log("[DEBUG isBettingRoundComplete] =>", everyoneActed);
return everyoneActed;
}
// 次のプレイヤー選出
nextPlayer() {
const total = this.players.length;
let count = 0;
while (count < total) {
this.currentPlayerIndex = (this.currentPlayerIndex + 1) % total;
const curr = this.players[this.currentPlayerIndex];
// --- [1] もしこのプレイヤーがまだアクション必要な状態 && 切断済みなら自動アクション ---
if (
curr.disconnected &&
!curr.hasFolded &&
!curr.isAllIn &&
!curr.hasActed
) {
// チップが足りていれば(=すでにcurrentBetに達していれば)チェック扱い、
// そうでなければフォールドにする
if (curr.bet < this.currentBet) {
// コール or レイズする必要があるが、切断中なのでフォールド
curr.hasFolded = true;
curr.hasActed = true;
} else {
// すでに currentBet に追いついてる => チェック扱い
curr.hasActed = true;
}
}
// --- [2] このプレイヤーが「まだ行動していない、フォールドしていない、オールインでもない」なら手番終了 ---
if (!curr.hasFolded && !curr.isAllIn && !curr.hasActed) {
return;
}
count++;
}
// 全員がアクション完了 or フォールド状態なら、ベットラウンド終了
}
// フェーズ進行
advancePhase() {
console.log(`[DEBUG] advancePhase: currentPhase=${this.phase}`);
switch (this.phase) {
case PHASES.PRE_FLOP:
this.phase = PHASES.FLOP;
this.dealFlop();
console.log("[DEBUG] -> Moved to FLOP. Dealt flop cards.");
this.resetBetsForNextRound(); // ★フロップに移るタイミングでリセット
break;
case PHASES.FLOP:
this.phase = PHASES.TURN;
this.dealTurn();
console.log("[DEBUG] -> Moved to TURN. Dealt turn card.");
this.resetBetsForNextRound(); // ★ターンに移るタイミングでリセット
break;
case PHASES.TURN:
this.phase = PHASES.RIVER;
this.dealRiver();
console.log("[DEBUG] -> Moved to RIVER. Dealt river card.");
this.resetBetsForNextRound(); // ★リバーに移るタイミングでリセット
break;
case PHASES.RIVER:
this.phase = PHASES.SHOWDOWN;
console.log("[DEBUG] -> Moved to SHOWDOWN.");
// ここではSHOWDOWNに入るので、resetBetsForNextRound()は不要
break;
case PHASES.SHOWDOWN:
console.log("[DEBUG] -> Already at SHOWDOWN. No further advance.");
break;
default:
console.log("[DEBUG] -> Unknown phase. Doing nothing.");
break;
}
console.log(`[DEBUG] advancePhase end: currentPhase=${this.phase}`);
}
resetBetsForNextRound() {
this.currentBet = 0;
for (let p of this.players) {
if (!p.hasFolded && !p.isAllIn) {
p.bet = 0;
p.hasActed = false;
}
}
// 今までは「前のベッティングラウンド終了時の currentPlayerIndex」を開始位置にしていたが
// フロップ以降は「ボタンの左隣」(ヘッズアップはBB)が先にアクションするよう修正
if (this.phase !== PHASES.PRE_FLOP) {
// フロップ,ターン,リバーの場合
if (this.players.length === 2) {
// ヘッズアップはBBが先
this.currentPlayerIndex = this.bigBlindIndex;
} else {
// 3人以上はボタンの左隣= (dealerButtonIndex + 1) からスタート
let nextIndex = (this.dealerButtonIndex + 1) % this.players.length;
// SBがフォールドしていたり、オールインならさらに左隣を探す
while (
this.players[nextIndex].hasFolded ||
this.players[nextIndex].isAllIn
) {
nextIndex = (nextIndex + 1) % this.players.length;
}
this.currentPlayerIndex = nextIndex;
}
}
// プリフロップ時(phase===PRE_FLOP)は既存のロジックどおりでOK
this.actionStartIndex = this.currentPlayerIndex;
}
dealFlop() {
console.log("[DEBUG] dealFlop: dealing three cards...");
this.deck.pop(); // バーン
this.communityCards.push(this.deck.pop(), this.deck.pop(), this.deck.pop());
}
dealTurn() {
console.log("[DEBUG] dealTurn: dealing one card...");
this.deck.pop(); // バーン
this.communityCards.push(this.deck.pop());
}
dealRiver() {
console.log("[DEBUG] dealRiver: dealing one card...");
this.deck.pop(); // バーン
this.communityCards.push(this.deck.pop());
}
// -------- ① サイドポットを算出する --------
computeSidePots() {
// フォールドしていないプレイヤーを抽出
const contenders = this.players.filter((p) => !p.hasFolded);
// totalBet が小さい順にソート
const sorted = [...contenders].sort((a, b) => a.totalBet - b.totalBet);
let sidePots = [];
let previous = 0; // 直前の totalBet
let remaining = contenders.length; // 残りのプレイヤー数
for (let i = 0; i < sorted.length; i++) {
const current = sorted[i].totalBet;
const diff = current - previous;
if (diff > 0) {
// diff * remaining が、このポットの総チップ量になる
sidePots.push({
amount: diff * remaining,
// このポットに参加できるのは totalBet >= current の人
eligiblePlayers: contenders.filter((p) => p.totalBet >= current),
});
previous = current;
}
remaining--;
}
return sidePots;
}
// pokersolverからHandをimportしている想定
// const { Hand } = require('pokersolver');
determineSidePotWinners() {
const sidePots = this.computeSidePots();
let results = [];
for (const pot of sidePots) {
const eligible = pot.eligiblePlayers;
const playerHands = eligible.map((player) => {
const solverCards = [...player.hand, ...this.communityCards].map(
convertToPokerSolverFormat
);
const solvedHand = Hand.solve(solverCards);
return { player, solvedHand };
});
const winnersHands = Hand.winners(playerHands.map((ph) => ph.solvedHand));
// winnersHandsは複数の要素が含まれる場合があります(完全に同じ強さでタイのとき)
// winnersHands に対応するプレイヤーを取得
const winningPlayers = winnersHands
.map((wHand) => {
return (
playerHands.find((ph) => ph.solvedHand === wHand)?.player || null
);
})
.filter(Boolean);
// ---- サイドポットを勝者で分配 ----
const share = Math.floor(pot.amount / winningPlayers.length);
let leftover = pot.amount % winningPlayers.length;
winningPlayers.forEach((w) => {
w.chips += share;
});
// leftover配分時に、"ボタン+1" から順にプレイヤーを回す例
if (leftover > 0) {
// 現在のプレイヤーリストを「ボタンからスタートして左回り」の並びに並べ替え
const reorder = [];
for (let i = 0; i < this.players.length; i++) {
reorder.push(
this.players[(this.dealerButtonIndex + 1 + i) % this.players.length]
);
}
// reorderの順序に従って、winners だけ取り出す
const sortedWinners = reorder.filter((p) => winningPlayers.includes(p));
// これで button+1 の座席から順に leftover を1チップずつ加算
let i = 0;
while (leftover > 0 && sortedWinners.length > 0) {
sortedWinners[i].chips += 1;
leftover--;
i = (i + 1) % sortedWinners.length;
}
}
// ---- 勝者の手役(descr)を表示したい場合 ----
// 同じポットを勝った複数のプレイヤーがいる場合、それぞれ手役をまとめる
const winnersInfo = winningPlayers.map((wp) => {
// 自分がどの solvedHand だったか探す
const ph = playerHands.find((ph) => ph.player === wp);
// ph.solvedHand.name => 例 "Full House"
// ph.solvedHand.descr => 例 "Full House, Aces full of Kings"
return `${wp.name} (${ph.solvedHand.descr})`;
});
results.push({
potAmount: pot.amount,
winners: winningPlayers,
winnersInfo, // 追加: 勝者たちの役情報も保持
});
}
return results;
}
}
// pokersolver用のカードフォーマット変換
function convertToPokerSolverFormat(card) {
const rankMap = {
2: "2",
3: "3",
4: "4",
5: "5",
6: "6",
7: "7",
8: "8",
9: "9",
10: "T",
J: "J",
Q: "Q",
K: "K",
A: "A",
};
const suitMap = { "♠": "s", "♥": "h", "♦": "d", "♣": "c" };
return rankMap[card.rank] + suitMap[card.suit];
}
//
// ルーム(テーブル)管理
//
const rooms = {};
for (let i = 1; i <= MAX_TABLES; i++) {
rooms[i] = new TexasHoldemGame();
}
const playerRoomMap = {};
//
// サーバーセットアップ
//
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
app.use(express.static("public"));
// ゲーム状態を各ソケットごとに送信
async function broadcastGameState(roomId) {
const sockets = await io.in(String(roomId)).fetchSockets();
sockets.forEach((socket) => {
socket.emit("gameUpdate", getPublicGameState(roomId, socket.id));
});
}
function getPublicGameState(roomId, viewerId) {
const room = rooms[roomId];
return {
phase: room.phase,
pot: room.pot,
currentBet: room.currentBet,
currentPlayerId: room.players[room.currentPlayerIndex]?.id || null,
communityCards: room.communityCards,
players: room.players.map((p, idx) => {
// SHOWDOWNかつフォールドしていなければ全員のカード公開
// それ以外のフェーズでは、自分のカードだけ公開
const showCards =
p.id === viewerId || (room.phase === PHASES.SHOWDOWN && !p.hasFolded);
// 手札は公開条件を満たしていればそのまま表示、そうでなければ裏表示にする
const displayedHand = showCards
? p.hand
: p.hand.map(() => ({ rank: "??", suit: "??" }));
// --- ここで現在の役を計算するロジックを追加 ---
// (1) デフォルトでは空文字(または null)
let currentRank = "";
// (2) まだフォールドしていない & フロップ以降 & 自分だけが見られる場合
// という条件を満たせば、pokersolverで手役を計算
if (
!p.hasFolded &&
showCards &&
room.phase !== PHASES.PRE_FLOP &&
room.communityCards.length >= 3
) {
// 現在公開されているコミュニティカードを合算
const combined = [...p.hand, ...room.communityCards];
const solved = Hand.solve(combined.map(convertToPokerSolverFormat));
// 例: solved.name => 'Pair', solved.descr => 'Pair, Tens'
currentRank = solved.descr;
}
return {
id: p.id,
name: p.name,
chips: p.chips,
bet: p.bet,
hasFolded: p.hasFolded,
positionLabel: getPositionLabel(room, idx),
hand: displayedHand,
// フロップ以降で見られる場合にのみ役を返す
// (ショウダウンなら全員の役が見られる)
rank: currentRank,
};
}),
};
}
// ソケットイベント
io.on("connection", (socket) => {
console.log("New connection:", socket.id);
socket.on("joinGame", (data) => {
let roomId = parseInt(data.roomId, 10);
if (isNaN(roomId) || roomId < 1 || roomId > MAX_TABLES) {
roomId = 1;
}
const room = rooms[roomId];
if (room.players.length >= MAX_PLAYERS_PER_TABLE) {
socket.emit("gameMessage", {
message: `テーブル${roomId}は満員です。別のテーブルを選んでください。`,
});
return;
}
room.addPlayer({ id: socket.id, name: data.playerName });
socket.join(String(roomId));
playerRoomMap[socket.id] = roomId;
broadcastGameState(roomId);
});
socket.on("startGame", () => {
const roomId = playerRoomMap[socket.id];
if (!roomId) {
socket.emit("gameMessage", { message: "テーブルに参加していません" });
return;
}
const room = rooms[roomId];
console.log(`[DEBUG startGame] StartGame called by ${socket.id}.`);
console.log(
"[DEBUG startGame] Current players:",
room.players.map((p) => ({ id: p.id, name: p.name, chips: p.chips }))
);
if (room.players.length < 2) {
io.to(String(roomId)).emit("gameMessage", {
message: "プレイヤーが2人以上必要です",
});
return;
}
// ここで room.startRound() など
room.startRound();
broadcastGameState(roomId);
});
socket.on("playerAction", ({ action, raiseAmount }) => {
const roomId = playerRoomMap[socket.id];
if (!roomId) {
socket.emit("gameMessage", { message: "テーブルに参加していません" });
return;
}
const room = rooms[roomId];
const result = room.handleAction(socket.id, action, raiseAmount);
if (result.error) {
socket.emit("gameMessage", { message: result.error });
return;
}
// 誰か以外全員フォールドの終了処理
if (result.termination) {
const activePlayers = room.players.filter((p) => !p.hasFolded);
const winner = activePlayers[0];
winner.chips += room.pot;
const resultMessage = `全員がフォールドしたため、${winner.name}の勝利です。`;
io.to(String(roomId)).emit("gameResult", {
message: resultMessage,
pot: room.pot,
});
io.to(winner.id).emit("winNotification", { message: "Win" });
broadcastGameState(roomId);
// 5秒待ってから endRoundCleanup() → さらに2秒後に startRound()
setTimeout(() => {
endRoundCleanup(roomId);
broadcastGameState(roomId);
setTimeout(() => {
const room = rooms[roomId];
if (room && room.players.filter((p) => p.chips > 0).length >= 2) {
room.startRound();
} else {
room.phase = PHASES.WAITING;
}
broadcastGameState(roomId);
}, 2000);
}, 5000);
return;
}
// 通常の更新
broadcastGameState(roomId);
// ショウダウンに突入したらサイドポット計算
if (room.phase === PHASES.SHOWDOWN) {
const sidePotResults = room.determineSidePotWinners();
let resultMessage = "";
const winnersSet = new Set();
sidePotResults.forEach((pot, idx) => {
// winnersInfo を直接使って「○○(Four of a Kind)」のように表示
const winnersText = pot.winnersInfo.join(", ");
resultMessage += `サイドポット${idx + 1} (${
pot.potAmount
}): ${winnersText}\n`;
const share = Math.floor(pot.potAmount / pot.winners.length);
pot.winners.forEach((w) => {
w.chips += share;
winnersSet.add(w);
});
});
io.to(String(roomId)).emit("gameResult", {
message: resultMessage,
pot: room.pot,
});
// 勝者にWin通知
winnersSet.forEach((w) => {
io.to(w.id).emit("winNotification", { message: "Win" });
});
// ショウダウン画面を十分表示するため、数秒待ってから次へ
// (ここでは8秒)
setTimeout(() => {
room.phase = PHASES.WAITING;
room.incorporateWaitingPlayers();
room.players = room.players.filter((p) => !p.disconnected);
endRoundCleanup(roomId);
broadcastGameState(roomId);
setTimeout(() => {
if (room.players.filter((p) => p.chips > 0).length >= 2) {
room.startRound();
} else {
room.phase = PHASES.WAITING;
}
broadcastGameState(roomId);
}, 2000); // WAITINGにしてからさらに2秒後に次スタート(計10秒)
}, 8000);
}
});
socket.on("disconnect", () => {
const roomId = playerRoomMap[socket.id];
if (roomId) {
const room = rooms[roomId];
// プレイヤーを即座に remove せず、disconnected=true にする
const target = room.players.find((p) => p.id === socket.id);
if (target) {
target.disconnected = true;
}
// ただし waitingPlayers にいる場合は取り除いてOK(まだハンドに参加していないので)
room.waitingPlayers = room.waitingPlayers.filter(
(p) => p.id !== socket.id
);
// [注意] ここでは room.players からは削除しない
// room.players = room.players.filter((p) => p.id !== socket.id);
delete playerRoomMap[socket.id];
broadcastGameState(roomId);
}
console.log("Disconnect:", socket.id);
});
});
// 6max用のポジション名配列
const SIX_MAX_POSITIONS = ["BTN", "SB", "BB", "UTG", "HJ", "CO"];
function getPositionLabel(game, seatIndex) {
const n = game.players.length;
// ディーラーボタンの位置
const button = game.dealerButtonIndex;
// offsetを計算 (seatIndexがボタンから何番目にいるか)
const offset = (seatIndex - button + n) % n;
// ヘッズアップ(2人)なら特別処理
if (n === 2) {
// offset=0 => BTN/SB, offset=1 => BB
return offset === 0 ? "SB(BTN)" : "BB";
}
// 3~6人の場合: offsetに従ってSIX_MAX_POSITIONSを割り当て
if (offset < SIX_MAX_POSITIONS.length) {
return SIX_MAX_POSITIONS[offset];
}
// 念のため
return "";
}
// ラウンド終了処理の例
function endRoundCleanup(roomId) {
console.log(`[DEBUG endRoundCleanup] Called for roomId=${roomId}`);
const room = rooms[roomId];
if (!room) return;
// (1) 待機者合流
if (typeof room.incorporateWaitingPlayers === "function") {
console.log(
"[DEBUG endRoundCleanup] -> Calling incorporateWaitingPlayers()"
);
room.incorporateWaitingPlayers();
}
// (2) 0チップ or disconnected をplayers配列から除外
const beforeCount = room.players.length;
room.players = room.players.filter((p) => p.chips > 0 && !p.disconnected);
const removedPlayers = beforeCount - room.players.length;
console.log(
`[DEBUG endRoundCleanup] -> Removed ${removedPlayers} players from room.players.`
);
// waitingPlayers も同様に除外 (不要なら省略可)
const beforeWait = room.waitingPlayers.length;
room.waitingPlayers = room.waitingPlayers.filter(
(p) => p.chips > 0 && !p.disconnected
);
console.log(
`[DEBUG endRoundCleanup] -> Removed ${
beforeWait - room.waitingPlayers.length
} from waitingPlayers.`
);
// ログ出し
console.log(
"[DEBUG endRoundCleanup] final players:",
room.players.map((p) => ({
id: p.id,
name: p.name,
chips: p.chips,
}))
);
console.log(
"[DEBUG endRoundCleanup] final waitingPlayers:",
room.waitingPlayers.map((p) => ({
id: p.id,
name: p.name,
chips: p.chips,
}))
);
// 2人未満ならWAITINGにしておく
if (room.players.length < 2) {
console.log("[DEBUG endRoundCleanup] -> Not enough players => WAITING");
room.phase = PHASES.WAITING;
}
// (4) 最後に broadcast
console.log("[DEBUG endRoundCleanup] -> broadcastGameState(roomId)");
broadcastGameState(roomId);
console.log("[DEBUG endRoundCleanup] Done.");
}
server.listen(process.env.PORT || 8080, () => {
console.log(`Server running on port ${process.env.PORT || 8080}`);
});