Add web client with WebSocket support
Implemented browser-based web client alongside existing pygame desktop client with dual-protocol server architecture supporting both TCP and WebSocket. Backend Changes: - Refactored GameServer for dual-protocol support (TCP + WebSocket) - Added WebSocketHandler for handling WebSocket connections - Added HTTPServer using aiohttp for serving web client files - Updated protocol handling to work with both connection types - Server tracks clients with protocol metadata (TCP/WebSocket) - Protocol-agnostic message sending and broadcasting - Added WebSocket port (8889) and HTTP port (8000) configuration Web Client: - Complete HTML5/CSS/JavaScript implementation - Responsive dark-themed UI - HTML5 Canvas rendering matching pygame visual style - WebSocket connection with auto-detected server URL - Real-time multiplayer gameplay in browser - Player list with scores and status - Mobile-friendly responsive design Deployment Options: - Development: Built-in HTTP server for local testing - Production: Disable HTTP server, use nginx/Apache for static files - Flexible server configuration (--no-http, --no-websocket flags) - Comprehensive nginx/Apache deployment documentation New Files: - src/server/websocket_handler.py - WebSocket connection handler - src/server/http_server.py - Static file server - web/index.html - Web client interface - web/style.css - Responsive styling - web/protocol.js - Protocol implementation - web/game.js - Game client with Canvas rendering - web/README.md - Deployment documentation Updated Files: - requirements.txt - Added websockets and aiohttp dependencies - src/server/game_server.py - Dual-protocol support - src/shared/constants.py - WebSocket and HTTP port constants - run_server.py - Server options for web support - README.md - Web client documentation - CLAUDE.md - Architecture documentation Features: - Web and desktop clients can play together simultaneously - Same JSON protocol for both client types - Independent server components (disable what you don't need) - Production-ready with reverse proxy support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
367
web/game.js
Normal file
367
web/game.js
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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');
|
||||
|
||||
// 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', // Green - Player 1
|
||||
'#0000ff', // Blue - Player 2
|
||||
'#ffff00', // Yellow - Player 3
|
||||
'#ff00ff' // Magenta - Player 4
|
||||
];
|
||||
|
||||
// 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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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, index) => {
|
||||
const color = this.COLOR_SNAKES[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
updatePlayersList() {
|
||||
if (!this.gameState || !this.gameState.snakes) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playersList.innerHTML = '';
|
||||
|
||||
this.gameState.snakes.forEach((snake, index) => {
|
||||
const playerItem = document.createElement('div');
|
||||
playerItem.className = `player-item ${snake.alive ? 'alive' : 'dead'}`;
|
||||
playerItem.style.borderLeftColor = this.COLOR_SNAKES[index % this.COLOR_SNAKES.length];
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'player-name';
|
||||
nameSpan.textContent = snake.player_id === this.playerId ?
|
||||
`You (${snake.player_id.substring(0, 8)})` :
|
||||
snake.player_id.substring(0, 8);
|
||||
|
||||
const scoreSpan = document.createElement('span');
|
||||
scoreSpan.className = 'player-score';
|
||||
scoreSpan.textContent = snake.score;
|
||||
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user