From 2a01cd5aef7329c95ec32970c4a204b1ec0e8346 Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Fri, 12 May 2023 17:26:02 +0200 Subject: [PATCH 01/10] fixed halo size --- pygame_cards/constants.py | 2 ++ pygame_cards/hands.py | 25 ++++++++++++++----------- pygame_cards/set.py | 2 ++ 3 files changed, 18 insertions(+), 11 deletions(-) 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/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/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 From 9af82425fde6de2da348955c4054bb4c2602d85b Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Fri, 12 May 2023 17:26:11 +0200 Subject: [PATCH 02/10] adding base content --- .../networking_rockpaperscisors/content.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 examples/networking_rockpaperscisors/content.py diff --git a/examples/networking_rockpaperscisors/content.py b/examples/networking_rockpaperscisors/content.py new file mode 100644 index 0000000..33b50d3 --- /dev/null +++ b/examples/networking_rockpaperscisors/content.py @@ -0,0 +1,105 @@ +"""Shared content for the game.""" +from __future__ import annotations +from dataclasses import dataclass +from enum import Enum +from functools import cached_property + +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 CARDSIGN(Enum): + ROCK = "rock" + PAPER = "paper" + SCISSORS = "scissors" + + +EMOJI_TO_USE: dict[CARDSIGN, str] = { + CARDSIGN.ROCK: "🪨", + CARDSIGN.PAPER: "📄", + CARDSIGN.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: CARDSIGN + graphics_type = RockPaperScisorsGraphics + + +ROCK_PAPER_SCISSORS_CARDSET = CardsSet( + # Iterate over enum to create the cards + [RockPaperScissorsCard(sign=sign, name=sign.value) for sign in CARDSIGN] +) + + +if __name__ == "__main__": + print(ROCK_PAPER_SCISSORS_CARDSET) + + # pygame setup + pygame.init() + screen = pygame.display.set_mode((1280, 720)) + clock = pygame.time.Clock() + running = True + + # 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 + ), + ) + + while running: + # 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: + running = False + if event.type == pygame_cards.events.CARDSSET_CLICKED: + print(event.set, event.card) + + # 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() From cd7273b9968be26e17b91d138f749d2110444363 Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Fri, 12 May 2023 17:26:24 +0200 Subject: [PATCH 03/10] adding empty files for clients and servers --- examples/networking_rockpaperscisors/README.md | 10 ++++++++++ examples/networking_rockpaperscisors/client.py | 0 examples/networking_rockpaperscisors/server.py | 0 3 files changed, 10 insertions(+) create mode 100644 examples/networking_rockpaperscisors/README.md create mode 100644 examples/networking_rockpaperscisors/client.py create mode 100644 examples/networking_rockpaperscisors/server.py 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/client.py b/examples/networking_rockpaperscisors/client.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/networking_rockpaperscisors/server.py b/examples/networking_rockpaperscisors/server.py new file mode 100644 index 0000000..e69de29 From 1c975efbe66bcb232575487ab14c41853a56cd1b Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Fri, 12 May 2023 22:09:57 +0200 Subject: [PATCH 04/10] json encoding of basics --- pygame_cards/defaults.py | 4 +- pygame_cards/server/events.py | 23 --------- pygame_cards/server/json_encoding.py | 77 ++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 24 deletions(-) delete mode 100644 pygame_cards/server/events.py create mode 100644 pygame_cards/server/json_encoding.py 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/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)) From 5d09a2e58145a7da49fe2d876857cd3c0c2c424f Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Fri, 12 May 2023 22:15:48 +0200 Subject: [PATCH 05/10] client server base code --- .../networking_rockpaperscisors/client.py | 34 +++++++++++++++++++ .../networking_rockpaperscisors/server.py | 30 ++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/examples/networking_rockpaperscisors/client.py b/examples/networking_rockpaperscisors/client.py index e69de29..dec52b4 100644 --- a/examples/networking_rockpaperscisors/client.py +++ b/examples/networking_rockpaperscisors/client.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +import asyncio +import json +from websockets.sync.client import connect + + +""" def send_message(message: dict): + with connect("ws://localhost:8765") as websocket: + websocket.send(json.dumps(message)) + message = websocket.recv() + print(f"Received: {message}") + + +# Wait for Ctrl+C +try: + while True: + val = input("Enter your message: ") + send_message({"mesage": val}) +except KeyboardInterrupt: + print("Goodbye!") + """ + +# Do the same but with a single connection at the beginning + +with connect("ws://localhost:8765") as websocket: + try: + while True: + val = input("Enter your message: ") + websocket.send(json.dumps({"mesage": val})) + message = websocket.recv() + print(f"Received: {message}") + except KeyboardInterrupt: + print("Goodbye!") diff --git a/examples/networking_rockpaperscisors/server.py b/examples/networking_rockpaperscisors/server.py index e69de29..5000cec 100644 --- a/examples/networking_rockpaperscisors/server.py +++ b/examples/networking_rockpaperscisors/server.py @@ -0,0 +1,30 @@ +"""Small sevrer for rock paper scisors game. + +The server is based on websockets. +""" + +#!/usr/bin/env python + +import asyncio +import json +from websockets.server import serve + + +async def echo(websocket): + async for message in websocket: + print(message) + + try: + json.loads(message) + except: + print("not json") + + await websocket.send(message) + + +async def main(): + async with serve(echo, "localhost", 8765): + await asyncio.Future() # run forever + + +asyncio.run(main()) From 91682e5172cda866d0b11934518a35c86ba32330 Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Fri, 12 May 2023 22:15:58 +0200 Subject: [PATCH 06/10] renamed import --- pygame_cards/server/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a36365fd8bdb3255bc180881f5c12faa0a096e58 Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Fri, 12 May 2023 22:18:15 +0200 Subject: [PATCH 07/10] fixed test --- tests/test_events.py | 1 - 1 file changed, 1 deletion(-) 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): From 77d7cf76b624305a3f699926c4fd9ed0ebb37acd Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Sat, 13 May 2023 12:10:03 +0200 Subject: [PATCH 08/10] can send message from other thread --- .../networking_rockpaperscisors/client.py | 52 ++++++++++- .../networking_rockpaperscisors/pg_client.py | 89 +++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 examples/networking_rockpaperscisors/pg_client.py diff --git a/examples/networking_rockpaperscisors/client.py b/examples/networking_rockpaperscisors/client.py index dec52b4..bdd5e58 100644 --- a/examples/networking_rockpaperscisors/client.py +++ b/examples/networking_rockpaperscisors/client.py @@ -1,7 +1,11 @@ #!/usr/bin/env python +import argparse import asyncio import json +import threading +import time +import websockets from websockets.sync.client import connect @@ -23,7 +27,7 @@ # Do the same but with a single connection at the beginning -with connect("ws://localhost:8765") as websocket: +""" with connect("ws://localhost:8765") as websocket: try: while True: val = input("Enter your message: ") @@ -32,3 +36,49 @@ print(f"Received: {message}") except KeyboardInterrupt: print("Goodbye!") + """ + + +class ServerListener: + must_stop: bool = False + + messages_to_send: list[str] = [] + + def set_websocket(self, websocket: websockets.WebSocketClientProtocol): + self.websocket = websocket + + def send_messages(self): + try: + while not self.must_stop: + while self.messages_to_send: + # Pop the message + msg = self.messages_to_send.pop(0) + self.websocket.send(json.dumps({"message": msg})) + # Wait max one second for receive timeout + + # message = self.websocket.recv() + + # print(f"Received: {message}") + time.sleep(0.5) + except KeyboardInterrupt: + print("Goodbye!") + self.websocket.close() + + +if __name__ == "__main__": + server_listener = ServerListener() + + def start_listening(server_listener): + with connect("ws://localhost:8765") as websocket: + server_listener.set_websocket(websocket) + server_listener.send_messages() + + listening_thread = threading.Thread(target=start_listening, args=(server_listener,)) + listening_thread.start() + + # Add a message very second + for i in range(10): + server_listener.messages_to_send.append("Hello") + time.sleep(1) + + server_listener.must_stop = True diff --git a/examples/networking_rockpaperscisors/pg_client.py b/examples/networking_rockpaperscisors/pg_client.py new file mode 100644 index 0000000..8ac201b --- /dev/null +++ b/examples/networking_rockpaperscisors/pg_client.py @@ -0,0 +1,89 @@ +"""This is a client with a pygame GUI.""" +# import and init pygame library +import threading +import asyncio +import pygame +import websockets +import asyncio +import pygame +from websockets.sync.client import connect + +IPADDRESS = "localhost" +PORT = 8765 + +EVENTTYPE = pygame.event.custom_type() + + +def send_server(message: str): + with connect(f"ws://{IPADDRESS}:{PORT}") as websocket: + websocket.send(message) + + +async def processMsg(message): + print(f"[Received]: {message}") + pygame.fastevent.post(pygame.event.Event(EVENTTYPE, message=message)) + + +def listen_server(future: asyncio.Future): + with connect(f"ws://{IPADDRESS}:{PORT}") as websocket: + # wait asynch for pygame events to send them + # to the server + print("Connected to server") + while not future.done(): + message = websocket.recv() + print(f"Received: {message}") + + +def start_server(loop: asyncio.AbstractEventLoop, future: asyncio.Future): + loop.run_until_complete(listen_server(future)) + + +def stop_server(loop: asyncio.AbstractEventLoop, future: asyncio.Future): + loop.call_soon_threadsafe(future.set_result, None) + send_server("stop") + + +loop = asyncio.get_event_loop() +future = loop.create_future() +thread = threading.Thread(target=start_server, args=(loop, future)) +thread.start() + +pygame.init() +pygame.fastevent.init() + +# screen dimensions +HEIGHT = 320 +WIDTH = 480 + +# set up the drawing window +screen = pygame.display.set_mode([WIDTH, HEIGHT]) + +color = pygame.Color("blue") +radius = 30 +x = int(WIDTH / 2) + +# run until the user asks to quit +while True: + # did the user close the window + for event in pygame.fastevent.get(): + if event.type == pygame.QUIT: + print("Stoping event loop") + stop_server(loop, future) + print("Waiting for termination") + thread.join() + print("Shutdown pygame") + pygame.quit() + + elif event.type == EVENTTYPE: + print(event.message) + color = pygame.Color("red") + x = (x + radius / 3) % (WIDTH - radius * 2) + radius + + # fill the background with white + screen.fill((255, 255, 255)) + + # draw a solid blue circle in the center + pygame.draw.circle(screen, color, (x, int(HEIGHT / 2)), radius) + + # flip the display + pygame.display.flip() From 1c357ab809ef94224d7b362b29316099c6c593fd Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Sun, 14 May 2023 21:30:57 +0200 Subject: [PATCH 09/10] fixed bug click without card under mouse --- pygame_cards/manager.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) 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], From 6218d3ab17dcc0749e4e4978a3278a503e4127fa Mon Sep 17 00:00:00 2001 From: lionel42 <43442120+lionel42@users.noreply.github.com> Date: Sun, 14 May 2023 22:19:54 +0200 Subject: [PATCH 10/10] working connection and game first draft --- .../networking_rockpaperscisors/client.py | 84 -------- .../networking_rockpaperscisors/content.py | 166 ++++++++++----- .../networking_rockpaperscisors/pg_client.py | 142 +++++++------ .../networking_rockpaperscisors/server.py | 196 +++++++++++++++++- 4 files changed, 381 insertions(+), 207 deletions(-) delete mode 100644 examples/networking_rockpaperscisors/client.py diff --git a/examples/networking_rockpaperscisors/client.py b/examples/networking_rockpaperscisors/client.py deleted file mode 100644 index bdd5e58..0000000 --- a/examples/networking_rockpaperscisors/client.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python - -import argparse -import asyncio -import json -import threading -import time -import websockets -from websockets.sync.client import connect - - -""" def send_message(message: dict): - with connect("ws://localhost:8765") as websocket: - websocket.send(json.dumps(message)) - message = websocket.recv() - print(f"Received: {message}") - - -# Wait for Ctrl+C -try: - while True: - val = input("Enter your message: ") - send_message({"mesage": val}) -except KeyboardInterrupt: - print("Goodbye!") - """ - -# Do the same but with a single connection at the beginning - -""" with connect("ws://localhost:8765") as websocket: - try: - while True: - val = input("Enter your message: ") - websocket.send(json.dumps({"mesage": val})) - message = websocket.recv() - print(f"Received: {message}") - except KeyboardInterrupt: - print("Goodbye!") - """ - - -class ServerListener: - must_stop: bool = False - - messages_to_send: list[str] = [] - - def set_websocket(self, websocket: websockets.WebSocketClientProtocol): - self.websocket = websocket - - def send_messages(self): - try: - while not self.must_stop: - while self.messages_to_send: - # Pop the message - msg = self.messages_to_send.pop(0) - self.websocket.send(json.dumps({"message": msg})) - # Wait max one second for receive timeout - - # message = self.websocket.recv() - - # print(f"Received: {message}") - time.sleep(0.5) - except KeyboardInterrupt: - print("Goodbye!") - self.websocket.close() - - -if __name__ == "__main__": - server_listener = ServerListener() - - def start_listening(server_listener): - with connect("ws://localhost:8765") as websocket: - server_listener.set_websocket(websocket) - server_listener.send_messages() - - listening_thread = threading.Thread(target=start_listening, args=(server_listener,)) - listening_thread.start() - - # Add a message very second - for i in range(10): - server_listener.messages_to_send.append("Hello") - time.sleep(1) - - server_listener.must_stop = True diff --git a/examples/networking_rockpaperscisors/content.py b/examples/networking_rockpaperscisors/content.py index 33b50d3..edabec1 100644 --- a/examples/networking_rockpaperscisors/content.py +++ b/examples/networking_rockpaperscisors/content.py @@ -3,6 +3,7 @@ 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 @@ -15,16 +16,32 @@ from pygame_cards.abstract import AbstractCard, AbstractCardGraphics -class CARDSIGN(Enum): +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 -EMOJI_TO_USE: dict[CARDSIGN, str] = { - CARDSIGN.ROCK: "🪨", - CARDSIGN.PAPER: "📄", - CARDSIGN.SCISSORS: "✂️", + return False + + +class Player(Enum): + PLAYER1 = "player1" + PLAYER2 = "player2" + + +EMOJI_TO_USE: dict[Sign, str] = { + Sign.ROCK: "🪨", + Sign.PAPER: "📄", + Sign.SCISSORS: "✂️", } @@ -48,58 +65,105 @@ def surface(self) -> pygame.Surface: @dataclass class RockPaperScissorsCard(AbstractCard): - sign: CARDSIGN + 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 CARDSIGN] + [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__": - print(ROCK_PAPER_SCISSORS_CARDSET) - - # pygame setup - pygame.init() - screen = pygame.display.set_mode((1280, 720)) - clock = pygame.time.Clock() - running = True - - # 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 - ), - ) - - while running: - # 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: - running = False - if event.type == pygame_cards.events.CARDSSET_CLICKED: - print(event.set, event.card) - - # 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() + sing = Sign("rock") + print(sing) diff --git a/examples/networking_rockpaperscisors/pg_client.py b/examples/networking_rockpaperscisors/pg_client.py index 8ac201b..20e2e0f 100644 --- a/examples/networking_rockpaperscisors/pg_client.py +++ b/examples/networking_rockpaperscisors/pg_client.py @@ -1,89 +1,103 @@ -"""This is a client with a pygame GUI.""" -# import and init pygame library +"""Pygame example using websockets to communicate with a server.""" +import json +import logging import threading -import asyncio +from time import sleep import pygame -import websockets -import asyncio -import pygame -from websockets.sync.client import connect - -IPADDRESS = "localhost" -PORT = 8765 +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 -EVENTTYPE = pygame.event.custom_type() +logging.basicConfig() +socket = "ws://localhost:8765/" +# websocket.enableTrace(True) -def send_server(message: str): - with connect(f"ws://{IPADDRESS}:{PORT}") as websocket: - websocket.send(message) +PLAYING = True +# Game to join +join_key = "cUVtaORqA0SfPjTT" +# If you are the first play +# join_key = None -async def processMsg(message): - print(f"[Received]: {message}") - pygame.fastevent.post(pygame.event.Event(EVENTTYPE, message=message)) +# 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 listen_server(future: asyncio.Future): - with connect(f"ws://{IPADDRESS}:{PORT}") as websocket: - # wait asynch for pygame events to send them - # to the server - print("Connected to server") - while not future.done(): - message = websocket.recv() - print(f"Received: {message}") +def on_message(ws, message): + print("Message received: ", message) -def start_server(loop: asyncio.AbstractEventLoop, future: asyncio.Future): - loop.run_until_complete(listen_server(future)) +def on_close(ws, close_status_code, close_msg): + global PLAYING + PLAYING = False + print(">>>>>>CLOSED") -def stop_server(loop: asyncio.AbstractEventLoop, future: asyncio.Future): - loop.call_soon_threadsafe(future.set_result, None) - send_server("stop") +def on_error(ws, error): + print(error) -loop = asyncio.get_event_loop() -future = loop.create_future() -thread = threading.Thread(target=start_server, args=(loop, future)) -thread.start() +# pygame setup pygame.init() -pygame.fastevent.init() - -# screen dimensions -HEIGHT = 320 -WIDTH = 480 +screen = pygame.display.set_mode((1280, 720)) +clock = pygame.time.Clock() -# set up the drawing window -screen = pygame.display.set_mode([WIDTH, HEIGHT]) -color = pygame.Color("blue") -radius = 30 -x = int(WIDTH / 2) +ws = websocket.WebSocketApp( + socket, on_open=on_open, on_message=on_message, on_close=on_close, on_error=on_error +) -# run until the user asks to quit -while True: - # did the user close the window - for event in pygame.fastevent.get(): - if event.type == pygame.QUIT: - print("Stoping event loop") - stop_server(loop, future) - print("Waiting for termination") - thread.join() - print("Shutdown pygame") - pygame.quit() +wst = threading.Thread(target=lambda: ws.run_forever()) +wst.daemon = True +wst.start() - elif event.type == EVENTTYPE: - print(event.message) - color = pygame.Color("red") - x = (x + radius / 3) % (WIDTH - radius * 2) + radius - # fill the background with white - screen.fill((255, 255, 255)) +# Create the cardset graphics +cardset_graphics = AlignedHand(cardset=ROCK_PAPER_SCISSORS_CARDSET, card_halo_ratio=0.2) - # draw a solid blue circle in the center - pygame.draw.circle(screen, color, (x, int(HEIGHT / 2)), radius) +# 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) - # flip the display +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 index 5000cec..d455cce 100644 --- a/examples/networking_rockpaperscisors/server.py +++ b/examples/networking_rockpaperscisors/server.py @@ -1,29 +1,209 @@ """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 websockets.server import serve +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 def echo(websocket): async for message in websocket: - print(message) + # 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: - json.loads(message) - except: - print("not json") + 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"]) - await websocket.send(message) + else: + # First player starts a new game. + await start(websocket) async def main(): - async with serve(echo, "localhost", 8765): + async with serve(handler, "localhost", 8765): await asyncio.Future() # run forever