diff --git a/examples/networking_rockpaperscisors/README.md b/examples/networking_rockpaperscisors/README.md new file mode 100644 index 0000000..21e4ead --- /dev/null +++ b/examples/networking_rockpaperscisors/README.md @@ -0,0 +1,10 @@ +# Online Rock Paper Scisors + +Small example of how to create a simple rock paper scisors game using the networking library + +The server.py file contains the server code and the client.py file contains the client code. + +First start the server and then start the client. +The client will try to connect to the server to start the game. + +The content.py file implement shared content between the server and the client. \ No newline at end of file diff --git a/examples/networking_rockpaperscisors/content.py b/examples/networking_rockpaperscisors/content.py new file mode 100644 index 0000000..edabec1 --- /dev/null +++ b/examples/networking_rockpaperscisors/content.py @@ -0,0 +1,169 @@ +"""Shared content for the game.""" +from __future__ import annotations +from dataclasses import dataclass +from enum import Enum +from functools import cached_property +from typing import Any + +import pygame +from pygame_emojis import load_emoji +from pygame_cards.hands import AlignedHand +from pygame_cards.manager import CardSetRights, CardsManager + +import pygame_cards.events + +from pygame_cards.set import CardsSet +from pygame_cards.abstract import AbstractCard, AbstractCardGraphics + + +class Sign(Enum): + ROCK = "rock" + PAPER = "paper" + SCISSORS = "scissors" + + # Override the > method to allow comparison between the signs + def __gt__(self, other: Sign) -> bool: + if self == Sign.ROCK: + return other == Sign.SCISSORS + if self == Sign.PAPER: + return other == Sign.ROCK + if self == Sign.SCISSORS: + return other == Sign.PAPER + + return False + + +class Player(Enum): + PLAYER1 = "player1" + PLAYER2 = "player2" + + +EMOJI_TO_USE: dict[Sign, str] = { + Sign.ROCK: "🪨", + Sign.PAPER: "📄", + Sign.SCISSORS: "✂️", +} + + +@dataclass +class RockPaperScisorsGraphics(AbstractCardGraphics): + card: RockPaperScissorsCard + + @cached_property + def surface(self) -> pygame.Surface: + # Transparent background + surf = pygame.Surface(self.size, pygame.SRCALPHA, 32) + + # Load the emoji as a pygame.Surface + emoji = EMOJI_TO_USE[self.card.sign] + surface = load_emoji(emoji, self.size) + # Draw the emoji + surf.blit(surface, (0, 0)) + + return surf + + +@dataclass +class RockPaperScissorsCard(AbstractCard): + sign: Sign + graphics_type = RockPaperScisorsGraphics + + +ROCK_PAPER_SCISSORS_CARDSET = CardsSet( + # Iterate over enum to create the cards + [RockPaperScissorsCard(sign=sign, name=sign.value) for sign in Sign] +) + + +class RockPaperScissors: + """ + A Rock paper scisors game. + + Play plays with :meth:`play`. + + Get past moves with :attr:`moves`. + + """ + + def __init__(self): + self.curent_moves: dict[Player, Sign] = {} + self.points: dict[Player, int] = {Player.PLAYER1: 0, Player.PLAYER2: 0} + self.points_to_win = 3 + + self.past_moves: list[tuple(Player, Sign)] = [] + + self.players = [] + + def play(self, player: Player, event: Any) -> Any | None: + """player plays sign + + Gets an event from a play message. + + Returns a message (json serializable object), + to be broadcasted to all players or None, + if nothing should be broadcasted. + + """ + if player in self.curent_moves: + raise RuntimeError("Already played, waiting for other player.") + + try: + sign = Sign(event) + except ValueError: + raise RuntimeError(f"Invalid sign {event}. Accepted values are in {Sign}") + + self.curent_moves[player] = sign + self.past_moves.append((player, sign)) + if len(self.curent_moves) < 2: + # Wait for other player + return None + + # Both players played + player1_sign = self.curent_moves[Player.PLAYER1] + player2_sign = self.curent_moves[Player.PLAYER2] + + # Reset the current moves + self.curent_moves = {} + + # Check who won + if player1_sign == player2_sign: + winner = None + else: + if player1_sign > player2_sign: + winner = Player.PLAYER1 + else: + winner = Player.PLAYER2 + + self.points[winner] += 1 + + if self.points[winner] >= self.points_to_win: + # If move is winning, send a "win" message. + return { + "type": "win", + "player": winner.value, + } + + # Send a "results" message to update the UI. + return { + "type": "results", + "winner": winner.value if winner else None, + } + + def get_past_events(self) -> list[str]: + """Returns a list of past moves as strings.""" + return [f"{player.value},{sign.value}" for player, sign in self.past_moves] + + def add_player(self) -> Player: + """Add a player to the game.""" + + if len(self.players) >= 2: + raise RuntimeError("Already 2 players") + + player = Player(f"player{len(self.players)+1}") + self.players.append(player) + return player + + +if __name__ == "__main__": + sing = Sign("rock") + print(sing) diff --git a/examples/networking_rockpaperscisors/pg_client.py b/examples/networking_rockpaperscisors/pg_client.py new file mode 100644 index 0000000..20e2e0f --- /dev/null +++ b/examples/networking_rockpaperscisors/pg_client.py @@ -0,0 +1,103 @@ +"""Pygame example using websockets to communicate with a server.""" +import json +import logging +import threading +from time import sleep +import pygame +import websocket +from content import RockPaperScissors, Player, ROCK_PAPER_SCISSORS_CARDSET +import pygame_cards.events +from pygame_cards.hands import AlignedHand +from pygame_cards.manager import CardSetRights, CardsManager + + +logging.basicConfig() +socket = "ws://localhost:8765/" +# websocket.enableTrace(True) + +PLAYING = True + +# Game to join +join_key = "cUVtaORqA0SfPjTT" +# If you are the first play +# join_key = None + + +# Call backs from the websocket +def on_open(ws): + if join_key is None: + ws.send(json.dumps({"type": "init"})) + else: + ws.send(json.dumps({"type": "init", "join": join_key})) + print(">>>>>>OPENED") + + +def on_message(ws, message): + print("Message received: ", message) + + +def on_close(ws, close_status_code, close_msg): + global PLAYING + PLAYING = False + print(">>>>>>CLOSED") + + +def on_error(ws, error): + print(error) + + +# pygame setup +pygame.init() +screen = pygame.display.set_mode((1280, 720)) +clock = pygame.time.Clock() + + +ws = websocket.WebSocketApp( + socket, on_open=on_open, on_message=on_message, on_close=on_close, on_error=on_error +) + +wst = threading.Thread(target=lambda: ws.run_forever()) +wst.daemon = True +wst.start() + + +# Create the cardset graphics +cardset_graphics = AlignedHand(cardset=ROCK_PAPER_SCISSORS_CARDSET, card_halo_ratio=0.2) + +# Create a manager and add the carset to it +manager = CardsManager() +manager.add_set( + cardset_graphics, + position=(500, 500), + card_set_rights=CardSetRights( + clickable=True, draggable_out=False, draggable_in=False + ), +) +# manager.logger.setLevel(logging.DEBUG) + +while PLAYING: + # poll for events + # pygame.QUIT event means the user clicked X to close your window + for event in pygame.event.get(): + if event.type == pygame.QUIT: + PLAYING = False + ws.close() + if ( + event.type == pygame_cards.events.CARDSSET_CLICKED + and event.card is not None + ): + ws.send(json.dumps({"type": "play", "event": event.card.name})) + + # fill the screen with a color to wipe away anything from last frame + screen.fill("purple") + + manager.process_events(event) + time_delta = clock.tick(60) # limits FPS to 60 + + manager.update(time_delta) + manager.draw(screen) + # flip() the display to put your work on screen + pygame.display.flip() + +pygame.quit() +wst.join() diff --git a/examples/networking_rockpaperscisors/server.py b/examples/networking_rockpaperscisors/server.py new file mode 100644 index 0000000..d455cce --- /dev/null +++ b/examples/networking_rockpaperscisors/server.py @@ -0,0 +1,210 @@ +"""Small sevrer for rock paper scisors game. + +The server is based on websockets. + +The available events are: +- join a new game: {"type": "init",} +- join an existing game: {"type": "init", "join": ""} +- play a move: {"type": "play", "event": ""} + +""" + +#!/usr/bin/env python + +import asyncio +import json +from typing import Any +import websockets +import secrets +from websockets.server import serve, WebSocketServerProtocol + +from content import RockPaperScissors, Player + +JOIN: dict[str, tuple[RockPaperScissors, set[Any]]] = {} +WATCH = {} + + +async def error(websocket: WebSocketServerProtocol, message: str): + """Send an error message.""" + + event = { + "type": "error", + "message": message, + } + + await websocket.send(json.dumps(event)) + + +async def replay(websocket: WebSocketServerProtocol, game: RockPaperScissors): + """Send previous moves.""" + + for move in game.get_past_events(): + event = { + "type": "play", + "event": move, + } + + await websocket.send(json.dumps(event)) + + +async def play( + websocket: WebSocketServerProtocol, + game: RockPaperScissors, + player: Player, + connected, +): + """Receive and process a play from a player.""" + + async for message in websocket: + # Parse instructions from the client. + instructions = json.loads(message) + + print("received", instructions) + + if "type" not in instructions: + await error(websocket, "expected 'type' field") + continue + + if instructions["type"] != "play": + await error(websocket, "expected 'play' event") + continue + + if "event" not in instructions: + await error(websocket, "expected 'event' field") + continue + try: + event = instructions["event"] + # Play the move. + event_to_broadcast = game.play(player, event) + + except Exception as exc: + # Send an "error" if the game could not resolve the event. + await error(websocket, f"Game error: {exc}") + continue + + if event_to_broadcast is not None: + websockets.broadcast(connected, json.dumps(event_to_broadcast)) + + +async def start(websocket): + """Start a new game and add the first player to the game.""" + game = RockPaperScissors() + + connected = {websocket} + + join_key = secrets.token_urlsafe(nbytes=12) + JOIN[join_key] = game, connected + + watch_key = secrets.token_urlsafe(12) + + WATCH[watch_key] = game, connected + + try: + # Send the secret access token to the browser of the first player, + # where it'll be used for building a "join" link. + event = { + "type": "init", + "join": join_key, + } + await websocket.send(json.dumps(event)) + + await play(websocket, game, game.add_player(), connected) + + finally: + del JOIN[join_key] + + +async def join(websocket: WebSocketServerProtocol, join_key): + """Handle a connection from the second player: join an existing game.""" + + # Find the game. + try: + game, connected = JOIN[join_key] + + except KeyError: + await error(websocket, "Game not found.") + return + + # Register to receive moves from this game. + connected.add(websocket) + + try: + # Send the first move, in case the first player already played it. + await replay(websocket, game) + + # Receive and process moves from the second player. + await play(websocket, game, game.add_player(), connected) + + finally: + connected.remove(websocket) + + +async def watch(websocket, watch_key): + """Handle a connection from a spectator: watch an existing game.""" + + # Find the game. + try: + game, connected = WATCH[watch_key] + + except KeyError: + await error(websocket, "Game not found.") + return + + # Register to receive moves from this game. + connected.add(websocket) + + try: + # Send previous moves, in case the game already started. + await replay(websocket, game) + + # Keep the connection open, but don't receive any messages. + await websocket.wait_closed() + + finally: + connected.remove(websocket) + + +async def handler(websocket): + """Handle a connection and dispatch it according to who is connecting.""" + + # Receive and parse the "init" event from the UI. + + message = await websocket.recv() + + try: + event = json.loads(message) + except json.JSONDecodeError: + await error(websocket, "expected a JSON object") + return + + if not isinstance(event, dict): + await error(websocket, "expected a JSON object as dictionarry") + return + + if "type" not in event: + await error(websocket, "expected 'type' field") + return + + if event["type"] != "init": + await error(websocket, "expected 'init' event type") + return + + if "join" in event: + # Second player joins an existing game. + await join(websocket, event["join"]) + + elif "watch" in event: + # Spectator watches an existing game. + await watch(websocket, event["watch"]) + + else: + # First player starts a new game. + await start(websocket) + + +async def main(): + async with serve(handler, "localhost", 8765): + await asyncio.Future() # run forever + + +asyncio.run(main()) diff --git a/pygame_cards/constants.py b/pygame_cards/constants.py index d215f12..c89e883 100644 --- a/pygame_cards/constants.py +++ b/pygame_cards/constants.py @@ -8,6 +8,8 @@ BOARD_SIZE: tuple[int, int] = (720, 560) # The radius of the circled borders of the cards CARD_BORDER_RADIUS_RATIO: float = 0.05 +# Halo around the card +CARD_HALO_RATIO: float = 0.1 # Common screen resolutions diff --git a/pygame_cards/defaults.py b/pygame_cards/defaults.py index 356ff9c..9f82e55 100644 --- a/pygame_cards/defaults.py +++ b/pygame_cards/defaults.py @@ -2,6 +2,8 @@ class DefaultCardsSet(CardsSet): + """A card set that the user already has installed in the package.""" + name: str @@ -21,4 +23,4 @@ def get_default_card_set(name: str) -> DefaultCardsSet: return CardSets.n36 case _: - raise ValueError(f"Unkown card set with name {name}") + raise ValueError(f"Unkown DefaultCardsSet with name {name}") diff --git a/pygame_cards/hands.py b/pygame_cards/hands.py index 57b76fa..a3a2716 100644 --- a/pygame_cards/hands.py +++ b/pygame_cards/hands.py @@ -97,19 +97,21 @@ def calculate_x_positions(self) -> tuple[list[float], float]: The offset_value is the value used to make the spacing between the cards. """ - # calculate dimenstions required for the displayed surf + n_cards = len(self.cardset) + # calculate the offset between the cards in pixels offset = self.card_spacing * self.card_size[0] - total_x = ( - len(self.cardset) * self.card_size[0] + (len(self.cardset) - 1) * offset - ) + + # Start offset, allows for halo when card is hovered + x_start = self.card_halo_ratio * self.card_size[0] + + total_x = n_cards * self.card_size[0] + (n_cards - 1) * offset + x_start if total_x > self.size[0]: self.logger.warning("Too many cards for hands size, rescaling will apply.") - offset = (self.size[0] - len(self.cardset) * self.card_size[0]) / ( - len(self.cardset) - 1 - ) + width_taken = n_cards * self.card_size[0] + x_start + offset = (self.size[0] - width_taken) / (n_cards - 1) x_positions = [ - i * self.card_size[0] + i * offset for i in range(len(self.cardset)) + i * self.card_size[0] + i * offset + x_start for i in range(n_cards) ] # Revert the position in case of another overlap x_positions = [ @@ -122,9 +124,7 @@ def calculate_x_positions(self) -> tuple[list[float], float]: return x_positions, offset - def with_hovered( - self, card: AbstractCard | None, radius: float = 20, **kwargs - ) -> pygame.Surface: + def with_hovered(self, card: AbstractCard | None, **kwargs) -> pygame.Surface: if card is None: return pygame.Surface((0, 0)) index = self.cardset.index(card) @@ -132,6 +132,9 @@ def with_hovered( x_posistions, _ = self.calculate_x_positions() x_pos = x_posistions[index] + # Halo radius + radius = self.card_halo_ratio * self.card_size[0] + card.graphics.size = self.card_size highlighted_surf = outer_halo(card.graphics.surface, radius=radius, **kwargs) # assume the center will be on it diff --git a/pygame_cards/manager.py b/pygame_cards/manager.py index 691b321..d8f0fc0 100644 --- a/pygame_cards/manager.py +++ b/pygame_cards/manager.py @@ -151,6 +151,7 @@ def update(self, time: int) -> bool: in ms. :return: whether the surface was updated or not. """ + # Explain the logic here: if self.mouse_pos is None: # update the mouse pos if not in an event @@ -197,6 +198,7 @@ def update(self, time: int) -> bool: if self._is_aquiring_card and self._stop_aquiring_card: # Was a single click + self.logger.debug("Was a single click") _card_set_rights = self._card_sets_rigths[ self.card_sets.index(self._cardset_under_mouse) ] @@ -208,6 +210,9 @@ def update(self, time: int) -> bool: self._cardset_under_mouse, self._card_under_mouse ) pygame.event.post(clicked_event) + self.logger.debug( + f"Posted clicked from single click {clicked_event = }" + ) # Single click done self._is_aquiring_card, self._stop_aquiring_card = False, False @@ -217,6 +222,8 @@ def update(self, time: int) -> bool: and self._card_under_acquisition is None and self._cardset_under_acquisition is None ): + # User has an aquired a card and does something with it + self.logger.debug("User has an aquired a card and does something with it") _card_set_rights = self._card_sets_rigths[ self.card_sets.index(self._cardset_under_mouse) ] @@ -244,12 +251,13 @@ def update(self, time: int) -> bool: self._card_under_acquisition.graphics.clear_cache() # self._cardset_under_mouse = None - self._card_under_mouse = None + # self._card_under_mouse = None self._subcardset_under_mouse = None self._is_aquiring_card = False if self._stop_aquiring_card: # Card released + self.logger.debug("Card released") if ( self._cardset_under_mouse == self._cardset_of_acquisition and self.get_cardset_rights(self._cardset_under_mouse).clickable @@ -263,6 +271,9 @@ def update(self, time: int) -> bool: self._card_under_acquisition, ) ) + self.logger.debug( + f"Posted clicked after release {clicked_event = }" + ) if ( self._cardset_under_acquisition and len(self._cardset_under_acquisition) == 1 @@ -273,6 +284,10 @@ def update(self, time: int) -> bool: self._cardset_under_acquisition[0], ) ) + self.logger.debug( + "Posted clicked after release only one card in set" + f" {clicked_event = }" + ) if ( self._cardset_under_mouse is not None @@ -327,12 +342,12 @@ def update(self, time: int) -> bool: and self._cardset_under_mouse is not None and self.get_cardset_rights(self._cardset_under_mouse).clickable ): - pygame.event.post( - cardsset_clicked( - self._cardset_under_mouse, - self._card_under_mouse, - ) + clicked_event = cardsset_clicked( + self._cardset_under_mouse, + self._card_under_mouse, ) + pygame.event.post(clicked_event) + self.logger.debug(f"Posted {clicked_event = }") # Update the mouse position and speed self.mouse_speed = ( self.mouse_pos[0] - self.last_mouse_pos[0], diff --git a/pygame_cards/server/client.py b/pygame_cards/server/client.py index c0985c1..69f17ea 100644 --- a/pygame_cards/server/client.py +++ b/pygame_cards/server/client.py @@ -7,7 +7,7 @@ import websockets import json import pygame -from pygame_cards.server.events import CARD_PLAYED, PygameEventsEncoder +from pygame_cards.server.json_encoding import CARD_PLAYED, PygameEventsEncoder from pygame_cards.defaults import get_default_card_set from pygame_cards.server.player import Player diff --git a/pygame_cards/server/events.py b/pygame_cards/server/events.py deleted file mode 100644 index 2a81c92..0000000 --- a/pygame_cards/server/events.py +++ /dev/null @@ -1,23 +0,0 @@ -from datetime import datetime -from json import JSONEncoder -from typing import Any -import pygame - -from pygame_cards.defaults import DefaultCardsSet - - -CARD_PLAYED = pygame.event.custom_type() - -# subclass JSONEncoder -class PygameEventsEncoder(JSONEncoder): - def default(self, o: pygame.event.Event | Any): - match o: - case pygame.event.EventType(): - return {"_type_PygameEventsEncoder": o.type} | o.__dict__ - case datetime(): - return str(o) - case DefaultCardsSet(): - return o.name - - case _: - return JSONEncoder.default(self, o) diff --git a/pygame_cards/server/json_encoding.py b/pygame_cards/server/json_encoding.py new file mode 100644 index 0000000..722c19c --- /dev/null +++ b/pygame_cards/server/json_encoding.py @@ -0,0 +1,77 @@ +from datetime import datetime +from json import JSONEncoder, JSONDecoder +import json +import logging +from typing import Any +import pygame + +from pygame_cards.defaults import DefaultCardsSet, get_default_card_set + + +CARD_PLAYED = pygame.event.custom_type() + +# subclass JSONEncoder +class PygameEventsEncoder(JSONEncoder): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = logging.getLogger("pygame_cards.PygameEventsEncoder") + + def encode(self, o: pygame.event.Event | Any) -> str: + self.logger.debug(f"PygameEventsEncoder.default({o=})") + match o: + case pygame.event.EventType(): + o = { + "pgc": "event", + "type": o.type, + "__dict__": self.encode(o.__dict__), + } + case datetime(): + o = str(o) + case DefaultCardsSet(): + o = { + "pgc": "DefaultCardsSet", + "name": o.name, + } + + case _: + pass + return JSONEncoder.encode(self, o) + + +class PygameEventsDecoder(JSONDecoder): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = logging.getLogger("pygame_cards.PygameEventsDecoder") + + def decode(self, json_str: str) -> pygame.event.Event | Any: + self.logger.debug(f"PygameEventsDecoder.default({json_str=})") + + decoded = JSONDecoder.decode(self, json_str) + if not (isinstance(decoded, dict) and "pgc" in decoded): + return decoded + + match decoded["pgc"]: + case "event": + return pygame.event.Event( + decoded["type"], self.decode(decoded["__dict__"]) + ) + case "DefaultCardsSet": + return get_default_card_set(decoded["name"]) + case _: + pass + + +if __name__ == "__main__": + from pygame_cards.classics import CardSets + + # logging.basicConfig(level=logging.DEBUG) + + e = pygame.event.Event(CARD_PLAYED, {"card": "Ace of Spades"}) + + e_encoded = json.dumps(e, cls=PygameEventsEncoder) + print(e_encoded) + print(json.loads(e_encoded, cls=PygameEventsDecoder)) + + cs_encoded = json.dumps(CardSets.n52, cls=PygameEventsEncoder) + print(cs_encoded) + print(json.loads(cs_encoded, cls=PygameEventsDecoder)) diff --git a/pygame_cards/set.py b/pygame_cards/set.py index c264f4e..94d99dc 100644 --- a/pygame_cards/set.py +++ b/pygame_cards/set.py @@ -36,6 +36,7 @@ def __init__( size: tuple[int, int] = constants.CARDSET_SIZE, card_size: tuple[int, int] = constants.CARD_SIZE, card_border_radius_ratio: float = constants.CARD_BORDER_RADIUS_RATIO, + card_halo_ratio: float = constants.CARD_HALO_RATIO, graphics_type: type | None = None, max_cards: int = 0, ): @@ -43,6 +44,7 @@ def __init__( self._size = size self.card_size = card_size self.card_border_radius_ratio = card_border_radius_ratio + self.card_halo_ratio = card_halo_ratio self.max_cards = max_cards self.graphics_type = graphics_type diff --git a/tests/test_events.py b/tests/test_events.py index 5956ddd..0ac8c79 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -40,7 +40,6 @@ def setUp(self) -> None: test_card_set, size=(30, 10), card_size=(10, 8), - card_border_radius=1, ) def test_can_add_graphics(self):