diff --git a/client.py b/client.py index 16e93ac..0d5d791 100644 --- a/client.py +++ b/client.py @@ -1,6 +1,6 @@ import socket import threading -import json +import pickle import pygame import sys import time @@ -8,64 +8,88 @@ import time HOST = '127.0.0.1' PORT = 65432 MOVE_SPEED = 200 # pixels per second -FAKE_LATENCY = 0.1 # seconds of artificial latency for both sending and receiving + +class Event: + def __init__(self, name, payload): + self.name = name + self.payload = payload + +def recvall(sock, n): + data = b'' + while len(data) < n: + packet = sock.recv(n - len(data)) + if not packet: + return None + data += packet + return data + +def send_event(sock, event): + data = pickle.dumps(event) + length = len(data) + sock.sendall(length.to_bytes(4, byteorder='big') + data) + +def recv_event(sock): + raw_len = recvall(sock, 4) + if not raw_len: + return None + msg_len = int.from_bytes(raw_len, byteorder='big') + data = recvall(sock, msg_len) + return pickle.loads(data) # Global state -players = {} # Server-authoritative state: client_id -> {"username", "x", "y"} +players = {} # Server-authoritative state: client_id -> {"username", "x", "y", "mouse_x", "mouse_y"} my_client_id = None # Our unique client id from the server - -# Local predicted state for the local player. -predicted_state = {"x": 100, "y": 100, "keys": set(), "username": "Guest"} - -ping = 0 # measured ping in seconds +predicted_state = { + "x": 100, "y": 100, + "keys": set(), + "username": "Guest", + "mouse_x": 0, + "mouse_y": 0 +} +ping = 0 # measured ping in seconds +server_details = {} # Updated from server_info and state_update events def listen_to_server(sock): - """ - Listen for incoming messages from the server and update local state. - Also performs reconciliation by snapping to the server state when no keys are pressed. - Simulates network latency on incoming messages. - """ - global my_client_id, players, predicted_state, ping - file_obj = sock.makefile('r') + global my_client_id, players, predicted_state, ping, server_details while True: try: - line = file_obj.readline() - if not line: + event = recv_event(sock) + if event is None: break - # Simulate fake latency for incoming messages. - time.sleep(FAKE_LATENCY) - msg = json.loads(line) - event_name = msg.get('event') - payload = msg.get('payload') - if event_name == "self_id": + if event.name == "self_id": if my_client_id is None: - my_client_id = payload.get("client_id") + my_client_id = event.payload.get("client_id") print(f"[INFO] My client ID: {my_client_id}") - elif event_name == "client_connect": - print(f"[INFO] Client connected: {payload.get('client_id')}") - elif event_name == "client_disconnect": - client_id = payload.get('client_id') - print(f"[INFO] Client disconnected: {client_id}") - if client_id in players: - del players[client_id] - elif event_name == "state_update": - # Update our players dictionary from the server. - players = payload.get("players", {}) - # If we have an update for our own player, reconcile. + elif event.name == "server_info": + server_details = event.payload + print(f"[INFO] Received server info: {server_details}") + elif event.name == "client_connect": + print(f"[INFO] Client connected: {event.payload.get('client_id')}") + elif event.name == "client_disconnect": + cid = event.payload.get("client_id") + print(f"[INFO] Client disconnected: {cid}") + if cid in players: + del players[cid] + elif event.name == "state_update": + players = event.payload.get("players", {}) + # Update dynamic server details. + if "total_users" in event.payload: + server_details["total_users"] = event.payload["total_users"] + if "max_users" in event.payload: + server_details["max_users"] = event.payload["max_users"] if my_client_id and my_client_id in players: server_x = players[my_client_id].get("x", 100) server_y = players[my_client_id].get("y", 100) + tickrate = server_details.get("tickrate", 64) + correction_factor = 0.1 * (64 / tickrate) if not predicted_state["keys"]: - # Snap immediately if no movement is occurring. predicted_state["x"] = server_x predicted_state["y"] = server_y else: - # If keys are pressed, nudge the predicted state gradually. - correction_factor = 0.1 # 10% correction per update while moving. predicted_state["x"] += (server_x - predicted_state["x"]) * correction_factor predicted_state["y"] += (server_y - predicted_state["y"]) * correction_factor - elif event_name == "pong": - sent_time = payload.get('timestamp') + elif event.name == "pong": + sent_time = event.payload.get("timestamp") if sent_time: round_trip = time.time() - sent_time ping = round_trip @@ -74,21 +98,13 @@ def listen_to_server(sock): print(f"[ERROR] {e}") break -def send_message(sock, msg_dict): - """ - Send a JSON message to the server with fake latency. - """ - time.sleep(FAKE_LATENCY) # simulate network latency on outgoing messages - message = json.dumps(msg_dict) + "\n" - sock.sendall(message.encode()) +def send_message(sock, event): + send_event(sock, event) def ping_loop(sock): - """ - Periodically send ping messages to measure latency. - """ while True: ts = time.time() - send_message(sock, {'type': 'ping', 'timestamp': ts}) + send_message(sock, Event("ping", {"timestamp": ts})) time.sleep(1) def main(): @@ -96,74 +112,72 @@ def main(): username = input("Enter your username: ") predicted_state["username"] = username - # Connect to the server. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((HOST, PORT)) - # Start threads to listen to the server and to send pings. threading.Thread(target=listen_to_server, args=(sock,), daemon=True).start() threading.Thread(target=ping_loop, args=(sock,), daemon=True).start() - # Register for needed events. - default_events = ["client_connect", "client_disconnect", "state_update"] - for event in default_events: - send_message(sock, {'type': 'register_event', 'event': event}) - print(f"[INFO] Registered for event: {event}") + # Register for events. + for event_name in ["client_connect", "client_disconnect", "state_update"]: + send_message(sock, Event("register_event", {"event": event_name})) + print(f"[INFO] Registered for event: {event_name}") - # Inform the server of our chosen username. - send_message(sock, {'type': 'set_username', 'username': username}) + send_message(sock, Event("set_username", {"username": username})) - # Initialize Pygame. pygame.init() screen = pygame.display.set_mode((800, 600)) - pygame.display.set_caption("Pygame Client - Multiplayer (Ghost vs Client)") + pygame.display.set_caption("Pygame Client - Multiplayer (Real vs Fake)") clock = pygame.time.Clock() - # Define colors. - ghost_color = (0, 255, 0) # Green for the server (ghost) state. - client_color = (0, 0, 255) # Blue for the client (predicted) state. - other_color = (255, 0, 0) # Red for other players. - - # Local set to track pressed keys. + ghost_color = (0, 255, 0) # Ghost: server state (outlined) + client_color = (0, 0, 255) # Client: predicted state (filled) + other_color = (255, 0, 0) # Remote players + remote_mouse_color = (255, 255, 0) # Color for remote mouse pointer (yellow) local_keys = set() while True: - dt = clock.tick(60) / 1000.0 # Frame time (aiming for 60 FPS) - for event in pygame.event.get(): - if event.type == pygame.QUIT: + dt = clock.tick(60) / 1000.0 + for ev in pygame.event.get(): + if ev.type == pygame.QUIT: pygame.quit() sys.exit() - elif event.type == pygame.KEYDOWN: + elif ev.type == pygame.KEYDOWN: key_name = None - if event.key == pygame.K_LEFT: + if ev.key == pygame.K_LEFT: key_name = "left" - elif event.key == pygame.K_RIGHT: + elif ev.key == pygame.K_RIGHT: key_name = "right" - elif event.key == pygame.K_UP: + elif ev.key == pygame.K_UP: key_name = "up" - elif event.key == pygame.K_DOWN: + elif ev.key == pygame.K_DOWN: key_name = "down" if key_name and key_name not in local_keys: local_keys.add(key_name) predicted_state["keys"].add(key_name) - send_message(sock, {'type': 'keydown', 'key': key_name}) - elif event.type == pygame.KEYUP: + send_message(sock, Event("keydown", {"key": key_name})) + elif ev.type == pygame.KEYUP: key_name = None - if event.key == pygame.K_LEFT: + if ev.key == pygame.K_LEFT: key_name = "left" - elif event.key == pygame.K_RIGHT: + elif ev.key == pygame.K_RIGHT: key_name = "right" - elif event.key == pygame.K_UP: + elif ev.key == pygame.K_UP: key_name = "up" - elif event.key == pygame.K_DOWN: + elif ev.key == pygame.K_DOWN: key_name = "down" if key_name and key_name in local_keys: local_keys.remove(key_name) if key_name in predicted_state["keys"]: predicted_state["keys"].remove(key_name) - send_message(sock, {'type': 'keyup', 'key': key_name}) - - # Update the predicted state based on locally pressed keys. + send_message(sock, Event("keyup", {"key": key_name})) + elif ev.type == pygame.MOUSEMOTION: + mouse_x, mouse_y = ev.pos + predicted_state["mouse_x"] = mouse_x + predicted_state["mouse_y"] = mouse_y + send_message(sock, Event("mouse_move", {"x": mouse_x, "y": mouse_y})) + + # Update predicted state locally. dx, dy = 0, 0 if "left" in predicted_state["keys"]: dx -= MOVE_SPEED * dt @@ -176,17 +190,15 @@ def main(): predicted_state["x"] += dx predicted_state["y"] += dy - # Get ghost (server) position without interpolation. + # For our own player, the ghost (server) position is used as the "real" state. if my_client_id and my_client_id in players: ghost_x = players[my_client_id].get("x", 100) ghost_y = players[my_client_id].get("y", 100) else: ghost_x, ghost_y = predicted_state["x"], predicted_state["y"] - # Render the scene. screen.fill((0, 0, 0)) - - # Draw remote players (using server state). + # Draw remote players (only real state). for cid, data in players.items(): if cid == my_client_id: continue @@ -198,15 +210,18 @@ def main(): text_surface = font.render(uname, True, (255, 255, 255)) text_rect = text_surface.get_rect(center=(x, y - 30)) screen.blit(text_surface, text_rect) - - # Draw the ghost (server-authoritative) position as an outlined circle. + # Draw remote mouse pointer if available. + if "mouse_x" in data and "mouse_y" in data: + mx = int(data.get("mouse_x", 0)) + my = int(data.get("mouse_y", 0)) + pygame.draw.circle(screen, remote_mouse_color, (mx, my), 5) + # Draw our ghost (server-authoritative) as an outlined circle. font = pygame.font.SysFont(None, 24) ghost_text = font.render("Ghost", True, (255, 255, 255)) ghost_rect = ghost_text.get_rect(center=(int(ghost_x), int(ghost_y) - 30)) pygame.draw.circle(screen, ghost_color, (int(ghost_x), int(ghost_y)), 20, 2) screen.blit(ghost_text, ghost_rect) - - # Draw the client-predicted position as the actual player (filled circle). + # Draw our client-predicted player as a filled circle. pred_x = int(predicted_state.get("x", 100)) pred_y = int(predicted_state.get("y", 100)) font = pygame.font.SysFont(None, 24) @@ -214,12 +229,21 @@ def main(): client_rect = client_text.get_rect(center=(pred_x, pred_y - 30)) pygame.draw.circle(screen, client_color, (pred_x, pred_y), 20) screen.blit(client_text, client_rect) - - # Display ping on the screen. + # Display ping. ping_font = pygame.font.SysFont(None, 24) ping_surface = ping_font.render(f"Ping: {int(ping*1000)} ms", True, (255, 255, 0)) screen.blit(ping_surface, (10, 10)) - + # Display HUD info: total users, client ID, and tickrate. + hud_font = pygame.font.SysFont(None, 24) + total_users = server_details.get("total_users", len(players)) + max_users = server_details.get("max_users", "?") + tickrate = server_details.get("tickrate", 64) + hud_text = hud_font.render( + f"Users: {total_users}/{max_users} | Client ID: {my_client_id or 'N/A'} | Tickrate: {tickrate} tps", + True, + (255, 255, 255) + ) + screen.blit(hud_text, (10, 40)) pygame.display.flip() if __name__ == "__main__": diff --git a/server.py b/server.py index af02416..69bf562 100644 --- a/server.py +++ b/server.py @@ -1,78 +1,124 @@ import socket import threading -import json +import pickle import time import uuid HOST = '127.0.0.1' PORT = 65432 - -# Global dictionaries for event subscriptions, client IDs, and game state. -event_subscriptions = {} # event_name -> list of client sockets -client_ids = {} # conn -> client_id -client_states = {} # conn -> {"client_id", "username", "x", "y", "keys"} -lock = threading.Lock() - MOVE_SPEED = 200 # pixels per second +TICKRATE = 64 # ticks per second +MAX_USERS = 100 + +class Event: + def __init__(self, name, payload): + self.name = name + self.payload = payload + +def recvall(sock, n): + data = b'' + while len(data) < n: + packet = sock.recv(n - len(data)) + if not packet: + return None + data += packet + return data + +def send_event(sock, event): + data = pickle.dumps(event) + length = len(data) + sock.sendall(length.to_bytes(4, byteorder='big') + data) + +def recv_event(sock): + raw_len = recvall(sock, 4) + if not raw_len: + return None + msg_len = int.from_bytes(raw_len, byteorder='big') + data = recvall(sock, msg_len) + return pickle.loads(data) + +# Global dictionaries +event_subscriptions = {} # event name -> list of client sockets +client_ids = {} # conn -> client_id +client_states = {} # conn -> {"client_id", "username", "x", "y", "keys", "mouse_x", "mouse_y"} +lock = threading.Lock() def handle_client(conn, addr): client_id = str(uuid.uuid4()) with lock: client_ids[conn] = client_id - # Initialize state: default username "Guest", starting position, and an empty set of pressed keys. - client_states[conn] = {"client_id": client_id, "username": "Guest", "x": 100, "y": 100, "keys": set()} + # Initialize state for new client. + client_states[conn] = { + "client_id": client_id, + "username": "Guest", + "x": 100, + "y": 100, + "keys": set(), + "mouse_x": 0, + "mouse_y": 0 + } print(f"[NEW CONNECTION] {addr} connected as {client_id}") - # Send a dedicated "self_id" event so the client knows its unique id. try: - msg = json.dumps({'event': 'self_id', 'payload': {'client_id': client_id}}) + "\n" - conn.sendall(msg.encode()) + # Send self_id so the client knows its unique id. + send_event(conn, Event("self_id", {"client_id": client_id})) + # Send server info. + server_info = { + "tickrate": TICKRATE, + "total_users": len(client_states), + "max_users": MAX_USERS, + "server_ip": HOST, + "server_port": PORT, + "server_name": "My Multiplayer Server" + } + send_event(conn, Event("server_info", server_info)) except Exception as e: - print(f"[ERROR] sending self_id: {e}") + print(f"[ERROR] sending self_id/server_info: {e}") # Broadcast that a new client connected. - send_event("client_connect", {"client_id": client_id}) + broadcast_event("client_connect", {"client_id": client_id}) try: - file_obj = conn.makefile('r') - for line in file_obj: - line = line.strip() - if not line: - continue - try: - msg = json.loads(line) - msg_type = msg.get('type') - if msg_type == 'register_event': - event_name = msg.get('event') - with lock: - event_subscriptions.setdefault(event_name, []).append(conn) - print(f"[REGISTER] {client_id} subscribed to '{event_name}'") - elif msg_type == 'set_username': - username = msg.get('username', 'Guest') - with lock: - if conn in client_states: - client_states[conn]['username'] = username - print(f"[USERNAME] {client_id} set username to '{username}'") - elif msg_type == 'keydown': - key = msg.get('key') - with lock: - if conn in client_states: - client_states[conn]['keys'].add(key) - print(f"[KEYDOWN] {client_id} key '{key}' pressed") - elif msg_type == 'keyup': - key = msg.get('key') - with lock: - if conn in client_states and key in client_states[conn]['keys']: - client_states[conn]['keys'].remove(key) - print(f"[KEYUP] {client_id} key '{key}' released") - elif msg_type == 'ping': - # Respond immediately with a pong, echoing the timestamp. - timestamp = msg.get('timestamp') - response = json.dumps({'event': 'pong', 'payload': {'timestamp': timestamp}}) + "\n" - conn.sendall(response.encode()) - # Additional message types can be handled here. - except json.JSONDecodeError: - print(f"[ERROR] Invalid JSON from {client_id}: {line}") + while True: + event = recv_event(conn) + if event is None: + break + if event.name == "register_event": + event_name = event.payload.get('event') + with lock: + event_subscriptions.setdefault(event_name, []).append(conn) + print(f"[REGISTER] {client_id} subscribed to '{event_name}'") + elif event.name == "set_username": + username = event.payload.get('username', 'Guest') + with lock: + if conn in client_states: + client_states[conn]['username'] = username + print(f"[USERNAME] {client_id} set username to '{username}'") + elif event.name == "keydown": + key = event.payload.get('key') + with lock: + if conn in client_states: + client_states[conn]['keys'].add(key) + print(f"[KEYDOWN] {client_id} key '{key}' pressed") + elif event.name == "keyup": + key = event.payload.get('key') + with lock: + if conn in client_states and key in client_states[conn]['keys']: + client_states[conn]['keys'].remove(key) + print(f"[KEYUP] {client_id} key '{key}' released") + elif event.name == "mouse_move": + x = event.payload.get('x', 0) + y = event.payload.get('y', 0) + with lock: + if conn in client_states: + client_states[conn]['mouse_x'] = x + client_states[conn]['mouse_y'] = y + # Optionally log mouse moves. + # print(f"[MOUSE_MOVE] {client_id} moved mouse to ({x}, {y})") + elif event.name == "ping": + timestamp = event.payload.get('timestamp') + send_event(conn, Event("pong", {"timestamp": timestamp})) + # You can handle additional events here. except Exception as e: print(f"[ERROR] Exception with {client_id}: {e}") finally: @@ -83,34 +129,26 @@ def handle_client(conn, addr): client_ids.pop(conn, None) client_states.pop(conn, None) print(f"[DISCONNECT] {client_id} disconnected.") - send_event("client_disconnect", {"client_id": client_id}) + broadcast_event("client_disconnect", {"client_id": client_id}) conn.close() -def send_event(event_name, payload): - """ - Broadcast an event to all clients subscribed to the given event. - """ - message = json.dumps({'event': event_name, 'payload': payload}) + "\n" +def broadcast_event(event_name, payload): + event = Event(event_name, payload) with lock: receivers = event_subscriptions.get(event_name, []) - for client in receivers[:]: # iterate over a copy to allow removals + for client in receivers[:]: try: - client.sendall(message.encode()) + send_event(client, event) except Exception as e: print(f"[ERROR] Failed to send to client, removing: {e}") receivers.remove(client) def game_loop(): - """ - Main game loop: update each client’s position based on currently pressed keys, - and broadcast the overall game state (including server time) to all clients. - """ - dt = 0.1 #100 ms; - + dt = 1 / TICKRATE # Tick interval while True: time.sleep(dt) with lock: - # Update each client's position. + # Update each client's position based on pressed keys. for state in client_states.values(): dx, dy = 0, 0 if 'left' in state['keys']: @@ -123,16 +161,23 @@ def game_loop(): dy += MOVE_SPEED * dt state['x'] += dx state['y'] += dy - # Build payload: include current server time and each client’s state. + # Build payload for state_update. players_payload = {} for state in client_states.values(): players_payload[state['client_id']] = { "username": state['username'], "x": state['x'], - "y": state['y'] + "y": state['y'], + "mouse_x": state.get('mouse_x', 0), + "mouse_y": state.get('mouse_y', 0) } - payload = {"server_time": time.time(), "players": players_payload} - send_event("state_update", payload) + total_users = len(client_states) + payload = { + "players": players_payload, + "total_users": total_users, + "max_users": MAX_USERS + } + broadcast_event("state_update", payload) def start_server(): print("[STARTING] Server is starting...")