diff --git a/.gitignore b/.gitignore index 537f58e..e590358 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ bank.json ext/ .vscode/ +pygame-wasm/phantomcastle/build/ +pygame-wasm/phantomcastle/assets/ diff --git a/pygame-wasm/phantomcastle/Makefile b/pygame-wasm/phantomcastle/Makefile new file mode 100644 index 0000000..f867a5c --- /dev/null +++ b/pygame-wasm/phantomcastle/Makefile @@ -0,0 +1,12 @@ +assets_files = assets/bg1k.png assets/brick.png assets/coin.png assets/ghost.png assets/win.png + +all: build/web.zip + +assets: + mkdir assets + +assets/%.png: assets $(../../assets/*.png) + cp ../../$@ assets/ + +build/web.zip: Makefile main.py $(assets_files) + pygbag --width 1000 --height 1000 --build --archive . diff --git a/pygame-wasm/phantomcastle/main.py b/pygame-wasm/phantomcastle/main.py new file mode 100644 index 0000000..b9bc98e --- /dev/null +++ b/pygame-wasm/phantomcastle/main.py @@ -0,0 +1,673 @@ +""" + Игра "Призрачный лабиринт: сокровища небесного замка" + версия для сборки в виде WASM (для браузера) игры с помощью pygbag + + pip install pygame pygbag + потом make (в Linux/WSL) +""" + +import asyncio +import os +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" + + +@contextmanager +def get_assets_direct(names): + """передача файлов картинок 1 в 1""" + assets_dir = "assets" + yield {asset: os.path.join(assets_dir, asset) for asset in names} + + +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 KeysHandler(ABC): + @abstractmethod + def handle_keys(self, keys_pressed): + pass + + +class Hero(DrawableGameObject, KeysHandler): + """объект главного героя""" + + 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_keys(self, keys_pressed): + if not self.active: + return + + wide, short = 3, 1 + if keys_pressed[pygame.K_UP]: + self.move(Direction.UP, wide) + if keys_pressed[pygame.K_DOWN]: + self.move(Direction.DOWN, wide) + if keys_pressed[pygame.K_LEFT]: + self.move(Direction.LEFT, wide) + if keys_pressed[pygame.K_RIGHT]: + self.move(Direction.RIGHT, wide) + if keys_pressed[pygame.K_w]: + self.move(Direction.UP, short) + if keys_pressed[pygame.K_s]: + self.move(Direction.DOWN, short) + if keys_pressed[pygame.K_a]: + self.move(Direction.LEFT, short) + if keys_pressed[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, KeysHandler): + 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" + 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_keys(self, keys_pressed): + if not self.active: + return + if keys_pressed[pygame.K_n]: + self.parent.want_new_level = True + self.parent.done = True + + +class Scene(DrawableGameObject, KeysHandler): + """основной игровой объект""" + + # кнопки для выхода из игры + 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_keys(self, keys_pressed): + if self.done: + return + if not self.done: + self.hero.handle_keys(keys_pressed) + self.check_level_completed() + self.end.handle_keys(keys_pressed) + + async def event_loop(self): + clock = pygame.time.Clock() + while not self.done: + pygame.event.pump() + self.handle_keys(pygame.key.get_pressed()) + self.draw() + pygame.display.flip() + await asyncio.sleep(0) + clock.tick(30) + + +async 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 + await scene.event_loop() + + want_new_level = scene.want_new_level + total_levels = scene.total_levels + total_coins = scene.total_coins + + +async def main(): + pygame.init() + required_assets = [ + "bg1k.png", + "ghost.png", + "brick.png", + "win.png", + "coin.png", + ] + with get_assets_direct(required_assets) as assets: + await game(assets) + + +asyncio.run(main())