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:
241
web/README.md
Normal file
241
web/README.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Web Client Deployment
|
||||
|
||||
This directory contains the web client for the Multiplayer Snake game.
|
||||
|
||||
## Development Mode
|
||||
|
||||
For local development, use the built-in HTTP server:
|
||||
|
||||
```bash
|
||||
python run_server.py --name "Dev Server"
|
||||
```
|
||||
|
||||
This starts:
|
||||
- TCP server on port 8888 (for pygame clients)
|
||||
- WebSocket server on port 8889 (for web clients)
|
||||
- HTTP server on port 8000 (serving web files)
|
||||
|
||||
Access the web client at: http://localhost:8000
|
||||
|
||||
## Production Deployment with nginx
|
||||
|
||||
For production, disable the built-in HTTP server and use nginx to serve static files and proxy WebSocket connections.
|
||||
|
||||
### Step 1: Run server without HTTP
|
||||
|
||||
```bash
|
||||
python run_server.py --no-http --host 0.0.0.0
|
||||
```
|
||||
|
||||
### Step 2: Configure nginx
|
||||
|
||||
Create an nginx configuration file (e.g., `/etc/nginx/sites-available/snake-game`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name snake.example.com;
|
||||
|
||||
# Serve static web files
|
||||
location / {
|
||||
root /path/to/multiplayer-snake/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# Proxy WebSocket connections
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8889;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket timeouts
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/snake-game /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Step 3: Update web client URL
|
||||
|
||||
Users should connect to: `ws://snake.example.com/ws` (or `wss://` for SSL)
|
||||
|
||||
## SSL/TLS Support (HTTPS + WSS)
|
||||
|
||||
For secure connections with Let's Encrypt:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name snake.example.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name snake.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/snake.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/snake.example.com/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||
|
||||
# Static files
|
||||
location / {
|
||||
root /path/to/multiplayer-snake/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Secure WebSocket
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8889;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Apache Configuration
|
||||
|
||||
For Apache with mod_proxy_wstunnel:
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName snake.example.com
|
||||
DocumentRoot /path/to/multiplayer-snake/web
|
||||
|
||||
<Directory /path/to/multiplayer-snake/web>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# WebSocket proxy
|
||||
ProxyPass /ws ws://localhost:8889
|
||||
ProxyPassReverse /ws ws://localhost:8889
|
||||
|
||||
# Enable WebSocket
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule /ws/(.*) ws://localhost:8889/$1 [P,L]
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Enable required modules:
|
||||
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
## Caddy Configuration
|
||||
|
||||
Caddy makes it even simpler with automatic HTTPS:
|
||||
|
||||
```
|
||||
snake.example.com {
|
||||
root * /path/to/multiplayer-snake/web
|
||||
file_server
|
||||
|
||||
handle /ws {
|
||||
reverse_proxy localhost:8889
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/snake-game.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Snake Game Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/path/to/multiplayer-snake
|
||||
ExecStart=/path/to/multiplayer-snake/venv/bin/python run_server.py --no-http --host 0.0.0.0
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable snake-game
|
||||
sudo systemctl start snake-game
|
||||
sudo systemctl status snake-game
|
||||
```
|
||||
|
||||
## Firewall Configuration
|
||||
|
||||
Open required ports:
|
||||
|
||||
```bash
|
||||
# HTTP/HTTPS (nginx/Apache)
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
|
||||
# Game server TCP (if accessed directly)
|
||||
sudo ufw allow 8888/tcp
|
||||
|
||||
# WebSocket (if not behind proxy)
|
||||
sudo ufw allow 8889/tcp
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### WebSocket connection fails
|
||||
|
||||
- Check firewall rules
|
||||
- Verify nginx/Apache WebSocket proxy configuration
|
||||
- Check browser console for errors
|
||||
- Test WebSocket endpoint: `wscat -c ws://your-server:8889`
|
||||
|
||||
### Static files not loading
|
||||
|
||||
- Verify nginx root path is correct
|
||||
- Check file permissions: `chmod -R 755 /path/to/web`
|
||||
- Check nginx error logs: `sudo tail -f /var/log/nginx/error.log`
|
||||
|
||||
### Game server not starting
|
||||
|
||||
- Check if ports are already in use: `sudo netstat -tulpn | grep 8889`
|
||||
- Review server logs
|
||||
- Verify Python dependencies are installed
|
||||
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();
|
||||
});
|
||||
73
web/index.html
Normal file
73
web/index.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multiplayer Snake Game</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🐍 Multiplayer Snake</h1>
|
||||
</header>
|
||||
|
||||
<div id="connection-panel" class="panel">
|
||||
<h2>Connect to Server</h2>
|
||||
<div class="form-group">
|
||||
<label for="player-name">Your Name:</label>
|
||||
<input type="text" id="player-name" placeholder="Enter your name" value="Player" maxlength="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="server-url">Server URL:</label>
|
||||
<input type="text" id="server-url" placeholder="ws://localhost:8889" value="">
|
||||
</div>
|
||||
<button id="connect-btn" class="btn btn-primary">Connect</button>
|
||||
<div id="connection-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="game-panel" class="panel" style="display: none;">
|
||||
<div class="game-area">
|
||||
<canvas id="game-canvas"></canvas>
|
||||
<div id="game-overlay" class="overlay">
|
||||
<div class="overlay-content">
|
||||
<h2>Waiting for game to start...</h2>
|
||||
<p>Press <kbd>SPACE</kbd> to start the game</p>
|
||||
<p>Use <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> or <kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> to move</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="score-panel">
|
||||
<h3>Players</h3>
|
||||
<div id="players-list"></div>
|
||||
</div>
|
||||
<div class="controls-panel">
|
||||
<h3>Controls</h3>
|
||||
<div class="control-info">
|
||||
<p><kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> Move</p>
|
||||
<p><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> Move</p>
|
||||
<p><kbd>SPACE</kbd> Start Game</p>
|
||||
</div>
|
||||
<button id="disconnect-btn" class="btn btn-secondary">Disconnect</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="protocol.js"></script>
|
||||
<script src="game.js"></script>
|
||||
<script>
|
||||
// Auto-detect WebSocket URL
|
||||
const wsUrl = document.getElementById('server-url');
|
||||
if (window.location.protocol === 'file:') {
|
||||
wsUrl.value = 'ws://localhost:8889';
|
||||
} else {
|
||||
const host = window.location.hostname;
|
||||
const port = window.location.port ? parseInt(window.location.port) + 889 : 8889;
|
||||
wsUrl.value = `ws://${host}:${port}`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
65
web/protocol.js
Normal file
65
web/protocol.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Network protocol for client-server communication
|
||||
* Matches the Python protocol implementation
|
||||
*/
|
||||
|
||||
const MessageType = {
|
||||
// Client -> Server
|
||||
JOIN: 'JOIN',
|
||||
MOVE: 'MOVE',
|
||||
START_GAME: 'START_GAME',
|
||||
LEAVE: 'LEAVE',
|
||||
|
||||
// Server -> Client
|
||||
WELCOME: 'WELCOME',
|
||||
STATE_UPDATE: 'STATE_UPDATE',
|
||||
PLAYER_JOINED: 'PLAYER_JOINED',
|
||||
PLAYER_LEFT: 'PLAYER_LEFT',
|
||||
GAME_STARTED: 'GAME_STARTED',
|
||||
GAME_OVER: 'GAME_OVER',
|
||||
ERROR: 'ERROR'
|
||||
};
|
||||
|
||||
class Message {
|
||||
constructor(type, data = {}) {
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return JSON.stringify({
|
||||
type: this.type,
|
||||
data: this.data
|
||||
});
|
||||
}
|
||||
|
||||
static fromJSON(jsonStr) {
|
||||
const obj = JSON.parse(jsonStr);
|
||||
return new Message(obj.type, obj.data || {});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for creating messages
|
||||
function createJoinMessage(playerName) {
|
||||
return new Message(MessageType.JOIN, { player_name: playerName });
|
||||
}
|
||||
|
||||
function createMoveMessage(direction) {
|
||||
return new Message(MessageType.MOVE, { direction: direction });
|
||||
}
|
||||
|
||||
function createStartGameMessage() {
|
||||
return new Message(MessageType.START_GAME);
|
||||
}
|
||||
|
||||
function createLeaveMessage() {
|
||||
return new Message(MessageType.LEAVE);
|
||||
}
|
||||
|
||||
// Direction constants (matching Python)
|
||||
const Direction = {
|
||||
UP: [0, -1],
|
||||
DOWN: [0, 1],
|
||||
LEFT: [-1, 0],
|
||||
RIGHT: [1, 0]
|
||||
};
|
||||
275
web/style.css
Normal file
275
web/style.css
Normal file
@@ -0,0 +1,275 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #00ff88;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#connection-panel {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #00ff88;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #00ff88 0%, #00cc6f 100%);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e0e0e0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.status.info {
|
||||
background: rgba(0, 136, 255, 0.2);
|
||||
color: #00aaff;
|
||||
}
|
||||
|
||||
#game-panel {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.game-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#game-canvas {
|
||||
display: block;
|
||||
background: #0a0a0a;
|
||||
border: 2px solid #00ff88;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.3);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.overlay-content h2 {
|
||||
color: #00ff88;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.overlay-content p {
|
||||
margin: 10px 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.score-panel,
|
||||
.controls-panel {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.score-panel h3,
|
||||
.controls-panel h3 {
|
||||
color: #00ff88;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#players-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.player-item.alive {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.player-item.dead {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.player-score {
|
||||
color: #00ff88;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.control-info p {
|
||||
margin: 8px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 1024px) {
|
||||
#game-panel {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.score-panel,
|
||||
.controls-panel {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user