From 8c4ec3d4cb3de39bef6816167ffd6d037ce9ea8a Mon Sep 17 00:00:00 2001
From: Dmitry <b4tm4n@mail.ru>
Date: Tue, 2 Apr 2024 16:30:18 +0300
Subject: [PATCH] phc: refactor

---
 mod_pygame/phantom_castle.py | 255 +++++++++++++++++++++--------------
 1 file changed, 151 insertions(+), 104 deletions(-)

diff --git a/mod_pygame/phantom_castle.py b/mod_pygame/phantom_castle.py
index 081dcae..5b16b7e 100644
--- a/mod_pygame/phantom_castle.py
+++ b/mod_pygame/phantom_castle.py
@@ -32,6 +32,9 @@ from typing import NamedTuple, Optional
 
 import pygame
 
+FONT_NAME = "Arial"
+FPS = 30
+
 
 def download_asset(asset, path):
     """
@@ -59,7 +62,7 @@ def make_stub_image(path, name):
     rect = pygame.Rect(5, 5, 190, 190)
     pygame.draw.rect(img, "black", rect, 3)
 
-    font = pygame.font.SysFont("Arial", 44)
+    font = pygame.font.SysFont(FONT_NAME, 44)
     text1 = font.render(name, True, "blue")
     text1_rect = text1.get_rect()
     text1_rect.center = img.get_rect().center
@@ -174,7 +177,7 @@ def maze_gen(row=4, col=4):
                 or 1 == maze[r1][c1] == maze[r2][c2]
             )
         ]
-        if len(nexts):
+        if nexts:
             r1, c1, r2, c2 = choice(nexts)
             maze[r1][c1] = maze[r2][c2] = 0
             path.append((r2, c2))
@@ -199,14 +202,14 @@ def maze_gen(row=4, col=4):
             None,
         ),
     }
-    for key in upd:
-        for i in range(len(maze) - len(key) + 1):
-            for j in range(len(maze[0]) - len(key[0]) + 1):
+    for pattern, replacement in upd.items():
+        for i in range(len(maze) - len(pattern) + 1):
+            for j in range(len(maze[0]) - len(pattern[0]) + 1):
                 if all(
                     maze[i + k][j : j + len(v)] == list(map(int, v))
-                    for k, v in enumerate(key)
+                    for k, v in enumerate(pattern)
                 ):
-                    for k, v in filter(lambda x: x[1], enumerate(upd[key])):
+                    for k, v in filter(lambda x: x[1], enumerate(replacement)):
                         maze[i + k][j : j + len(v)] = list(map(int, v))
 
     return maze
@@ -222,8 +225,27 @@ class Direction(Enum):
     UP = 3
     DOWN = 4
 
+    def as_coords(self):
+        match self:
+            case Direction.LEFT:
+                return Coords(-1, 0)
+            case Direction.RIGHT:
+                return Coords(1, 0)
+            case Direction.UP:
+                return Coords(0, -1)
+            case Direction.DOWN:
+                return Coords(0, 1)
 
-class GameObject(ABC):
+
+class SurfaceWithRect(NamedTuple):
+    surface: pygame.Surface
+    rect: pygame.Rect
+
+    def draw_to(self, target: pygame.Surface):
+        target.blit(self.surface, self.rect)
+
+
+class DrawableGameObject(ABC):
     """обобщение игрового элемента"""
 
     coords = property(
@@ -233,7 +255,7 @@ class GameObject(ABC):
     def __init__(
         self,
         coords: Coords,
-        parent: Optional["GameObject"] = None,
+        parent: Optional["DrawableGameObject"] = None,
         assets: dict | None = None,
     ):
         self.parent = parent
@@ -265,17 +287,20 @@ class GameObject(ABC):
     def draw(self):
         pass
 
-    def handle_event(self, event: pygame.event.Event):
+
+class EventHandler(ABC):
+    @abstractmethod
+    def handle_event(self):
         pass
 
 
-class Hero(GameObject):
+class Hero(DrawableGameObject, EventHandler):
     """объект главного героя"""
 
     def __init__(
         self,
         coords: Coords,
-        parent: GameObject,
+        parent: DrawableGameObject,
         assets: dict | None = None,
     ):
         super().__init__(coords, parent, assets)
@@ -296,7 +321,7 @@ class Hero(GameObject):
 
     def _check_collision(self, coords):
         """Проверка пересечения со стенами"""
-        new_rect = self.rect.copy()
+        new_rect = self.surface.get_bounding_rect()
         new_rect.topleft = coords
         new_rect.scale_by_ip(0.99, 0.99)
         return self.scene.walls.check_collision(new_rect)
@@ -336,46 +361,25 @@ class Hero(GameObject):
 
     def flip(self):
         self.looking_right = not self.looking_right
-        self._surface = pygame.transform.flip(self.surface, True, False)
+        self._surface = pygame.transform.flip(self.surface, flip_x=True, flip_y=False)
 
-    def update_direction(self, direction: Coords):
-        if direction.x != 0:
-            going_right = direction.x > 0
+    def update_direction(self, direction: Direction):
+        if direction in (Direction.LEFT, Direction.RIGHT):
+            going_right = direction == Direction.RIGHT
             if self.looking_right != going_right:
                 self.flip()
 
-        if direction.x < 0:
-            new_direction = Direction.LEFT
-        elif direction.x > 0:
-            new_direction = Direction.RIGHT
-        elif direction.y < 0:
-            new_direction = Direction.UP
-        elif direction.y > 0:
-            new_direction = Direction.DOWN
-
-        if new_direction != self.direction:
+        if direction != self.direction:
             self.speed = 0
-            self.direction = new_direction
+            self.direction = direction
         else:
             self.speed += 1
 
-    def move(self, direction: Coords, step: int = 1):
+    def move(self, direction: Direction, step: int = 1):
         self.update_direction(direction)
-        self.coords += direction * step * self.speed // 3
+        self.coords += direction.as_coords() * step * self.speed // 3
         self.scene.coins.collect(self.rect)
 
-    def move_left(self, step: int = 1):
-        self.move(Coords(-1, 0), step)
-
-    def move_right(self, step: int = 1):
-        self.move(Coords(1, 0), step)
-
-    def move_up(self, step: int = 1):
-        self.move(Coords(0, -1), step)
-
-    def move_down(self, step: int = 1):
-        self.move(Coords(0, 1), step)
-
     def handle_event(self, event: pygame.event.Event):
         if not self.active:
             return
@@ -384,30 +388,30 @@ class Hero(GameObject):
         if event.type == pygame.KEYDOWN:
             match event.key:
                 case pygame.K_UP:
-                    self.move_up(wide)
+                    self.move(Direction.UP, wide)
                 case pygame.K_DOWN:
-                    self.move_down(wide)
+                    self.move(Direction.DOWN, wide)
                 case pygame.K_LEFT:
-                    self.move_left(wide)
+                    self.move(Direction.LEFT, wide)
                 case pygame.K_RIGHT:
-                    self.move_right(wide)
+                    self.move(Direction.RIGHT, wide)
                 case pygame.K_w:
-                    self.move_up(short)
+                    self.move(Direction.UP, short)
                 case pygame.K_s:
-                    self.move_down(short)
+                    self.move(Direction.DOWN, short)
                 case pygame.K_a:
-                    self.move_left(short)
+                    self.move(Direction.LEFT, short)
                 case pygame.K_d:
-                    self.move_right(short)
+                    self.move(Direction.RIGHT, short)
 
 
-class WallBlock(GameObject):
+class WallBlock(DrawableGameObject):
     """объект элемента стены"""
 
     def __init__(
         self,
         coords: Coords,
-        parent: GameObject,
+        parent: DrawableGameObject,
         assets: dict | None = None,
     ):
         super().__init__(coords, parent, assets)
@@ -422,12 +426,12 @@ class WallBlock(GameObject):
         self.parent.surface.blit(self.surface, self.rect)
 
 
-class Walls(GameObject):
+class Walls(DrawableGameObject):
     """объект стен"""
 
     def __init__(
         self,
-        parent: GameObject,
+        parent: DrawableGameObject,
         maze: list[list[int]],
         box_sz: Coords,
         assets: dict | None = None,
@@ -452,13 +456,13 @@ class Walls(GameObject):
         return False
 
 
-class Coin(GameObject):
+class Coin(DrawableGameObject):
     """объект монетки"""
 
     def __init__(
         self,
         coords: Coords,
-        parent: GameObject,
+        parent: DrawableGameObject,
         assets: dict | None = None,
     ):
         super().__init__(coords, parent, assets)
@@ -472,13 +476,19 @@ class Coin(GameObject):
     def draw(self):
         self.parent.surface.blit(self.surface, self.rect)
 
+    @property
+    def bounding_rect(self) -> pygame.Rect:
+        new_rect = self.surface.get_bounding_rect()
+        new_rect.center = self.rect.center
+        return new_rect
 
-class Coins(GameObject):
+
+class Coins(DrawableGameObject):
     """объект коллекции монеток"""
 
     def __init__(
         self,
-        parent: GameObject,
+        parent: DrawableGameObject,
         maze: list[list[int]],
         box_sz: Coords,
         count: int,
@@ -504,26 +514,26 @@ class Coins(GameObject):
         self.collected_coins = []
 
         # Надпись, если все монетки собраны
-        font = pygame.font.SysFont("Arial", 30)
+        font = pygame.font.SysFont(FONT_NAME, 30)
         text = "Все монетки собраны!"
         self.done_txt = font.render(text, 1, "#050366e3")
         self.done_txt_rect = self.done_txt.get_rect()
         self.done_txt_rect.topleft = Coords(10, 10)
 
     @property
-    def capacity(self):
+    def capacity(self) -> int:
         return self._capacity
 
     @property
-    def coins_left(self):
+    def coins_left(self) -> int:
         return len(self.coins)
 
     @property
-    def coins_collected(self):
+    def coins_collected(self) -> int:
         return self.capacity - self.coins_left
 
     @property
-    def all_collected(self):
+    def all_collected(self) -> int:
         return self.coins_left == 0
 
     def draw(self):
@@ -543,48 +553,81 @@ class Coins(GameObject):
         self.collected_coins.append(coin)
 
     def collect(self, rect: pygame.Rect):
-        mined = [*filter(lambda coin: coin.rect.colliderect(rect), self.coins)]
+        mined = [*filter(lambda coin: coin.bounding_rect.colliderect(rect), self.coins)]
         for coin in mined:
             self.coins.remove(coin)
             self.add_to_collected(coin)
 
 
-class EndLevel(GameObject):
-    def __init__(self, scene: GameObject):
+class EndLevelMenu(DrawableGameObject, EventHandler):
+    def __init__(self, scene: DrawableGameObject):
         super().__init__(Coords.zero(), scene, scene.assets)
-        self.image = pygame.image.load(scene.assets["win.png"])
+        self._surface, self.rect = self._create_end_game_label()
+        self.win_image = self._create_win_image()
+        self.win_label = self._create_win_label()
+        self.keys_hint = self._create_keys_hint()
+        self.stats_label = None
         self.active = False
 
-        # надпись завершения игры
-        font = pygame.font.SysFont("Arial", 70)
+    def _create_end_game_label(self) -> SurfaceWithRect:
+        """Надпись завершения игры"""
+        font = pygame.font.SysFont(FONT_NAME, 70)
         text = "Конец игры!"
-        self._surface = font.render(text, 1, "#1b10a8c4")
-        self.rect = self._surface.get_rect()
-        self.rect.center = self.parent.rect.center
+        surface = font.render(text, 1, "#1b10a8c4")
+        rect = surface.get_rect()
+        rect.center = Coords(*self.parent.rect.center) + Coords(0, 38)
+        return SurfaceWithRect(surface, rect)
 
-        # совет по кнопкам
-        hint = "Для новой игры нажмите N, для выхода Q"
-        font_hint = pygame.font.SysFont("Arial", 27)
-        self.hint = font_hint.render(hint, 1, "#24053da4")
-        self.hint_rect = self.hint.get_rect()
-        self.hint_rect.center = self.parent.rect.center
-        self.hint_rect = self.hint_rect.move(Coords(0, 300))
+    def _create_keys_hint(self) -> SurfaceWithRect:
+        """Совет по кнопкам"""
+        hint_text = "Для новой игры нажмите N, для выхода Q"
+        font_hint = pygame.font.SysFont(FONT_NAME, 27)
+        surface = font_hint.render(hint_text, 1, "#24053da4")
+        rect = surface.get_rect()
+        rect.center = self.parent.rect.center
+        rect = rect.move(Coords(0, 220))
+        return SurfaceWithRect(surface, rect)
 
-        # Надпись для хорошего финала
+    def _create_win_label(self) -> SurfaceWithRect:
+        """Надпись для хорошего финала"""
+        font = pygame.font.SysFont(FONT_NAME, 33)
         text = "Все монетки собраны!"
-        self.goodtxt = font_hint.render(text, 1, "#96081ba4")
-        self.goodtxt_rect = self.goodtxt.get_rect()
-        self.goodtxt_rect.center = self.parent.rect.center
-        self.goodtxt_rect = self.goodtxt_rect.move(Coords(0, -100))
+        surface = font.render(text, 1, "#96081ba4")
+        rect = surface.get_rect()
+        rect.center = self.parent.rect.center
+        rect.move_ip(Coords(0, -200))
+        return SurfaceWithRect(surface, rect)
+
+    def _create_win_image(self) -> SurfaceWithRect:
+        """Картинка для хорошего финала"""
+        surface = pygame.image.load(self.scene.assets["win.png"])
+        rect = surface.get_rect()
+        rect.center = self.parent.rect.center
+        return SurfaceWithRect(surface, rect)
+
+    def _create_stats_label(self) -> SurfaceWithRect:
+        """Общая статистика игры"""
+        stats_text = f"Всего пройдено уровней: {self.scene.total_levels}, собрано монет: {self.scene.total_coins}"
+        stats_font = pygame.font.SysFont(FONT_NAME, 27)
+        surface = stats_font.render(stats_text, 1, "#031f03a4")
+        rect = surface.get_rect()
+        rect.center = Coords(*self.scene.rect.center) + Coords(0, 350)
+        return SurfaceWithRect(surface, rect)
 
     def draw(self):
         if not self.active:
             return
         if self.scene.coins.all_collected:
-            self.parent.surface.blit(self.image, self.rect)
-            self.parent.surface.blit(self.goodtxt, self.goodtxt_rect)
+            self.win_image.draw_to(self.parent.surface)
+            self.win_label.draw_to(self.parent.surface)
+
         self.parent.surface.blit(self.surface, self.rect)
-        self.parent.surface.blit(self.hint, self.hint_rect)
+        self.keys_hint.draw_to(self.parent.surface)
+
+        # статистика
+        if self.stats_label is None:
+            self.stats_label = self._create_stats_label()
+        self.stats_label.draw_to(self.parent.surface)
 
     def handle_event(self, event: pygame.event.Event):
         if not self.active:
@@ -595,7 +638,7 @@ class EndLevel(GameObject):
                 self.parent.done = True
 
 
-class Scene(GameObject):
+class Scene(DrawableGameObject, EventHandler):
     """основной игровой объект"""
 
     # кнопки для выхода из игры
@@ -612,10 +655,12 @@ class Scene(GameObject):
         self.box_sz = box_sz
         self._surface = pygame.display.set_mode(screen_sz)
         self.surface.fill("white")
-        self.rect = self._surface.get_rect()
+        self.rect = self.surface.get_rect()
         self.background = pygame.image.load(self.assets["bg1k.png"])
         self.background = pygame.transform.scale(self.background, self.rect.size)
 
+        self.total_levels, self.total_coins = 0, 0
+
         hero_sz = Coords(*map(int, box_sz * 0.8))
         hero_y_offset = (box_sz.y - hero_sz.y) // 2 + box_sz.y
         self.hero = Hero(Coords(0, hero_y_offset), self)
@@ -626,31 +671,27 @@ class Scene(GameObject):
         self.walls = Walls(self, self.maze, box_sz)
         self.coins = Coins(self, self.maze, box_sz, coins_count)
 
-        self.end = EndLevel(self)
+        self.end = EndLevelMenu(self)
         self.end.active = False
         self.want_new_level = False
         self.exit_rect = self.get_exit_rect()
-        # #для тестирования экрана конца уровня
-        # self.hero.coords = Coords(*self.exit_rect.topleft) + Coords(
-        #    -self.box_sz.x // 2, 5
-        # )
 
     def get_exit_rect(self) -> pygame.Rect:
         # находим клетку в которой будет выход с карты
         maze_sz = get_maze_sz(self.maze)
         coords = (maze_sz - Coords(1, 2)) * self.box_sz
         rect = pygame.Rect(coords, coords)
-        rect.width, rect.height = self.box_sz
-        # уменьшаем размер клетки и перемещаем её вправо
-        rect.width = self.box_sz.x // 4
-        rect = rect.move(Coords(self.box_sz.x // 2, 0))
-        return rect
+        rect.width, rect.height = 1, self.box_sz.y
+        return rect.move((self.box_sz.x, 0))
 
     def check_level_completed(self):
-        self.level_completed = self.exit_rect.colliderect(self.hero.rect)
-        if self.level_completed:
+        level_completed = self.exit_rect.colliderect(self.hero.rect)
+        if level_completed and not self.level_completed:
+            self.total_coins += self.coins.coins_collected
+            self.total_levels += 1
             self.end.active = True
             self.hero.active = False
+            self.level_completed = True
 
     def draw(self):
         if self.done:
@@ -694,7 +735,7 @@ class Scene(GameObject):
                 self.handle_event(event)
             self.draw()
             pygame.display.flip()
-            clock.tick(30)
+            clock.tick(FPS)
 
 
 def game(assets):
@@ -703,25 +744,31 @@ def game(assets):
     coins_count = 10
     pygame.display.set_caption("Призрачный лабиринт: сокровища небесного замка")
 
+    total_levels = 0
+    total_coins = 0
     want_new_level = True
     while want_new_level:
         scene = Scene(assets, screen_sz, maze_sz, coins_count)
+        scene.total_levels, scene.total_coins = total_levels, total_coins
         scene.event_loop()
+
         want_new_level = scene.want_new_level
+        total_levels = scene.total_levels
+        total_coins = scene.total_coins
 
     pygame.quit()
 
 
 def main():
     pygame.init()
-    assets = [
+    required_assets = [
         "bg1k.png",
         "ghost.png",
         "brick.png",
         "win.png",
         "coin.png",
     ]
-    with get_assets(assets) as assets:
+    with get_assets(required_assets) as assets:
         game(assets)