diff --git a/.gitignore b/.gitignore index e590358..95bc1fd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ ext/ .vscode/ pygame-wasm/phantomcastle/build/ pygame-wasm/phantomcastle/assets/ +.venv*/ +__pycache__ +*.pyc +*.pyo diff --git a/pygame-wasm/phantomcastle/Makefile b/pygame-wasm/phantomcastle/Makefile index 7c3cf84..2420985 100644 --- a/pygame-wasm/phantomcastle/Makefile +++ b/pygame-wasm/phantomcastle/Makefile @@ -9,7 +9,7 @@ assets/%.png: assets $(../../assets/*.png) cp ../../$@ assets/ build/web: Makefile main.py $(assets_files) - pygbag --width 1000 --height 1000 --can_close 1 --title "Призрачный лабиринт: сокровища небесного замка" --build . + pygbag --width 1000 --height 1000 --can_close 1 --title "Призрачный лабиринт: сокровища небесного замка" --icon assets/ghost.png --build . sed -i -e "s|https://pygame-web.github.io/archives/0.9/|https://b4tman.ru/phantomcastle/pygbag/0.9/|g" build/web/index.html sed -i -e "s|en-us|ru-RU|g" build/web/index.html diff --git a/pygame-wasm/phantomcastle/common.py b/pygame-wasm/phantomcastle/common.py new file mode 100644 index 0000000..f6cac23 --- /dev/null +++ b/pygame-wasm/phantomcastle/common.py @@ -0,0 +1,91 @@ +from abc import ABC, abstractmethod +from typing import NamedTuple, Optional + +FONT_NAME = "Arial" + +from sys import platform + +IS_WASM = platform == "emscripten" + +import pygame + +from coords import Coords + + +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 + self._mask = None + + def __str__(self): + return f"{self.__class__.__name__}({self.coords})" + + @property + def surface(self) -> pygame.Surface | None: + return self._surface or (self.parent.surface if self.parent else None) + + @property + def mask(self) -> pygame.Mask | None: + if not self._mask: + self._mask = pygame.mask.from_surface(self.surface.convert_alpha()) + return self._mask + + def overlap(self, rect: pygame.Rect, mask: pygame.Mask) -> bool: + if not self.rect.colliderect(rect): + return False + + offset = Coords(*self.rect.topleft) - Coords(*rect.topleft) + overlap = mask.overlap(self.mask, offset) + return overlap is not 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_keys(self, keys_pressed): + pass + + @abstractmethod + def handle_event(self): + pass diff --git a/pygame-wasm/phantomcastle/coords.py b/pygame-wasm/phantomcastle/coords.py new file mode 100644 index 0000000..7c2de7d --- /dev/null +++ b/pygame-wasm/phantomcastle/coords.py @@ -0,0 +1,91 @@ +from enum import Enum +from typing import NamedTuple, Optional + + +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 + + def dir_norm(self): + """нормализация вектора, но только для получения направления + x, y могут быть только -1, 0, 1 + может быть только направление по горизонтали либо по вертикали + либо без направления + """ + src = self + if self.x and self.y: + src = ( + self.__class__(self.x, 0) + if abs(self.x) > abs(self.y) + else self.__class__(0, self.y) + ) + return self.__class__(*((n > 0) - (n < 0) for n in src)) + + @classmethod + def zero(cls): + return cls(0, 0) + + +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) + + @staticmethod + def from_coords(coords: Coords) -> Optional["Direction"]: + match coords.dir_norm(): + case Coords(-1, 0): + return Direction.LEFT + case Coords(1, 0): + return Direction.RIGHT + case Coords(0, -1): + return Direction.UP + case Coords(0, 1): + return Direction.DOWN diff --git a/pygame-wasm/phantomcastle/game/__init__.py b/pygame-wasm/phantomcastle/game/__init__.py new file mode 100644 index 0000000..cf07a60 --- /dev/null +++ b/pygame-wasm/phantomcastle/game/__init__.py @@ -0,0 +1,5 @@ +from .scene import Scene +from .hero import Hero +from .wall import Walls +from .coins import Coins +from .endlevelmenu import EndLevelMenu diff --git a/pygame-wasm/phantomcastle/game/coins.py b/pygame-wasm/phantomcastle/game/coins.py new file mode 100644 index 0000000..f61fa14 --- /dev/null +++ b/pygame-wasm/phantomcastle/game/coins.py @@ -0,0 +1,108 @@ +from random import sample +import pygame +from coords import Coords +from common import DrawableGameObject, FONT_NAME +from maze import get_maze_sz + + +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, actor: DrawableGameObject): + mined = [*filter(lambda coin: coin.overlap(actor.rect, actor.mask), self.coins)] + for coin in mined: + self.coins.remove(coin) + self.add_to_collected(coin) diff --git a/pygame-wasm/phantomcastle/game/endlevelmenu.py b/pygame-wasm/phantomcastle/game/endlevelmenu.py new file mode 100644 index 0000000..1191b2e --- /dev/null +++ b/pygame-wasm/phantomcastle/game/endlevelmenu.py @@ -0,0 +1,97 @@ +import pygame +from coords import Coords +from common import DrawableGameObject, EventHandler, FONT_NAME, IS_WASM, SurfaceWithRect + + +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" + if not IS_WASM: + hint_text += ", для выхода 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 request_new_level(self): + self.scene.want_new_level = True + self.scene.done = True + + def handle_keys(self, keys_pressed): + if not self.active: + return + if keys_pressed[pygame.K_n]: + self.request_new_level() + + def handle_mouse_event(self, event: pygame.event.Event): + if event.type == pygame.MOUSEBUTTONDOWN and self.keys_hint.rect.collidepoint( + event.pos + ): + self.request_new_level() + + def handle_event(self, event: pygame.event.Event): + if not self.active: + return + self.handle_mouse_event(event) diff --git a/pygame-wasm/phantomcastle/game/hero.py b/pygame-wasm/phantomcastle/game/hero.py new file mode 100644 index 0000000..9ac0c76 --- /dev/null +++ b/pygame-wasm/phantomcastle/game/hero.py @@ -0,0 +1,138 @@ +import pygame +from coords import Coords, Direction +from common import DrawableGameObject, EventHandler + + +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.mouse_active = 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, self.mask) + + @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: + super().set_coords(coords) + self.scene.coins.collect(self) + return + + # уменьшение шага + 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) + self.scene.coins.collect(self) + + 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 + + def handle_mouse_event(self, event: pygame.event.Event): + if event.type not in ( + pygame.MOUSEBUTTONDOWN, + pygame.MOUSEBUTTONUP, + pygame.MOUSEMOTION, + ): + return + match event.type: + case pygame.MOUSEBUTTONDOWN: + self.mouse_active = self.rect.collidepoint(event.pos) + case pygame.MOUSEBUTTONUP: + self.mouse_active = False + case pygame.MOUSEMOTION if self.mouse_active: + rel = Coords(*event.rel) + direction = Direction.from_coords(rel) + if direction: + self.update_direction(direction) + self.coords += rel + + def handle_event(self, event: pygame.event.Event): + if not self.active: + return + self.handle_mouse_event(event) + + 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) diff --git a/pygame-wasm/phantomcastle/game/scene.py b/pygame-wasm/phantomcastle/game/scene.py new file mode 100644 index 0000000..57f2511 --- /dev/null +++ b/pygame-wasm/phantomcastle/game/scene.py @@ -0,0 +1,129 @@ +import asyncio +import pygame +from maze import maze_gen, get_maze_sz +from coords import Coords +from common import DrawableGameObject, EventHandler, IS_WASM + +from game.hero import Hero +from game.wall import Walls +from game.coins import Coins +from game.endlevelmenu import EndLevelMenu + + +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, + fps: 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() + self.fps = fps + + 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) + + def handle_exit(self, event: pygame.event.Event): + if IS_WASM: + return + if ( + event.type == pygame.QUIT + or event.type == pygame.KEYDOWN + and event.key in self.exit_keys + ): + self.done = True + + def handle_event(self, event: pygame.event.Event): + self.handle_exit(event) + if self.done: + return + self.hero.handle_event(event) + self.check_level_completed() + self.end.handle_event(event) + + async def event_loop(self): + clock = pygame.time.Clock() + while not self.done: + for event in pygame.event.get(): + self.handle_event(event) + self.handle_keys(pygame.key.get_pressed()) + self.draw() + pygame.display.flip() + await asyncio.sleep(0) + clock.tick(self.fps) diff --git a/pygame-wasm/phantomcastle/game/wall.py b/pygame-wasm/phantomcastle/game/wall.py new file mode 100644 index 0000000..e4befad --- /dev/null +++ b/pygame-wasm/phantomcastle/game/wall.py @@ -0,0 +1,55 @@ +import pygame +from coords import Coords +from common import DrawableGameObject + + +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) + self._mask = pygame.mask.Mask(self.rect.size, fill=True) + + 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, mask: pygame.Mask) -> bool: + for block in self.blocks: + if block.overlap(rect, mask): + return True + return False diff --git a/pygame-wasm/phantomcastle/main.py b/pygame-wasm/phantomcastle/main.py index d94547b..b2d4f8f 100644 --- a/pygame-wasm/phantomcastle/main.py +++ b/pygame-wasm/phantomcastle/main.py @@ -1,6 +1,14 @@ """ Игра "Призрачный лабиринт: сокровища небесного замка" - версия для сборки в виде WASM (для браузера) игры с помощью pygbag + + Призрак в лабиринте, управление стрелками и WASD, выход Esc или Q. + Чтобы пройти уровень, нужно дойти до выхода, желательно собрав все монетки. + После прохождения уровня можно начать новый (кнопкой N) или выйти из игры. + Также можно управлять мышкой, перетаскивая героя (зажав ЛКМ) и кликая на надпись, + для начала новой игры. + + Это универсальная версия. Подходит для сборки в виде WASM (для браузера) игры, + с помощью pygbag и для запуска напрямую. pip install pygame pygbag потом make (в Linux/WSL) @@ -8,15 +16,13 @@ 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 +from game import Scene +from coords import Coords import pygame -FONT_NAME = "Arial" +FPS = 30 @contextmanager @@ -26,727 +32,6 @@ def get_assets_direct(names): 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 - - def dir_norm(self): - """нормализация вектора, но только для получения направления - x, y могут быть только -1, 0, 1 - может быть только направление по горизонтали либо по вертикали - либо без направления - """ - src = self - if self.x and self.y: - src = ( - self.__class__(self.x, 0) - if abs(self.x) > abs(self.y) - else self.__class__(0, self.y) - ) - return self.__class__(*((n > 0) - (n < 0) for n in src)) - - @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) - - @staticmethod - def from_coords(coords: Coords) -> Optional["Direction"]: - match coords.dir_norm(): - case Coords(-1, 0): - return Direction.LEFT - case Coords(1, 0): - return Direction.RIGHT - case Coords(0, -1): - return Direction.UP - case Coords(0, 1): - return Direction.DOWN - - -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 - self._mask = None - - def __str__(self): - return f"{self.__class__.__name__}({self.coords})" - - @property - def surface(self) -> pygame.Surface | None: - return self._surface or (self.parent.surface if self.parent else None) - - @property - def mask(self) -> pygame.Mask | None: - if not self._mask: - self._mask = pygame.mask.from_surface(self.surface.convert_alpha()) - return self._mask - - def overlap(self, rect: pygame.Rect, mask: pygame.Mask) -> bool: - if not self.rect.colliderect(rect): - return False - - offset = Coords(*self.rect.topleft) - Coords(*rect.topleft) - overlap = mask.overlap(self.mask, offset) - return overlap is not 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_keys(self, keys_pressed): - pass - - @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.mouse_active = 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, self.mask) - - @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: - super().set_coords(coords) - self.scene.coins.collect(self) - return - - # уменьшение шага - 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) - self.scene.coins.collect(self) - - 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 - - def handle_mouse_event(self, event: pygame.event.Event): - if event.type not in ( - pygame.MOUSEBUTTONDOWN, - pygame.MOUSEBUTTONUP, - pygame.MOUSEMOTION, - ): - return - match event.type: - case pygame.MOUSEBUTTONDOWN: - self.mouse_active = self.rect.collidepoint(event.pos) - case pygame.MOUSEBUTTONUP: - self.mouse_active = False - case pygame.MOUSEMOTION if self.mouse_active: - rel = Coords(*event.rel) - direction = Direction.from_coords(rel) - if direction: - self.update_direction(direction) - self.coords += rel - - def handle_event(self, event: pygame.event.Event): - if not self.active: - return - self.handle_mouse_event(event) - - 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) - self._mask = pygame.mask.Mask(self.rect.size, fill=True) - - 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, mask: pygame.Mask) -> bool: - for block in self.blocks: - if block.overlap(rect, mask): - 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, actor: DrawableGameObject): - mined = [*filter(lambda coin: coin.overlap(actor.rect, actor.mask), 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" - 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 request_new_level(self): - self.scene.want_new_level = True - self.scene.done = True - - def handle_keys(self, keys_pressed): - if not self.active: - return - if keys_pressed[pygame.K_n]: - self.request_new_level() - - def handle_mouse_event(self, event: pygame.event.Event): - if event.type == pygame.MOUSEBUTTONDOWN and self.keys_hint.rect.collidepoint( - event.pos - ): - self.request_new_level() - - def handle_event(self, event: pygame.event.Event): - if not self.active: - return - self.handle_mouse_event(event) - - -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_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) - - def handle_event(self, event: pygame.event.Event): - if self.done: - return - self.hero.handle_event(event) - self.check_level_completed() - self.end.handle_event(event) - - async def event_loop(self): - clock = pygame.time.Clock() - while not self.done: - for event in pygame.event.get(): - self.handle_event(event) - 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) @@ -757,7 +42,7 @@ async def game(assets): total_coins = 0 want_new_level = True while want_new_level: - scene = Scene(assets, screen_sz, maze_sz, coins_count) + scene = Scene(assets, screen_sz, maze_sz, coins_count, FPS) scene.total_levels, scene.total_coins = total_levels, total_coins await scene.event_loop() @@ -765,6 +50,8 @@ async def game(assets): total_levels = scene.total_levels total_coins = scene.total_coins + pygame.quit() + async def main(): pygame.init() diff --git a/pygame-wasm/phantomcastle/maze.py b/pygame-wasm/phantomcastle/maze.py new file mode 100644 index 0000000..6a027ed --- /dev/null +++ b/pygame-wasm/phantomcastle/maze.py @@ -0,0 +1,72 @@ +from random import choice, randrange + +from coords import Coords + + +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))