diff --git a/README.md b/README.md index b2b8085..2261ef2 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ WebSockets server runs on port **8011**. To run WS Server on different port, edi ### Architecture ```plantuml +scale 1024 width actor "Player 1" as P1 actor "Player 2" as P2 actor "Player 3" as P3 @@ -129,6 +130,7 @@ WS --> ExtVis2: WS Game State ### WebSockets ```plantuml +scale 1024 width box "FairHopper Game Server" #lightcyan participant Game as "Game Engine" participant WS as "WS Server" @@ -136,6 +138,8 @@ endbox participant Client1 as "Visualisation\nClient 1" participant Client2 as "Visualisation\nClient 2" +== Player movement mode == + Game ->o WS: Send initial state Client1 ->o WS: Client connect @@ -155,6 +159,27 @@ loop #lightyellow On game state change WS o-> Client2: Game state deactivate end + +== Product purchase mode == + +Game -> WS: Purchase start +activate WS #coral + WS o-> Client1: Purchase start + WS o-> Client2: Purchase start +deactivate + +loop #lightyellow Purchase countdown timer + Game ->o WS: Timer count down + activate WS #coral + WS o-> Client1: Purchase time left + WS o-> Client2: Purchase time left + deactivate +end +Game -> WS: Purchase done +activate WS #coral + WS o-> Client1: Purchase done + WS o-> Client2: Purchase done +deactivate ``` diff --git a/frontend/index.html b/frontend/index.html index 0815d7a..53da497 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,12 @@ FairHopper Visualisation Client
-

Product selection

+
+

+ Product selection +

+

+
@@ -127,10 +132,11 @@ renderPlayers(data.players); } - function productPurchaseStart(products) { + function productPurchaseStart(products, purchaseTimeout) { console.log("productPurchaseStart:", products); const containerElement = document.getElementById("purchase-container"); const contentElement = document.getElementById("products-content"); + const purchaseTimeoutElement = document.getElementById("purchase-countdown"); const html = products.map(product => { return ` @@ -145,6 +151,12 @@ contentElement.innerHTML = html; containerElement.classList.remove("d-none") + purchaseTimeoutElement.innerText = purchaseTimeout; + } + + function productPurchaseTimerTick(timeLeft) { + const purchaseTimeoutElement = document.getElementById("purchase-countdown"); + purchaseTimeoutElement.innerText = timeLeft; } function productPurchased(product) { @@ -172,7 +184,10 @@ renderGameDump(wsMessage.data); break; case "product_purchase_start": - productPurchaseStart(wsMessage.data.products) + productPurchaseStart(wsMessage.data.products, wsMessage.data.timeout) + break; + case "product_purchase_timer_tick": + productPurchaseTimerTick(wsMessage.data.time_left) break; case "product_purchased": productPurchased(wsMessage.data) @@ -199,42 +214,7 @@ window.onload = () => { wsConnect(); - - productPurchaseStart([ - { - "name": "CocaCola", - "uuid": "4af72121-c4c5-4a28-b514-2ba577a7f6c5", - "description": null - }, - { - "name": "Pepsi", - "uuid": "a14ad558-6ab2-4aa7-9456-f525430c38f8", - "description": null - }, - { - "name": "Fanta", - "uuid": "7ea2fe22-c938-4217-91ec-e96c040b077f", - "description": null - }, - { - "name": "Snickers", - "uuid": "04d8ad0c-fa80-4342-9449-390b162995fd", - "description": null - }, - { - "name": "Mars", - "uuid": "f8674776-dc57-418f-b4ea-29f4ec4fdf35", - "description": null - }, - { - "name": "Burek", - "uuid": "329942c8-9a6d-42e5-b859-3df577c5bce7", - "description": null - } - ]) - } - \ No newline at end of file diff --git a/frontend/styles.css b/frontend/styles.css index 6b0a649..aebb515 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -69,11 +69,15 @@ ul.players { border-radius: 10px; } -.purchase-container h3 { +.purchase-container .header { color: white; margin-bottom: 20px; } +.purchase-container .header .countdown { + margin-left: auto; +} + .purchase-container .products-content { display: grid; grid-gap: 10px; diff --git a/hopper/countdown_timer.py b/hopper/countdown_timer.py index 8fd01c5..90d0e90 100644 --- a/hopper/countdown_timer.py +++ b/hopper/countdown_timer.py @@ -5,21 +5,24 @@ from typing import Callable, Optional class CountdownTimer(Thread): def __init__( - self, seconds: int, callback: Optional[Callable[[], None]] = None + self, seconds: int, timer_tick_callback: Optional[Callable[[int], None]] = None, timer_done_callback: Optional[Callable[[], None]] = None ) -> None: self.seconds = seconds self.stop_event = Event() - self.callback = callback + self.timer_tick_callback = timer_tick_callback + self.timer_done_callback = timer_done_callback super().__init__(daemon=True) def run(self) -> None: - cnt = self.seconds - while cnt and not self.stop_event.is_set(): - cnt -= 1 + time_left = self.seconds + while time_left and not self.stop_event.is_set(): time.sleep(1) + time_left -= 1 + if self.timer_tick_callback: + self.timer_tick_callback(time_left) - if cnt == 0 and self.callback: - self.callback() + if time_left == 0 and self.timer_done_callback: + self.timer_done_callback() def stop(self) -> None: self.stop_event.set() diff --git a/hopper/engine.py b/hopper/engine.py index 59c9806..84cccf0 100644 --- a/hopper/engine.py +++ b/hopper/engine.py @@ -6,7 +6,6 @@ from typing import Optional from hopper.countdown_timer import CountdownTimer from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds -from hopper.interfaces import SendGameDumpInterface from hopper.models.board import ( BOARD_DUMP_CHARS, BoardLayout, @@ -19,13 +18,12 @@ from hopper.models.board import ( ) from hopper.models.player import Player, PlayerList, Position from hopper.watchdog import InactivityWatchdog +from hopper.ws_server import WSServer from settings import settings class GameEngine: - def __init__( - self, board: GameBoard, ws_server: Optional[SendGameDumpInterface] = None - ) -> None: + def __init__(self, board: GameBoard, ws_server: WSServer = None) -> None: self.board = board self.ws_server = ws_server self.players = PlayerList() @@ -82,7 +80,12 @@ class GameEngine: if self.ws_server: await self.ws_server.send_game_dump() + #!!!!!!!!!!!!!!! + await self._player_on_destination(player) + #!!!!!!!!!!!!!!! + await asyncio.sleep(settings.game.MOVE_DELAY) + return player def _create_player_start_position(self) -> Position: @@ -171,23 +174,38 @@ class GameEngine: await self.ws_server.send_game_dump() self.__debug_print_board() - await self.ws_server.send_product_purchase_message(products=settings.products) + await self.ws_server.send_product_purchase_start_message( + player=player, products=settings.products + ) + + logging.info( + f"Starting purchase countdown timer for {settings.purchase_timeout} seconds" + ) + + def on_purchase_timer_tick(time_left) -> None: + asyncio.run( + self.ws_server.send_product_purchase_time_left_message( + player=player, time_left=time_left + ) + ) + + def on_purchase_timer_done() -> None: + logging.info("Ding ding! Purchase countdown timer timeout") + self._purchase_countdown_timer = None + asyncio.run( + self.ws_server.send_product_purchase_done_message( + player=player, product=None + ) + ) + self.game_state = GameState.RUNNING - logging.info(f"Starting purchase countdown timer for {settings.purchase_timeout} seconds") self._purchase_countdown_timer = CountdownTimer( seconds=settings.purchase_timeout, - callback=self._on_purchase_timeout, + timer_tick_callback=on_purchase_timer_tick, + timer_done_callback=on_purchase_timer_done, ) self._purchase_countdown_timer.start() - def _on_purchase_timeout(self) -> None: - logging.info("Ding ding! Purchase countdown timer timeout") - self._purchase_countdown_timer = None - - asyncio.run(self.ws_server.send_product_purchase_done_message(product=None)) - - self.game_state = GameState.RUNNING - def get_board_layout(self) -> BoardLayout: return BoardLayout(board=self.board, players=self.players) @@ -198,7 +216,7 @@ class GameEngineFactory: board_width: int, board_height: int, obstacle_count: int = 0, - ws_server: Optional[SendGameDumpInterface] = None, + ws_server: WSServer = None, ) -> GameEngine: board = GameBoard( width=board_width, @@ -224,7 +242,7 @@ class GameEngineFactory: @staticmethod def create_default( - ws_server: Optional[SendGameDumpInterface] = None, + ws_server: WSServer = None, ) -> GameEngine: return GameEngineFactory.create( board_width=settings.board.WIDTH, diff --git a/hopper/interfaces.py b/hopper/interfaces.py deleted file mode 100644 index 937d302..0000000 --- a/hopper/interfaces.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Iterable, Optional, Protocol - -from hopper.models.product import Product - - -class SendGameDumpInterface(Protocol): - async def send_game_dump(self) -> None: - ... - - async def send_product_purchase_message(self, products: Iterable[Product]) -> None: - ... - - async def send_product_purchase_done_message( - self, product: Optional[Product] = None - ) -> None: - ... diff --git a/hopper/models/ws_dto.py b/hopper/models/ws_dto.py index bbb8e8d..dbc8a3d 100644 --- a/hopper/models/ws_dto.py +++ b/hopper/models/ws_dto.py @@ -26,22 +26,26 @@ class ProductDto(BaseModel): description: Optional[str] = None -class GameDumpPlayerDto(PlayerDto): - ... - - class GameDumpDto(BaseModel): board: BoardDto destination: DestinationDto - players: list[GameDumpPlayerDto] + players: list[PlayerDto] layers: list[LayerDto] class ProductPurchaseStartDto(BaseModel): + player: PlayerDto products: list[ProductDto] + timeout: int + + +class ProductPurchaseTimerDto(BaseModel): + time_left: int + player: PlayerDto class ProductPurchaseDoneDto(BaseModel): + player: PlayerDto product: Optional[ProductDto] = None @@ -64,11 +68,16 @@ class WSGameDumpMessage(WSMessage): data: GameDumpDto -class WSProductPurchaseStart(WSMessage): +class WSProductPurchaseStartMessage(WSMessage): message: str = "product_purchase_start" data: ProductPurchaseStartDto -class WSProductPurchaseDone(WSMessage): +class WSProductPurchaseTimerTickMessage(WSMessage): + message: str = "product_purchase_timer_tick" + data: ProductPurchaseTimerDto + + +class WSProductPurchaseDoneMessage(WSMessage): message: str = "product_purchase_done" data: ProductPurchaseDoneDto diff --git a/hopper/watchdog.py b/hopper/watchdog.py index 99399d3..13a7b2c 100644 --- a/hopper/watchdog.py +++ b/hopper/watchdog.py @@ -3,16 +3,15 @@ import datetime import logging import time from threading import Thread -from typing import Optional -from hopper.interfaces import SendGameDumpInterface from hopper.models.player import PlayerList +from hopper.ws_server import WSServer from settings import settings class InactivityWatchdog(Thread): def __init__( - self, players: PlayerList, ws_server: Optional[SendGameDumpInterface] = None + self, players: PlayerList, ws_server: WSServer = None ) -> None: self.players = players self.ws_server = ws_server @@ -61,8 +60,6 @@ class InactivityWatchdog(Thread): self.send_game_dump() def send_game_dump(self): - if not self.ws_server: - return logging.info("Sending WS game dump") asyncio.run(self.ws_server.send_game_dump()) diff --git a/hopper/ws_server.py b/hopper/ws_server.py index e5d457c..6be46ed 100644 --- a/hopper/ws_server.py +++ b/hopper/ws_server.py @@ -7,16 +7,20 @@ import websockets from websockets import WebSocketServerProtocol from websockets.exceptions import ConnectionClosedOK +from hopper.models.player import Player from hopper.models.product import Product from hopper.models.ws_dto import ( GameDumpDto, ProductPurchaseDoneDto, ProductPurchaseStartDto, + ProductPurchaseTimerDto, WSGameDumpMessage, WSMessage, - WSProductPurchaseDone, - WSProductPurchaseStart, + WSProductPurchaseDoneMessage, + WSProductPurchaseStartMessage, + WSProductPurchaseTimerTickMessage, ) +from settings import settings class WSServer(Thread): @@ -85,16 +89,35 @@ class WSServer(Thread): message = self._create_game_dump_message() await self.send_message_to_clients(message) - async def send_product_purchase_message(self, products: Iterable[Product]) -> None: - message = WSProductPurchaseStart( - data=ProductPurchaseStartDto(products=products) + async def send_product_purchase_start_message( + self, player: Player, products: Iterable[Product] + ) -> None: + message = WSProductPurchaseStartMessage( + data=ProductPurchaseStartDto( + player=player, + products=products, + timeout=settings.purchase_timeout, + ) + ) + await self.send_message_to_clients(message) + + async def send_product_purchase_time_left_message( + self, player: Player, time_left: int + ) -> None: + message = WSProductPurchaseTimerTickMessage( + data=ProductPurchaseTimerDto( + player=player, + time_left=time_left, + ) ) await self.send_message_to_clients(message) async def send_product_purchase_done_message( - self, product: Optional[Product] = None + self, player: Player, product: Optional[Product] = None ) -> None: - message = WSProductPurchaseDone(data=ProductPurchaseDoneDto(product=product)) + message = WSProductPurchaseDoneMessage( + data=ProductPurchaseDoneDto(player=player, product=product), + ) await self.send_message_to_clients(message) async def run_async(self) -> None: