/** * Multiplayer Snake Game Web Client */ class GameClient { constructor() { this.ws = null; this.playerId = null; this.gameState = null; this.canvas = document.getElementById('game-canvas'); this.ctx = this.canvas.getContext('2d'); // Client-side prediction this.playerInputBuffers = {}; // player_id -> input_buffer array this.predictedHeads = {}; // player_id -> [x, y] predicted position // Game constants (matching Python) this.GRID_WIDTH = 40; this.GRID_HEIGHT = 30; this.CELL_SIZE = 20; // Colors (matching Python) this.COLOR_BACKGROUND = '#000000'; this.COLOR_GRID = '#282828'; this.COLOR_FOOD = '#ff0000'; this.COLOR_SNAKES = [ '#00ff00', // 0: Bright Green '#0000ff', // 1: Bright Blue '#ffff00', // 2: Yellow '#ff00ff', // 3: Magenta '#00ffff', // 4: Cyan '#ff8000', // 5: Orange '#8000ff', // 6: Purple '#ff0080', // 7: Pink '#80ff00', // 8: Lime '#0080ff', // 9: Sky Blue '#ff4040', // 10: Coral '#40ff40', // 11: Mint '#4040ff', // 12: Periwinkle '#ffff80', // 13: Light Yellow '#80ffff', // 14: Light Cyan '#ff80ff', // 15: Light Magenta '#c0c0c0', // 16: Silver '#ffc000', // 17: Gold '#c000c0', // 18: Dark Magenta '#00c0c0', // 19: Teal '#c0c000', // 20: Olive '#c06000', // 21: Brown '#60c000', // 22: Chartreuse '#0060c0', // 23: Azure '#c00060', // 24: Rose '#6000c0', // 25: Indigo '#00c060', // 26: Spring Green '#ffa0a0', // 27: Light Red '#a0ffa0', // 28: Light Green '#a0a0ff', // 29: Light Blue '#ffe0a0', // 30: Peach '#e0a0ff' // 31: Lavender ]; // Setup canvas this.canvas.width = this.GRID_WIDTH * this.CELL_SIZE; this.canvas.height = this.GRID_HEIGHT * this.CELL_SIZE; // Bind UI elements this.connectBtn = document.getElementById('connect-btn'); this.disconnectBtn = document.getElementById('disconnect-btn'); this.playerNameInput = document.getElementById('player-name'); this.serverUrlInput = document.getElementById('server-url'); this.connectionStatus = document.getElementById('connection-status'); this.connectionPanel = document.getElementById('connection-panel'); this.gamePanel = document.getElementById('game-panel'); this.gameOverlay = document.getElementById('game-overlay'); this.playersList = document.getElementById('players-list'); // Setup event listeners this.setupEventListeners(); // Start render loop this.render(); } setupEventListeners() { this.connectBtn.addEventListener('click', () => this.connect()); this.disconnectBtn.addEventListener('click', () => this.disconnect()); // Keyboard controls document.addEventListener('keydown', (e) => this.handleKeyPress(e)); // Enter key in inputs triggers connect this.playerNameInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.connect(); }); this.serverUrlInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.connect(); }); } connect() { const serverUrl = this.serverUrlInput.value.trim(); const playerName = this.playerNameInput.value.trim() || 'Player'; if (!serverUrl) { this.showStatus('Please enter a server URL', 'error'); return; } this.showStatus('Connecting...', 'info'); try { this.ws = new WebSocket(serverUrl); this.ws.onopen = () => { console.log('WebSocket connected'); this.showStatus('Connected! Joining game...', 'success'); // Send JOIN message const joinMsg = createJoinMessage(playerName); this.ws.send(joinMsg.toJSON()); }; this.ws.onmessage = (event) => { try { const message = Message.fromJSON(event.data); this.handleMessage(message); } catch (error) { console.error('Error parsing message:', error); } }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); this.showStatus('Connection error', 'error'); }; this.ws.onclose = () => { console.log('WebSocket closed'); this.showStatus('Disconnected from server', 'error'); this.showConnectionPanel(); }; } catch (error) { console.error('Connection error:', error); this.showStatus('Failed to connect: ' + error.message, 'error'); } } disconnect() { if (this.ws) { this.ws.close(); this.ws = null; } this.gameState = null; this.playerId = null; this.showConnectionPanel(); } handleMessage(message) { switch (message.type) { case MessageType.WELCOME: this.playerId = message.data.player_id; console.log('Assigned player ID:', this.playerId); this.showGamePanel(); break; case MessageType.STATE_UPDATE: this.gameState = message.data.game_state; this.updatePlayersList(); // Clear predictions on authoritative update this.predictedHeads = {}; if (this.gameState.game_running) { this.hideOverlay(); } break; case MessageType.PLAYER_JOINED: console.log('Player joined:', message.data.player_name); break; case MessageType.PLAYER_LEFT: console.log('Player left:', message.data.player_id); break; case MessageType.GAME_STARTED: console.log('Game started!'); this.hideOverlay(); break; case MessageType.GAME_OVER: console.log('Game over! Winner:', message.data.winner_id); this.showOverlay('Game Over!', message.data.winner_id ? `Winner: ${message.data.winner_id}` : 'No winner'); break; case MessageType.ERROR: console.error('Server error:', message.data.error); this.showStatus('Error: ' + message.data.error, 'error'); break; case MessageType.PLAYER_INPUT: // Update input buffer and predict next position const playerId = message.data.player_id; const direction = message.data.direction; const inputBuffer = message.data.input_buffer || []; this.playerInputBuffers[playerId] = inputBuffer; // Predict next head position if (this.gameState && this.gameState.snakes) { const snake = this.gameState.snakes.find(s => s.player_id === playerId); if (snake && snake.body && snake.body.length > 0) { // Use first buffered input if available, otherwise current direction const nextDir = inputBuffer.length > 0 ? inputBuffer[0] : direction; const head = snake.body[0]; this.predictedHeads[playerId] = [ head[0] + nextDir[0], head[1] + nextDir[1] ]; } } break; } } handleKeyPress(event) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return; } let direction = null; switch (event.key.toLowerCase()) { case 'arrowup': case 'w': direction = Direction.UP; event.preventDefault(); break; case 'arrowdown': case 's': direction = Direction.DOWN; event.preventDefault(); break; case 'arrowleft': case 'a': direction = Direction.LEFT; event.preventDefault(); break; case 'arrowright': case 'd': direction = Direction.RIGHT; event.preventDefault(); break; case ' ': // Start game const startMsg = createStartGameMessage(); this.ws.send(startMsg.toJSON()); event.preventDefault(); break; } if (direction) { const moveMsg = createMoveMessage(direction); this.ws.send(moveMsg.toJSON()); } } render() { // Clear canvas this.ctx.fillStyle = this.COLOR_BACKGROUND; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); // Draw grid this.drawGrid(); if (this.gameState) { // Draw food if (this.gameState.food) { this.ctx.fillStyle = this.COLOR_FOOD; for (const food of this.gameState.food) { const [x, y] = food.position; this.drawCell(x, y, this.COLOR_FOOD); } } // Draw snakes if (this.gameState.snakes) { this.gameState.snakes.forEach((snake) => { const color = this.COLOR_SNAKES[snake.color_index % this.COLOR_SNAKES.length]; if (snake.body && snake.alive) { // Draw body for (let i = 0; i < snake.body.length; i++) { const [x, y] = snake.body[i]; // Make head brighter if (i === 0) { const brightColor = this.brightenColor(color, 50); this.drawCell(x, y, brightColor); } else { this.drawCell(x, y, color); } } // Draw predicted head position (if available) if (this.predictedHeads[snake.player_id]) { const [px, py] = this.predictedHeads[snake.player_id]; const brightColor = this.brightenColor(color, 50); // Draw with reduced opacity (darker color) const predictedColor = this.darkenColor(brightColor, 0.6); this.drawCell(px, py, predictedColor); } } }); } } requestAnimationFrame(() => this.render()); } drawGrid() { this.ctx.strokeStyle = this.COLOR_GRID; this.ctx.lineWidth = 1; for (let x = 0; x <= this.GRID_WIDTH; x++) { this.ctx.beginPath(); this.ctx.moveTo(x * this.CELL_SIZE, 0); this.ctx.lineTo(x * this.CELL_SIZE, this.canvas.height); this.ctx.stroke(); } for (let y = 0; y <= this.GRID_HEIGHT; y++) { this.ctx.beginPath(); this.ctx.moveTo(0, y * this.CELL_SIZE); this.ctx.lineTo(this.canvas.width, y * this.CELL_SIZE); this.ctx.stroke(); } } drawCell(x, y, color) { this.ctx.fillStyle = color; this.ctx.fillRect( x * this.CELL_SIZE, y * this.CELL_SIZE, this.CELL_SIZE, this.CELL_SIZE ); // Border this.ctx.strokeStyle = this.COLOR_BACKGROUND; this.ctx.lineWidth = 1; this.ctx.strokeRect( x * this.CELL_SIZE, y * this.CELL_SIZE, this.CELL_SIZE, this.CELL_SIZE ); } brightenColor(hex, amount) { // Convert hex to RGB const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); // Brighten const newR = Math.min(255, r + amount); const newG = Math.min(255, g + amount); const newB = Math.min(255, b + amount); // Convert back to hex return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`; } darkenColor(hex, factor) { // Convert hex to RGB and multiply by factor const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); const newR = Math.floor(r * factor); const newG = Math.floor(g * factor); const newB = Math.floor(b * factor); return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`; } updatePlayersList() { if (!this.gameState || !this.gameState.snakes) { return; } this.playersList.innerHTML = ''; // Sort snakes by length descending const sortedSnakes = [...this.gameState.snakes].sort((a, b) => b.body.length - a.body.length); sortedSnakes.forEach((snake) => { const playerItem = document.createElement('div'); playerItem.className = `player-item ${snake.alive ? 'alive' : 'dead'}`; playerItem.style.borderLeftColor = this.COLOR_SNAKES[snake.color_index % this.COLOR_SNAKES.length]; const nameSpan = document.createElement('span'); nameSpan.className = 'player-name'; nameSpan.textContent = snake.player_id === this.playerId ? `You (${snake.player_name})` : snake.player_name; const scoreSpan = document.createElement('span'); scoreSpan.className = 'player-score'; scoreSpan.textContent = snake.body.length; playerItem.appendChild(nameSpan); playerItem.appendChild(scoreSpan); this.playersList.appendChild(playerItem); }); } showStatus(message, type) { this.connectionStatus.textContent = message; this.connectionStatus.className = `status ${type}`; } showConnectionPanel() { this.connectionPanel.style.display = 'block'; this.gamePanel.style.display = 'none'; } showGamePanel() { this.connectionPanel.style.display = 'none'; this.gamePanel.style.display = 'flex'; } hideOverlay() { this.gameOverlay.classList.add('hidden'); } showOverlay(title, message) { this.gameOverlay.classList.remove('hidden'); this.gameOverlay.querySelector('h2').textContent = title; this.gameOverlay.querySelector('p').textContent = message; } } // Initialize game when DOM is ready document.addEventListener('DOMContentLoaded', () => { new GameClient(); });