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:
118
CLAUDE.md
118
CLAUDE.md
@@ -4,12 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **network multiplayer Snake game** built with Python 3.11, using asyncio for networking, pygame for graphics, and UDP multicast for automatic server discovery.
|
||||
This is a **network multiplayer Snake game** built with Python 3.11, featuring both web and desktop clients with dual-protocol support (TCP + WebSocket).
|
||||
|
||||
**Project Structure:**
|
||||
- `src/shared/` - Shared code (models, protocol, constants, discovery utilities)
|
||||
- `src/server/` - Game server with authoritative game state and multicast beacon
|
||||
- `src/client/` - Game client with pygame rendering, discovery, and server selection UI
|
||||
- `src/server/` - Dual-protocol game server (TCP + WebSocket), HTTP server, multicast beacon
|
||||
- `src/client/` - Pygame desktop client with discovery and server selection UI
|
||||
- `web/` - Browser-based web client (HTML/CSS/JavaScript)
|
||||
- `tests/` - Test files using pytest
|
||||
|
||||
## Setup Commands
|
||||
@@ -30,27 +31,29 @@ pip install -r requirements-dev.txt
|
||||
## Running the Game
|
||||
|
||||
```bash
|
||||
# Start the server (with discovery beacon enabled by default)
|
||||
python run_server.py --name "My Game" --port 8888
|
||||
# Web Client (easiest - no installation needed)
|
||||
python run_server.py --name "My Game"
|
||||
# Open browser to http://localhost:8000
|
||||
|
||||
# Start clients with auto-discovery (no host needed)
|
||||
# Desktop Client (pygame)
|
||||
python run_client.py --name Alice
|
||||
# If multiple servers found, a selection UI appears
|
||||
# Auto-discovers server on local network
|
||||
|
||||
# Manual connection (skip discovery)
|
||||
python run_client.py 192.168.1.100 --port 8888 --name Bob
|
||||
# Server Options:
|
||||
# --host HOST Bind address (default: localhost)
|
||||
# --port PORT TCP port for desktop clients (default: 8888)
|
||||
# --ws-port PORT WebSocket port for web clients (default: 8889, 0=disable)
|
||||
# --http-port PORT HTTP server port (default: 8000, 0=disable)
|
||||
# --name NAME Server name for discovery
|
||||
# --no-discovery Disable multicast beacon
|
||||
# --no-websocket Disable WebSocket server (desktop clients only)
|
||||
# --no-http Disable HTTP server (use nginx/Apache in production)
|
||||
# --web-dir PATH Web files directory (default: web)
|
||||
|
||||
# Server options:
|
||||
# --host HOST Bind address (default: localhost)
|
||||
# --port PORT Port number (default: 8888)
|
||||
# --name NAME Server name for discovery
|
||||
# --no-discovery Disable multicast beacon
|
||||
|
||||
# Client options:
|
||||
# [host] Server host (omit for auto-discovery)
|
||||
# --port PORT Server port
|
||||
# --name NAME Player name
|
||||
# --discover Force discovery mode
|
||||
# Production Deployment:
|
||||
python run_server.py --no-http --host 0.0.0.0
|
||||
# Use nginx to serve web/ directory and proxy WebSocket to port 8889
|
||||
# See web/README.md for nginx configuration examples
|
||||
|
||||
# Press SPACE to start the game, arrow keys/WASD to move
|
||||
```
|
||||
@@ -76,35 +79,54 @@ mypy src/
|
||||
|
||||
## Architecture
|
||||
|
||||
**Client-Server Model:**
|
||||
- Server (`src/server/game_server.py`) runs the authoritative game loop using asyncio
|
||||
- Clients connect via TCP and send input commands (MOVE, START_GAME, etc.)
|
||||
- Server broadcasts game state updates to all connected clients at 10 FPS
|
||||
- Clients render the game state locally at 60 FPS using pygame
|
||||
**Dual-Protocol Client-Server Model:**
|
||||
- Server runs authoritative game loop using asyncio
|
||||
- **Desktop clients** connect via TCP (port 8888) with newline-delimited JSON
|
||||
- **Web clients** connect via WebSocket (port 8889) with JSON frames
|
||||
- Server broadcasts game state updates to all clients at 10 FPS
|
||||
- Desktop clients render at 60 FPS with pygame
|
||||
- Web clients render at 60 FPS with HTML5 Canvas
|
||||
|
||||
**Key Components:**
|
||||
- `src/shared/protocol.py` - JSON-based message protocol (MessageType enum, Message class)
|
||||
- `src/shared/models.py` - Data models (Snake, Position, Food, GameState) with serialization
|
||||
- `src/shared/constants.py` - Game configuration (grid size, colors, tick rate, multicast settings)
|
||||
- `src/shared/discovery.py` - Multicast discovery utilities (ServerInfo, DiscoveryMessage)
|
||||
- `src/server/game_logic.py` - Game rules (movement, collision detection, food spawning)
|
||||
- `src/server/server_beacon.py` - UDP multicast beacon for server discovery
|
||||
- `src/client/renderer.py` - Pygame rendering of game state
|
||||
- `src/client/server_discovery.py` - Client-side server discovery
|
||||
- `src/client/server_selector.py` - Pygame UI for selecting from multiple servers
|
||||
**Key Backend Components:**
|
||||
- `src/shared/protocol.py` - JSON-based message protocol (same for TCP and WebSocket)
|
||||
- `src/shared/models.py` - Data models with serialization (Snake, Position, Food, GameState)
|
||||
- `src/shared/constants.py` - Configuration (grid, colors, tick rate, ports)
|
||||
- `src/shared/discovery.py` - Multicast discovery utilities
|
||||
- `src/server/game_server.py` - Dual-protocol game server (TCP + WebSocket)
|
||||
- `src/server/game_logic.py` - Game rules (movement, collision, food)
|
||||
- `src/server/websocket_handler.py` - WebSocket connection handler
|
||||
- `src/server/http_server.py` - Static file server for web client (aiohttp)
|
||||
- `src/server/server_beacon.py` - UDP multicast beacon
|
||||
|
||||
**Desktop Client Components:**
|
||||
- `src/client/game_client.py` - pygame client with TCP connection
|
||||
- `src/client/renderer.py` - Pygame rendering
|
||||
- `src/client/server_discovery.py` - Multicast discovery
|
||||
- `src/client/server_selector.py` - Server selection UI
|
||||
|
||||
**Web Client Components:**
|
||||
- `web/index.html` - Main HTML interface
|
||||
- `web/style.css` - Responsive styling (dark theme)
|
||||
- `web/protocol.js` - Protocol implementation (JavaScript)
|
||||
- `web/game.js` - Game client with Canvas rendering and WebSocket connection
|
||||
|
||||
**Game Flow:**
|
||||
1. Server starts on port 8888 and broadcasts presence on multicast group 239.255.0.1:9999
|
||||
2. Clients send DISCOVER message to multicast group and collect SERVER_ANNOUNCE responses
|
||||
3. If multiple servers found, client shows selection UI; if one found, auto-connects
|
||||
4. Clients connect via TCP and receive WELCOME message with player_id
|
||||
5. Any player can press SPACE to send START_GAME message
|
||||
6. Server creates snakes for all connected players and spawns food
|
||||
7. Server runs game loop: update positions → check collisions → broadcast state
|
||||
8. Game ends when only 0-1 snakes remain alive
|
||||
1. Server starts TCP (8888), WebSocket (8889), HTTP (8000), and broadcasts on multicast (9999)
|
||||
2. Desktop clients discover via multicast; web clients use auto-detected WebSocket URL
|
||||
3. Clients connect and receive WELCOME with player_id
|
||||
4. Any player presses SPACE to send START_GAME
|
||||
5. Server creates snakes and spawns food
|
||||
6. Server loop: update → check collisions → broadcast state (protocol-agnostic)
|
||||
7. Game ends when ≤1 snake alive
|
||||
|
||||
**Discovery Protocol:**
|
||||
- Multicast group: 239.255.0.1:9999 (local network)
|
||||
- Client → Multicast: DISCOVER (broadcast request)
|
||||
- Server → Client: SERVER_ANNOUNCE (direct response with host, port, name, player count)
|
||||
- Server also periodically broadcasts presence every 2 seconds
|
||||
**Protocol Handling:**
|
||||
- Server stores clients as `(connection, ClientType.TCP|WEBSOCKET)`
|
||||
- `send_to_client()` detects type and sends via appropriate method
|
||||
- TCP: newline-delimited JSON (`message\n`)
|
||||
- WebSocket: JSON frames (native WebSocket message)
|
||||
|
||||
**Production Deployment:**
|
||||
- Run server with `--no-http` flag
|
||||
- Use nginx/Apache to serve `web/` directory
|
||||
- Proxy WebSocket connections to port 8889
|
||||
- See `web/README.md` for configuration examples
|
||||
|
||||
69
README.md
69
README.md
@@ -5,7 +5,10 @@ A network multiplayer Snake game built with Python, asyncio, and pygame.
|
||||
## Features
|
||||
|
||||
- Real-time multiplayer gameplay with client-server architecture
|
||||
- **Web client** - Play in your browser without installing anything!
|
||||
- **Desktop client** - Native pygame client for local play
|
||||
- **Automatic server discovery** using multicast (zero-configuration LAN play)
|
||||
- **Dual-protocol support** - TCP for desktop, WebSocket for web
|
||||
- Support for multiple players simultaneously
|
||||
- Classic Snake gameplay with collision detection
|
||||
- Color-coded snakes for each player
|
||||
@@ -27,40 +30,86 @@ pip install -r requirements-dev.txt
|
||||
|
||||
## Running the Game
|
||||
|
||||
### Quick Start (Auto-Discovery)
|
||||
### Web Client (Easiest!)
|
||||
|
||||
1. **Start the server** (in one terminal):
|
||||
1. **Start the server**:
|
||||
```bash
|
||||
python run_server.py --name "My Game"
|
||||
```
|
||||
|
||||
2. **Open your browser** and navigate to:
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
3. **Enter your name** and click Connect. The WebSocket URL is auto-detected!
|
||||
|
||||
4. **Play!** Multiple players can join from different browsers/devices.
|
||||
|
||||
### Desktop Client (Pygame)
|
||||
|
||||
1. **Start the server** (if not already running):
|
||||
```bash
|
||||
python run_server.py
|
||||
# Optional: python run_server.py --name "My Server" --port 8888
|
||||
```
|
||||
|
||||
2. **Start one or more clients** (in separate terminals):
|
||||
2. **Start pygame clients** (in separate terminals):
|
||||
```bash
|
||||
python run_client.py --name Alice
|
||||
# Clients will automatically discover servers on the local network
|
||||
# Auto-discovers the server on your network
|
||||
```
|
||||
|
||||
### Mixed Play
|
||||
|
||||
Web and desktop clients can play together! The server supports both protocols simultaneously:
|
||||
- Desktop clients connect via TCP (port 8888)
|
||||
- Web clients connect via WebSocket (port 8889)
|
||||
|
||||
### Manual Connection
|
||||
|
||||
```bash
|
||||
# Server
|
||||
python run_server.py --host 0.0.0.0 --port 8888 --name "Game Room"
|
||||
# Server with all options
|
||||
python run_server.py --host 0.0.0.0 --port 8888 --ws-port 8889 --name "Game Room"
|
||||
|
||||
# Client (specify host directly)
|
||||
# Desktop client (direct connection)
|
||||
python run_client.py 192.168.1.100 --port 8888 --name Bob
|
||||
|
||||
# Web client: enter ws://192.168.1.100:8889 in the browser
|
||||
```
|
||||
|
||||
### Server Options
|
||||
|
||||
```bash
|
||||
python run_server.py --help
|
||||
|
||||
# Network
|
||||
# --host HOST Host address to bind to (default: localhost)
|
||||
# --port PORT Port number (default: 8888)
|
||||
# --port PORT TCP port for desktop clients (default: 8888)
|
||||
# --ws-port PORT WebSocket port for web clients (default: 8889, 0 to disable)
|
||||
# --http-port PORT HTTP server port (default: 8000, 0 to disable)
|
||||
# --name NAME Server name for discovery (default: Snake Server)
|
||||
# --no-discovery Disable multicast beacon
|
||||
|
||||
# Features
|
||||
# --no-discovery Disable multicast discovery beacon
|
||||
# --no-websocket Disable WebSocket server (desktop only)
|
||||
# --no-http Disable HTTP server (use external web server like nginx)
|
||||
# --web-dir PATH Path to web files (default: web)
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production with nginx or Apache:
|
||||
|
||||
```bash
|
||||
# Run server without built-in HTTP server
|
||||
python run_server.py --no-http --host 0.0.0.0
|
||||
|
||||
# nginx serves static files from web/ directory
|
||||
# and proxies WebSocket connections to port 8889
|
||||
```
|
||||
|
||||
See `web/README.md` for detailed nginx/Apache configuration.
|
||||
|
||||
### Client Options
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
# Production dependencies
|
||||
pygame>=2.5.0
|
||||
websockets>=12.0
|
||||
aiohttp>=3.9.0
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from src.server.game_server import GameServer
|
||||
from src.shared.constants import DEFAULT_HOST, DEFAULT_PORT
|
||||
from src.server.http_server import HTTPServer
|
||||
from src.shared.constants import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_WS_PORT, DEFAULT_HTTP_PORT
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -18,7 +20,24 @@ async def main() -> None:
|
||||
"--port",
|
||||
type=int,
|
||||
default=DEFAULT_PORT,
|
||||
help=f"Port number to bind to (default: {DEFAULT_PORT})",
|
||||
help=f"TCP port number (default: {DEFAULT_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ws-port",
|
||||
type=int,
|
||||
default=DEFAULT_WS_PORT,
|
||||
help=f"WebSocket port (default: {DEFAULT_WS_PORT}, 0 to disable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--http-port",
|
||||
type=int,
|
||||
default=DEFAULT_HTTP_PORT,
|
||||
help=f"HTTP server port (default: {DEFAULT_HTTP_PORT}, 0 to disable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--web-dir",
|
||||
default="web",
|
||||
help="Directory containing web client files (default: web)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
@@ -30,17 +49,48 @@ async def main() -> None:
|
||||
action="store_true",
|
||||
help="Disable multicast discovery beacon",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-websocket",
|
||||
action="store_true",
|
||||
help="Disable WebSocket server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-http",
|
||||
action="store_true",
|
||||
help="Disable HTTP server (for production with external web server)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine WebSocket port
|
||||
ws_port = None if args.no_websocket or args.ws_port == 0 else args.ws_port
|
||||
|
||||
# Create game server
|
||||
server = GameServer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
server_name=args.name,
|
||||
enable_discovery=not args.no_discovery,
|
||||
ws_port=ws_port,
|
||||
)
|
||||
|
||||
await server.start()
|
||||
# Start HTTP server if enabled
|
||||
http_task = None
|
||||
if not args.no_http and args.http_port > 0:
|
||||
web_dir = Path(args.web_dir)
|
||||
if web_dir.exists():
|
||||
http_server = HTTPServer(web_dir, args.http_port, "0.0.0.0")
|
||||
await http_server.start()
|
||||
http_task = asyncio.create_task(asyncio.Future()) # Keep running
|
||||
else:
|
||||
print(f"Warning: Web directory '{web_dir}' not found. HTTP server disabled.")
|
||||
|
||||
# Start game server
|
||||
try:
|
||||
await server.start()
|
||||
finally:
|
||||
if http_task:
|
||||
http_task.cancel()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Dict, Set
|
||||
from typing import Dict, Set, Union, Any
|
||||
from enum import Enum
|
||||
|
||||
from ..shared.protocol import (
|
||||
Message,
|
||||
@@ -20,8 +21,14 @@ from .game_logic import GameLogic
|
||||
from .server_beacon import ServerBeacon
|
||||
|
||||
|
||||
class ClientType(Enum):
|
||||
"""Type of client connection."""
|
||||
TCP = "TCP"
|
||||
WEBSOCKET = "WebSocket"
|
||||
|
||||
|
||||
class GameServer:
|
||||
"""Multiplayer Snake game server."""
|
||||
"""Multiplayer Snake game server with dual-protocol support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -29,24 +36,32 @@ class GameServer:
|
||||
port: int = DEFAULT_PORT,
|
||||
server_name: str = "Snake Server",
|
||||
enable_discovery: bool = True,
|
||||
ws_port: int | None = None,
|
||||
):
|
||||
"""Initialize the game server.
|
||||
|
||||
Args:
|
||||
host: Host address to bind to
|
||||
port: Port number to bind to
|
||||
port: TCP port number to bind to
|
||||
server_name: Name of the server for discovery
|
||||
enable_discovery: Enable multicast discovery beacon
|
||||
ws_port: WebSocket port (None to disable WebSocket)
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.ws_port = ws_port
|
||||
self.server_name = server_name
|
||||
self.enable_discovery = enable_discovery
|
||||
self.clients: Dict[str, asyncio.StreamWriter] = {}
|
||||
|
||||
# Store clients with their connection type
|
||||
# Format: {player_id: (connection, client_type)}
|
||||
self.clients: Dict[str, tuple[Any, ClientType]] = {}
|
||||
self.player_names: Dict[str, str] = {}
|
||||
|
||||
self.game_logic = GameLogic()
|
||||
self.game_task: asyncio.Task | None = None
|
||||
self.beacon_task: asyncio.Task | None = None
|
||||
self.ws_task: asyncio.Task | None = None
|
||||
self.beacon: ServerBeacon | None = None
|
||||
|
||||
async def handle_client(
|
||||
@@ -74,10 +89,16 @@ class GameServer:
|
||||
# Parse message
|
||||
try:
|
||||
message = Message.from_json(data.decode().strip())
|
||||
await self.handle_message(player_id, message, writer)
|
||||
await self.handle_message(player_id, message, writer, ClientType.TCP)
|
||||
except Exception as e:
|
||||
print(f"Error parsing message from {player_id}: {e}")
|
||||
await self.send_message(writer, create_error_message(str(e)))
|
||||
error_msg = create_error_message(str(e))
|
||||
try:
|
||||
data = error_msg.to_json() + "\n"
|
||||
writer.write(data.encode())
|
||||
await writer.drain()
|
||||
except:
|
||||
pass
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -92,17 +113,19 @@ class GameServer:
|
||||
self,
|
||||
player_id: str,
|
||||
message: Message,
|
||||
writer: asyncio.StreamWriter
|
||||
connection: Any = None,
|
||||
client_type: ClientType = ClientType.TCP
|
||||
) -> None:
|
||||
"""Handle a message from a client.
|
||||
|
||||
Args:
|
||||
player_id: ID of the player who sent the message
|
||||
message: The message received
|
||||
writer: Stream writer for responding
|
||||
connection: Client connection (for JOIN messages)
|
||||
client_type: Type of client connection
|
||||
"""
|
||||
if message.type == MessageType.JOIN:
|
||||
await self.handle_join(player_id, message, writer)
|
||||
await self.handle_join(player_id, message, connection, client_type)
|
||||
elif message.type == MessageType.MOVE:
|
||||
await self.handle_move(player_id, message)
|
||||
elif message.type == MessageType.START_GAME:
|
||||
@@ -114,21 +137,23 @@ class GameServer:
|
||||
self,
|
||||
player_id: str,
|
||||
message: Message,
|
||||
writer: asyncio.StreamWriter
|
||||
connection: Any,
|
||||
client_type: ClientType
|
||||
) -> None:
|
||||
"""Handle a player joining.
|
||||
|
||||
Args:
|
||||
player_id: ID of the joining player
|
||||
message: JOIN message with player name
|
||||
writer: Stream writer for the client
|
||||
connection: Client connection (writer or websocket)
|
||||
client_type: Type of client connection
|
||||
"""
|
||||
player_name = message.data.get("player_name", f"Player_{player_id[:8]}")
|
||||
self.clients[player_id] = writer
|
||||
self.clients[player_id] = (connection, client_type)
|
||||
self.player_names[player_id] = player_name
|
||||
|
||||
# Send welcome message to new player
|
||||
await self.send_message(writer, create_welcome_message(player_id))
|
||||
await self.send_to_client(player_id, create_welcome_message(player_id))
|
||||
|
||||
# Notify all clients about new player
|
||||
await self.broadcast(create_player_joined_message(player_id, player_name))
|
||||
@@ -138,7 +163,7 @@ class GameServer:
|
||||
snake = self.game_logic.create_snake(player_id)
|
||||
self.game_logic.state.snakes.append(snake)
|
||||
|
||||
print(f"Player {player_name} ({player_id}) joined. Total players: {len(self.clients)}")
|
||||
print(f"Player {player_name} ({player_id}) joined via {client_type.value}. Total players: {len(self.clients)}")
|
||||
|
||||
async def handle_move(self, player_id: str, message: Message) -> None:
|
||||
"""Handle a player movement command.
|
||||
@@ -220,19 +245,29 @@ class GameServer:
|
||||
# Wait for next tick
|
||||
await asyncio.sleep(TICK_RATE)
|
||||
|
||||
async def send_message(self, writer: asyncio.StreamWriter, message: Message) -> None:
|
||||
"""Send a message to a client.
|
||||
async def send_to_client(self, player_id: str, message: Message) -> None:
|
||||
"""Send a message to a specific client.
|
||||
|
||||
Args:
|
||||
writer: Stream writer for the client
|
||||
player_id: Player ID to send to
|
||||
message: Message to send
|
||||
"""
|
||||
if player_id not in self.clients:
|
||||
return
|
||||
|
||||
connection, client_type = self.clients[player_id]
|
||||
|
||||
try:
|
||||
data = message.to_json() + "\n"
|
||||
writer.write(data.encode())
|
||||
await writer.drain()
|
||||
if client_type == ClientType.TCP:
|
||||
# TCP: newline-delimited JSON
|
||||
data = message.to_json() + "\n"
|
||||
connection.write(data.encode())
|
||||
await connection.drain()
|
||||
elif client_type == ClientType.WEBSOCKET:
|
||||
# WebSocket: JSON frames
|
||||
await connection.send(message.to_json())
|
||||
except Exception as e:
|
||||
print(f"Error sending message: {e}")
|
||||
print(f"Error sending message to {player_id} ({client_type.value}): {e}")
|
||||
|
||||
async def broadcast(self, message: Message, exclude: Set[str] = None) -> None:
|
||||
"""Broadcast a message to all connected clients.
|
||||
@@ -242,9 +277,9 @@ class GameServer:
|
||||
exclude: Set of player IDs to exclude from broadcast
|
||||
"""
|
||||
exclude = exclude or set()
|
||||
for player_id, writer in list(self.clients.items()):
|
||||
for player_id in list(self.clients.keys()):
|
||||
if player_id not in exclude:
|
||||
await self.send_message(writer, message)
|
||||
await self.send_to_client(player_id, message)
|
||||
|
||||
def get_player_count(self) -> int:
|
||||
"""Get the current number of connected players.
|
||||
@@ -254,8 +289,27 @@ class GameServer:
|
||||
"""
|
||||
return len(self.clients)
|
||||
|
||||
async def start_websocket_server(self) -> None:
|
||||
"""Start WebSocket server."""
|
||||
from .websocket_handler import WebSocketHandler, start_websocket_server
|
||||
|
||||
async def on_message(player_id: str, message: Message, websocket: Any) -> None:
|
||||
"""Handle WebSocket message."""
|
||||
await self.handle_message(player_id, message, websocket, ClientType.WEBSOCKET)
|
||||
|
||||
async def on_disconnect(player_id: str) -> None:
|
||||
"""Handle WebSocket disconnection."""
|
||||
await self.remove_player(player_id)
|
||||
|
||||
handler = WebSocketHandler(
|
||||
on_message=on_message,
|
||||
on_disconnect=on_disconnect,
|
||||
)
|
||||
|
||||
await start_websocket_server(self.host, self.ws_port, handler)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the server."""
|
||||
"""Start the server (TCP and optionally WebSocket)."""
|
||||
# Start discovery beacon if enabled
|
||||
if self.enable_discovery:
|
||||
self.beacon = ServerBeacon(
|
||||
@@ -265,6 +319,12 @@ class GameServer:
|
||||
)
|
||||
self.beacon_task = asyncio.create_task(self.beacon.start())
|
||||
|
||||
# Start WebSocket server if enabled
|
||||
if self.ws_port:
|
||||
self.ws_task = asyncio.create_task(self.start_websocket_server())
|
||||
print(f"WebSocket server will start on ws://{self.host}:{self.ws_port}")
|
||||
|
||||
# Start TCP server
|
||||
server = await asyncio.start_server(
|
||||
self.handle_client,
|
||||
self.host,
|
||||
@@ -278,7 +338,7 @@ class GameServer:
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
finally:
|
||||
# Clean up beacon on shutdown
|
||||
# Clean up
|
||||
if self.beacon:
|
||||
await self.beacon.stop()
|
||||
if self.beacon_task:
|
||||
@@ -287,6 +347,12 @@ class GameServer:
|
||||
await self.beacon_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.ws_task:
|
||||
self.ws_task.cancel()
|
||||
try:
|
||||
await self.ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
|
||||
61
src/server/http_server.py
Normal file
61
src/server/http_server.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""HTTP server for serving web client static files."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from ..shared.constants import DEFAULT_HTTP_PORT
|
||||
|
||||
|
||||
class HTTPServer:
|
||||
"""Simple HTTP server for serving static web client files."""
|
||||
|
||||
def __init__(self, web_dir: str | Path, port: int = DEFAULT_HTTP_PORT, host: str = "0.0.0.0"):
|
||||
"""Initialize HTTP server.
|
||||
|
||||
Args:
|
||||
web_dir: Directory containing web client files
|
||||
port: Port to listen on
|
||||
host: Host address to bind to
|
||||
"""
|
||||
self.web_dir = Path(web_dir)
|
||||
self.port = port
|
||||
self.host = host
|
||||
self.app = web.Application()
|
||||
self.runner = None
|
||||
|
||||
# Setup routes
|
||||
self.app.router.add_static('/', self.web_dir, show_index=True)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the HTTP server."""
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
|
||||
site = web.TCPSite(self.runner, self.host, self.port)
|
||||
await site.start()
|
||||
|
||||
print(f"HTTP server serving {self.web_dir} on http://{self.host}:{self.port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the HTTP server."""
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
|
||||
|
||||
async def start_http_server(web_dir: str | Path, port: int = DEFAULT_HTTP_PORT, host: str = "0.0.0.0") -> None:
|
||||
"""Start HTTP server and run forever.
|
||||
|
||||
Args:
|
||||
web_dir: Directory containing web files
|
||||
port: Port to listen on
|
||||
host: Host to bind to
|
||||
"""
|
||||
server = HTTPServer(web_dir, port, host)
|
||||
await server.start()
|
||||
|
||||
# Run forever
|
||||
try:
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
await server.stop()
|
||||
95
src/server/websocket_handler.py
Normal file
95
src/server/websocket_handler.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""WebSocket handler for web clients."""
|
||||
|
||||
import asyncio
|
||||
import websockets
|
||||
from typing import Callable, Awaitable
|
||||
from websockets.server import WebSocketServerProtocol
|
||||
|
||||
from ..shared.protocol import Message
|
||||
|
||||
|
||||
class WebSocketHandler:
|
||||
"""Handles WebSocket connections for web clients."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_message: Callable[[str, Message, WebSocketServerProtocol], Awaitable[None]],
|
||||
on_disconnect: Callable[[str], Awaitable[None]],
|
||||
):
|
||||
"""Initialize WebSocket handler.
|
||||
|
||||
Args:
|
||||
on_message: Callback for received messages (player_id, message, websocket)
|
||||
on_disconnect: Callback for disconnections (player_id)
|
||||
"""
|
||||
self.on_message = on_message
|
||||
self.on_disconnect = on_disconnect
|
||||
|
||||
async def handle_client(
|
||||
self,
|
||||
websocket: WebSocketServerProtocol,
|
||||
path: str,
|
||||
) -> None:
|
||||
"""Handle a WebSocket client connection.
|
||||
|
||||
Args:
|
||||
websocket: WebSocket connection
|
||||
path: Request path (unused)
|
||||
"""
|
||||
# Generate player ID (will be replaced when JOIN message received)
|
||||
import uuid
|
||||
player_id = str(uuid.uuid4())
|
||||
|
||||
remote_addr = websocket.remote_address
|
||||
print(f"WebSocket connection from {remote_addr}, assigned ID: {player_id}")
|
||||
|
||||
try:
|
||||
# Handle messages
|
||||
async for message_data in websocket:
|
||||
try:
|
||||
# Parse JSON message
|
||||
message = Message.from_json(message_data)
|
||||
await self.on_message(player_id, message, websocket)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error parsing WebSocket message from {player_id}: {e}")
|
||||
# Send error back to client
|
||||
from ..shared.protocol import create_error_message
|
||||
error_msg = create_error_message(str(e))
|
||||
await websocket.send(error_msg.to_json())
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
print(f"WebSocket client {player_id} disconnected")
|
||||
except Exception as e:
|
||||
print(f"Error handling WebSocket client {player_id}: {e}")
|
||||
finally:
|
||||
# Notify disconnection
|
||||
await self.on_disconnect(player_id)
|
||||
|
||||
|
||||
async def start_websocket_server(
|
||||
host: str,
|
||||
port: int,
|
||||
handler: WebSocketHandler,
|
||||
) -> None:
|
||||
"""Start the WebSocket server.
|
||||
|
||||
Args:
|
||||
host: Host address to bind to
|
||||
port: Port number to bind to
|
||||
handler: WebSocketHandler instance
|
||||
"""
|
||||
print(f"Starting WebSocket server on ws://{host}:{port}")
|
||||
|
||||
async with websockets.serve(
|
||||
handler.handle_client,
|
||||
host,
|
||||
port,
|
||||
# Increase max message size for game states
|
||||
max_size=10 * 1024 * 1024, # 10MB
|
||||
# Ping interval to keep connections alive
|
||||
ping_interval=20,
|
||||
ping_timeout=10,
|
||||
):
|
||||
# Run forever
|
||||
await asyncio.Future()
|
||||
@@ -3,6 +3,8 @@
|
||||
# Network settings
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_WS_PORT = 8889
|
||||
DEFAULT_HTTP_PORT = 8000
|
||||
|
||||
# Multicast discovery settings
|
||||
MULTICAST_GROUP = "239.255.0.1"
|
||||
|
||||
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