8 changed files with 898 additions and 71 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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