phantomcastle: universal version + split

This commit is contained in:
Dmitry Belyaev 2024-04-06 13:30:07 +03:00
parent 6ee4776711
commit f0d60d50a8
12 changed files with 806 additions and 729 deletions

4
.gitignore vendored
View File

@ -3,3 +3,7 @@ ext/
.vscode/ .vscode/
pygame-wasm/phantomcastle/build/ pygame-wasm/phantomcastle/build/
pygame-wasm/phantomcastle/assets/ pygame-wasm/phantomcastle/assets/
.venv*/
__pycache__
*.pyc
*.pyo

View File

@ -9,7 +9,7 @@ assets/%.png: assets $(../../assets/*.png)
cp ../../$@ assets/ cp ../../$@ assets/
build/web: Makefile main.py $(assets_files) 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|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 sed -i -e "s|en-us|ru-RU|g" build/web/index.html

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
from .scene import Scene
from .hero import Hero
from .wall import Walls
from .coins import Coins
from .endlevelmenu import EndLevelMenu

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -1,6 +1,14 @@
""" """
Игра "Призрачный лабиринт: сокровища небесного замка" Игра "Призрачный лабиринт: сокровища небесного замка"
версия для сборки в виде WASM (для браузера) игры с помощью pygbag
Призрак в лабиринте, управление стрелками и WASD, выход Esc или Q.
Чтобы пройти уровень, нужно дойти до выхода, желательно собрав все монетки.
После прохождения уровня можно начать новый (кнопкой N) или выйти из игры.
Также можно управлять мышкой, перетаскивая героя (зажав ЛКМ) и кликая на надпись,
для начала новой игры.
Это универсальная версия. Подходит для сборки в виде WASM (для браузера) игры,
с помощью pygbag и для запуска напрямую.
pip install pygame pygbag pip install pygame pygbag
потом make (в Linux/WSL) потом make (в Linux/WSL)
@ -8,15 +16,13 @@
import asyncio import asyncio
import os import os
from abc import ABC, abstractmethod
from contextlib import contextmanager from contextlib import contextmanager
from enum import Enum from game import Scene
from random import choice, randrange, sample from coords import Coords
from typing import NamedTuple, Optional
import pygame import pygame
FONT_NAME = "Arial" FPS = 30
@contextmanager @contextmanager
@ -26,727 +32,6 @@ def get_assets_direct(names):
yield {asset: os.path.join(assets_dir, asset) for asset in 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): async def game(assets):
screen_sz = Coords(1000, 1000) screen_sz = Coords(1000, 1000)
maze_sz = Coords(6, 6) maze_sz = Coords(6, 6)
@ -757,7 +42,7 @@ async def game(assets):
total_coins = 0 total_coins = 0
want_new_level = True want_new_level = True
while want_new_level: 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 scene.total_levels, scene.total_coins = total_levels, total_coins
await scene.event_loop() await scene.event_loop()
@ -765,6 +50,8 @@ async def game(assets):
total_levels = scene.total_levels total_levels = scene.total_levels
total_coins = scene.total_coins total_coins = scene.total_coins
pygame.quit()
async def main(): async def main():
pygame.init() pygame.init()

View File

@ -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))