diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 354d136..6dce473 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -31,7 +31,7 @@ jobs: uses: Silleellie/pylint-github-action@v2 with: # The path, relative to the root of the repo, of the package(s) or pyton file(s) to lint - lint-path: danmaku + lint-path: danmaku/**/*.py # Python version which will install all dependencies and lint package(s) python-version: "3.10" # The path, relative to the root of the repo, of the requirements to install diff --git a/README.md b/README.md index baaf0d7..0f8fa17 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,31 @@ # 弾幕 (Danmaku) -![pylint](https://img.shields.io/badge/PyLint-9.71-yellow?logo=python&logoColor=white) +![pylint](https://img.shields.io/badge/PyLint-9.95-yellow?logo=python&logoColor=white) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) ## Goal To create a bullet hell game similar to TouHou Project, Undertale, etc. +## How to build + +``` +poetry shell +poetry install +``` +or +``` +python3 -m venv .venv +.\.venv\Scripts\activate +pip install --upgrade -r .\requirements.txt +``` +Then, +``` +.\build +``` + +## For developers + +Most important things is written down [here](./docs/mecs.md) + ## Refactoring - [x] main.py - [x] bullet.py @@ -18,7 +39,7 @@ To create a bullet hell game similar to TouHou Project, Undertale, etc. - [x] Game mechanics - [x] Drops - - [ ] HP (?) + - [ ] HP (?) - [x] XP - [x] Powerups - [ ] Coins (?) @@ -38,13 +59,13 @@ To create a bullet hell game similar to TouHou Project, Undertale, etc. - [ ] Effects (particles) - [ ] Scaling - [ ] Fullscreen - - [ ] Boss HP bar + - [x] Boss HP bar - [x] Player HP/Bomb info - [x] Player points info - - [ ] UI + - [x] UI - [x] Main menu - [x] Main menu style - - [ ] Background + - [x] Background - [x] Leaderboard - [x] Settings - [x] DB @@ -66,6 +87,6 @@ To create a bullet hell game similar to TouHou Project, Undertale, etc. - [x] Replace resource path strings with constants from db - [x] Sounds - [x] Death - - [ ] Shoot - - [ ] Hit + - [x] Shoot + - [x] Hit - [x] Music diff --git a/assets/DataBase.db b/assets/DataBase.db index 8d336d6..1751fa6 100644 Binary files a/assets/DataBase.db and b/assets/DataBase.db differ diff --git a/assets/sounds/game.wav b/assets/sounds/game.wav new file mode 100644 index 0000000..2a0ae46 Binary files /dev/null and b/assets/sounds/game.wav differ diff --git a/assets/sounds/hit.wav b/assets/sounds/hit.wav new file mode 100644 index 0000000..357b8e0 Binary files /dev/null and b/assets/sounds/hit.wav differ diff --git a/assets/sounds/lose.wav b/assets/sounds/lose.wav new file mode 100644 index 0000000..0674173 Binary files /dev/null and b/assets/sounds/lose.wav differ diff --git a/assets/sounds/menu.wav b/assets/sounds/menu.wav new file mode 100644 index 0000000..5df96c0 Binary files /dev/null and b/assets/sounds/menu.wav differ diff --git a/assets/sounds/shoot.wav b/assets/sounds/shoot.wav new file mode 100644 index 0000000..c08b642 Binary files /dev/null and b/assets/sounds/shoot.wav differ diff --git a/assets/sounds/win.wav b/assets/sounds/win.wav new file mode 100644 index 0000000..b62f38c Binary files /dev/null and b/assets/sounds/win.wav differ diff --git a/assets/textures/basic_enemy.png b/assets/textures/basic_enemy.png deleted file mode 100644 index 7e5b205..0000000 Binary files a/assets/textures/basic_enemy.png and /dev/null differ diff --git a/assets/textures/enemy/basic_enemy_1.png b/assets/textures/enemy/basic_enemy_down_1.png similarity index 100% rename from assets/textures/enemy/basic_enemy_1.png rename to assets/textures/enemy/basic_enemy_down_1.png diff --git a/assets/textures/enemy/basic_enemy_3.png b/assets/textures/enemy/basic_enemy_down_3.png similarity index 100% rename from assets/textures/enemy/basic_enemy_3.png rename to assets/textures/enemy/basic_enemy_down_3.png diff --git a/assets/textures/enemy/basic_enemy_2.png b/assets/textures/enemy/basic_enemy_down_static_2.png similarity index 100% rename from assets/textures/enemy/basic_enemy_2.png rename to assets/textures/enemy/basic_enemy_down_static_2.png diff --git a/assets/textures/enemy/strong_enemy_1.png b/assets/textures/enemy/strong_enemy_down_1.png similarity index 100% rename from assets/textures/enemy/strong_enemy_1.png rename to assets/textures/enemy/strong_enemy_down_1.png diff --git a/assets/textures/enemy/strong_enemy_3.png b/assets/textures/enemy/strong_enemy_down_3.png similarity index 100% rename from assets/textures/enemy/strong_enemy_3.png rename to assets/textures/enemy/strong_enemy_down_3.png diff --git a/assets/textures/enemy/strong_enemy_2.png b/assets/textures/enemy/strong_enemy_down_static_2.png similarity index 100% rename from assets/textures/enemy/strong_enemy_2.png rename to assets/textures/enemy/strong_enemy_down_static_2.png diff --git a/assets/textures/menu.png b/assets/textures/menu.png new file mode 100644 index 0000000..1798e68 Binary files /dev/null and b/assets/textures/menu.png differ diff --git a/assets/textures/player.png b/assets/textures/player.png deleted file mode 100644 index 7addc68..0000000 Binary files a/assets/textures/player.png and /dev/null differ diff --git a/assets/textures/strong_enemy.png b/assets/textures/strong_enemy.png deleted file mode 100644 index cc6c0e7..0000000 Binary files a/assets/textures/strong_enemy.png and /dev/null differ diff --git a/danmaku/animated.py b/danmaku/animated.py deleted file mode 100644 index 1d2182d..0000000 --- a/danmaku/animated.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Declaration of Animated class""" - -import pygame - -from danmaku.gameobject import GameObject - - -class Animated(GameObject): - """Base class for animated objects - - Args: - xy (tuple[int | float, int | float]): Position of the object. - width_height (tuple[int | float, int | float]): Width and height of the object. - speed (int | float): Speed of the object. - frames (list[str]): List of frames - freq (int | float): Frequency of animation - period (int | float | None, optional): Period of animation. Defaults to None. - - You can pass freq as '0' and just use period - - """ - - def __init__( - self, - xy: tuple[int | float, int | float], - width_height: tuple[int | float, int | float], - speed: int | float, - frames: list[str], - freq: int | float, - period: int | float | None = None, - ) -> None: - super().__init__(xy, width_height, speed) - - if len(frames): - self.animation_frames = frames - if period is not None: - self.animation_period = period - self.animation_freq = 1 / period - else: - self.animation_freq = freq - self.animation_period = 1 / freq - self.animation_current = 0 - self.animation_last = 0 - - if len(frames): - self.texture_file = self.animation_frames[self.animation_current] - - def can_animate(self) -> bool: - """Check if possible to animate""" - time = pygame.time.get_ticks() / 1000 - - if time - self.animation_last >= self.animation_period: - self.animation_last = time - return True - return False - - def animate(self) -> None: - """Animate one frame if possible""" - if self.can_animate(): - self.animation_current = (self.animation_current + 1) % len( - self.animation_frames - ) - self.texture_file = self.animation_frames[self.animation_current] diff --git a/danmaku/database/construct.py b/danmaku/database/construct.py index 54eafea..4fdfed3 100644 --- a/danmaku/database/construct.py +++ b/danmaku/database/construct.py @@ -16,8 +16,8 @@ basic_enemy = EnemyTypes.create( name="basic enemy", - texture_file="basic_enemy_2.png;basic_enemy_1.png;" - "basic_enemy_2.png;basic_enemy_3.png", + texture_file="basic_enemy_down_static_2.png;basic_enemy_down_1.png;" + "basic_enemy_down_static_2.png;basic_enemy_down_3.png", texture_size_width=50, texture_size_height=65, speed=30, @@ -31,8 +31,8 @@ strong_enemy = EnemyTypes.create( name="strong enemy", - texture_file="strong_enemy_2.png;strong_enemy_1.png;" - "strong_enemy_2.png;strong_enemy_3.png", + texture_file="strong_enemy_down_static_2.png;strong_enemy_down_1.png;" + "strong_enemy_down_static_2.png;strong_enemy_down_3.png", texture_size_width=50, texture_size_height=65, speed=20, @@ -46,8 +46,8 @@ boss = EnemyTypes.create( name="boss", - texture_file="strong_enemy_2.png;strong_enemy_1.png;" - "strong_enemy_2.png;strong_enemy_3.png", + texture_file="strong_enemy_down_static_2.png;strong_enemy_down_1.png;" + "strong_enemy_down_static_2.png;strong_enemy_down_3.png", texture_size_width=60, texture_size_height=85, speed=10, @@ -128,7 +128,7 @@ texture_size_height=50, speed=200, shoot_v=250, - hp=1300, + hp=500, dm=10, endurance=1, hitbox_radius=10, diff --git a/danmaku/database/database.py b/danmaku/database/database.py index e0aeb62..87e8742 100644 --- a/danmaku/database/database.py +++ b/danmaku/database/database.py @@ -88,43 +88,63 @@ def get_saved_objects() -> list: def get_saved_game() -> dict: """Get saved game from database - Returns dict: {"score", "level", "power"} + Returns dict: {"score", "level", "power", "bombs"} """ game = tuple(iter(SavedGame.select().dicts()))[-1] return game -def get_game_history() -> list: +def get_game_history() -> list[dict[str, int]]: """Get game history from database - Returns list: [{"score", "level"}] + Returns list: [{"score", "level", "time"}] """ games = tuple(iter(SavedGame.select())) res = [] for i in games: - objects = { - "score": i.score, - "level": i.level, - } + objects = {"score": i.score, "level": i.level, "time": i.time} res.append(objects) return res +def delete_last_game(): + """Remove last game record from database""" + games = list(iter(SavedGame.select())) + SavedGame.delete_by_id(games[-1]) + SavedGame.update() + + def set_saved_objects(name: str, objects: Iterable) -> None: """Set saved objects to database""" for e in objects: + if hasattr(e, "my_type"): + my_type = e.my_type + else: + my_type = "" + if hasattr(e, "health"): + health = e.health + else: + health = 0 + if hasattr(e, "damage"): + damage = e.damage + else: + damage = 0 n = SavedObjects.create( object=name, - object_type=e.my_type, + object_type=my_type, object_position=f"{e.x}, {e.y}", - object_hp=e.health, - object_damage=e.damage, + object_hp=health, + object_damage=damage, ) n.save() -def set_saved_game(cur_level: int, score: int, power: int) -> None: +def set_saved_game( + cur_level: int, score: int, power: int, bombs: int, time: float +) -> None: """Set saved game to database""" - n = SavedGame.create(score=score, level=cur_level, power=power) + n = SavedGame.create( + score=score, level=cur_level, power=power, bombs=bombs, time=time + ) n.save() @@ -136,6 +156,7 @@ def delete_saved_objects() -> None: def get_settings() -> dict: + """Get all settings""" settings = {} for setting in Settings.select(): match setting.type: @@ -143,7 +164,7 @@ def get_settings() -> dict: value = int(setting.value) possible_values = list(map(int, setting.possible_values.split(";"))) case "bool": - value = bool(setting.value) + value = setting.value == "True" possible_values = [True, False] case "str": value = setting.value @@ -159,17 +180,15 @@ def get_settings() -> dict: def delete_settings(): + """Delete all settings""" for setting in Settings.select(): Settings.delete_by_id(setting) Settings.update() def set_settings(settings: dict) -> None: + """Set settings from dictionary""" for key, value in settings.items(): s = Settings.get(Settings.name == key) s.value = value s.save() - - -if __name__ == "__main__": - print(get_saved_game()) diff --git a/danmaku/database/models.py b/danmaku/database/models.py index f491b49..2366e74 100644 --- a/danmaku/database/models.py +++ b/danmaku/database/models.py @@ -6,6 +6,7 @@ CharField, IntegerField, BooleanField, + FloatField, ) from danmaku.utils import resource_path @@ -13,7 +14,7 @@ db = SqliteDatabase(resource_path("DataBase.db")) -# pylint: disable=missing-class-docstring +# pylint: disable=missing-class-docstring,too-few-public-methods class BaseModel(Model): @@ -71,6 +72,8 @@ class SavedGame(BaseModel): level = IntegerField() score = IntegerField() power = IntegerField() + bombs = IntegerField() + time = FloatField() class Settings(BaseModel): diff --git a/danmaku/game.py b/danmaku/game.py deleted file mode 100644 index 36b5238..0000000 --- a/danmaku/game.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Game scene.""" - -import random - -import vgame -from vgame import Keys -import pygame - -from danmaku.utils import not_in_border, resource_path -from danmaku.enemy import Enemy -from danmaku.player import Player -from danmaku.bullet import Bullet -from danmaku.database import ( - get_saved_objects, - get_saved_game, - set_saved_objects, - set_saved_game, - delete_saved_objects, - get_settings, -) -from danmaku.pause import Pause -from danmaku.background import Background -from danmaku.drop import Drop, PowerUp, Points - - -LEVEL1 = (Enemy((150, 15), "basic enemy"),) -LEVEL2 = ( - Enemy((50, -25), "basic enemy"), - Enemy((200, -50), "basic enemy"), -) -LEVEL3 = (Enemy((110, 5), "strong enemy"),) -LEVEL4 = ( - Enemy((50, -25), "strong enemy"), - Enemy((200, -50), "strong enemy"), -) -LEVEL5 = ( - Enemy((50, -20), "basic enemy"), - Enemy((200, -50), "basic enemy"), - Enemy((110, -35), "strong enemy"), -) -LEVEL6 = ( - Enemy((50, -15), "strong enemy"), - Enemy((200, -50), "basic enemy"), - Enemy((110, -35), "strong enemy"), -) -LEVEL7 = (Enemy((150, -40), "boss"),) -LEVEL8 = ( - Enemy((50, -15), "strong enemy"), - Enemy((200, -50), "strong enemy"), - Enemy((300 - 50, -35), "strong enemy"), -) -LEVEL9 = ( - Enemy((50, -15), "strong enemy"), - Enemy((200, -50), "strong enemy"), - Enemy((300 - 50, -35), "strong enemy"), -) -LEVEL10 = ( - Enemy((50, -15), "strong enemy"), - Enemy((200, -50), "boss"), - Enemy((300 - 50, -35), "strong enemy"), -) -LEVELS = ( - LEVEL1, - LEVEL2, - LEVEL3, - LEVEL4, - LEVEL5, - LEVEL6, - LEVEL7, - LEVEL8, - LEVEL9, - LEVEL10, -) - - -# pylint: disable=attribute-defined-outside-init, missing-class-docstring -class Game(vgame.Scene): - new_game: bool = True - - def load(self): - self.graphics.library.path = resource_path("textures") - - self.settings = get_settings() - - pygame.mixer.init() - pygame.mixer.music.set_volume(self.settings["music_volume"]["value"] / 100) - pygame.mixer.music.load(resource_path("sounds/bgm.wav")) - pygame.mixer.music.play(loops=-1) - - self.paused = False - self.pause_object = Pause() - self.exit_status = "" - - self.background_object = Background(0, 0, self.width, self.height) - - self.bullets: list[Bullet] = [] - self.drops: list[Drop] = [] - - if self.new_game: - self.cur_level = 0 - self.enemies: list[Enemy] = list(LEVELS[self.cur_level]) - self.player = Player((self.width // 2, self.height - 50), "player") - - else: - self.enemies: list[Enemy] = [] - for entity in get_saved_objects(): - match entity["object"]: - case "enemy": - self.enemies.append( - Enemy( - entity["object_position"], - entity["object_type"], - start_hp=entity["object_hp"], - ) - ) - case "bullet": - self.bullets.append( - Bullet( - entity["object_position"], - entity["object_damage"], - entity["object_type"], - ) - ) - case "player": - self.player = Player( - entity["object_position"], - entity["object_type"], - updated_hp=entity["object_hp"], - ) - - saved_game = get_saved_game() - self.cur_level = saved_game["level"] - self.player.score = saved_game["score"] - self.player.power = saved_game["power"] - delete_saved_objects() - - self.player.set_bounds(0, 0, self.width, self.height) - - def update_pause(self): - """Called from update loop if paused""" - self.pause_object.update(self.pressed_keys) - status = self.pause_object.exit_status - match status: - case "continue": - self.paused = False - case "settings": - raise NotImplementedError() - case "menu": - delete_saved_objects() - set_saved_objects("enemy", self.enemies) - set_saved_objects("bullet", self.bullets) - set_saved_objects("player", [self.player]) - set_saved_game(self.cur_level, self.player.score, self.player.power) - self.stop() - - def update_game(self): - """Called from update loop if *not* paused""" - - vx = (Keys.RIGHT in self.pressed_keys) - (Keys.LEFT in self.pressed_keys) - vy = (Keys.DOWN in self.pressed_keys) - (Keys.UP in self.pressed_keys) - - if {Keys.SPACE, Keys.Z} & self.pressed_keys: - self.bullets += self.player.shoot() - if Keys.X in self.pressed_keys: - self.bullets += self.player.bomb() - if Keys.LEFT_SHIFT in self.pressed_keys: - self.player.slow = True - else: - self.player.slow = False - - self.player.vx, self.player.vy = vx, vy - - self.player.update(self.delta) - self.player.animate() - - for enemy in self.enemies: - if self.player.collision(enemy): - self.player.get_damage(enemy.damage / 100) - self.bullets += enemy.shoot() - enemy.animate() - enemy.update(self.delta) - if ( - enemy.y > self.height / 2 and not 0 <= enemy.x < self.width - ) or enemy.y > self.height + enemy.height / 2: - self.enemies.remove(enemy) - - for bullet in filter(lambda b: b.enemy, self.bullets): - if self.player.collision(bullet): - self.player.get_damage(bullet.damage) - self.bullets.remove(bullet) - continue - - for bullet in filter(lambda b: not b.enemy, self.bullets): - for enemy in self.enemies: - if enemy.collision(bullet): - enemy.get_damage(bullet.damage) - if enemy.health <= 0: - self.player.score += enemy.cost - self.drops += enemy.generate_drops() - self.enemies.remove(enemy) - - self.bullets.remove(bullet) - break - - for bullet in self.bullets: - bullet.update(self.delta) - - if not not_in_border( - bullet.x, bullet.y, bullet.vx, bullet.vy, self.width, self.height - ): - self.bullets.remove(bullet) - - for drop in self.drops: - if self.player.collision(drop): - if isinstance(drop, PowerUp): - self.player.power += 2 - elif isinstance(drop, Points): - self.player.score += 10 - self.drops.remove(drop) - continue - - drop.update(self.delta) - - if not not_in_border( - drop.x, drop.y, drop.vx, drop.vy, self.width, self.height - ): - self.drops.remove(drop) - - self.background_object.animate() - - if len(self.enemies) == 0: - if len(LEVELS) > self.cur_level + 1: - self.cur_level += 1 - self.enemies = list(LEVELS[self.cur_level]) - else: - set_saved_game(self.cur_level, self.player.score, self.player.power) - self.exit_status = "win" - self.stop() - - if self.player.health <= 0: - set_saved_game(self.cur_level, self.player.score, self.player.power) - self.exit_status = "lose" - death_sfx = pygame.mixer.Sound(resource_path("sounds/death.wav")) - death_sfx.set_volume(self.settings["sfx_volume"]["value"] / 100) - channel = death_sfx.play() - while channel.get_busy(): - pygame.time.wait(10) - self.stop() - - def update(self): - # self.print_stats() - if Keys.ESCAPE in self.pressed_keys: - self.pressed_keys.remove(Keys.ESCAPE) - self.paused = not self.paused - if self.paused: - self.pause_object.load(self.width, self.height) - - if self.paused: - self.update_pause() - else: - self.update_game() - - def draw(self): - self.graphics.draw_sprite(self.background_object) - - self.player.draw(self.graphics) - - for enemy in self.enemies: - self.graphics.draw_sprite(enemy) - - for bullet in self.bullets: - self.graphics.draw_sprite(bullet) - - for drop in self.drops: - self.graphics.draw_sprite(drop) - - self.graphics.text(f"HP: {self.player.health}", (0, 0)) - self.graphics.text(f"Score: {self.player.score}", (150, 0)) - - if self.paused: - self.pause_object.draw(self.graphics) - - def exit(self): - pygame.mixer.music.stop() - - def print_stats(self): - """Dev debug""" - print("\x1b[?25l", end="") # hide cursor - print("\x1b[2J\x1b[0;0H", end="") # clear console - - # Tech stuff - print( - f"\x1b[{32 if self.fps >= self.framerate else 31}mFPS:\t{self.fps:.2f}\x1b[0m", - f"Gr. dT:\t{self.graphics_delta*1000}ms", - "", - f"\x1b[{32 if self.tps >= self.tickrate else 31}mTPS:\t{self.tps:.2f}\x1b[0m", - f"dT:\t{self.delta*1000}ms", - sep="\n", - ) - - print(end="\n\n") - - # Game stats - print( - f"HP: {self.player.health}", - f"Score: {self.player.score}", - f"Level: {self.cur_level}", - "", - f"Enemies: {len(self.enemies)}", - f"Bullets: {len(self.bullets)}", - f"Drops: {len(self.drops)}", - sep="\n", - ) - - print("\x1b[?25h", end="") # show cursor diff --git a/danmaku/game/animated.py b/danmaku/game/animated.py new file mode 100644 index 0000000..c39ef68 --- /dev/null +++ b/danmaku/game/animated.py @@ -0,0 +1,197 @@ +"""Declaration of Animated class""" + +import pygame + +from danmaku.game.gameobject import GameObject +from danmaku.utils import Direction + + +class Animated(GameObject): + """Base class for animated objects + + Args: + xy (tuple[int | float, int | float]): Position of the object. + width_height (tuple[int | float, int | float]): Width and height of the object. + speed (int | float): Speed of the object. + frames (list[str]): List of frames + freq (int | float | None, optional): Frequency of animation + period (int | float | None, optional): Period of animation. Defaults to None. + + You can pass freq as '0' and just use period + + """ + + def __init__( + self, + xy: tuple[int | float, int | float], + width_height: tuple[int | float, int | float], + speed: int | float, + frames: list[str], + freq: int | float | None = None, + period: int | float | None = None, + ) -> None: + super().__init__(xy, width_height, speed) + + if len(frames): + self.animation_frames = frames + if period is not None: + self.animation_period = period + self.animation_freq = 1 / period + elif freq is not None: + self.animation_freq = freq + self.animation_period = 1 / freq + else: + raise ValueError("You must pass period or freq") + self.animation_current = 0 + self.animation_last = 0 + self.last_direction = 0 + + if len(frames): + self.texture_file = self.animation_frames[self.animation_current] + + def can_animate(self) -> bool: + """Check if possible to animate""" + time = pygame.time.get_ticks() / 1000 + + if time - self.animation_last >= self.animation_period: + self.animation_last = time + return True + return False + + def animate(self) -> None: + """Animate one frame if possible""" + if self.can_animate(): + self.animation_current = (self.animation_current + 1) % len( + self.animation_frames + ) + self.texture_file = self.animation_frames[self.animation_current] + + def animate_absolute(self, time: int) -> None: + """Set frame according to time (milliseconds)""" + frame_duration = self.animation_period * 1000 + + self.animation_current = int(time // frame_duration) % len( + self.animation_frames + ) + + self.texture_file = self.animation_frames[self.animation_current] + + +class AnimatedDirectional(Animated): + """Base class for animated objects, that change animations based on direction""" + + def __init__( + self, + xy: tuple[int | float, int | float], + width_height: tuple[int | float, int | float], + speed: int | float, + frames: list[str], + freq: int | float | None = None, + period: int | float | None = None, + ) -> None: + super().__init__(xy, width_height, speed, [], freq, period) + self.last_direction = 0 + + self.animation_frames = { + Direction.LEFT: [], + Direction.RIGHT: [], + Direction.UP: [], + Direction.DOWN: [], + Direction.STATIC: [], + } + + for file in frames: + if "left" in file: + self.animation_frames[Direction.LEFT].append(file) + if "right" in file: + self.animation_frames[Direction.RIGHT].append(file) + if "up" in file: + self.animation_frames[Direction.UP].append(file) + if "down" in file: + self.animation_frames[Direction.DOWN].append(file) + if "static" in file or "idle" in file: + self.animation_frames[Direction.STATIC].append(file) + + self.texture_file = self.animation_frames[Direction.STATIC][ + self.animation_current + ] + + def animate( + self, direction_vector: tuple[int | float, int | float] = (0, 0) + ) -> None: + """Animate one frame if possible""" + + if self.can_animate(): + direction = None + if direction_vector[0] == direction_vector[1] and direction_vector[0] == 0: + direction = Direction.STATIC + elif direction_vector[0] > 0: + direction = Direction.RIGHT + elif direction_vector[1] > 0: + direction = Direction.DOWN + elif direction_vector[0] < 0: + direction = Direction.LEFT + elif direction_vector[1] < 0: + direction = Direction.UP + + if direction == Direction.STATIC: + a = "" + if self.last_direction == Direction.UP: + a = "up" + elif self.last_direction == Direction.DOWN: + a = "down" + elif self.last_direction == Direction.RIGHT: + a = "right" + elif self.last_direction == Direction.LEFT: + a = "left" + for i in self.animation_frames[direction]: + if a in i: + self.texture_file = i + elif direction is not None: + self.animation_current = (self.animation_current + 1) % len( + self.animation_frames[direction] + ) + self.texture_file = self.animation_frames[direction][ + self.animation_current + ] + self.last_direction = direction + + def animate_absolute( + self, time: int, direction_vector: tuple[int | float, int | float] = (0, 0) + ) -> None: + """Set frame according to time (milliseconds)""" + + direction = None + if direction_vector[0] == direction_vector[1] and direction_vector[0] == 0: + direction = Direction.STATIC + elif direction_vector[0] > 0: + direction = Direction.RIGHT + elif direction_vector[1] > 0: + direction = Direction.DOWN + elif direction_vector[0] < 0: + direction = Direction.LEFT + elif direction_vector[1] < 0: + direction = Direction.UP + + if direction == Direction.STATIC: + a = "" + if self.last_direction == Direction.UP: + a = "up" + elif self.last_direction == Direction.DOWN: + a = "down" + elif self.last_direction == Direction.RIGHT: + a = "right" + elif self.last_direction == Direction.LEFT: + a = "left" + for i in self.animation_frames[direction]: + if a in i: + self.texture_file = i + + elif direction is not None: + frame_duration = self.animation_period * 1000 + + self.animation_current = int(time // frame_duration) % len( + self.animation_frames[direction] + ) + self.texture_file = self.animation_frames[direction][self.animation_current] + self.last_direction = direction diff --git a/danmaku/background.py b/danmaku/game/background.py similarity index 62% rename from danmaku/background.py rename to danmaku/game/background.py index 29c6d92..9b0e780 100644 --- a/danmaku/background.py +++ b/danmaku/game/background.py @@ -2,7 +2,7 @@ import vgame -from danmaku.animated import Animated +from danmaku.game.animated import Animated class Background(Animated): @@ -14,11 +14,16 @@ def __init__( y: int | float, width: int | float, height: int | float, + frames: list[str], ): - self.frame_count = 48 - frames = [f"background/background_{i}.png" for i in range(self.frame_count)] + if not frames: + self.frame_count = 48 + frames = [f"background/background_{i}.png" for i in range(self.frame_count)] + else: + pass + super().__init__((x, y), (width, height), 0, frames, 0, period=0.1) - self.texture_size = self.width, self.height + self.texture_size = int(self.width), int(self.height) def draw(self, graphics: vgame.graphics.Graphics): graphics.draw_sprite(self) diff --git a/danmaku/bullet.py b/danmaku/game/bullet.py similarity index 85% rename from danmaku/bullet.py rename to danmaku/game/bullet.py index 95e50c4..b07c0c4 100644 --- a/danmaku/bullet.py +++ b/danmaku/game/bullet.py @@ -1,27 +1,27 @@ -"""Entity's bullet declaration.""" - -from danmaku.entity import Entity -from danmaku.database import get_bullet_type - - -class Bullet(Entity): - """Bullet object.""" - - def __init__( - self, xy: tuple[int | float, int | float], damage: int | float, object_type - ): - args = get_bullet_type(object_type) - super().__init__( - xy, - (args["texture_size"][0], args["texture_size"][1]), - args["speed"], - 0, - damage, - ) - self.enemy = args["enemy"] - self.vx, self.vy = args["vx_vy"] - self.hitbox_radius = args["hitbox_radius"] - - self.texture_file = args["texture_file"] - self.texture_size = args["texture_size"] - self.my_type = object_type +"""Entity's bullet declaration.""" + +from danmaku.game.entity import Entity +from danmaku.database import get_bullet_type + + +class Bullet(Entity): + """Bullet object.""" + + def __init__( + self, xy: tuple[int | float, int | float], damage: int | float, object_type: str + ): + args = get_bullet_type(object_type) + super().__init__( + xy, + (args["texture_size"][0], args["texture_size"][1]), + args["speed"], + 0, + damage, + ) + self.enemy: bool = args["enemy"] + self.vx, self.vy = args["vx_vy"] + self.hitbox_radius = args["hitbox_radius"] + + self.texture_file = args["texture_file"] + self.texture_size = args["texture_size"] + self.my_type = object_type diff --git a/danmaku/drop.py b/danmaku/game/drop.py similarity index 94% rename from danmaku/drop.py rename to danmaku/game/drop.py index 6796b5a..2a2bd93 100644 --- a/danmaku/drop.py +++ b/danmaku/game/drop.py @@ -1,6 +1,6 @@ """Entity's bullet declaration.""" -from danmaku.gameobject import GameObject +from danmaku.game.gameobject import GameObject # from danmaku.database import ... diff --git a/danmaku/enemy.py b/danmaku/game/enemy.py similarity index 71% rename from danmaku/enemy.py rename to danmaku/game/enemy.py index a6121ec..2ded3aa 100644 --- a/danmaku/enemy.py +++ b/danmaku/game/enemy.py @@ -1,109 +1,125 @@ -"""Enemy object declaration.""" - -from random import randint, choices -from math import sin, cos, pi - -from danmaku.animated import Animated -from danmaku.bullet import Bullet -from danmaku.database import get_enemy_type -from danmaku.shooter import Shooter -from danmaku.drop import PowerUp, Points - - -class Enemy(Shooter, Animated): - """Enemy object.""" - - def __init__( - self, - xy: tuple[int | float, int | float], - object_type: str, - start_hp: int | float = 0, - ): - args = get_enemy_type(object_type) - - health = start_hp or args["hp"] - - super().__init__( - xy, - args["texture_size"], - args["speed"], - health, - args["dm"], - args["endurance"], - "basic enemy bullet", - 0, - args["shoot_v"] / 1000, - hitbox_radius=args["texture_size"][0] // 2, - direction=(0, 1), - ) - - frames = list(map(lambda x: f"/enemy/{x}", args["texture_file"].split(";"))) - Animated.__init__( - self, xy, args["texture_size"], args["speed"], frames, 0, period=0.1 - ) - self.texture_size = args["texture_size"] - self.my_type = object_type - self.cost = args["cost"] - - self.vx, self.vy = 0, 1 - - def shoot(self) -> list[Bullet]: - if self.can_shoot(): - match self.my_type: - case "boss": - if randint(0, 6) == 0: - return self.shoot_cluster() - return self.shoot_radial(waves=2, base_angle=randint(0, 359)) - case "basic enemy": - bullet = Bullet((self.x, self.y), self.damage, self.bullet_type) - bullet.vx = randint(-100, 100) / 100 - bullet.vy = (1 - bullet.vx**2) ** 0.5 - return [bullet] - case "strong enemy": - return self.shoot_radial(waves=3, n=5) - return [] - - def shoot_radial(self, base_angle=0, angle_step=0, waves=1, n=6) -> list[Bullet]: - """Shoot circle of bullets""" - - bullets = [] - - for wave in range(waves): - first_angle = base_angle + wave * angle_step - for add_angle in range(0, 360, 360 // n): - angle = pi * ((first_angle + add_angle) % 360) / 180 - bullet = Bullet((self.x, self.y), self.damage, self.bullet_type) - bullet.vx = cos(angle) - bullet.vy = sin(angle) - for _ in range(wave): - bullet.update(0.3) - bullets.append(bullet) - return bullets - - def shoot_cluster(self, waves=1, n=10, base_angle=0, arc=180): - bullets = [] - for wave in range(waves): - for i, a in enumerate(range(0, arc, arc // n)): - angle = pi * ((base_angle + a) % 360) / 180 - bullet = Bullet((self.x, self.y), self.damage, self.bullet_type) - bullet.vx = cos(angle) * (i + 1) * 0.2 - bullet.vy = sin(angle) * (i + 1) * 0.2 - for _ in range(wave): - bullet.update(0.3) - bullets.append(bullet) - return bullets - - def generate_drops(self) -> list: - drops = [] - count = 1 - if self.my_type == "boss": - count = 5 - for _ in range(count): - pos = (self.x + randint(-10, 10), self.y + randint(-10, 10)) - match choices(("powerup", "points", None), (1, 1, 2))[0]: - case "powerup": - drops.append(PowerUp(pos)) - case "points": - drops.append(Points(pos)) - - return drops +"""Enemy object declaration.""" + +from random import randint, choices +from math import sin, cos, pi + +from danmaku.game.animated import AnimatedDirectional +from danmaku.game.bullet import Bullet +from danmaku.database import get_enemy_type +from danmaku.game.shooter import Shooter +from danmaku.game.drop import PowerUp, Points, Drop + + +class Enemy(Shooter, AnimatedDirectional): + """Enemy object.""" + + def __init__( + self, + xy: tuple[int | float, int | float], + object_type: str, + start_hp: int | float = 0, + ): + args = get_enemy_type(object_type) + + health: int | float = start_hp or args["hp"] + + super().__init__( + xy, + args["texture_size"], + args["speed"], + health, + args["dm"], + args["endurance"], + "basic enemy bullet", + 0, + args["shoot_v"] / 1000, + hitbox_radius=args["texture_size"][0] // 2, + direction=(0, 1), + ) + + frames = list(map(lambda x: f"/enemy/{x}", args["texture_file"].split(";"))) + AnimatedDirectional.__init__( + self, + xy, + args["texture_size"], + args["speed"], + frames, + period=0.1, + ) + self.texture_size = args["texture_size"] + self.my_type = object_type + self.cost: int = args["cost"] + + self.vx, self.vy = 0, 1 + + def shoot(self) -> list[Bullet]: + if self.can_shoot(): + match self.my_type: + case "boss": + if randint(0, 6) == 0: + return self.shoot_cluster() + return self.shoot_radial(waves=2, base_angle=randint(0, 359)) + case "basic enemy": + self.shoot_sound(2) + bullet = Bullet((self.x, self.y), self.damage, self.bullet_type) + bullet.vx = randint(-100, 100) / 100 + bullet.vy = (1 - bullet.vx**2) ** 0.5 + return [bullet] + case "strong enemy": + return self.shoot_radial(waves=3, n=5, base_angle=randint(0, 359)) + case _: + pass + return [] + + def shoot_radial( + self, base_angle: int = 0, angle_step: int = 0, waves: int = 1, n: int = 6 + ) -> list[Bullet]: + """Shoot circle of bullets""" + + bullets: list[Bullet] = [] + + for wave in range(waves): + first_angle = base_angle + wave * angle_step + for add_angle in range(0, 360, 360 // n): + angle = pi * ((first_angle + add_angle) % 360) / 180 + bullet = Bullet((self.x, self.y), self.damage, self.bullet_type) + bullet.vx = cos(angle) + bullet.vy = sin(angle) + for _ in range(wave): + bullet.update(0.3) + bullets.append(bullet) + return bullets + + def shoot_cluster( + self, waves: int = 1, n: int = 10, base_angle: int = 0, arc: int = 180 + ) -> list[Bullet]: + """Shoot spiral wave of bullets""" + bullets: list[Bullet] = [] + for wave in range(waves): + for i, a in enumerate(range(0, arc, arc // n)): + angle = pi * ((base_angle + a) % 360) / 180 + bullet = Bullet((self.x, self.y), self.damage, self.bullet_type) + bullet.vx = cos(angle) * (i + 1) * 0.2 + bullet.vy = sin(angle) * (i + 1) * 0.2 + for _ in range(wave): + bullet.update(0.3) + bullets.append(bullet) + return bullets + + def generate_drops(self) -> list[Drop]: + """Generate drops""" + drops: list[Drop] = [] + count = 1 + if self.my_type == "boss": + count = 5 + for _ in range(count): + pos = (self.x + randint(-10, 10), self.y + randint(-10, 10)) + match choices(("powerup", "points", None), (1, 1, 2))[0]: + case "powerup": + drops.append(PowerUp(pos)) + case "points": + drops.append(Points(pos)) + case _: + pass + + return drops diff --git a/danmaku/entity.py b/danmaku/game/entity.py similarity index 59% rename from danmaku/entity.py rename to danmaku/game/entity.py index 629af96..63d6432 100644 --- a/danmaku/entity.py +++ b/danmaku/game/entity.py @@ -1,6 +1,10 @@ """Base class for alive objects""" -from danmaku.gameobject import GameObject +import pygame + +from danmaku.database import get_settings +from danmaku.game.gameobject import GameObject +from danmaku.utils import resource_path class Entity(GameObject): @@ -25,3 +29,11 @@ def __init__( def get_damage(self, damage: int | float): """Decrease health point.""" self.health -= damage / self.endurance + self.damage_sound(3) + + def damage_sound(self, channel: int): + """Play damage sound""" + pygame.mixer.init() + sound = pygame.mixer.Sound(resource_path("sounds/hit.wav")) + sound.set_volume(get_settings()["sfx_volume"]["value"] / 100) + pygame.mixer.Channel(channel).play(sound) diff --git a/danmaku/game/game.py b/danmaku/game/game.py new file mode 100644 index 0000000..9142fc8 --- /dev/null +++ b/danmaku/game/game.py @@ -0,0 +1,413 @@ +"""Game scene.""" + +import vgame +from vgame import Keys +import pygame + +from danmaku.utils import not_in_border, resource_path +from danmaku.game.enemy import Enemy +from danmaku.game.player import Player +from danmaku.game.bullet import Bullet +from danmaku.database import ( + get_saved_objects, + get_saved_game, + set_saved_objects, + set_saved_game, + delete_saved_objects, + get_settings, + delete_last_game, +) +from danmaku.game.pause import Pause +from danmaku.game.background import Background +from danmaku.game.drop import Drop, PowerUp, Points +from danmaku.game.level import Level, Stage, BossStage + + +# pylint: disable=attribute-defined-outside-init, missing-class-docstring +class Game(vgame.Scene): + new_game: bool = True + + def load(self): + # self.game_border = self.width // 4 * 3 + + # game field aspect ratio is 3:4 + self.game_border = self.height // 4 * 3 + + stage_1 = Stage([Enemy((self.game_border // 2, 15), "basic enemy")]) + stage_2 = Stage( + [ + Enemy((50, -25), "basic enemy"), + Enemy((self.game_border - 50, -50), "basic enemy"), + ] + ) + stage_3 = Stage([Enemy((self.game_border // 2.5, 5), "strong enemy")]) + stage_4 = Stage( + [ + Enemy((50, -25), "strong enemy"), + Enemy((self.game_border - 50, -50), "strong enemy"), + ] + ) + stage_5 = Stage( + [ + Enemy((50, -80), "basic enemy"), + Enemy((self.game_border // 2, -20), "strong enemy"), + Enemy((self.game_border - 50, -80), "basic enemy"), + ] + ) + stage_6 = Stage( + [ + Enemy((50, -15), "strong enemy"), + Enemy((self.game_border // 2, -35), "strong enemy"), + Enemy((self.game_border - 50, -50), "basic enemy"), + ] + ) + stage_7 = BossStage( + enemies=[], boss=Enemy((self.game_border // 2, -40), "boss"), actions=[] + ) + + stage_8 = Stage( + [ + Enemy((50, -15), "strong enemy"), + Enemy((self.game_border // 2, -50), "strong enemy"), + Enemy((self.game_border - 50, -35), "strong enemy"), + ] + ) + stage_9 = Stage( + [ + Enemy((50, -15), "strong enemy"), + Enemy((self.game_border // 2, -50), "strong enemy"), + Enemy((self.game_border - 50, -35), "strong enemy"), + ] + ) + stage_10 = BossStage( + enemies=[ + Enemy((50, -15), "strong enemy"), + Enemy((self.game_border - 50, -35), "strong enemy"), + ], + boss=Enemy((self.game_border // 2, -50), "boss"), + actions=[], + ) + + level_1 = Level( + stages=[ + stage_1, + stage_2, + stage_3, + stage_4, + stage_5, + stage_6, + stage_7, + ] + ) + + level_2 = Level(stages=[stage_8, stage_9, stage_10]) + + self.levels = level_1, level_2 + + self.graphics.library.path = resource_path("textures") + + self.settings = get_settings() + + self.start_time = pygame.time.get_ticks() + self.current_time = 0 + + pygame.mixer.init() + pygame.mixer.music.set_volume(self.settings["music_volume"]["value"] / 100) + pygame.mixer.music.load(resource_path("sounds/game.wav")) + pygame.mixer.music.play(loops=-1) + + self.paused = False + self.pause_object = Pause() + self.exit_status = "" + + self.background_object = Background(0, 0, self.game_border, self.height, []) + + self.bullets: list[Bullet] = [] + self.drops: list[Drop] = [] + + self.boss_hp: int | float | None = None + + if self.new_game: + self.current_level: int = 0 + self.last_time = 0 + self.enemies: list[Enemy] = list(self.levels[self.current_level].enemies) + self.player = Player( + (self.game_border // 2, self.height - 50), + "player", + bombs=self.settings["bombs"]["value"], + lives=self.settings["lives"]["value"], + ) + + else: + self.enemies: list[Enemy] = [] + for entity in get_saved_objects(): + match entity["object"]: + case "enemy": + self.enemies.append( + Enemy( + entity["object_position"], + entity["object_type"], + start_hp=entity["object_hp"], + ) + ) + case "bullet": + self.bullets.append( + Bullet( + entity["object_position"], + entity["object_damage"], + entity["object_type"], + ) + ) + case "player": + self.player = Player( + entity["object_position"], + entity["object_type"], + updated_hp=entity["object_hp"], + ) + case "powerup": + self.drops.append(PowerUp(entity["object_position"])) + case "points": + self.drops.append(Points(entity["object_position"])) + + saved_game = get_saved_game() + self.current_level: int = saved_game["level"] + self.player.score = saved_game["score"] + self.player.power = saved_game["power"] + self.player.bombs = saved_game["bombs"] + self.last_time = saved_game["time"] + self.start_time = pygame.time.get_ticks() + delete_saved_objects() + delete_last_game() + + self.player.set_bounds(0, 0, self.game_border, self.height) + + self.animation_start_time = pygame.time.get_ticks() + + def update_pause(self): + """Called from update loop if paused""" + self.pause_object.update(self.pressed_keys) + status = self.pause_object.exit_status + match status: + case "continue": + self.paused = False + case "menu": + delete_saved_objects() + set_saved_objects("enemy", self.enemies) + set_saved_objects("bullet", self.bullets) + set_saved_objects("player", [self.player]) + set_saved_objects( + "points", [x for x in self.drops if isinstance(x, Points)] + ) + set_saved_objects( + "powerup", [x for x in self.drops if isinstance(x, PowerUp)] + ) + set_saved_game( + self.current_level, + self.player.score, + self.player.power, + self.player.bombs, + self.current_time, + ) + self.stop() + + def update_game(self): + """Called from update loop if *not* paused""" + + vx = (Keys.RIGHT in self.pressed_keys) - (Keys.LEFT in self.pressed_keys) + vy = (Keys.DOWN in self.pressed_keys) - (Keys.UP in self.pressed_keys) + + if {Keys.SPACE, Keys.Z} & self.pressed_keys: + self.bullets += self.player.shoot() + if Keys.X in self.pressed_keys: + self.bullets += self.player.bomb() + if Keys.LEFT_SHIFT in self.pressed_keys: + self.player.slow = True + else: + self.player.slow = False + + self.player.vx, self.player.vy = vx, vy + + self.player.update(self.delta) + + stage = self.levels[self.current_level].stage + stage.update() + if isinstance(stage, BossStage): + self.boss_hp = stage.boss.health + if stage.boss.health < 0: + self.boss_hp = None + else: + self.boss_hp = None + + for enemy in self.enemies: + if self.player.collision(enemy): + self.player.get_damage(enemy.damage / 100) + self.bullets += enemy.shoot() + enemy.update(self.delta) + if ( + enemy.y > self.height / 2 and not 0 <= enemy.x < self.game_border + ) or enemy.y > self.height + enemy.height / 2: + self.enemies.remove(enemy) + + for bullet in filter(lambda b: b.enemy, self.bullets): + if self.player.collision(bullet): + self.player.get_damage(bullet.damage) + self.bullets.remove(bullet) + continue + + for bullet in filter(lambda b: not b.enemy, self.bullets): + for enemy in self.enemies: + if enemy.collision(bullet): + enemy.get_damage(bullet.damage) + if enemy.health <= 0: + self.player.score += enemy.cost + self.drops += enemy.generate_drops() + self.enemies.remove(enemy) + + self.bullets.remove(bullet) + break + + for bullet in self.bullets: + bullet.update(self.delta) + + if not not_in_border( + bullet.x, bullet.y, bullet.vx, bullet.vy, self.game_border, self.height + ): + self.bullets.remove(bullet) + + for drop in self.drops: + if self.player.collision(drop): + if isinstance(drop, PowerUp): + self.player.power += 2 + elif isinstance(drop, Points): + self.player.score += 10 + self.drops.remove(drop) + continue + + drop.update(self.delta) + + if not not_in_border( + drop.x, drop.y, drop.vx, drop.vy, self.game_border, self.height + ): + self.drops.remove(drop) + + if len(self.enemies) == 0: + self.next_level() + + if self.player.health <= 0: + set_saved_game( + self.current_level, + self.player.score, + self.player.power, + self.player.bombs, + self.current_time, + ) + self.exit_status = "lose" + # death_sfx = pygame.mixer.Sound(resource_path("sounds/death.wav")) + # death_sfx.set_volume(self.settings["sfx_volume"]["value"] / 100) + # channel = death_sfx.play() + # while channel.get_busy(): + # pygame.time.wait(10) + self.stop() + + def next_level(self) -> None: + """Start next level if possible""" + if self.levels[self.current_level].next_stage(): + self.enemies = list(self.levels[self.current_level].enemies) + elif len(self.levels) > self.current_level + 1: + self.current_level += 1 + self.enemies = list(self.levels[self.current_level].enemies) + else: + set_saved_game( + self.current_level, + self.player.score, + self.player.power, + self.player.bombs, + self.current_time, + ) + self.exit_status = "win" + self.stop() + + def update(self): + # self.print_stats() + if Keys.ESCAPE in self.pressed_keys: + self.pressed_keys.remove(Keys.ESCAPE) + self.paused = not self.paused + if self.paused: + self.pause_object.load(self.width, self.height) + + if self.paused: + self.update_pause() + else: + self.current_time = ( + round((pygame.time.get_ticks() - self.start_time) / 1000, 1) + + self.last_time + ) + self.update_game() + + def draw(self): + animation_time = pygame.time.get_ticks() - self.animation_start_time + self.background_object.animate_absolute(animation_time) + + self.graphics.rectangle((0, 0), (self.width, self.height), (30, 157, 214, 180)) + self.graphics.draw_sprite(self.background_object) + + self.player.animate_absolute(animation_time, (self.player.vx, self.player.vy)) + self.player.draw(self.graphics) + + for enemy in self.enemies: + enemy.animate_absolute(animation_time, (enemy.vx, enemy.vy)) + self.graphics.draw_sprite(enemy) + + for bullet in self.bullets: + self.graphics.draw_sprite(bullet) + + for drop in self.drops: + self.graphics.draw_sprite(drop) + + self.graphics.text(f"HP: {self.player.health}", (self.game_border + 10, 0)) + self.graphics.text(f"Score: {self.player.score}", (self.game_border + 10, 50)) + self.graphics.text( + f"Time: {round(self.current_time, 1)}", (self.game_border + 10, 100) + ) + self.graphics.text(f"Bombs: {self.player.bombs}", (self.game_border + 10, 150)) + if self.boss_hp is not None: + self.graphics.text(f"BOSS: {self.boss_hp}", (self.game_border + 10, 200)) + + if self.paused: + self.pause_object.draw(self.graphics) + + def exit(self): + pygame.mixer.music.stop() + + def print_stats(self): + """Dev debug""" + print("\x1b[?25l", end="") # hide cursor + print("\x1b[2J\x1b[0;0H", end="") # clear console + + # Tech stuff + print( + f"\x1b[{32 if self.fps >= self.framerate else 31}mFPS:\t{self.fps:.2f}\x1b[0m", + f"Gr. dT:\t{self.graphics_delta*1000}ms", + "", + f"\x1b[{32 if self.tps >= self.tickrate else 31}mTPS:\t{self.tps:.2f}\x1b[0m", + f"dT:\t{self.delta*1000}ms", + sep="\n", + ) + + print(end="\n\n") + + # Game stats + print( + f"HP: {self.player.health}", + f"Score: {self.player.score}", + f"Level: {self.current_level}", + f"Power: {self.player.power}", + f"Boss: {self.boss_hp}", + "", + f"Enemies: {len(self.enemies)}", + f"Bullets: {len(self.bullets)}", + f"Drops: {len(self.drops)}", + sep="\n", + ) + + print("\x1b[?25h", end="") # show cursor diff --git a/danmaku/gameobject.py b/danmaku/game/gameobject.py similarity index 92% rename from danmaku/gameobject.py rename to danmaku/game/gameobject.py index 211fdff..9af208c 100644 --- a/danmaku/gameobject.py +++ b/danmaku/game/gameobject.py @@ -1,49 +1,51 @@ -"""Base game object.""" - -import math - -from vgame.graphics import Graphics, Sprite - - -class GameObject(Sprite): - """ - A base game entity object. - """ - - hitbox_radius: int - - def __init__( - self, - xy: tuple[int | float, int | float], - width_height: tuple[int | float, int | float], - speed: int | float = 0, - hitbox_radius: int = 0, - direction: tuple[int | float, int | float] = (0, 0), - ): - super().__init__() - self.x, self.y = xy - self.speed = speed - self.width, self.height = width_height - self.vx, self.vy = direction - self.hitbox_radius = hitbox_radius - - def update(self, delta: int | float): - self.x += self.vx * delta * self.speed - self.y += self.vy * delta * self.speed - - self.rect.centerx, self.rect.centery, self.rect.w, self.rect.h = ( - int(self.x), - int(self.y), - int(self.width), - int(self.height), - ) - - def collision(self, other) -> bool: - """Check collision.""" - return ( - math.hypot(self.x - other.x, self.y - other.y) - < self.hitbox_radius + other.hitbox_radius - ) - - def draw(self, graphics: Graphics): - graphics.draw_sprite(self) +"""Base game object.""" + +import math + +from vgame.graphics import Graphics, Sprite + + +class GameObject(Sprite): + """ + A base game entity object. + """ + + hitbox_radius: int + vx: int | float + vy: int | float + + def __init__( + self, + xy: tuple[int | float, int | float], + width_height: tuple[int | float, int | float], + speed: int | float = 0, + hitbox_radius: int = 0, + direction: tuple[int | float, int | float] = (0, 0), + ): + super().__init__() + self.x, self.y = xy + self.speed = speed + self.width, self.height = width_height + self.vx, self.vy = direction + self.hitbox_radius = hitbox_radius + + def update(self, delta: int | float): + self.x += self.vx * delta * self.speed + self.y += self.vy * delta * self.speed + + self.rect.centerx, self.rect.centery, self.rect.w, self.rect.h = ( + int(self.x), + int(self.y), + int(self.width), + int(self.height), + ) + + def collision(self, other: "GameObject") -> bool: + """Check collision.""" + return ( + math.hypot(self.x - other.x, self.y - other.y) + < self.hitbox_radius + other.hitbox_radius + ) + + def draw(self, graphics: Graphics): + graphics.draw_sprite(self) diff --git a/danmaku/game/level.py b/danmaku/game/level.py new file mode 100644 index 0000000..b85a60c --- /dev/null +++ b/danmaku/game/level.py @@ -0,0 +1,122 @@ +""" + +Game contains levels +Level contains stages +Stage contains enemies and/or boss + +""" + +from typing import Any +import pygame + +from danmaku.game.enemy import Enemy + + +class Stage: + """Base stage + + Contains enemies""" + + _enemies: list[Enemy] + + _start_time: int + # appearance of enemies can be bound to time + + def __init__(self, enemies: list[Enemy]) -> None: + self._enemies = list(enemies) + + self._start_time = pygame.time.get_ticks() + + def update(self) -> None: + """WIP + + Update all enemies on the stage""" + + @property + def enemies(self) -> list[Enemy]: + """Enemies on the stage""" + return self._enemies + + +class BossStage(Stage): + """Stage centered on boss""" + + # +enemies: list[Enemy] + _boss: Enemy + + ## Boss actions ## + # Boss actions are bound to time passed from stage start + # Actions are being stored in tuples in format: + # (time in milliseconds, action, args) + + # ( 1000, "move_to", (10, 20) ) + # ( 2000, "shoot_radial", () ) + + def __init__( + self, + enemies: list[Enemy], + boss: Enemy, + actions: list[tuple[int, str, tuple[Any]]], + ) -> None: + super().__init__(enemies) + self._boss = boss + self._actions = list(actions) + + def update(self) -> None: + if self._actions: + if pygame.time.get_ticks() - self._start_time >= self._actions[0][0]: + print(f"Function: {self._actions[0][1]}\tArgs: {self._actions[0][2]}") + self._actions.pop(0) + + @property + def boss(self) -> Enemy: + """Boss property""" + return self._boss + + @property + def enemies(self) -> list[Enemy]: + return self._enemies + [self._boss] + + +class Level: + """Game level + + Contains stages + For chapter separation by theme + (can contain only one boss, but with multiple boss stages)""" + + stages: list[Stage] + current_stage: int + + enemies: list[Enemy] # enemies from current stage + + def __init__(self, stages: list[Stage]) -> None: + self.stages = list(stages) + self.current_stage = 0 + self.enemies = list(self.stages[self.current_stage].enemies) + + def __len__(self) -> int: + return len(self.stages) + + def __getitem__(self, index: int) -> Stage: + return self.stages[index] + + @property + def stage(self) -> Stage: + """Current stage""" + return self.stages[self.current_stage] + + def next_stage(self) -> bool: + """Switch stage to next if possible + + Returns: bool - success""" + if len(self.stages) > self.current_stage + 1: + self.current_stage += 1 + self.enemies = list(self.stages[self.current_stage].enemies) + return True + return False + + def set_stage(self, index: int) -> None: + """Set stage by index""" + self.current_stage = index + self.enemies = list(self.stages[self.current_stage].enemies) diff --git a/danmaku/pause.py b/danmaku/game/pause.py similarity index 52% rename from danmaku/pause.py rename to danmaku/game/pause.py index 8cf4bc4..a1d1fd2 100644 --- a/danmaku/pause.py +++ b/danmaku/game/pause.py @@ -1,25 +1,26 @@ """In-game pause menu.""" import vgame +from danmaku.game.background import Background +from danmaku.ui.button import Button, Cursor # pylint: disable=attribute-defined-outside-init, missing-class-docstring class Pause: - def load(self, width, height): + def load(self, width: int, height: int): """Load pause menu.""" self.selection_index = 0 + self.cursor = Cursor((10, 100)) + self.width, self.height = width, height + self.background_object = Background(0, 0, self.width, self.height, ["menu.png"]) - self.buttons = ( - ("Continue", "continue"), - ("Settings", "settings"), - ("Main menu", "menu"), - ) + self.buttons = (Button("Continue", "continue"), Button("Main menu", "menu")) self.exit_status: str = "" - def update(self, pressed_keys): + def update(self, pressed_keys: set[int]): """Update pause menu.""" if vgame.Keys.UP in pressed_keys: pressed_keys.discard(vgame.Keys.UP) @@ -28,16 +29,23 @@ def update(self, pressed_keys): pressed_keys.discard(vgame.Keys.DOWN) self.selection_index = (self.selection_index + 1) % len(self.buttons) if {vgame.Keys.RETURN, vgame.Keys.Z, vgame.Keys.SPACE} & pressed_keys: - self.exit_status = self.buttons[self.selection_index][1] + self.exit_status = self.buttons[self.selection_index].codename + self.cursor.y = 100 + self.selection_index * 50 + self.cursor.update(0) def draw(self, graphics: vgame.graphics.Graphics): """Draw pause menu.""" - graphics.rectangle((0, 0), (self.width, self.height), (0, 0, 0, 180), alpha=1) - graphics.text("Danmaku", (0, 10), (255, 255, 180)) + graphics.draw_sprite(self.background_object) + graphics.text("Danmaku", (self.width // 2 - 70, 10), (0, 74, 127)) + + self.cursor.draw(graphics) for i, button in enumerate(self.buttons): + selected_color = (0, 74, 127) + color = selected_color if i == self.selection_index else (255, 255, 255) + graphics.text( - button[0], - (0, 100 + i * 50), - (255, 200, 180) if i == self.selection_index else (255, 255, 255), + button.text, + (70, 100 + i * 50), + color, ) diff --git a/danmaku/player.py b/danmaku/game/player.py similarity index 52% rename from danmaku/player.py rename to danmaku/game/player.py index 17dbac7..5b0afd4 100644 --- a/danmaku/player.py +++ b/danmaku/game/player.py @@ -1,176 +1,147 @@ -"""Player object declaration.""" - -import math -import vgame -from danmaku.bullet import Bullet -from danmaku.database import get_player_type -from danmaku.utils import constrain, Direction -from danmaku.shooter import Shooter -from danmaku.animated import Animated - - -class Player(Shooter, Animated): - """Player object.""" - - def __init__( - self, xy: tuple[int | float, int | float], object_type: str, updated_hp=0 - ) -> None: - args = get_player_type(object_type) - - health = updated_hp or args["hp"] - - super().__init__( - xy, - args["texture_size"], - args["speed"], - health, - args["dm"], - args["endurance"], - "basic player bullet", - 0, - args["shoot_v"] / 1000, - ) - - # Animation - files = args["texture_file"].split(";") - self.animation_frames = { - Direction.LEFT: [], - Direction.RIGHT: [], - Direction.UP: [], - Direction.DOWN: [], - } - - for i in files: - path = f"/player/{i}" - if "left" in i: - self.animation_frames[Direction.LEFT].append(path) - if "right" in i: - self.animation_frames[Direction.RIGHT].append(path) - if "up" in i: - self.animation_frames[Direction.UP].append(path) - if "down" in i: - self.animation_frames[Direction.DOWN].append(path) - - Animated.__init__( - self, xy, args["texture_size"], args["speed"], [], 0, period=0.1 - ) - - self.texture_file = self.animation_frames[Direction.LEFT][ - self.animation_current - ] - self.texture_size = args["texture_size"] - - self.my_type = object_type - self.score = 0 - self.power = 1 - - self.hitbox_radius = args["hitbox_radius"] - self.slow = False - - # Bounds - self.left = self.top = 0 - self.right = self.bottom = 10e6 - - def shoot(self) -> list[Bullet]: - res: list[Bullet] = [] - - if self.can_shoot(): - - bullet = Bullet( - (self.x, self.y), - self.damage + self.power, - self.bullet_type, - ) - - res.append(bullet) - - if self.power > 4: - vx = math.cos(math.pi * 85 / 180) - vy = -math.sin(math.pi * 85 / 180) - - b1 = Bullet( - (self.x, self.y), - self.damage + self.power, - self.bullet_type, - ) - b2 = Bullet( - (self.x, self.y), - self.damage + self.power, - self.bullet_type, - ) - b1.vx = vx - b2.vx = -vx - b1.vy = b2.vy = vy - res.extend([b1, b2]) - - return res - - def bomb(self) -> list[Bullet]: - """Spawn bomb, AKA super-bullet""" - res: list[Bullet] = [] - if self.can_shoot(): - bullet = Bullet( - (self.x, self.y), - self.damage + self.power + 50, - "player bomb", - ) - res.append(bullet) - return res - - def set_bounds( - self, - left: int | float, - top: int | float, - right: int | float, - bottom: int | float, - ) -> None: - """Set movement bounds""" - self.left = left - self.top = top - self.right = right - self.bottom = bottom - - def update(self, delta: int | float) -> None: - speed = self.speed if not self.slow else self.speed * 0.5 - - self.x += self.vx * delta * speed - self.x = constrain( - self.x, self.left + self.width / 2, self.right - self.width / 2 - ) - - self.y += self.vy * delta * speed - self.y = constrain( - self.y, self.top + self.height / 2, self.bottom - self.height / 2 - ) - - self.rect.centerx, self.rect.centery, self.rect.w, self.rect.h = ( - int(self.x), - int(self.y), - int(self.width), - int(self.height), - ) - - def animate(self) -> None: - """Animate one frame.""" - if self.can_animate(): - direction = None - if self.vx > 0: - direction = Direction.RIGHT - elif self.vy > 0: - direction = Direction.DOWN - elif self.vx < 0: - direction = Direction.LEFT - elif self.vy < 0: - direction = Direction.UP - if direction is not None: - self.animation_current = (self.animation_current + 1) % len( - self.animation_frames[direction] - ) - self.texture_file = self.animation_frames[direction][ - self.animation_current - ] - - def draw(self, graphics: vgame.graphics.Graphics) -> None: - graphics.draw_sprite(self) - if self.slow: - graphics.circle((self.x, self.y), self.hitbox_radius, (200, 0, 200)) +"""Player object declaration.""" + +import math +import vgame +from danmaku.game.bullet import Bullet +from danmaku.database import get_player_type +from danmaku.utils import constrain +from danmaku.game.shooter import Shooter +from danmaku.game.animated import AnimatedDirectional + + +class Player(Shooter, AnimatedDirectional): + """Player object.""" + + def __init__( + self, + xy: tuple[int | float, int | float], + object_type: str, + bombs: int = 0, + lives: int = 1, + updated_hp: int = 0, + ) -> None: + args = get_player_type(object_type) + + health: int | float = updated_hp or args["hp"] * lives + + super().__init__( + xy, + args["texture_size"], + args["speed"], + health, + args["dm"], + args["endurance"], + "basic player bullet", + 0, + args["shoot_v"] / 1000, + ) + + # Animation + + address = "player" + + AnimatedDirectional.__init__( + self, + xy, + args["texture_size"], + args["speed"], + [f"{address}/{i}" for i in args["texture_file"].split(";")], + period=0.1, + ) + + self.texture_size = args["texture_size"] + + self.my_type = object_type + self.score: int = 0 + self.power: int = 1 + self.bombs = bombs + + self.hitbox_radius = args["hitbox_radius"] + self.slow = False + + # Bounds + self.left = self.top = 0 + self.right = self.bottom = 10e6 + + def shoot(self) -> list[Bullet]: + self.shoot_sound(1) + res: list[Bullet] = [] + + if self.can_shoot(): + + bullet = Bullet( + (self.x, self.y), + self.damage + self.power, + self.bullet_type, + ) + + res.append(bullet) + + if self.power > 4: + vx = math.cos(math.pi * 85 / 180) + vy = -math.sin(math.pi * 85 / 180) + + b1 = Bullet( + (self.x, self.y), + self.damage + self.power, + self.bullet_type, + ) + b2 = Bullet( + (self.x, self.y), + self.damage + self.power, + self.bullet_type, + ) + b1.vx = vx + b2.vx = -vx + b1.vy = b2.vy = vy + res.extend([b1, b2]) + + return res + + def bomb(self) -> list[Bullet]: + """Spawn bomb, AKA super-bullet""" + self.shoot_sound(1) + res: list[Bullet] = [] + if self.bombs != 0: + if self.can_shoot(): + bullet = Bullet( + (self.x, self.y), + (self.damage + self.power) * 30, + "player bomb", + ) + res.append(bullet) + self.bombs -= 1 + return res + + def set_bounds( + self, + left: int | float, + top: int | float, + right: int | float, + bottom: int | float, + ) -> None: + """Set movement bounds""" + self.left = left + self.top = top + self.right = right + self.bottom = bottom + + def update(self, delta: int | float) -> None: + speed = self.speed if not self.slow else self.speed * 0.5 + + self.x += self.vx * delta * speed + self.x = constrain( + self.x, self.left + self.width / 2, self.right - self.width / 2 + ) + + self.y += self.vy * delta * speed + self.y = constrain( + self.y, self.top + self.height / 2, self.bottom - self.height / 2 + ) + + super().update(0) + + def draw(self, graphics: vgame.graphics.Graphics) -> None: + graphics.draw_sprite(self) + if self.slow: + graphics.circle((self.x, self.y), self.hitbox_radius, (200, 0, 200)) diff --git a/danmaku/shooter.py b/danmaku/game/shooter.py similarity index 80% rename from danmaku/shooter.py rename to danmaku/game/shooter.py index 48ea645..67ab43e 100644 --- a/danmaku/shooter.py +++ b/danmaku/game/shooter.py @@ -4,8 +4,10 @@ import pygame -from danmaku.entity import Entity -from danmaku.bullet import Bullet +from danmaku.game.entity import Entity +from danmaku.game.bullet import Bullet +from danmaku.database import get_settings +from danmaku.utils import resource_path class Shooter(Entity): @@ -59,6 +61,13 @@ def can_shoot(self) -> bool: return True return False + def shoot_sound(self, channel: int): + """Play sound of shooting""" + pygame.mixer.init() + sound = pygame.mixer.Sound(resource_path("sounds/shoot.wav")) + sound.set_volume(get_settings()["sfx_volume"]["value"] / 100) + pygame.mixer.Channel(channel).play(sound) + @abstractmethod def shoot(self) -> list[Bullet]: """Generate bullets.""" diff --git a/danmaku/main.py b/danmaku/main.py index 6778de1..94e2528 100644 --- a/danmaku/main.py +++ b/danmaku/main.py @@ -2,12 +2,14 @@ from vgame import Runner -from danmaku.menu import Menu -from danmaku.game import Game -from danmaku.history import History -from danmaku.settings import Settings +from danmaku.ui.menu import Menu +from danmaku.game.game import Game +from danmaku.ui.history import History +from danmaku.ui.settings import Settings +from danmaku.ui.game_end import GameEnd +from danmaku.database import get_settings -WIDTH, HEIGHT = 300, 500 +WIDTH, HEIGHT = 640, 480 TICKRATE = 120 @@ -16,28 +18,63 @@ def run_game(is_new: bool): "Game exit handling" - game = Game(width=WIDTH, height=HEIGHT, title="Danmaku | Game", tickrate=TICKRATE) + game = Game( + width=WIDTH, + height=HEIGHT, + title="Danmaku | Game", + tickrate=TICKRATE, + fullscreen=get_settings()["fullscreen"]["value"], + ) game.new_game = is_new runner.run(game) match game.exit_status: case "win": - # show win screen - ... + end = GameEnd( + width=WIDTH, + height=HEIGHT, + title="Danmaku | Game Over", + tickrate=TICKRATE, + fullscreen=get_settings()["fullscreen"]["value"], + ) + end.set("You win", (30, 157, 214), "sounds/win.wav") + runner.run(end) case "lose": - # show lose screen - ... + end = GameEnd( + width=WIDTH, + height=HEIGHT, + title="Danmaku | Game Over", + tickrate=TICKRATE, + fullscreen=get_settings()["fullscreen"]["value"], + ) + end.set("Game over", (30, 157, 214), "sounds/lose.wav") + runner.run(end) while runner.running: - menu = Menu(width=WIDTH, height=HEIGHT, title="Danmaku | Menu") + menu = Menu( + width=WIDTH, + height=HEIGHT, + title="Danmaku | Menu", + fullscreen=get_settings()["fullscreen"]["value"], + ) runner.run(menu) match menu.exit_status: case "game", new_game: run_game(new_game) case "settings": - settings = Settings(width=WIDTH, height=HEIGHT, title="Danmaku | Settings") + settings = Settings( + width=WIDTH, + height=HEIGHT, + title="Danmaku | Settings", + fullscreen=get_settings()["fullscreen"]["value"], + ) runner.run(settings) case "history": - history = History(width=WIDTH, height=HEIGHT, title="Danmaku | History") + history = History( + width=WIDTH, + height=HEIGHT, + title="Danmaku | History", + fullscreen=get_settings()["fullscreen"]["value"], + ) runner.run(history) diff --git a/danmaku/button.py b/danmaku/ui/button.py similarity index 81% rename from danmaku/button.py rename to danmaku/ui/button.py index 7b7c458..854628d 100644 --- a/danmaku/button.py +++ b/danmaku/ui/button.py @@ -1,12 +1,13 @@ """Menu button class declaration.""" from dataclasses import dataclass +from typing import Any import pygame -import vgame +from vgame.graphics import Graphics, Sprite -class ClickableButton(vgame.graphics.Sprite): +class ClickableButton(Sprite): """Menu button class clickable by mouse""" def __init__( @@ -24,7 +25,7 @@ def __init__( self.text = text self.font_size = font_size - def draw(self, graphics: vgame.graphics.Graphics): + def draw(self, graphics: Graphics): graphics.rectangle(self.rect.topleft, self.rect.size, self.button_color) graphics.text(f" {self.text} ", self.rect.topleft, self.text_color) @@ -43,10 +44,10 @@ class Button: codename: str -class Cursor(vgame.graphics.Sprite): +class Cursor(Sprite): """Cursor class""" - def __init__(self, xy): + def __init__(self, xy: tuple[int, int]): super().__init__() self.x, self.y = xy self.width, self.height = 42, 40 @@ -61,12 +62,16 @@ def update(self, delta: int | float): int(self.height), ) - def draw(self, graphics: vgame.graphics.Graphics): + def draw(self, graphics: Graphics): graphics.draw_sprite(self) class SettingsValue: - def __init__(self, name: str, display_name: str, possible_values, value) -> None: + """An interactive value bar for settings""" + + def __init__( + self, name: str, display_name: str, possible_values: tuple[Any], value: Any + ) -> None: self.codename = name self.text = display_name self.possible_values = possible_values @@ -74,9 +79,11 @@ def __init__(self, name: str, display_name: str, possible_values, value) -> None self.selection_index = self.possible_values.index(self.value) def increase(self): + """Select next value""" self.selection_index = (self.selection_index + 1) % len(self.possible_values) self.value = self.possible_values[self.selection_index] def decrease(self): + """Select previous value""" self.selection_index = (self.selection_index - 1) % len(self.possible_values) self.value = self.possible_values[self.selection_index] diff --git a/danmaku/ui/game_end.py b/danmaku/ui/game_end.py new file mode 100644 index 0000000..e244a73 --- /dev/null +++ b/danmaku/ui/game_end.py @@ -0,0 +1,53 @@ +"""Main menu scene.""" + +import pygame +import vgame + +from danmaku.database import get_settings +from danmaku.utils import resource_path + + +# pylint: disable=attribute-defined-outside-init, missing-class-docstring +class GameEnd(vgame.Scene): + def load(self): + self.graphics.library.path = resource_path("textures") + + pygame.mixer.init() + pygame.mixer.music.set_volume(get_settings()["music_volume"]["value"] / 100) + pygame.mixer.music.load(resource_path(self.music)) + pygame.mixer.music.play() + + def set(self, text: str, background: tuple[int, int, int], music: str): + """Set end screen attributes""" + self.text = text + if "win" in text: + self.delta_color = 2 + else: + self.delta_color = 0 + self.background_color = background + self.text_color: list[int] = list(background) + self.v = (255 - self.text_color[self.delta_color]) / 500 + self.music = music + + def update(self): + for i in range(3): + if i == self.delta_color: + self.text_color[i] += self.v + else: + if self.text_color[i] - self.v > 0: + self.text_color[i] -= self.v + if self.text_color[self.delta_color] + self.v >= 255: + pygame.time.wait(1000) + self.stop() + + def draw(self) -> None: + self.graphics.rectangle( + (0, 0), (self.width, self.height), self.background_color + ) + + # NOTE: update vgame to version 1.6.4 + self.graphics.text( + self.text, (30, self.height // 3), tuple(self.text_color), font_size=90 + ) + + def exit(self): ... diff --git a/danmaku/history.py b/danmaku/ui/history.py similarity index 77% rename from danmaku/history.py rename to danmaku/ui/history.py index bfd1e10..fa63702 100644 --- a/danmaku/history.py +++ b/danmaku/ui/history.py @@ -10,7 +10,7 @@ class History(vgame.Scene): def load(self): self.selection_index = 0 - self.history = get_game_history() + self.history: list[dict[str, int]] = get_game_history() self.record_count = len(self.history) def update(self): @@ -27,12 +27,14 @@ def update(self): self.stop() def draw(self): - self.graphics.text("History", (0, 0), (255, 255, 180)) + self.graphics.rectangle((0, 0), (self.width, self.height), (30, 157, 214, 180)) + + self.graphics.text("History", (self.width // 2 - 30, 0), (0, 74, 127)) for i, game in enumerate(self.history[self.selection_index :]): self.graphics.text( - f"Level: {game['level'] + 1}, Score: {game['score']}", - (0, 100 + 50 * i), + f"Level: {game['level'] + 1}, Score: {game['score']}, Time: {game['time']}", + (20, 50 + 50 * i), (255, 255, 255), ) diff --git a/danmaku/menu.py b/danmaku/ui/menu.py similarity index 79% rename from danmaku/menu.py rename to danmaku/ui/menu.py index 91b4ade..a0a79fa 100644 --- a/danmaku/menu.py +++ b/danmaku/ui/menu.py @@ -3,9 +3,10 @@ import pygame import vgame -from danmaku.database import get_saved_objects -from danmaku.button import Button, Cursor +from danmaku.database import get_saved_objects, get_settings +from danmaku.ui.button import Button, Cursor from danmaku.utils import resource_path +from danmaku.game.background import Background # pylint: disable=attribute-defined-outside-init, missing-class-docstring @@ -13,8 +14,15 @@ class Menu(vgame.Scene): def load(self): self.graphics.library.path = resource_path("textures") + pygame.mixer.init() + pygame.mixer.music.set_volume(get_settings()["music_volume"]["value"] / 100) + pygame.mixer.music.load(resource_path("sounds/menu.wav")) + pygame.mixer.music.play(loops=-1) + self.selection_index = 0 + self.background_object = Background(0, 0, self.width, self.height, ["menu.png"]) + self.buttons = ( Button("New game", "new_game"), Button("Continue", "continue"), @@ -56,16 +64,20 @@ def update(self): case "quit": # Maybe rework to quit through exit status pygame.event.post(pygame.event.Event(pygame.constants.QUIT)) + case _: + pass self.cursor.y = 100 + self.selection_index * 50 self.cursor.update(self.delta) def draw(self): - self.graphics.text("Danmaku", (0, 10), (255, 255, 180)) + self.graphics.draw_sprite(self.background_object) + + self.graphics.text("Danmaku", (self.width // 2 - 70, 10), (0, 74, 127)) self.cursor.draw(self.graphics) for i, button in enumerate(self.buttons): - selected_color = (180, 255, 255) + selected_color = (0, 74, 127) if button.codename == "continue": if not get_saved_objects(): selected_color = (255, 100, 100) diff --git a/danmaku/settings.py b/danmaku/ui/settings.py similarity index 84% rename from danmaku/settings.py rename to danmaku/ui/settings.py index 760935b..bb3312f 100644 --- a/danmaku/settings.py +++ b/danmaku/ui/settings.py @@ -4,7 +4,7 @@ from danmaku.database import get_settings -from danmaku.button import SettingsValue, Button +from danmaku.ui.button import SettingsValue, Button from danmaku.database.database import set_settings @@ -15,9 +15,7 @@ def load(self): settings_dict = get_settings() - self.buttons: list[object] = [ - SettingsValue("music_volume", "Music Volume", (0, 25, 50, 75, 100), 50), - ] + self.buttons: list[object] = [] for key, value in settings_dict.items(): self.buttons.append( @@ -52,23 +50,25 @@ def update(self): self.stop() def draw(self): - self.graphics.text("Danmaku", (0, 10), (255, 255, 180)) + self.graphics.rectangle((0, 0), (self.width, self.height), (30, 157, 214, 180)) + + self.graphics.text("Settings", (self.width // 2 - 40, 0), (0, 74, 127)) for i, button in enumerate(self.buttons): - color = (255, 200, 180) if i == self.selection_index else (255, 255, 255) + color = (0, 74, 127) if i == self.selection_index else (255, 255, 255) if isinstance(button, Button): self.graphics.text( button.text, - (0, 100 + i * 50), + (20, 100 + i * 50), color, ) if isinstance(button, SettingsValue): self.graphics.text( f"{button.text}: < {button.value} >", - (0, 100 + i * 50), + (20, 100 + i * 50), color, ) diff --git a/danmaku/utils.py b/danmaku/utils.py index 4cadd11..5fd61d9 100644 --- a/danmaku/utils.py +++ b/danmaku/utils.py @@ -25,7 +25,7 @@ def not_in_border( return True -def resource_path(relative_path) -> str: +def resource_path(relative_path: str) -> str: """Get absolute path to resource, works for dev and for PyInstaller""" try: # PyInstaller creates a temp folder and stores path in _MEIPASS @@ -51,3 +51,12 @@ class Direction(IntEnum): RIGHT = 1 UP = 2 DOWN = 3 + STATIC = 4 + + +# def str_to_stage(stage_str: str) -> Stage: ... + + +# def str_to_level(level_str: str) -> Level: +# """Convert level string to level object.""" +# ... diff --git a/docs/levels_prototoype.json b/docs/levels_prototoype.json new file mode 100644 index 0000000..ea07e32 --- /dev/null +++ b/docs/levels_prototoype.json @@ -0,0 +1,26 @@ +{ + "levels": [ + { + "stages": [ + { + "type": "stage", + "enemies": [ + { + "type": "basic enemy", + "scheme": [ + [1000, "appear", null], + [1500, "shoot", null] + ] + }, + { + "type": "basic enemy", + "scheme": [ + [1000, "appear", null] + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/mecs.md b/docs/mecs.md index 97bb771..be57081 100644 --- a/docs/mecs.md +++ b/docs/mecs.md @@ -8,8 +8,8 @@ - Player - Enemies - ## Player movement + ``` -1 ^ @@ -19,10 +19,10 @@ v 1 ``` + Player has two axis (x, y) which vary from -1 to 1. Player also has speed module (pixels / second). -Player's move distance calculation is (speed module) * (axis) * (time delta) - +Player's move distance calculation is (speed module) _ (axis) _ (time delta) ## Scenes @@ -45,14 +45,12 @@ graph LR As we can see, all other scenes return to the main menu - - ## Position, hitboxes - Position of an object is a position of it's center point -*placement:* +_placement:_ + ``` /-----\ | | @@ -60,11 +58,13 @@ Position of an object is a position of it's center point | | \-----/ ``` + (where the star is at) **NOT** coordinates of left top corner -*wrong placement:* +_wrong placement:_ + ``` *-----\ | | @@ -87,6 +87,9 @@ classDiagram GameObject <-- Animated Animated <-- Background Entity <-- Shooter + GameObject <-- Drop + Drop <-- Points + Drop <-- PowerUp class Sprite { texture_file: str @@ -94,8 +97,12 @@ classDiagram rect: Rect } class GameObject { - x: int - y: int + x, y: int + vx, vy: float [-1;1] + width, height: int + hitbox_radius: int + speed: int + update() draw() collision() -> bool @@ -103,31 +110,279 @@ classDiagram class Entity { health: int damage: int + endurance: float + get_damage() } class Shooter { shoot_freq: float last_shot: float + can_shoot() -> bool - shoot() + shoot() -> list[Bullet] } class Player { + player_type: str score: int power: int } class Enemy { + enemy_type: str cost: int } class Animated { animation_frames: list[str] animation_current: int animation_period: float + + can_animate() -> bool animate() } class Bullet { - + bullet_type: str } class Background { } + class Drop { + + } + class PowerUp { + + } + class Points +``` + +## Enemies' actions processing + +```mermaid +graph TB + +A([Start]) +B["Merge all actions (with links to objects)"] +C[Sort by timing] +E{Is it time yet?} +F[Execute action] +G[Remove from queue] +H{While actions left} +Z([End]) + +A --> B +B --> C +C --> H + +subgraph LOOP + direction TB + + D[Pick first] + + D --> E + E --> |Yes| F + E --> |No| D + F --> G +end + + +LOOP --> H +H --> |Yes| LOOP +H --> |No| Z + +``` + +## File hierarchy (import diagram) + +
+ Old + + ```mermaid + %%{init: {"flowchart": {"curve": "basis"}} }%% + graph TB + + GAME("game.py") + MAIN("main.py") + + + animated --> gameobject + + background --> animated + + bullet --> entity + bullet --> database + + button + + drop --> gameobject + + enemy --> shooter + enemy --> database + enemy --> animated + enemy --> bullet + enemy --> drop + + entity --> gameobject + + GAME --> enemy + GAME --> player + GAME --> level + GAME --> database + GAME --> drop + GAME --> background + GAME --> pause + GAME --> utils + GAME --> bullet + + gameobject + + history --> database + + level --> enemy + + MAIN --> GAME + MAIN --> menu + MAIN --> settings + MAIN --> history + + menu --> background + menu --> button + menu --> database + menu --> utils + + pause + + player --> shooter + player --> database + player --> animated + player --> bullet + player --> utils + + settings --> button + settings --> database + + + shooter --> entity + shooter --> bullet + + utils + + subgraph S_UI + direction TB + end + + subgraph S_GAME + direction TB + end + + ``` + +
+ +--- + +
+New + +```mermaid +graph TB + + main --> game + main --> menu + main --> settings + main --> history + + animated --> gameobject + + background --> animated + + bullet --> entity + bullet --> database + + drop --> gameobject + + enemy --> animated + enemy --> bullet + enemy --> drop + enemy --> shooter + enemy --> database + + entity --> gameobject + + game --> background + game --> bullet + game --> database + game --> drop + game --> enemy + game --> level + game --> pause + game --> player + game --> utils + + player --> animated + player --> database + player --> bullet + player --> shooter + player --> utils + + shooter --> bullet + shooter --> entity + + level --> enemy + + history --> database + + settings --> database + settings --> button + + menu --> database + menu --> button + menu --> utils + + subgraph S_UI + direction TB + button + history + menu + settings + end + + subgraph S_GAME + direction TB + animated + background + bullet + drop + enemy + entity + game + pause + player + shooter + level + gameobject + end +``` + +
+ +## Levels + + + +```mermaid +graph LR + +B([Все враги исчезли]) +C{Остались волны?} +D[Новая волна] +E{Остались уровни?} +F[Следующий уровень] +G[Победа] + +B --> C + +C --> |да| D +C --> |нет| E + +E --> |да| F +E --> |нет| G + ``` \ No newline at end of file diff --git a/docs/presentation.pptx b/docs/presentation.pptx index e03d94a..bb81b6d 100644 Binary files a/docs/presentation.pptx and b/docs/presentation.pptx differ diff --git a/poetry.lock b/poetry.lock index e225afb..26f0907 100644 --- a/poetry.lock +++ b/poetry.lock @@ -201,7 +201,7 @@ testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jar [[package]] name = "vgame" -version = "1.6.0" +version = "1.6.4" description = "" optional = false python-versions = "^3.10" @@ -215,7 +215,7 @@ pygame = "^2.5.2" type = "git" url = "https://github.com/virashu/pygametest" reference = "HEAD" -resolved_reference = "e9093767c0f93afacae45c1be1d7cafe233483bc" +resolved_reference = "aeeece542a60aa0594cab01f2a4a20489d93a585" [metadata] lock-version = "2.0" diff --git a/pyproject.toml b/pyproject.toml index 5ac362f..4d5bf37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "danmaku" -version = "0.11.0" +version = "1.0.0" description = "" authors = ["Vlad <89295404+Virashu@users.noreply.github.com>"] readme = "README.md" diff --git a/requirements.txt b/requirements.txt index 8094c62..2017619 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -peewee==3.17.0 ; python_version >= "3.10" and python_version < "3.13" +peewee==3.17.1 ; python_version >= "3.10" and python_version < "3.13" pygame==2.5.2 ; python_version >= "3.10" and python_version < "3.13" vgame @ git+https://github.com/virashu/pygametest ; python_version >= "3.10" and python_version < "3.13"