585 lines
26 KiB
HTML
585 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ShortDeck Poker - Professional Client</title>
|
||
|
||
<!-- React & TypeScript (via CDN for quick setup) -->
|
||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||
<script crossorigin src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
<script crossorigin src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||
|
||
<!-- Tailwind CSS -->
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
|
||
<!-- Custom Styles -->
|
||
<style>
|
||
body {
|
||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||
font-family: 'Inter', system-ui, sans-serif;
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.poker-table {
|
||
background: radial-gradient(ellipse at center, #059669 0%, #047857 70%, #065f46 100%);
|
||
border: 8px solid #92400e;
|
||
box-shadow: 0 0 50px rgba(0, 0, 0, 0.7);
|
||
}
|
||
|
||
.card {
|
||
width: 60px;
|
||
height: 84px;
|
||
border-radius: 8px;
|
||
background: white;
|
||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: bold;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.card.red {
|
||
color: #dc2626;
|
||
}
|
||
|
||
.card.back {
|
||
background: linear-gradient(45deg, #1e40af, #3730a3);
|
||
color: white;
|
||
}
|
||
|
||
.player-seat {
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.player-seat.active {
|
||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.6);
|
||
border: 2px solid #10b981;
|
||
}
|
||
|
||
.action-button {
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.action-button:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root">
|
||
<div style="color: white; text-align: center; padding: 20px;">
|
||
正在加载...
|
||
</div>
|
||
</div>
|
||
|
||
<script type="text/babel">
|
||
console.log('🚀 开始加载 ShortDeck Poker Client');
|
||
|
||
const { useState, useEffect, useContext, createContext } = React;
|
||
|
||
if (typeof React === 'undefined') {
|
||
document.getElementById('root').innerHTML = '<div style="color: red; text-align: center; padding: 20px;">React加载失败</div>';
|
||
}
|
||
|
||
// API Configuration
|
||
const API_BASE_URL = 'http://127.0.0.1:8001';
|
||
|
||
// Game Context
|
||
const GameContext = createContext();
|
||
|
||
// Game Provider Component
|
||
const GameProvider = ({ children }) => {
|
||
const [gameState, setGameState] = useState({
|
||
playerId: null,
|
||
playerName: '',
|
||
players: [],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
|
||
const joinGame = async (playerName) => {
|
||
setGameState(prev => ({ ...prev, loading: true, error: null }));
|
||
try {
|
||
const response = await axios.post(`${API_BASE_URL}/join`, { name: playerName });
|
||
setGameState(prev => ({
|
||
...prev,
|
||
playerId: response.data.player_id,
|
||
playerName: response.data.name,
|
||
loading: false
|
||
}));
|
||
setTimeout(fetchGameInfo, 500);
|
||
} catch (error) {
|
||
setGameState(prev => ({
|
||
...prev,
|
||
error: error.response?.data?.detail || '加入游戏失败',
|
||
loading: false
|
||
}));
|
||
}
|
||
};
|
||
|
||
const fetchGameInfo = async () => {
|
||
if (gameState.playerId === null) return;
|
||
|
||
try {
|
||
const response = await axios.get(`${API_BASE_URL}/info/${gameState.playerId}`);
|
||
const data = response.data;
|
||
|
||
setGameState(prev => ({
|
||
...prev,
|
||
gameId: data.game_id || '',
|
||
players: data.players || [],
|
||
dealerIndex: data.dealer_index || 0,
|
||
currentTurn: data.current_turn || 0,
|
||
stage: data.stage || 'waiting',
|
||
totalPot: data.total_pot || 0,
|
||
sidePots: data.side_pots || [],
|
||
boardCards: data.board_cards || [],
|
||
playerCards: data.player_cards || [],
|
||
stacks: data.stacks || [],
|
||
playerStates: data.player_states || [],
|
||
currentPot: data.current_pot || [],
|
||
actions: data.actions || { can_act: false },
|
||
error: null
|
||
}));
|
||
|
||
if (data.player_cards && data.player_cards.length > 0) {
|
||
fetchHandStrength();
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch game info:', error);
|
||
}
|
||
};
|
||
|
||
const fetchHandStrength = async () => {
|
||
if (gameState.playerId === null) return;
|
||
|
||
try {
|
||
const response = await axios.get(`${API_BASE_URL}/hand_strength/${gameState.playerId}`);
|
||
if (!response.data.error) {
|
||
setGameState(prev => ({
|
||
...prev,
|
||
handStrength: response.data
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch hand strength:', error);
|
||
}
|
||
};
|
||
|
||
const applyAction = async (action, amount = null) => {
|
||
if (gameState.playerId === null) return;
|
||
|
||
setGameState(prev => ({ ...prev, loading: true }));
|
||
try {
|
||
const payload = {
|
||
player_id: gameState.playerId,
|
||
action,
|
||
amount
|
||
};
|
||
await axios.post(`${API_BASE_URL}/apply_action`, payload);
|
||
setTimeout(fetchGameInfo, 500);
|
||
} catch (error) {
|
||
setGameState(prev => ({
|
||
...prev,
|
||
error: error.response?.data?.detail || '行动失败',
|
||
loading: false
|
||
}));
|
||
}
|
||
};
|
||
|
||
const resetGame = async () => {
|
||
try {
|
||
await axios.post(`${API_BASE_URL}/reset`);
|
||
setGameState({
|
||
playerId: null,
|
||
playerName: '',
|
||
gameId: '',
|
||
players: [],
|
||
dealerIndex: 0,
|
||
currentTurn: 0,
|
||
stage: 'waiting',
|
||
totalPot: 0,
|
||
sidePots: [],
|
||
boardCards: [],
|
||
playerCards: [],
|
||
stacks: [],
|
||
playerStates: [],
|
||
currentPot: [],
|
||
actions: { can_act: false },
|
||
handStrength: null,
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
} catch (error) {
|
||
console.error('Reset failed:', error);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (gameState.playerId !== null) {
|
||
const interval = setInterval(fetchGameInfo, 2000);
|
||
return () => clearInterval(interval);
|
||
}
|
||
}, [gameState.playerId]);
|
||
|
||
return React.createElement(GameContext.Provider, {
|
||
value: { gameState, joinGame, applyAction, resetGame, fetchGameInfo }
|
||
}, children);
|
||
};
|
||
|
||
const useGame = () => {
|
||
const context = useContext(GameContext);
|
||
if (!context) {
|
||
throw new Error('useGame must be used within a GameProvider');
|
||
}
|
||
return context;
|
||
};
|
||
|
||
// Join Game Form Component
|
||
const JoinGameForm = () => {
|
||
const { joinGame, gameState } = useGame();
|
||
const [playerName, setPlayerName] = useState('');
|
||
|
||
const handleJoinGame = (e) => {
|
||
e.preventDefault();
|
||
if (playerName.trim()) {
|
||
joinGame(playerName.trim());
|
||
}
|
||
};
|
||
|
||
return React.createElement('div', {
|
||
className: 'min-h-screen flex items-center justify-center px-4'
|
||
},
|
||
React.createElement('div', { className: 'max-w-md w-full bg-gray-800 rounded-lg p-6' },
|
||
React.createElement('h2', {
|
||
className: 'text-2xl font-bold text-white mb-6 text-center'
|
||
}, 'ShortDeck Poker'),
|
||
|
||
React.createElement('form', { onSubmit: handleJoinGame },
|
||
React.createElement('div', { className: 'mb-6' },
|
||
React.createElement('label', {
|
||
className: 'block text-white text-sm font-bold mb-2'
|
||
}, '玩家名称'),
|
||
React.createElement('input', {
|
||
type: 'text',
|
||
value: playerName,
|
||
onChange: (e) => setPlayerName(e.target.value),
|
||
placeholder: '输入你的名称',
|
||
className: 'w-full px-3 py-2 bg-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||
disabled: gameState.loading
|
||
})
|
||
),
|
||
|
||
React.createElement('button', {
|
||
type: 'submit',
|
||
className: 'w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition-colors',
|
||
disabled: gameState.loading || !playerName.trim()
|
||
}, gameState.loading ? '加入中...' : '🎰 加入游戏'),
|
||
|
||
React.createElement('div', {
|
||
className: 'mt-4 p-3 bg-gray-700 rounded-lg text-sm text-gray-300'
|
||
},
|
||
React.createElement('div', { className: 'font-bold mb-2' }, '💡 如何与AI对战:'),
|
||
React.createElement('ol', { className: 'list-decimal list-inside space-y-1 text-xs' },
|
||
React.createElement('li', null, '点击"加入游戏"按钮'),
|
||
React.createElement('li', null, '在新终端中运行: python client2_random_agent.py'),
|
||
React.createElement('li', null, 'RandomAgent会自动加入游戏与您对战')
|
||
)
|
||
)
|
||
),
|
||
|
||
gameState.error && React.createElement('div', {
|
||
className: 'mt-4 p-3 bg-red-600 text-white rounded-lg text-sm'
|
||
}, gameState.error)
|
||
)
|
||
);
|
||
};
|
||
|
||
// Card format conversion function
|
||
const formatCard = (card) => {
|
||
if (!card) return '';
|
||
|
||
// Convert backend format (e.g., 'Kd', 'Ah') to frontend format (e.g., 'K♦', 'A♥')
|
||
const suitMap = {
|
||
's': '♠',
|
||
'h': '♥',
|
||
'd': '♦',
|
||
'c': '♣'
|
||
};
|
||
|
||
// Extract rank and suit from card string like 'Kd'
|
||
const rank = card.slice(0, -1); // Everything except last character
|
||
const suit = card.slice(-1).toLowerCase(); // Last character, lowercase
|
||
|
||
return rank + (suitMap[suit] || suit);
|
||
};
|
||
|
||
// Card Component
|
||
const Card = ({ card, isBack = false }) => {
|
||
if (isBack || !card) {
|
||
return React.createElement('div', {
|
||
className: 'card back'
|
||
}, '🂠');
|
||
}
|
||
|
||
const formattedCard = formatCard(card);
|
||
const isRed = formattedCard.includes('♥') || formattedCard.includes('♦');
|
||
return React.createElement('div', {
|
||
className: `card ${isRed ? 'red' : ''}`
|
||
}, formattedCard);
|
||
};
|
||
|
||
// Player Seat Component
|
||
const PlayerSeat = ({ player, index, isCurrentPlayer, isDealer, isCurrentTurn, stack, currentBet, state, cards }) => {
|
||
return React.createElement('div', {
|
||
className: `bg-gray-800 rounded-lg p-4 text-white relative transition-all ${isCurrentTurn ? 'player-seat active' : ''} ${state !== 'ACTIVE' ? 'opacity-50' : ''}`
|
||
},
|
||
// Dealer Button
|
||
isDealer && React.createElement('div', {
|
||
className: 'absolute -top-2 -right-2 bg-yellow-500 text-black rounded-full w-8 h-8 flex items-center justify-center text-xs font-bold'
|
||
}, 'D'),
|
||
|
||
// Player Name
|
||
React.createElement('div', {
|
||
className: 'font-bold mb-2 text-center'
|
||
}, `${player}${isCurrentPlayer ? ' (你)' : ''}`),
|
||
|
||
// Cards
|
||
React.createElement('div', {
|
||
className: 'flex gap-1 justify-center mb-2'
|
||
},
|
||
isCurrentPlayer && cards && cards.length > 0
|
||
? cards.map((card, idx) => React.createElement(Card, { key: idx, card }))
|
||
: [React.createElement(Card, { key: 0, isBack: true }), React.createElement(Card, { key: 1, isBack: true })]
|
||
),
|
||
|
||
// Stack and Bet Info
|
||
React.createElement('div', { className: 'text-sm text-center' },
|
||
React.createElement('div', null, `筹码: $${stack || 0}`),
|
||
currentBet > 0 && React.createElement('div', { className: 'text-yellow-400' }, `下注: $${currentBet}`),
|
||
React.createElement('div', { className: 'text-xs text-gray-400' }, state || 'WAITING')
|
||
)
|
||
);
|
||
};
|
||
|
||
// Action Panel Component
|
||
const ActionPanel = () => {
|
||
const { gameState, applyAction } = useGame();
|
||
const [raiseAmount, setRaiseAmount] = useState('');
|
||
|
||
if (!gameState.actions.can_act || gameState.loading) {
|
||
return React.createElement('div', {
|
||
className: 'bg-gray-800 rounded-lg p-4 text-center text-gray-400'
|
||
}, gameState.loading ? '处理中...' : '等待其他玩家行动...');
|
||
}
|
||
|
||
const { actions } = gameState;
|
||
|
||
return React.createElement('div', { className: 'bg-gray-800 rounded-lg p-4' },
|
||
React.createElement('h3', { className: 'text-white font-bold mb-4 text-center' }, '选择你的行动'),
|
||
|
||
React.createElement('div', { className: 'grid grid-cols-2 gap-3 mb-4' },
|
||
// Fold
|
||
actions.can_fold && React.createElement('button', {
|
||
onClick: () => applyAction('fold'),
|
||
className: 'action-button bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded-lg font-bold transition-all'
|
||
}, '弃牌 (Fold)'),
|
||
|
||
// Check
|
||
actions.can_check && React.createElement('button', {
|
||
onClick: () => applyAction('check'),
|
||
className: 'action-button bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded-lg font-bold transition-all'
|
||
}, '过牌 (Check)'),
|
||
|
||
// Call
|
||
actions.can_call && React.createElement('button', {
|
||
onClick: () => applyAction('call'),
|
||
className: 'action-button bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg font-bold transition-all'
|
||
}, `跟注 $${actions.call_amount || 0}`),
|
||
|
||
// Bet
|
||
actions.can_bet && React.createElement('button', {
|
||
onClick: () => applyAction('bet', parseInt(raiseAmount) || actions.min_raise),
|
||
className: 'action-button bg-purple-600 hover:bg-purple-700 text-white py-2 px-4 rounded-lg font-bold transition-all'
|
||
}, '下注 (Bet)')
|
||
),
|
||
|
||
// Raise section
|
||
actions.can_raise && React.createElement('div', { className: 'border-t border-gray-600 pt-4' },
|
||
React.createElement('div', { className: 'flex gap-2 items-center mb-2' },
|
||
React.createElement('input', {
|
||
type: 'number',
|
||
value: raiseAmount,
|
||
onChange: (e) => setRaiseAmount(e.target.value),
|
||
placeholder: `最小: ${actions.min_raise || 0}`,
|
||
min: actions.min_raise || 0,
|
||
max: actions.max_raise || 1000,
|
||
className: 'flex-1 px-3 py-2 bg-gray-700 text-white rounded border-0 focus:ring-2 focus:ring-blue-500'
|
||
}),
|
||
React.createElement('button', {
|
||
onClick: () => applyAction('raise', parseInt(raiseAmount) || actions.min_raise),
|
||
className: 'action-button bg-orange-600 hover:bg-orange-700 text-white py-2 px-4 rounded-lg font-bold transition-all'
|
||
}, '加注 (Raise)')
|
||
),
|
||
React.createElement('div', { className: 'text-xs text-gray-400' },
|
||
`加注范围: $${actions.min_raise || 0} - $${actions.max_raise || 1000}`
|
||
)
|
||
)
|
||
);
|
||
};
|
||
|
||
// Game Table Component
|
||
const GameTable = () => {
|
||
const { gameState } = useGame();
|
||
|
||
return React.createElement('div', { className: 'poker-table rounded-full w-96 h-64 mx-auto mb-8 flex items-center justify-center relative' },
|
||
// Community Cards
|
||
React.createElement('div', { className: 'flex gap-2' },
|
||
gameState.boardCards && gameState.boardCards.length > 0
|
||
? gameState.boardCards.map((card, idx) => React.createElement(Card, { key: idx, card }))
|
||
: [1, 2, 3, 4, 5].map(i => React.createElement('div', {
|
||
key: i,
|
||
className: 'w-12 h-16 border-2 border-dashed border-gray-400 rounded opacity-30'
|
||
}))
|
||
),
|
||
|
||
// Pot Info
|
||
React.createElement('div', {
|
||
className: 'absolute top-4 left-1/2 transform -translate-x-1/2 bg-yellow-600 text-white px-4 py-2 rounded-full font-bold'
|
||
}, `底池: $${gameState.totalPot || 0}`)
|
||
);
|
||
};
|
||
|
||
// Players Layout
|
||
const PlayersLayout = () => {
|
||
const { gameState } = useGame();
|
||
|
||
if (gameState.players.length === 0) {
|
||
return React.createElement('div', {
|
||
className: 'text-center text-gray-400 py-8'
|
||
}, '等待其他玩家加入...');
|
||
}
|
||
|
||
return React.createElement('div', { className: 'grid grid-cols-1 md:grid-cols-2 gap-4 mb-8' },
|
||
gameState.players.map((player, index) =>
|
||
React.createElement(PlayerSeat, {
|
||
key: index,
|
||
player,
|
||
index,
|
||
isCurrentPlayer: index === gameState.playerId,
|
||
isDealer: index === gameState.dealerIndex,
|
||
isCurrentTurn: index === gameState.currentTurn,
|
||
stack: gameState.stacks[index],
|
||
currentBet: gameState.currentPot[index],
|
||
state: gameState.playerStates[index],
|
||
cards: index === gameState.playerId ? gameState.playerCards : []
|
||
})
|
||
)
|
||
);
|
||
};
|
||
|
||
// Hand Strength Display
|
||
const HandStrengthDisplay = () => {
|
||
const { gameState } = useGame();
|
||
|
||
if (!gameState.handStrength) return null;
|
||
|
||
const { handStrength } = gameState;
|
||
const strengthPercent = (handStrength.strength * 100).toFixed(1);
|
||
|
||
return React.createElement('div', { className: 'bg-gray-800 rounded-lg p-4 mb-4' },
|
||
React.createElement('h3', { className: 'text-white font-bold mb-2' }, '手牌强度'),
|
||
React.createElement('div', { className: 'mb-2' },
|
||
React.createElement('div', { className: 'text-lg font-bold text-yellow-400' }, handStrength.hand_type),
|
||
React.createElement('div', { className: 'text-sm text-gray-300' }, handStrength.description)
|
||
),
|
||
React.createElement('div', { className: 'bg-gray-700 rounded-full h-2 mb-2' },
|
||
React.createElement('div', {
|
||
className: 'bg-gradient-to-r from-red-500 via-yellow-500 to-green-500 h-2 rounded-full transition-all',
|
||
style: { width: `${strengthPercent}%` }
|
||
})
|
||
),
|
||
React.createElement('div', { className: 'text-xs text-gray-400 text-center' },
|
||
`强度: ${strengthPercent}%`
|
||
)
|
||
);
|
||
};
|
||
|
||
// Game View Component
|
||
const GameView = () => {
|
||
const { gameState, resetGame } = useGame();
|
||
|
||
return React.createElement('div', { className: 'container mx-auto px-4 py-8' },
|
||
// Header
|
||
React.createElement('div', { className: 'flex justify-between items-center mb-8' },
|
||
React.createElement('h1', { className: 'text-3xl font-bold text-white' }, 'ShortDeck Poker'),
|
||
React.createElement('div', { className: 'flex gap-4' },
|
||
React.createElement('div', { className: 'text-white text-sm' },
|
||
React.createElement('div', null, `游戏ID: ${gameState.gameId.slice(0, 8)}...`),
|
||
React.createElement('div', null, `玩家: ${gameState.playerName}`),
|
||
React.createElement('div', null, `阶段: ${gameState.stage}`)
|
||
),
|
||
React.createElement('button', {
|
||
onClick: resetGame,
|
||
className: 'bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm'
|
||
}, '重置游戏')
|
||
)
|
||
),
|
||
|
||
// Game Table
|
||
React.createElement(GameTable),
|
||
|
||
// Players Layout
|
||
React.createElement(PlayersLayout),
|
||
|
||
// Hand Strength (if available)
|
||
React.createElement(HandStrengthDisplay),
|
||
|
||
// Action Panel
|
||
React.createElement(ActionPanel),
|
||
|
||
// Error Display
|
||
gameState.error && React.createElement('div', {
|
||
className: 'fixed top-4 right-4 bg-red-600 text-white p-4 rounded-lg shadow-lg max-w-sm'
|
||
}, gameState.error)
|
||
);
|
||
};
|
||
|
||
// Main App Component
|
||
const GameApp = () => {
|
||
const { gameState } = useGame();
|
||
|
||
if (gameState.playerId === null) {
|
||
return React.createElement(JoinGameForm);
|
||
}
|
||
|
||
return React.createElement(GameView);
|
||
};
|
||
|
||
// App Initialization
|
||
const App = () => {
|
||
return React.createElement(GameProvider, null,
|
||
React.createElement(GameApp)
|
||
);
|
||
};
|
||
|
||
try {
|
||
ReactDOM.render(React.createElement(App), document.getElementById('root'));
|
||
console.log('✅ ShortDeck Poker Client 加载成功');
|
||
} catch (error) {
|
||
console.error('❌ React 渲染失败:', error);
|
||
document.getElementById('root').innerHTML = `
|
||
<div style="color: red; text-align: center; padding: 20px;">
|
||
<h2>渲染失败</h2>
|
||
<p>错误: ${error.message}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |