add pygame-wasm/phantomcastle

This commit is contained in:
Dmitry Belyaev 2024-04-02 22:17:25 +03:00
parent 8c4ec3d4cb
commit f1b7b2e860
3 changed files with 687 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
bank.json bank.json
ext/ ext/
.vscode/ .vscode/
pygame-wasm/phantomcastle/build/
pygame-wasm/phantomcastle/assets/

View File

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

View File

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