""" 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 tempfile import urllib.request from abc import ABC, abstractmethod from contextlib import contextmanager from enum import Enum from random import choice, randrange, sample from typing import NamedTuple, Optional import pygame FONT_NAME = "Arial" FPS = 30 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(FONT_NAME, 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 = {} tempfiles = [] for asset in names: # поиск файлов filepath = os.path.join(assets_dir, asset) if os.path.isfile(filepath): files[asset] = filepath continue # создание временного файла _, ext = os.path.splitext(asset) fd, temppath = tempfile.mkstemp(suffix=ext) os.close(fd) tempfiles.append(temppath) # попытка загрузки, если не получилось то создание заглушки if not download_asset(asset, temppath): make_stub_image(temppath, asset) files[asset] = temppath # передача управления yield files # очистка del files for filename in tempfiles: 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 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 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(pattern) ): for k, v in filter(lambda x: x[1], enumerate(replacement)): 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 Direction(Enum): LEFT = 1 RIGHT = 2 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 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( lambda self: self.get_coords(), lambda self, c: self.set_coords(c) ) def __init__( self, coords: Coords, parent: Optional["DrawableGameObject"] = 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 class EventHandler(ABC): @abstractmethod def handle_event(self): pass class Hero(DrawableGameObject, EventHandler): """объект главного героя""" def __init__( self, coords: Coords, parent: DrawableGameObject, 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._speed = 1 self.direction = Direction.RIGHT # картинка изначально влево, а надо бы начинать со взгляда вправо self.flip() def draw(self): self.parent.surface.blit(self.surface, self.rect) def _check_collision(self, coords): """Проверка пересечения со стенами""" 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) @property def speed(self): return max(self._speed, 1) @speed.setter def speed(self, value): self._speed = min(value, 15) 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, flip_x=True, flip_y=False) 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 != self.direction: self.speed = 0 self.direction = direction else: self.speed += 1 def move(self, direction: Direction, step: int = 1): self.update_direction(direction) self.coords += direction.as_coords() * step * self.speed // 3 self.scene.coins.collect(self.rect) def handle_event(self, event: pygame.event.Event): if not self.active: return wide, short = 3, 1 if event.type == pygame.KEYDOWN: match event.key: case pygame.K_UP: self.move(Direction.UP, wide) case pygame.K_DOWN: self.move(Direction.DOWN, wide) case pygame.K_LEFT: self.move(Direction.LEFT, wide) case pygame.K_RIGHT: self.move(Direction.RIGHT, wide) case pygame.K_w: self.move(Direction.UP, short) case pygame.K_s: self.move(Direction.DOWN, short) case pygame.K_a: self.move(Direction.LEFT, short) case pygame.K_d: self.move(Direction.RIGHT, short) class WallBlock(DrawableGameObject): """объект элемента стены""" def __init__( self, coords: Coords, parent: DrawableGameObject, 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(DrawableGameObject): """объект стен""" def __init__( self, parent: DrawableGameObject, 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(DrawableGameObject): """объект монетки""" def __init__( self, coords: Coords, parent: DrawableGameObject, 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) @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(DrawableGameObject): """объект коллекции монеток""" def __init__( self, parent: DrawableGameObject, 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(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) -> int: return self._capacity @property def coins_left(self) -> int: return len(self.coins) @property def coins_collected(self) -> int: return self.capacity - self.coins_left @property def all_collected(self) -> int: 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.bounding_rect.colliderect(rect), self.coins)] for coin in mined: self.coins.remove(coin) self.add_to_collected(coin) class EndLevelMenu(DrawableGameObject, EventHandler): def __init__(self, scene: DrawableGameObject): super().__init__(Coords.zero(), scene, scene.assets) 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 def _create_end_game_label(self) -> SurfaceWithRect: """Надпись завершения игры""" font = pygame.font.SysFont(FONT_NAME, 70) text = "Конец игры!" surface = font.render(text, 1, "#1b10a8c4") rect = surface.get_rect() rect.center = Coords(*self.parent.rect.center) + Coords(0, 38) return SurfaceWithRect(surface, rect) 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 = "Все монетки собраны!" 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.win_image.draw_to(self.parent.surface) self.win_label.draw_to(self.parent.surface) self.parent.surface.blit(self.surface, self.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: return if event.type == pygame.KEYDOWN: if event.key == pygame.K_n: self.parent.want_new_level = True self.parent.done = True class Scene(DrawableGameObject, EventHandler): """основной игровой объект""" # кнопки для выхода из игры 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) 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) 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 = EndLevelMenu(self) self.end.active = False self.want_new_level = False self.exit_rect = self.get_exit_rect() 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 = 1, self.box_sz.y return rect.move((self.box_sz.x, 0)) def check_level_completed(self): 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: 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): clock = pygame.time.Clock() pygame.key.set_repeat(50, 30) while not self.done: for event in pygame.event.get(): self.handle_event(event) self.draw() pygame.display.flip() clock.tick(FPS) def game(assets): screen_sz = Coords(1000, 1000) maze_sz = Coords(6, 6) 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() required_assets = [ "bg1k.png", "ghost.png", "brick.png", "win.png", "coin.png", ] with get_assets(required_assets) as assets: game(assets) main()