diff --git a/mod_pygame/phantom_castle.py b/mod_pygame/phantom_castle.py new file mode 100644 index 0000000..73ffdb0 --- /dev/null +++ b/mod_pygame/phantom_castle.py @@ -0,0 +1,684 @@ +""" + 3.5 Вставка рисунков. Управление рисунков + https://stepik.org/lesson/937437/step/2?unit=943412 + и + https://stepik.org/lesson/937437/step/3?unit=943412 + + Задача: + Попробуйте сделать управление объектом, который есть не просто прямоугольник, а, например, рисунок. + и + Сделайте программу, чтобы при движении влево было одно изображение, а при движении вправо другое изображение героя. + + Решение: + Игра "Призрачный лабиринт: сокровища небесного замка". + Призрак в лабиринте, управление стрелками и WASD, выход Esc или Q. + Чтобы пройти уровень, нужно дойти до выхода, желательно собрав все монетки. + После прохождения уровня можно начать новый или выйти из игры. + + Картинки берутся из папки assets, если их нет то автоматически скачиваются из репозитория. + Если скачать не получилось то будут нарисованы заглушки. + + Зависимости: pygame +""" + +import os +import shutil +import tempfile +import urllib.request +from abc import ABC, abstractmethod +from contextlib import contextmanager +from random import choice, randrange, sample +from typing import NamedTuple, Optional + +import pygame + + +def download_asset(asset, path): + """ + Загрузка картинок из репозитория + """ + prefix = "https://gitea.b4tman.ru/temp/py_stepik/raw/branch/master/assets/" + print("Качаю картинку", asset, end=" ... ", flush=True) + try: + urllib.request.urlretrieve(prefix + asset, path) + except Exception: + print("не смог :(") + return False + print("скачал!") + return True + + +def make_stub_image(path, name): + """Создание пустой картинки, на случай если скачать не получилось""" + + img = pygame.surface.Surface((200, 200), flags=pygame.SRCALPHA) + img.fill((255, 255, 255, 0)) + pygame.draw.line(img, "#ff000065", (5, 5), (195, 195), 2) + pygame.draw.line(img, "#ff000065", (195, 5), (5, 195), 2) + + rect = pygame.Rect(5, 5, 190, 190) + pygame.draw.rect(img, "black", rect, 3) + + font = pygame.font.SysFont("Arial", 44) + text1 = font.render(name, True, "blue") + text1_rect = text1.get_rect() + text1_rect.center = img.get_rect().center + img.blit(text1, text1_rect) + pygame.image.save(img, path) + + +@contextmanager +def get_assets(names): + """Получение соответствия с расположением файлов картинок + + Размер картинок нужно менять поэтому они всегда сохраняются во временные файлы. + """ + + assets_dir = "assets" + files = {} + # поиск файлов (загрузка если их нет) и создание временных + for asset in names: + _, ext = os.path.splitext(asset) + temppath = tempfile.mktemp(suffix=ext) + filepath = os.path.join(assets_dir, asset) + if os.path.isfile(filepath): + shutil.copyfile(filepath, temppath) + else: + if not download_asset(asset, temppath): + make_stub_image(temppath, asset) + files[asset] = temppath + + # передача управления + yield files + # очистка + for _, filename in files.items(): + try: + os.remove(filename) + except FileNotFoundError: + pass + + +class Coords(NamedTuple): + """ + Вспомогательный класс для упрощения работы с координатами + """ + + x: int | float + y: int | float + + def __add__(self, other): + if isinstance(other, self.__class__): + return self.__class__(self.x + other.x, self.y + other.y) + if isinstance(other, (int, float)): + return self.__class__(self.x + other, self.y + other) + return NotImplemented + + def __sub__(self, other): + if isinstance(other, self.__class__): + return self.__class__(self.x - other.x, self.y - other.y) + if isinstance(other, (int, float)): + return self.__class__(self.x - other, self.y - other) + return NotImplemented + + def __floordiv__(self, other): + if isinstance(other, self.__class__): + return self.__class__(self.x // other.x, self.y // other.y) + if isinstance(other, (int, float)): + return self.__class__(self.x // other, self.y // other) + return NotImplemented + + def __mul__(self, other): + if isinstance(other, self.__class__): + return self.__class__(self.x * other.x, self.y * other.y) + if isinstance(other, (int, float)): + return self.__class__(self.x * other, self.y * other) + return NotImplemented + + def transform(self, ref: "Coords"): + return self * ref + + @classmethod + def zero(cls): + return cls(0, 0) + + +def maze_gen(row=4, col=4): + """генератор карты + взял с коментария + https://stepik.org/lesson/502494/step/3?discussion=6527620&unit=494196 + """ + row = max(2 * row + 1, 3) + col = max(2 * col + 1, 3) + maze = [[2] * col] + maze.extend([[2] + [1] * (col - 2) + [2] for _ in range(row - 2)]) + maze.append(maze[0]) + + curr = (randrange(1, len(maze) - 1, 2), randrange(1, len(maze[0]) - 1, 2)) + path = [curr] + maze[curr[0]][curr[1]] = 0 + + while path: + nexts = [ + (r1, c1, r2, c2) + for r, c in zip((1, 0, -1, 0), (0, 1, 0, -1)) + if ( + (r1 := curr[0] + r) is None + or (c1 := curr[1] + c) is None + or (r2 := curr[0] + 2 * r) is None + or (c2 := curr[1] + 2 * c) is None + or 1 == maze[r1][c1] == maze[r2][c2] + ) + ] + if len(nexts): + r1, c1, r2, c2 = choice(nexts) + maze[r1][c1] = maze[r2][c2] = 0 + path.append((r2, c2)) + else: + curr = path.pop() + + upd = { + ("22", "20"): (None, "00"), + ("02", "22"): ("00", None), + ("11101", "00101", "11111", "10100", "10111"): ( + None, + None, + None, + "10001", + None, + ), + ("10111", "10100", "11111", "00101", "11101"): ( + None, + "10001", + None, + None, + 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): + if all( + maze[i + k][j : j + len(v)] == list(map(int, v)) + for k, v in enumerate(key) + ): + for k, v in filter(lambda x: x[1], enumerate(upd[key])): + maze[i + k][j : j + len(v)] = list(map(int, v)) + + return maze + + +def get_maze_sz(maze: list[list[int]]) -> Coords: + return Coords(len(maze[0]), len(maze)) + + +class GameObject(ABC): + """обобщение игрового элемента""" + + coords = property( + lambda self: self.get_coords(), lambda self, c: self.set_coords(c) + ) + + def __init__( + self, + coords: Coords, + parent: Optional["GameObject"] = None, + assets: dict | None = None, + ): + self.parent = parent + self.rect = pygame.Rect(coords, coords) + self.assets = assets or (parent.assets if parent else None) + self._surface = None + + @property + def surface(self) -> pygame.Surface | None: + return self._surface or (self.parent.surface if self.parent else None) + + @property + def scene(self): + return self.parent.scene if self.parent else self + + def get_coords(self) -> Coords: + return Coords(*self.rect.topleft) + + def set_coords(self, coords: Coords): + new_rect = self.rect.copy() + new_rect.topleft = coords + if self.parent: + if self.parent.rect: + if not self.parent.rect.contains(new_rect): + return + self.rect = new_rect + + @abstractmethod + def draw(self): + pass + + def handle_event(self, event: pygame.event.Event): + pass + + +class Hero(GameObject): + """объект главного героя""" + + def __init__( + self, + coords: Coords, + parent: GameObject, + assets: dict | None = None, + ): + super().__init__(coords, parent, assets) + self._surface = pygame.image.load(self.assets["ghost.png"]) + self.rect = self.surface.get_rect() + sf = Coords(0.8, 0.8) + self._surface, self.rect = self.scene.scale_box(self.surface, self.rect, sf) + self.rect.topleft = coords + self.active = True + self.looking_right = False + # картинка изначально влево, а надо бы начинать со взгляда вправо + self.flip() + + def draw(self): + self.parent.surface.blit(self.surface, self.rect) + + def _check_collision(self, coords): + """Проверка пересечения со стенами""" + new_rect = self.rect.copy() + new_rect.topleft = coords + return self.scene.walls.check_collision(new_rect) + + def _reduce_step(self, coords): + """Уменьшение шага движения, с целью подойти вплотную к стене""" + delta = coords - self.coords + dx, dy = 0, 0 + if abs(delta.x) > 1: + dx = 1 * (delta.x < 0 or -1) + if abs(delta.y) > 1: + dy = 1 * (delta.y < 0 or -1) + return coords + Coords(dx, dy) + + def set_coords(self, coords: Coords): + # проверка колизии + has_collision = self._check_collision(coords) + if not has_collision: + return super().set_coords(coords) + + # уменьшение шага + while has_collision and coords != self.coords: + coords_new = self._reduce_step(coords) + if coords_new == coords: + return # не могу уменьшить шаг + coords = coords_new + has_collision = self._check_collision(coords) + super().set_coords(coords) + + def flip(self): + self.looking_right = not self.looking_right + self._surface = pygame.transform.flip(self.surface, True, False) + + def move(self, direction: Coords, step: int = 1): + if direction.x != 0: + going_right = direction.x > 0 + if self.looking_right != going_right: + self.flip() + self.coords += direction * step + 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 + + wide, short = 30, 5 + if event.type == pygame.KEYDOWN: + match event.key: + case pygame.K_UP: + self.move_up(wide) + case pygame.K_DOWN: + self.move_down(wide) + case pygame.K_LEFT: + self.move_left(wide) + case pygame.K_RIGHT: + self.move_right(wide) + case pygame.K_w: + self.move_up(short) + case pygame.K_s: + self.move_down(short) + case pygame.K_a: + self.move_left(short) + case pygame.K_d: + self.move_right(short) + + +class WallBlock(GameObject): + """объект элемента стены""" + + def __init__( + self, + coords: Coords, + parent: GameObject, + assets: dict | None = None, + ): + super().__init__(coords, parent, assets) + self._surface = pygame.image.load(self.assets["brick.png"]) + self.rect = self.surface.get_rect() + self.rect.topleft = coords + # уменьшаем размер монетки + sf = Coords(1, 1) + self._surface, self.rect = self.scene.scale_box(self.surface, self.rect, sf) + + def draw(self): + self.parent.surface.blit(self.surface, self.rect) + + +class Walls(GameObject): + """объект стен""" + + def __init__( + self, + parent: GameObject, + maze: list[list[int]], + box_sz: Coords, + assets: dict | None = None, + ): + super().__init__(Coords.zero(), parent, assets) + self.box_sz = box_sz + self.blocks = [ + WallBlock(Coords(j, i).transform(box_sz), self, self.assets) + for i, row in enumerate(maze) + for j, item in enumerate(row) + if item > 0 + ] + + def draw(self): + for block in self.blocks: + block.draw() + + def check_collision(self, rect: pygame.Rect) -> bool: + for block in self.blocks: + if block.rect.colliderect(rect): + return True + return False + + +class Coin(GameObject): + """объект монетки""" + + def __init__( + self, + coords: Coords, + parent: GameObject, + assets: dict | None = None, + ): + super().__init__(coords, parent, assets) + self._surface = pygame.image.load(self.assets["coin.png"]) + self.rect = self.surface.get_rect() + self.rect.topleft = coords + # уменьшаем размер монетки + sf = Coords(0.7, 0.7) + self._surface, self.rect = self.scene.scale_box(self.surface, self.rect, sf) + + def draw(self): + self.parent.surface.blit(self.surface, self.rect) + + +class Coins(GameObject): + """объект коллекции монеток""" + + def __init__( + self, + parent: GameObject, + maze: list[list[int]], + box_sz: Coords, + count: int, + assets: dict | None = None, + ): + super().__init__(Coords.zero(), parent, assets) + self.box_sz = box_sz + self._capacity = count + + free_points = [] + excluded = Coords(0, 1), get_maze_sz(maze) - Coords(1, 2) + for i, row in enumerate(maze): + for j, item in enumerate(row): + p = Coords(j, i) + if item < 1 and p not in excluded: + free_points.append(p) + continue + + coin_points = sample(free_points, min(count, len(free_points))) + self.coins = [ + Coin(point.transform(box_sz), self, self.assets) for point in coin_points + ] + self.collected_coins = [] + + # Надпись, если все монетки собраны + font = pygame.font.SysFont("Arial", 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): + return self._capacity + + @property + def coins_left(self): + return len(self.coins) + + @property + def coins_collected(self): + return self.capacity - self.coins_left + + @property + def all_collected(self): + return self.coins_left == 0 + + def draw(self): + for coin in self.collected_coins: + coin.draw() + for coin in self.coins: + coin.draw() + if self.all_collected: + self.parent.surface.blit(self.done_txt, self.done_txt_rect) + + def add_to_collected(self, coin: Coin): + last_pos = Coords(10, 10) + if self.collected_coins: + last_pos = Coords(*self.collected_coins[-1].rect.topright) + last_pos -= Coords(coin.rect.width // 2, 0) + coin.coords = last_pos + self.collected_coins.append(coin) + + def collect(self, rect: pygame.Rect): + mined = [*filter(lambda coin: coin.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): + super().__init__(Coords.zero(), scene, scene.assets) + self.image = pygame.image.load(scene.assets["win.png"]) + self.active = False + + # надпись завершения игры + font = pygame.font.SysFont("Arial", 70) + text = "Конец игры!" + self._surface = font.render(text, 1, "#1b10a8c4") + self.rect = self._surface.get_rect() + self.rect.center = self.parent.rect.center + + # совет по кнопкам + 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)) + + # Надпись для хорошего финала + 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)) + + 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.parent.surface.blit(self.surface, self.rect) + self.parent.surface.blit(self.hint, self.hint_rect) + + def handle_event(self, event: pygame.event.Event): + if not self.active: + return + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_n: + self.parent.want_new_level = True + self.parent.done = True + + +class Scene(GameObject): + """основной игровой объект""" + + # кнопки для выхода из игры + exit_keys = (pygame.K_ESCAPE, pygame.K_q) + + def __init__( + self, assets: dict, screen_sz: Coords, maze_sz: Coords, coins_count: int + ): + super().__init__(Coords.zero(), None, assets) + self.maze = maze_gen(*maze_sz) + maze_sz = get_maze_sz(self.maze) + + box_sz = screen_sz // get_maze_sz(self.maze) + self.box_sz = box_sz + self._surface = pygame.display.set_mode(screen_sz) + self.surface.fill("white") + 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) + + 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) + self.done = False + self.level_completed = False + + self.maze = maze_gen(6, 6) + self.walls = Walls(self, self.maze, box_sz) + self.coins = Coins(self, self.maze, box_sz, coins_count) + + self.end = EndLevel(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 + + def check_level_completed(self): + self.level_completed = self.exit_rect.colliderect(self.hero.rect) + if self.level_completed: + self.end.active = True + self.hero.active = False + + def draw(self): + if self.done: + return + self.surface.fill("white") + self.surface.blit(self.background, self.coords) + if self.level_completed: + self.end.draw() + else: + self.hero.draw() + self.walls.draw() + self.coins.draw() + + def scale_box( + self, surface: pygame.Surface, rect: pygame.Rect, scale_factor: Coords + ): + rect.size = self.box_sz + rect.scale_by_ip(*scale_factor) + surface = pygame.transform.scale(surface, rect.size) + return surface, rect + + def handle_event(self, event: pygame.event.Event): + if self.done: + return + if ( + event.type == pygame.QUIT + or event.type == pygame.KEYDOWN + and event.key in self.exit_keys + ): + self.done = True + if not self.done: + self.hero.handle_event(event) + self.check_level_completed() + self.end.handle_event(event) + + def event_loop(self): + while not self.done: + event = pygame.event.wait() + self.handle_event(event) + self.draw() + pygame.display.flip() + + +def game(assets): + screen_sz = Coords(1000, 1000) + maze_sz = Coords(6, 6) + coins_count = 10 + pygame.display.set_caption("Призрачный лабиринт: сокровища небесного замка") + + want_new_level = True + while want_new_level: + scene = Scene(assets, screen_sz, maze_sz, coins_count) + scene.event_loop() + want_new_level = scene.want_new_level + + pygame.quit() + + +def main(): + pygame.init() + assets = [ + "bg1k.png", + "ghost.png", + "brick.png", + "win.png", + "coin.png", + ] + with get_assets(assets) as assets: + game(assets) + + +main()