8 changed files with 898 additions and 71 deletions
@ -0,0 +1,289 @@
@@ -0,0 +1,289 @@
|
||||
import type { WebSocket as WS } from 'ws'; |
||||
|
||||
type Player = { |
||||
id: string; |
||||
ws: WS; |
||||
symbol: 'X' | 'O' | null; |
||||
}; |
||||
|
||||
type GameState = { |
||||
board: (string | null)[]; |
||||
currentPlayer: 'X' | 'O'; |
||||
winner: string | null; |
||||
isDraw: boolean; |
||||
players: Player[]; |
||||
queue: string[]; // Player IDs waiting to play
|
||||
}; |
||||
|
||||
const games = new Map<string, GameState>(); |
||||
|
||||
function createGame(roomId: string): GameState { |
||||
const game: GameState = { |
||||
board: Array(9).fill(null), |
||||
currentPlayer: 'X', |
||||
winner: null, |
||||
isDraw: false, |
||||
players: [], |
||||
queue: [], |
||||
}; |
||||
games.set(roomId, game); |
||||
return game; |
||||
} |
||||
|
||||
function getGame(roomId: string): GameState | undefined { |
||||
return games.get(roomId); |
||||
} |
||||
|
||||
function checkWinner(board: (string | null)[]): string | null { |
||||
const lines = [ |
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
|
||||
[0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
|
||||
[0, 4, 8], [2, 4, 6], // diagonals
|
||||
]; |
||||
|
||||
for (const [a, b, c] of lines) { |
||||
if (board[a] && board[a] === board[b] && board[a] === board[c]) { |
||||
return board[a]; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
function checkDraw(board: (string | null)[]): boolean { |
||||
return board.every(cell => cell !== null) && !checkWinner(board); |
||||
} |
||||
|
||||
function makeMove(roomId: string, playerId: string, position: number): { success: boolean; error?: string; autoStartNext?: boolean } { |
||||
const game = games.get(roomId); |
||||
if (!game) { |
||||
return { success: false, error: 'Game not found' }; |
||||
} |
||||
|
||||
const player = game.players.find(p => p.id === playerId); |
||||
if (!player || !player.symbol) { |
||||
return { success: false, error: 'Player not in game' }; |
||||
} |
||||
|
||||
if (game.winner || game.isDraw) { |
||||
return { success: false, error: 'Game is over' }; |
||||
} |
||||
|
||||
if (game.currentPlayer !== player.symbol) { |
||||
return { success: false, error: 'Not your turn' }; |
||||
} |
||||
|
||||
if (game.board[position] !== null) { |
||||
return { success: false, error: 'Position already taken' }; |
||||
} |
||||
|
||||
game.board[position] = player.symbol; |
||||
|
||||
const winner = checkWinner(game.board); |
||||
const isDraw = checkDraw(game.board); |
||||
|
||||
if (winner) { |
||||
game.winner = winner; |
||||
// Auto-start next game if there's a queue
|
||||
// Note: We set winner first, then the websocket handler will broadcast
|
||||
// After broadcast, if there's a queue, we'll auto-start the next game
|
||||
} else if (isDraw) { |
||||
game.isDraw = true; |
||||
} else { |
||||
game.currentPlayer = game.currentPlayer === 'X' ? 'O' : 'X'; |
||||
} |
||||
|
||||
return { success: true, autoStartNext: winner !== null && game.queue.length > 0 }; |
||||
} |
||||
|
||||
function addPlayer(roomId: string, playerId: string, ws: WS): { success: boolean; error?: string; symbol?: 'X' | 'O' | null } { |
||||
let game = games.get(roomId); |
||||
if (!game) { |
||||
game = createGame(roomId); |
||||
} |
||||
|
||||
// Check if player already in game
|
||||
if (game.players.find(p => p.id === playerId)) { |
||||
return { success: false, error: 'Player already in game' }; |
||||
} |
||||
|
||||
// If 2 players already playing, add to queue
|
||||
const activePlayers = game.players.filter(p => p.symbol !== null); |
||||
if (activePlayers.length >= 2) { |
||||
game.queue.push(playerId); |
||||
const player: Player = { id: playerId, ws, symbol: null }; |
||||
game.players.push(player); |
||||
return { success: true, symbol: null }; |
||||
} |
||||
|
||||
// Assign symbol (X or O)
|
||||
const symbol = activePlayers.length === 0 ? 'X' : 'O'; |
||||
const player: Player = { id: playerId, ws, symbol }; |
||||
game.players.push(player); |
||||
|
||||
return { success: true, symbol }; |
||||
} |
||||
|
||||
function removePlayer(roomId: string, playerId: string): void { |
||||
const game = games.get(roomId); |
||||
if (!game) return; |
||||
|
||||
const playerIndex = game.players.findIndex(p => p.id === playerId); |
||||
if (playerIndex === -1) return; |
||||
|
||||
const player = game.players[playerIndex]; |
||||
const wasActive = player.symbol !== null; |
||||
|
||||
// Remove from players
|
||||
game.players.splice(playerIndex, 1); |
||||
|
||||
// Remove from queue if there
|
||||
const queueIndex = game.queue.indexOf(playerId); |
||||
if (queueIndex !== -1) { |
||||
game.queue.splice(queueIndex, 1); |
||||
} |
||||
|
||||
// If an active player left, promote next in queue
|
||||
if (wasActive && game.queue.length > 0) { |
||||
const nextPlayerId = game.queue.shift()!; |
||||
const nextPlayer = game.players.find(p => p.id === nextPlayerId); |
||||
if (nextPlayer) { |
||||
// Assign the symbol of the player who left
|
||||
nextPlayer.symbol = player.symbol; |
||||
} |
||||
} |
||||
|
||||
// If no players left, delete game
|
||||
if (game.players.length === 0) { |
||||
games.delete(roomId); |
||||
} else if (game.players.filter(p => p.symbol !== null).length < 2 && !game.winner && !game.isDraw) { |
||||
// Reset game if not enough players
|
||||
game.board = Array(9).fill(null); |
||||
game.currentPlayer = 'X'; |
||||
game.winner = null; |
||||
game.isDraw = false; |
||||
} |
||||
} |
||||
|
||||
function resetGame(roomId: string, resettingPlayerId?: string): void { |
||||
const game = games.get(roomId); |
||||
if (!game) return; |
||||
|
||||
const activePlayers = game.players.filter(p => p.symbol !== null); |
||||
const previousWinner = game.winner; |
||||
const wasDraw = game.isDraw; |
||||
const previousLoser = previousWinner ? activePlayers.find(p => p.symbol !== previousWinner && p.symbol !== null) : null; |
||||
const hasQueue = game.queue.length > 0; |
||||
|
||||
game.board = Array(9).fill(null); |
||||
game.currentPlayer = 'X'; |
||||
game.winner = null; |
||||
game.isDraw = false; |
||||
|
||||
// If there's a previous winner and others in queue, winner stays, loser goes to queue
|
||||
if (previousWinner && hasQueue && previousLoser) { |
||||
// Winner stays with their symbol (no change needed)
|
||||
|
||||
// Loser goes to end of queue
|
||||
previousLoser.symbol = null; |
||||
game.queue.push(previousLoser.id); |
||||
|
||||
// Promote next player from queue to play against winner
|
||||
const nextPlayerId = game.queue.shift(); |
||||
if (nextPlayerId) { |
||||
const nextPlayer = game.players.find(p => p.id === nextPlayerId); |
||||
if (nextPlayer) { |
||||
// Assign the opposite symbol of the winner
|
||||
nextPlayer.symbol = previousWinner === 'X' ? 'O' : 'X'; |
||||
} |
||||
} |
||||
} else if (wasDraw && hasQueue && activePlayers.length === 2) { |
||||
// If it was a draw and there's a queue, both players go to queue
|
||||
activePlayers.forEach(p => { |
||||
p.symbol = null; |
||||
game.queue.push(p.id); |
||||
}); |
||||
|
||||
// Promote next 2 from queue
|
||||
const newPlayer1 = game.queue.shift(); |
||||
const newPlayer2 = game.queue.shift(); |
||||
|
||||
if (newPlayer1) { |
||||
const p1 = game.players.find(p => p.id === newPlayer1); |
||||
if (p1) p1.symbol = 'X'; |
||||
} |
||||
if (newPlayer2) { |
||||
const p2 = game.players.find(p => p.id === newPlayer2); |
||||
if (p2) p2.symbol = 'O'; |
||||
} |
||||
} else if (activePlayers.length > 2) { |
||||
// If no winner or no queue, rotate all players
|
||||
activePlayers.forEach(p => { |
||||
p.symbol = null; |
||||
game.queue.push(p.id); |
||||
}); |
||||
|
||||
// Promote next 2 from queue
|
||||
const newPlayer1 = game.queue.shift(); |
||||
const newPlayer2 = game.queue.shift(); |
||||
|
||||
if (newPlayer1) { |
||||
const p1 = game.players.find(p => p.id === newPlayer1); |
||||
if (p1) p1.symbol = 'X'; |
||||
} |
||||
if (newPlayer2) { |
||||
const p2 = game.players.find(p => p.id === newPlayer2); |
||||
if (p2) p2.symbol = 'O'; |
||||
} |
||||
} |
||||
} |
||||
|
||||
function joinQueue(roomId: string, playerId: string): { success: boolean; error?: string } { |
||||
const game = games.get(roomId); |
||||
if (!game) { |
||||
return { success: false, error: 'Game not found' }; |
||||
} |
||||
|
||||
const player = game.players.find(p => p.id === playerId); |
||||
if (!player) { |
||||
return { success: false, error: 'Player not found' }; |
||||
} |
||||
|
||||
// If player is already in queue, do nothing
|
||||
if (game.queue.includes(playerId)) { |
||||
return { success: true }; |
||||
} |
||||
|
||||
// If player is currently playing, they can't join queue
|
||||
if (player.symbol !== null) { |
||||
return { success: false, error: 'Cannot join queue while playing' }; |
||||
} |
||||
|
||||
// Add to queue
|
||||
game.queue.push(playerId); |
||||
return { success: true }; |
||||
} |
||||
|
||||
function broadcastToRoom(roomId: string, message: any, excludePlayerId?: string): void { |
||||
const game = games.get(roomId); |
||||
if (!game) return; |
||||
|
||||
const messageStr = JSON.stringify(message); |
||||
game.players.forEach(player => { |
||||
if (player.id !== excludePlayerId && player.ws.readyState === 1) { // WebSocket.OPEN
|
||||
player.ws.send(messageStr); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
export { |
||||
createGame, |
||||
getGame, |
||||
makeMove, |
||||
addPlayer, |
||||
removePlayer, |
||||
resetGame, |
||||
joinQueue, |
||||
broadcastToRoom, |
||||
type GameState, |
||||
type Player, |
||||
}; |
||||
@ -0,0 +1,250 @@
@@ -0,0 +1,250 @@
|
||||
import { WebSocketServer, WebSocket as WS } from 'ws'; |
||||
import { createServer } from 'http'; |
||||
import { getGame, addPlayer, removePlayer, makeMove, resetGame, joinQueue, broadcastToRoom } from './game.service.js'; |
||||
|
||||
let wss: WebSocketServer | null = null; |
||||
|
||||
export function createWebSocketServer(server: any) { |
||||
wss = new WebSocketServer({ server, path: '/ws' }); |
||||
|
||||
wss.on('connection', (ws: WS, req) => { |
||||
const url = new URL(req.url || '', `http://${req.headers.host}`); |
||||
const roomId = url.searchParams.get('room') || 'default'; |
||||
const playerId = url.searchParams.get('playerId') || `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; |
||||
|
||||
console.log(`[WebSocket] Player ${playerId} connected to room ${roomId}`); |
||||
|
||||
// Add player to game
|
||||
const result = addPlayer(roomId, playerId, ws); |
||||
|
||||
if (!result.success) { |
||||
ws.send(JSON.stringify({ type: 'error', message: result.error })); |
||||
ws.close(); |
||||
return; |
||||
} |
||||
|
||||
// Send initial game state
|
||||
const initialGame = getGame(roomId); |
||||
if (initialGame) { |
||||
ws.send(JSON.stringify({ |
||||
type: 'gameState', |
||||
game: { |
||||
board: initialGame.board, |
||||
currentPlayer: initialGame.currentPlayer, |
||||
winner: initialGame.winner, |
||||
isDraw: initialGame.isDraw, |
||||
yourSymbol: result.symbol, |
||||
players: initialGame.players.map(p => ({ id: p.id, symbol: p.symbol })), |
||||
queue: initialGame.queue, |
||||
}, |
||||
})); |
||||
} |
||||
|
||||
// Broadcast player joined to other players
|
||||
const joinGame = getGame(roomId); |
||||
if (joinGame) { |
||||
joinGame.players.forEach(player => { |
||||
if (player.id !== playerId && player.ws.readyState === 1) { |
||||
player.ws.send(JSON.stringify({ |
||||
type: 'playerJoined', |
||||
playerId, |
||||
symbol: result.symbol, |
||||
game: { |
||||
board: joinGame.board, |
||||
currentPlayer: joinGame.currentPlayer, |
||||
winner: joinGame.winner, |
||||
isDraw: joinGame.isDraw, |
||||
yourSymbol: player.symbol, |
||||
players: joinGame.players.map(p => ({ id: p.id, symbol: p.symbol })), |
||||
queue: joinGame.queue, |
||||
}, |
||||
})); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
// Handle messages
|
||||
ws.on('message', (data: Buffer) => { |
||||
try { |
||||
const message = JSON.parse(data.toString()); |
||||
|
||||
switch (message.type) { |
||||
case 'move': |
||||
if (typeof message.position !== 'number' || message.position < 0 || message.position > 8) { |
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid position' })); |
||||
return; |
||||
} |
||||
|
||||
const moveResult = makeMove(roomId, playerId, message.position); |
||||
if (!moveResult.success) { |
||||
ws.send(JSON.stringify({ type: 'error', message: moveResult.error })); |
||||
return; |
||||
} |
||||
|
||||
const updatedGame = getGame(roomId); |
||||
if (updatedGame) { |
||||
// Broadcast current game state (with winner if game ended)
|
||||
updatedGame.players.forEach(player => { |
||||
if (player.ws.readyState === 1) { // WebSocket.OPEN
|
||||
player.ws.send(JSON.stringify({ |
||||
type: 'gameState', |
||||
game: { |
||||
board: updatedGame.board, |
||||
currentPlayer: updatedGame.currentPlayer, |
||||
winner: updatedGame.winner, |
||||
isDraw: updatedGame.isDraw, |
||||
yourSymbol: player.symbol, |
||||
players: updatedGame.players.map(p => ({ id: p.id, symbol: p.symbol })), |
||||
queue: updatedGame.queue, |
||||
}, |
||||
})); |
||||
} |
||||
}); |
||||
|
||||
// Auto-start next game if there's a winner and queue
|
||||
if (moveResult.autoStartNext && updatedGame.winner && updatedGame.queue.length > 0) { |
||||
const winner = updatedGame.winner; |
||||
const loser = updatedGame.players.find(p => p.symbol !== winner && p.symbol !== null); |
||||
|
||||
if (loser) { |
||||
// Move loser to end of queue
|
||||
loser.symbol = null; |
||||
updatedGame.queue.push(loser.id); |
||||
|
||||
// Promote next player from queue
|
||||
const nextPlayerId = updatedGame.queue.shift(); |
||||
if (nextPlayerId) { |
||||
const nextPlayer = updatedGame.players.find(p => p.id === nextPlayerId); |
||||
if (nextPlayer) { |
||||
// Reset board and assign opposite symbol to new player
|
||||
updatedGame.board = Array(9).fill(null); |
||||
updatedGame.currentPlayer = 'X'; |
||||
updatedGame.winner = null; |
||||
updatedGame.isDraw = false; |
||||
nextPlayer.symbol = winner === 'X' ? 'O' : 'X'; |
||||
// Winner keeps their symbol, so no change needed
|
||||
|
||||
// Broadcast new game state immediately
|
||||
updatedGame.players.forEach(player => { |
||||
if (player.ws.readyState === 1) { |
||||
player.ws.send(JSON.stringify({ |
||||
type: 'gameState', |
||||
game: { |
||||
board: updatedGame.board, |
||||
currentPlayer: updatedGame.currentPlayer, |
||||
winner: updatedGame.winner, |
||||
isDraw: updatedGame.isDraw, |
||||
yourSymbol: player.symbol, |
||||
players: updatedGame.players.map(p => ({ id: p.id, symbol: p.symbol })), |
||||
queue: updatedGame.queue, |
||||
}, |
||||
})); |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
break; |
||||
|
||||
case 'reset': |
||||
resetGame(roomId, playerId); |
||||
const resetGameState = getGame(roomId); |
||||
if (resetGameState) { |
||||
// Broadcast to all players with their individual symbols
|
||||
resetGameState.players.forEach(player => { |
||||
if (player.ws.readyState === 1) { // WebSocket.OPEN
|
||||
player.ws.send(JSON.stringify({ |
||||
type: 'gameState', |
||||
game: { |
||||
board: resetGameState.board, |
||||
currentPlayer: resetGameState.currentPlayer, |
||||
winner: resetGameState.winner, |
||||
isDraw: resetGameState.isDraw, |
||||
yourSymbol: player.symbol, |
||||
players: resetGameState.players.map(p => ({ id: p.id, symbol: p.symbol })), |
||||
queue: resetGameState.queue, |
||||
}, |
||||
})); |
||||
} |
||||
}); |
||||
} |
||||
break; |
||||
|
||||
case 'joinQueue': |
||||
const joinResult = joinQueue(roomId, playerId); |
||||
if (!joinResult.success) { |
||||
ws.send(JSON.stringify({ type: 'error', message: joinResult.error })); |
||||
return; |
||||
} |
||||
const queueGameState = getGame(roomId); |
||||
if (queueGameState) { |
||||
// Broadcast updated game state to all players
|
||||
queueGameState.players.forEach(player => { |
||||
if (player.ws.readyState === 1) { // WebSocket.OPEN
|
||||
player.ws.send(JSON.stringify({ |
||||
type: 'gameState', |
||||
game: { |
||||
board: queueGameState.board, |
||||
currentPlayer: queueGameState.currentPlayer, |
||||
winner: queueGameState.winner, |
||||
isDraw: queueGameState.isDraw, |
||||
yourSymbol: player.symbol, |
||||
players: queueGameState.players.map(p => ({ id: p.id, symbol: p.symbol })), |
||||
queue: queueGameState.queue, |
||||
}, |
||||
})); |
||||
} |
||||
}); |
||||
} |
||||
break; |
||||
|
||||
default: |
||||
ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' })); |
||||
} |
||||
} catch (error) { |
||||
console.error('[WebSocket] Error handling message:', error); |
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' })); |
||||
} |
||||
}); |
||||
|
||||
// Handle disconnect
|
||||
ws.on('close', () => { |
||||
console.log(`[WebSocket] Player ${playerId} disconnected from room ${roomId}`); |
||||
removePlayer(roomId, playerId); |
||||
|
||||
const game = getGame(roomId); |
||||
if (game) { |
||||
// Broadcast to all remaining players with their individual symbols
|
||||
game.players.forEach(player => { |
||||
if (player.ws.readyState === 1) { // WebSocket.OPEN
|
||||
player.ws.send(JSON.stringify({ |
||||
type: 'playerLeft', |
||||
playerId, |
||||
game: { |
||||
board: game.board, |
||||
currentPlayer: game.currentPlayer, |
||||
winner: game.winner, |
||||
isDraw: game.isDraw, |
||||
yourSymbol: player.symbol, |
||||
players: game.players.map(p => ({ id: p.id, symbol: p.symbol })), |
||||
queue: game.queue, |
||||
}, |
||||
})); |
||||
} |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
ws.on('error', (error) => { |
||||
console.error(`[WebSocket] Error for player ${playerId}:`, error); |
||||
}); |
||||
}); |
||||
|
||||
console.log('✅ WebSocket server started on /ws'); |
||||
} |
||||
|
||||
export function getWebSocketServer(): WebSocketServer | null { |
||||
return wss; |
||||
} |
||||
@ -0,0 +1,314 @@
@@ -0,0 +1,314 @@
|
||||
import { useState, useEffect, useRef } from 'react'; |
||||
|
||||
interface GameState { |
||||
board: (string | null)[]; |
||||
currentPlayer: 'X' | 'O'; |
||||
winner: string | null; |
||||
isDraw: boolean; |
||||
yourSymbol: 'X' | 'O' | null; |
||||
players: Array<{ id: string; symbol: 'X' | 'O' | null }>; |
||||
queue: string[]; |
||||
} |
||||
|
||||
export function TicTacToeApp() { |
||||
const [gameState, setGameState] = useState<GameState | null>(null); |
||||
const [connected, setConnected] = useState(false); |
||||
const [error, setError] = useState<string | null>(null); |
||||
const wsRef = useRef<WebSocket | null>(null); |
||||
const playerIdRef = useRef<string>(`player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); |
||||
|
||||
useEffect(() => { |
||||
// Get WebSocket URL - connect to backend server
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; |
||||
const backendUrl = apiUrl.replace('/api', ''); |
||||
const wsProtocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:'; |
||||
const wsHost = backendUrl.replace(/^https?:\/\//, ''); |
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws?room=default&playerId=${playerIdRef.current}`; |
||||
|
||||
const ws = new WebSocket(wsUrl); |
||||
wsRef.current = ws; |
||||
|
||||
ws.onopen = () => { |
||||
console.log('WebSocket connected'); |
||||
setConnected(true); |
||||
setError(null); |
||||
}; |
||||
|
||||
ws.onmessage = (event) => { |
||||
try { |
||||
const message = JSON.parse(event.data); |
||||
|
||||
if (message.type === 'gameState') { |
||||
setGameState(message.game); |
||||
} else if (message.type === 'playerJoined' && message.game) { |
||||
// Update game state when player joins
|
||||
setGameState(message.game); |
||||
} else if (message.type === 'playerLeft' && message.game) { |
||||
// Update game state when player leaves
|
||||
setGameState(message.game); |
||||
} else if (message.type === 'error') { |
||||
setError(message.message); |
||||
} |
||||
} catch (err) { |
||||
console.error('Error parsing WebSocket message:', err); |
||||
} |
||||
}; |
||||
|
||||
ws.onerror = (err) => { |
||||
console.error('WebSocket error:', err); |
||||
setError('Connection error. Please refresh the page.'); |
||||
setConnected(false); |
||||
}; |
||||
|
||||
ws.onclose = () => { |
||||
console.log('WebSocket disconnected'); |
||||
setConnected(false); |
||||
}; |
||||
|
||||
return () => { |
||||
ws.close(); |
||||
}; |
||||
}, []); |
||||
|
||||
const handleCellClick = (index: number) => { |
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { |
||||
setError('Not connected to server'); |
||||
return; |
||||
} |
||||
|
||||
if (!gameState) return; |
||||
if (gameState.winner || gameState.isDraw) return; |
||||
if (gameState.yourSymbol !== gameState.currentPlayer) return; |
||||
if (gameState.board[index] !== null) return; |
||||
|
||||
wsRef.current.send(JSON.stringify({ |
||||
type: 'move', |
||||
position: index, |
||||
})); |
||||
}; |
||||
|
||||
const handleReset = () => { |
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; |
||||
wsRef.current.send(JSON.stringify({ type: 'reset' })); |
||||
}; |
||||
|
||||
const handleJoinQueue = () => { |
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; |
||||
wsRef.current.send(JSON.stringify({ type: 'joinQueue' })); |
||||
}; |
||||
|
||||
const getStatusMessage = () => { |
||||
if (!gameState) return 'Connecting...'; |
||||
if (gameState.winner) { |
||||
const winnerSymbol = gameState.winner; |
||||
if (gameState.yourSymbol === winnerSymbol) { |
||||
return '🎉 You won!'; |
||||
} else { |
||||
return `Player ${winnerSymbol} won!`; |
||||
} |
||||
} |
||||
if (gameState.isDraw) { |
||||
return "It's a draw!"; |
||||
} |
||||
if (gameState.yourSymbol === null) { |
||||
return `Waiting in queue (${gameState.queue.indexOf(playerIdRef.current) + 1} of ${gameState.queue.length + gameState.players.filter(p => p.symbol !== null).length})...`; |
||||
} |
||||
if (gameState.currentPlayer === gameState.yourSymbol) { |
||||
return `Your turn (${gameState.yourSymbol})`; |
||||
} |
||||
return `Waiting for ${gameState.currentPlayer}...`; |
||||
}; |
||||
|
||||
return ( |
||||
<div className="min-h-screen bg-background px-4 py-8"> |
||||
<div className="max-w-2xl mx-auto"> |
||||
<div className="text-center mb-8"> |
||||
<h1 className="text-4xl font-bold text-primary mb-2">Tic Tac Toe</h1> |
||||
<p className="text-muted-foreground">Multiplayer game - play with friends!</p> |
||||
</div> |
||||
|
||||
{!connected && ( |
||||
<div className="bg-card border border-border rounded-xl p-6 text-center"> |
||||
<p className="text-muted-foreground">Connecting to game server...</p> |
||||
</div> |
||||
)} |
||||
|
||||
{error && ( |
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 text-red-800 text-sm"> |
||||
{error} |
||||
</div> |
||||
)} |
||||
|
||||
{connected && gameState && ( |
||||
<> |
||||
<div className="bg-card border-4 border-primary rounded-3xl p-6 mb-6 shadow-lg"> |
||||
<div className="text-center mb-6"> |
||||
<p className={`text-lg font-bold ${ |
||||
gameState.winner && gameState.yourSymbol === gameState.winner |
||||
? 'text-green-600' |
||||
: gameState.winner |
||||
? 'text-red-600' |
||||
: gameState.yourSymbol === gameState.currentPlayer |
||||
? 'text-primary' |
||||
: 'text-muted-foreground' |
||||
}`}>
|
||||
{getStatusMessage()} |
||||
</p> |
||||
|
||||
{gameState.yourSymbol && ( |
||||
<p className="text-sm text-muted-foreground mt-2"> |
||||
You are playing as <span className="font-bold text-primary">{gameState.yourSymbol}</span> |
||||
</p> |
||||
)} |
||||
|
||||
{gameState.players.length > 2 && ( |
||||
<div className="mt-4 text-sm text-muted-foreground"> |
||||
<p>Players: {gameState.players.filter(p => p.symbol !== null).length} playing, {gameState.queue.length} waiting</p> |
||||
</div> |
||||
)} |
||||
</div> |
||||
|
||||
<div className="grid grid-cols-3 gap-3 max-w-md mx-auto"> |
||||
{gameState.board.map((cell, index) => ( |
||||
<button |
||||
key={index} |
||||
onClick={() => handleCellClick(index)} |
||||
disabled={ |
||||
!gameState.yourSymbol || |
||||
gameState.yourSymbol !== gameState.currentPlayer || |
||||
cell !== null || |
||||
gameState.winner !== null || |
||||
gameState.isDraw |
||||
} |
||||
className={` |
||||
aspect-square text-4xl font-bold rounded-xl transition-all |
||||
${cell === null
|
||||
? 'bg-muted hover:bg-muted/80 border-2 border-border'
|
||||
: 'bg-card border-2 border-primary' |
||||
} |
||||
${gameState.yourSymbol === gameState.currentPlayer && cell === null && !gameState.winner && !gameState.isDraw |
||||
? 'cursor-pointer hover:scale-105 active:scale-95' |
||||
: 'cursor-not-allowed opacity-60' |
||||
} |
||||
${cell === 'X' ? 'text-primary' : cell === 'O' ? 'text-secondary' : ''} |
||||
`}
|
||||
> |
||||
{cell || ''} |
||||
</button> |
||||
))} |
||||
</div> |
||||
|
||||
{(gameState.winner || gameState.isDraw) && ( |
||||
<div className="mt-6 text-center"> |
||||
{(() => { |
||||
const isWinner = gameState.yourSymbol === gameState.winner; |
||||
const isLoser = gameState.yourSymbol !== null && gameState.yourSymbol !== gameState.winner && !gameState.isDraw; |
||||
const hasQueue = gameState.queue.length > 0; |
||||
const isInQueue = gameState.queue.includes(playerIdRef.current); |
||||
|
||||
// If winner and queue exists, game auto-started - show nothing or a message
|
||||
if (isWinner && hasQueue) { |
||||
return ( |
||||
<p className="text-sm text-primary font-semibold"> |
||||
New game started! Next player joined. |
||||
</p> |
||||
); |
||||
} |
||||
|
||||
// Winner with no queue: can play again
|
||||
if (isWinner && !hasQueue) { |
||||
return ( |
||||
<button |
||||
onClick={handleReset} |
||||
className="px-6 py-3 bg-primary text-primary-foreground rounded-full font-semibold hover:bg-primary/90 transition-all active:scale-95 shadow-md" |
||||
> |
||||
Play Again |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
// Loser: automatically in queue if there was a queue, show "Get in line" if not already
|
||||
if (isLoser) { |
||||
if (isInQueue) { |
||||
return ( |
||||
<p className="text-sm text-muted-foreground"> |
||||
You're in line to play again (position {gameState.queue.indexOf(playerIdRef.current) + 1}) |
||||
</p> |
||||
); |
||||
} else if (hasQueue) { |
||||
return ( |
||||
<button |
||||
onClick={handleJoinQueue} |
||||
className="px-6 py-3 bg-secondary text-primary-foreground rounded-full font-semibold hover:bg-secondary/90 transition-all active:scale-95 shadow-md" |
||||
> |
||||
Get in line to play again |
||||
</button> |
||||
); |
||||
} else { |
||||
return ( |
||||
<button |
||||
onClick={handleReset} |
||||
className="px-6 py-3 bg-primary text-primary-foreground rounded-full font-semibold hover:bg-primary/90 transition-all active:scale-95 shadow-md" |
||||
> |
||||
Play Again |
||||
</button> |
||||
); |
||||
} |
||||
} |
||||
|
||||
// Draw: if there's a queue, show "Get in line", otherwise "Play Again"
|
||||
if (gameState.isDraw) { |
||||
if (hasQueue && !isInQueue) { |
||||
return ( |
||||
<button |
||||
onClick={handleJoinQueue} |
||||
className="px-6 py-3 bg-secondary text-primary-foreground rounded-full font-semibold hover:bg-secondary/90 transition-all active:scale-95 shadow-md" |
||||
> |
||||
Get in line to play again |
||||
</button> |
||||
); |
||||
} else if (!hasQueue) { |
||||
return ( |
||||
<button |
||||
onClick={handleReset} |
||||
className="px-6 py-3 bg-primary text-primary-foreground rounded-full font-semibold hover:bg-primary/90 transition-all active:scale-95 shadow-md" |
||||
> |
||||
Play Again |
||||
</button> |
||||
); |
||||
} else { |
||||
return ( |
||||
<p className="text-sm text-muted-foreground"> |
||||
You're in line to play again (position {gameState.queue.indexOf(playerIdRef.current) + 1}) |
||||
</p> |
||||
); |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
})()} |
||||
</div> |
||||
)} |
||||
</div> |
||||
|
||||
<div className="bg-card border border-border rounded-xl p-4"> |
||||
<h3 className="font-bold text-foreground mb-3">Players</h3> |
||||
<div className="space-y-2"> |
||||
{gameState.players.map((player, idx) => ( |
||||
<div key={player.id} className="flex items-center justify-between text-sm"> |
||||
<span className={player.id === playerIdRef.current ? 'font-bold text-primary' : 'text-foreground'}> |
||||
{player.id === playerIdRef.current ? 'You' : `Player ${idx + 1}`} |
||||
</span> |
||||
<span className="text-muted-foreground"> |
||||
{player.symbol ? `Playing as ${player.symbol}` : 'Waiting in queue'} |
||||
</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
Loading…
Reference in new issue