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
|
## 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:**
|
**Project Structure:**
|
||||||
- `src/shared/` - Shared code (models, protocol, constants, discovery utilities)
|
- `src/shared/` - Shared code (models, protocol, constants, discovery utilities)
|
||||||
- `src/server/` - Game server with authoritative game state and multicast beacon
|
- `src/server/` - Dual-protocol game server (TCP + WebSocket), HTTP server, multicast beacon
|
||||||
- `src/client/` - Game client with pygame rendering, discovery, and server selection UI
|
- `src/client/` - Pygame desktop client with discovery and server selection UI
|
||||||
|
- `web/` - Browser-based web client (HTML/CSS/JavaScript)
|
||||||
- `tests/` - Test files using pytest
|
- `tests/` - Test files using pytest
|
||||||
|
|
||||||
## Setup Commands
|
## Setup Commands
|
||||||
@@ -30,27 +31,29 @@ pip install -r requirements-dev.txt
|
|||||||
## Running the Game
|
## Running the Game
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the server (with discovery beacon enabled by default)
|
# Web Client (easiest - no installation needed)
|
||||||
python run_server.py --name "My Game" --port 8888
|
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
|
python run_client.py --name Alice
|
||||||
# If multiple servers found, a selection UI appears
|
# Auto-discovers server on local network
|
||||||
|
|
||||||
# Manual connection (skip discovery)
|
# Server Options:
|
||||||
python run_client.py 192.168.1.100 --port 8888 --name Bob
|
# --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:
|
# Production Deployment:
|
||||||
# --host HOST Bind address (default: localhost)
|
python run_server.py --no-http --host 0.0.0.0
|
||||||
# --port PORT Port number (default: 8888)
|
# Use nginx to serve web/ directory and proxy WebSocket to port 8889
|
||||||
# --name NAME Server name for discovery
|
# See web/README.md for nginx configuration examples
|
||||||
# --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
|
|
||||||
|
|
||||||
# Press SPACE to start the game, arrow keys/WASD to move
|
# Press SPACE to start the game, arrow keys/WASD to move
|
||||||
```
|
```
|
||||||
@@ -76,35 +79,54 @@ mypy src/
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Client-Server Model:**
|
**Dual-Protocol Client-Server Model:**
|
||||||
- Server (`src/server/game_server.py`) runs the authoritative game loop using asyncio
|
- Server runs authoritative game loop using asyncio
|
||||||
- Clients connect via TCP and send input commands (MOVE, START_GAME, etc.)
|
- **Desktop clients** connect via TCP (port 8888) with newline-delimited JSON
|
||||||
- Server broadcasts game state updates to all connected clients at 10 FPS
|
- **Web clients** connect via WebSocket (port 8889) with JSON frames
|
||||||
- Clients render the game state locally at 60 FPS using pygame
|
- 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:**
|
**Key Backend Components:**
|
||||||
- `src/shared/protocol.py` - JSON-based message protocol (MessageType enum, Message class)
|
- `src/shared/protocol.py` - JSON-based message protocol (same for TCP and WebSocket)
|
||||||
- `src/shared/models.py` - Data models (Snake, Position, Food, GameState) with serialization
|
- `src/shared/models.py` - Data models with serialization (Snake, Position, Food, GameState)
|
||||||
- `src/shared/constants.py` - Game configuration (grid size, colors, tick rate, multicast settings)
|
- `src/shared/constants.py` - Configuration (grid, colors, tick rate, ports)
|
||||||
- `src/shared/discovery.py` - Multicast discovery utilities (ServerInfo, DiscoveryMessage)
|
- `src/shared/discovery.py` - Multicast discovery utilities
|
||||||
- `src/server/game_logic.py` - Game rules (movement, collision detection, food spawning)
|
- `src/server/game_server.py` - Dual-protocol game server (TCP + WebSocket)
|
||||||
- `src/server/server_beacon.py` - UDP multicast beacon for server discovery
|
- `src/server/game_logic.py` - Game rules (movement, collision, food)
|
||||||
- `src/client/renderer.py` - Pygame rendering of game state
|
- `src/server/websocket_handler.py` - WebSocket connection handler
|
||||||
- `src/client/server_discovery.py` - Client-side server discovery
|
- `src/server/http_server.py` - Static file server for web client (aiohttp)
|
||||||
- `src/client/server_selector.py` - Pygame UI for selecting from multiple servers
|
- `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:**
|
**Game Flow:**
|
||||||
1. Server starts on port 8888 and broadcasts presence on multicast group 239.255.0.1:9999
|
1. Server starts TCP (8888), WebSocket (8889), HTTP (8000), and broadcasts on multicast (9999)
|
||||||
2. Clients send DISCOVER message to multicast group and collect SERVER_ANNOUNCE responses
|
2. Desktop clients discover via multicast; web clients use auto-detected WebSocket URL
|
||||||
3. If multiple servers found, client shows selection UI; if one found, auto-connects
|
3. Clients connect and receive WELCOME with player_id
|
||||||
4. Clients connect via TCP and receive WELCOME message with player_id
|
4. Any player presses SPACE to send START_GAME
|
||||||
5. Any player can press SPACE to send START_GAME message
|
5. Server creates snakes and spawns food
|
||||||
6. Server creates snakes for all connected players and spawns food
|
6. Server loop: update → check collisions → broadcast state (protocol-agnostic)
|
||||||
7. Server runs game loop: update positions → check collisions → broadcast state
|
7. Game ends when ≤1 snake alive
|
||||||
8. Game ends when only 0-1 snakes remain alive
|
|
||||||
|
|
||||||
**Discovery Protocol:**
|
**Protocol Handling:**
|
||||||
- Multicast group: 239.255.0.1:9999 (local network)
|
- Server stores clients as `(connection, ClientType.TCP|WEBSOCKET)`
|
||||||
- Client → Multicast: DISCOVER (broadcast request)
|
- `send_to_client()` detects type and sends via appropriate method
|
||||||
- Server → Client: SERVER_ANNOUNCE (direct response with host, port, name, player count)
|
- TCP: newline-delimited JSON (`message\n`)
|
||||||
- Server also periodically broadcasts presence every 2 seconds
|
- 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
|
## Features
|
||||||
|
|
||||||
- Real-time multiplayer gameplay with client-server architecture
|
- 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)
|
- **Automatic server discovery** using multicast (zero-configuration LAN play)
|
||||||
|
- **Dual-protocol support** - TCP for desktop, WebSocket for web
|
||||||
- Support for multiple players simultaneously
|
- Support for multiple players simultaneously
|
||||||
- Classic Snake gameplay with collision detection
|
- Classic Snake gameplay with collision detection
|
||||||
- Color-coded snakes for each player
|
- Color-coded snakes for each player
|
||||||
@@ -27,40 +30,86 @@ pip install -r requirements-dev.txt
|
|||||||
|
|
||||||
## Running the Game
|
## 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
|
```bash
|
||||||
python run_server.py
|
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
|
```bash
|
||||||
python run_client.py --name Alice
|
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
|
### Manual Connection
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server
|
# Server with all options
|
||||||
python run_server.py --host 0.0.0.0 --port 8888 --name "Game Room"
|
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
|
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
|
### Server Options
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python run_server.py --help
|
python run_server.py --help
|
||||||
|
|
||||||
|
# Network
|
||||||
# --host HOST Host address to bind to (default: localhost)
|
# --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)
|
# --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
|
### Client Options
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
# Production dependencies
|
# Production dependencies
|
||||||
pygame>=2.5.0
|
pygame>=2.5.0
|
||||||
|
websockets>=12.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import argparse
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
from src.server.game_server import GameServer
|
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:
|
async def main() -> None:
|
||||||
@@ -18,7 +20,24 @@ async def main() -> None:
|
|||||||
"--port",
|
"--port",
|
||||||
type=int,
|
type=int,
|
||||||
default=DEFAULT_PORT,
|
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(
|
parser.add_argument(
|
||||||
"--name",
|
"--name",
|
||||||
@@ -30,17 +49,48 @@ async def main() -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Disable multicast discovery beacon",
|
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()
|
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(
|
server = GameServer(
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
server_name=args.name,
|
server_name=args.name,
|
||||||
enable_discovery=not args.no_discovery,
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, Set
|
from typing import Dict, Set, Union, Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from ..shared.protocol import (
|
from ..shared.protocol import (
|
||||||
Message,
|
Message,
|
||||||
@@ -20,8 +21,14 @@ from .game_logic import GameLogic
|
|||||||
from .server_beacon import ServerBeacon
|
from .server_beacon import ServerBeacon
|
||||||
|
|
||||||
|
|
||||||
|
class ClientType(Enum):
|
||||||
|
"""Type of client connection."""
|
||||||
|
TCP = "TCP"
|
||||||
|
WEBSOCKET = "WebSocket"
|
||||||
|
|
||||||
|
|
||||||
class GameServer:
|
class GameServer:
|
||||||
"""Multiplayer Snake game server."""
|
"""Multiplayer Snake game server with dual-protocol support."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -29,24 +36,32 @@ class GameServer:
|
|||||||
port: int = DEFAULT_PORT,
|
port: int = DEFAULT_PORT,
|
||||||
server_name: str = "Snake Server",
|
server_name: str = "Snake Server",
|
||||||
enable_discovery: bool = True,
|
enable_discovery: bool = True,
|
||||||
|
ws_port: int | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize the game server.
|
"""Initialize the game server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host: Host address to bind to
|
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
|
server_name: Name of the server for discovery
|
||||||
enable_discovery: Enable multicast discovery beacon
|
enable_discovery: Enable multicast discovery beacon
|
||||||
|
ws_port: WebSocket port (None to disable WebSocket)
|
||||||
"""
|
"""
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
|
self.ws_port = ws_port
|
||||||
self.server_name = server_name
|
self.server_name = server_name
|
||||||
self.enable_discovery = enable_discovery
|
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.player_names: Dict[str, str] = {}
|
||||||
|
|
||||||
self.game_logic = GameLogic()
|
self.game_logic = GameLogic()
|
||||||
self.game_task: asyncio.Task | None = None
|
self.game_task: asyncio.Task | None = None
|
||||||
self.beacon_task: asyncio.Task | None = None
|
self.beacon_task: asyncio.Task | None = None
|
||||||
|
self.ws_task: asyncio.Task | None = None
|
||||||
self.beacon: ServerBeacon | None = None
|
self.beacon: ServerBeacon | None = None
|
||||||
|
|
||||||
async def handle_client(
|
async def handle_client(
|
||||||
@@ -74,10 +89,16 @@ class GameServer:
|
|||||||
# Parse message
|
# Parse message
|
||||||
try:
|
try:
|
||||||
message = Message.from_json(data.decode().strip())
|
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:
|
except Exception as e:
|
||||||
print(f"Error parsing message from {player_id}: {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:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
@@ -92,17 +113,19 @@ class GameServer:
|
|||||||
self,
|
self,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
message: Message,
|
message: Message,
|
||||||
writer: asyncio.StreamWriter
|
connection: Any = None,
|
||||||
|
client_type: ClientType = ClientType.TCP
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a message from a client.
|
"""Handle a message from a client.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player_id: ID of the player who sent the message
|
player_id: ID of the player who sent the message
|
||||||
message: The message received
|
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:
|
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:
|
elif message.type == MessageType.MOVE:
|
||||||
await self.handle_move(player_id, message)
|
await self.handle_move(player_id, message)
|
||||||
elif message.type == MessageType.START_GAME:
|
elif message.type == MessageType.START_GAME:
|
||||||
@@ -114,21 +137,23 @@ class GameServer:
|
|||||||
self,
|
self,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
message: Message,
|
message: Message,
|
||||||
writer: asyncio.StreamWriter
|
connection: Any,
|
||||||
|
client_type: ClientType
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a player joining.
|
"""Handle a player joining.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player_id: ID of the joining player
|
player_id: ID of the joining player
|
||||||
message: JOIN message with player name
|
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]}")
|
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
|
self.player_names[player_id] = player_name
|
||||||
|
|
||||||
# Send welcome message to new player
|
# 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
|
# Notify all clients about new player
|
||||||
await self.broadcast(create_player_joined_message(player_id, player_name))
|
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)
|
snake = self.game_logic.create_snake(player_id)
|
||||||
self.game_logic.state.snakes.append(snake)
|
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:
|
async def handle_move(self, player_id: str, message: Message) -> None:
|
||||||
"""Handle a player movement command.
|
"""Handle a player movement command.
|
||||||
@@ -220,19 +245,29 @@ class GameServer:
|
|||||||
# Wait for next tick
|
# Wait for next tick
|
||||||
await asyncio.sleep(TICK_RATE)
|
await asyncio.sleep(TICK_RATE)
|
||||||
|
|
||||||
async def send_message(self, writer: asyncio.StreamWriter, message: Message) -> None:
|
async def send_to_client(self, player_id: str, message: Message) -> None:
|
||||||
"""Send a message to a client.
|
"""Send a message to a specific client.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
writer: Stream writer for the client
|
player_id: Player ID to send to
|
||||||
message: Message to send
|
message: Message to send
|
||||||
"""
|
"""
|
||||||
|
if player_id not in self.clients:
|
||||||
|
return
|
||||||
|
|
||||||
|
connection, client_type = self.clients[player_id]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = message.to_json() + "\n"
|
if client_type == ClientType.TCP:
|
||||||
writer.write(data.encode())
|
# TCP: newline-delimited JSON
|
||||||
await writer.drain()
|
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:
|
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:
|
async def broadcast(self, message: Message, exclude: Set[str] = None) -> None:
|
||||||
"""Broadcast a message to all connected clients.
|
"""Broadcast a message to all connected clients.
|
||||||
@@ -242,9 +277,9 @@ class GameServer:
|
|||||||
exclude: Set of player IDs to exclude from broadcast
|
exclude: Set of player IDs to exclude from broadcast
|
||||||
"""
|
"""
|
||||||
exclude = exclude or set()
|
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:
|
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:
|
def get_player_count(self) -> int:
|
||||||
"""Get the current number of connected players.
|
"""Get the current number of connected players.
|
||||||
@@ -254,8 +289,27 @@ class GameServer:
|
|||||||
"""
|
"""
|
||||||
return len(self.clients)
|
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:
|
async def start(self) -> None:
|
||||||
"""Start the server."""
|
"""Start the server (TCP and optionally WebSocket)."""
|
||||||
# Start discovery beacon if enabled
|
# Start discovery beacon if enabled
|
||||||
if self.enable_discovery:
|
if self.enable_discovery:
|
||||||
self.beacon = ServerBeacon(
|
self.beacon = ServerBeacon(
|
||||||
@@ -265,6 +319,12 @@ class GameServer:
|
|||||||
)
|
)
|
||||||
self.beacon_task = asyncio.create_task(self.beacon.start())
|
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(
|
server = await asyncio.start_server(
|
||||||
self.handle_client,
|
self.handle_client,
|
||||||
self.host,
|
self.host,
|
||||||
@@ -278,7 +338,7 @@ class GameServer:
|
|||||||
async with server:
|
async with server:
|
||||||
await server.serve_forever()
|
await server.serve_forever()
|
||||||
finally:
|
finally:
|
||||||
# Clean up beacon on shutdown
|
# Clean up
|
||||||
if self.beacon:
|
if self.beacon:
|
||||||
await self.beacon.stop()
|
await self.beacon.stop()
|
||||||
if self.beacon_task:
|
if self.beacon_task:
|
||||||
@@ -287,6 +347,12 @@ class GameServer:
|
|||||||
await self.beacon_task
|
await self.beacon_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
if self.ws_task:
|
||||||
|
self.ws_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.ws_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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
|
# Network settings
|
||||||
DEFAULT_HOST = "localhost"
|
DEFAULT_HOST = "localhost"
|
||||||
DEFAULT_PORT = 8888
|
DEFAULT_PORT = 8888
|
||||||
|
DEFAULT_WS_PORT = 8889
|
||||||
|
DEFAULT_HTTP_PORT = 8000
|
||||||
|
|
||||||
# Multicast discovery settings
|
# Multicast discovery settings
|
||||||
MULTICAST_GROUP = "239.255.0.1"
|
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