diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d08a30b..5ca51af 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,12 +2,19 @@ stages: - test - quality-assurance +py37: + stage: test + image: python:3.7-alpine + before_script: + - pip install tox + script: tox -e py3 + py38: stage: test image: python:3.8-alpine before_script: - pip install tox - script: tox -e py38 + script: tox -e py3 py39: @@ -15,7 +22,7 @@ py39: image: python:3.9-alpine before_script: - pip install tox - script: tox -e py39 + script: tox -e py3 linters: stage: quality-assurance diff --git a/dungeonbattle/bootstrap.py b/dungeonbattle/bootstrap.py index a2b5c72..0bc97be 100644 --- a/dungeonbattle/bootstrap.py +++ b/dungeonbattle/bootstrap.py @@ -11,5 +11,5 @@ class Bootstrap: game = Game() game.new_game() display = DisplayManager(term_manager.screen, game) - game.display_refresh = display.refresh + game.display_actions = display.handle_display_action game.run(term_manager.screen) diff --git a/dungeonbattle/display/display.py b/dungeonbattle/display/display.py index 64314e9..6aba26a 100644 --- a/dungeonbattle/display/display.py +++ b/dungeonbattle/display/display.py @@ -19,17 +19,25 @@ class Display: def newpad(self, height: int, width: int) -> Union[FakePad, Any]: return curses.newpad(height, width) if self.screen else FakePad() - def resize(self, y: int, x: int, height: int, width: int) -> None: + def init_pair(self, number: int, foreground: int, background: int) -> None: + return curses.init_pair(number, foreground, background) \ + if self.screen else None + + def color_pair(self, number: int) -> int: + return curses.color_pair(number) if self.screen else 0 + + def resize(self, y: int, x: int, height: int, width: int, + resize_pad: bool = True) -> None: self.x = x self.y = y self.width = width self.height = height - if self.pad: - self.pad.resize(height - 1, width - 1) + if hasattr(self, "pad") and resize_pad: + self.pad.resize(self.height - 1, self.width - 1) - def refresh(self, *args) -> None: + def refresh(self, *args, resize_pad: bool = True) -> None: if len(args) == 4: - self.resize(*args) + self.resize(*args, resize_pad) self.display() def display(self) -> None: diff --git a/dungeonbattle/display/display_manager.py b/dungeonbattle/display/display_manager.py index 5c249c8..b2cb125 100644 --- a/dungeonbattle/display/display_manager.py +++ b/dungeonbattle/display/display_manager.py @@ -1,10 +1,11 @@ import curses from dungeonbattle.display.mapdisplay import MapDisplay from dungeonbattle.display.statsdisplay import StatsDisplay -from dungeonbattle.display.menudisplay import MainMenuDisplay +from dungeonbattle.display.menudisplay import MenuDisplay, MainMenuDisplay from dungeonbattle.display.texturepack import TexturePack from typing import Any from dungeonbattle.game import Game, GameMode +from dungeonbattle.enums import DisplayActions class DisplayManager: @@ -17,23 +18,35 @@ class DisplayManager: self.statsdisplay = StatsDisplay(screen, pack) self.mainmenudisplay = MainMenuDisplay(self.game.main_menu, screen, pack) + self.settingsmenudisplay = MenuDisplay(screen, pack) self.displays = [self.statsdisplay, self.mapdisplay, - self.mainmenudisplay] + self.mainmenudisplay, self.settingsmenudisplay] self.update_game_components() + def handle_display_action(self, action: DisplayActions) -> None: + if action == DisplayActions.REFRESH: + self.refresh() + elif action == DisplayActions.UPDATE: + self.update_game_components() + def update_game_components(self) -> None: for d in self.displays: d.pack = TexturePack.get_pack(self.game.settings.TEXTURE_PACK) self.mapdisplay.update_map(self.game.map) self.statsdisplay.update_player(self.game.player) + self.settingsmenudisplay.update_menu(self.game.settings_menu) def refresh(self) -> None: if self.game.state == GameMode.PLAY: - self.mapdisplay.refresh(0, 0, self.rows * 4 // 5, self.cols) + # The map pad has already the good size + self.mapdisplay.refresh(0, 0, self.rows * 4 // 5, self.cols, + resize_pad=False) self.statsdisplay.refresh(self.rows * 4 // 5, 0, self.rows // 5, self.cols) if self.game.state == GameMode.MAINMENU: self.mainmenudisplay.refresh(0, 0, self.rows, self.cols) + if self.game.state == GameMode.SETTINGS: + self.settingsmenudisplay.refresh(0, 0, self.rows, self.cols - 1) self.resize_window() def resize_window(self) -> bool: diff --git a/dungeonbattle/display/mapdisplay.py b/dungeonbattle/display/mapdisplay.py index 0a07ec0..aa40039 100644 --- a/dungeonbattle/display/mapdisplay.py +++ b/dungeonbattle/display/mapdisplay.py @@ -12,25 +12,30 @@ class MapDisplay(Display): def update_map(self, m: Map) -> None: self.map = m - self.pad = self.newpad(m.height, m.width + 1) + self.pad = self.newpad(m.height, self.pack.tile_width * m.width + 1) def update_pad(self) -> None: - self.pad.addstr(0, 0, self.map.draw_string(self.pack)) + self.init_pair(1, self.pack.tile_fg_color, self.pack.tile_bg_color) + self.init_pair(2, self.pack.entity_fg_color, self.pack.entity_bg_color) + self.pad.addstr(0, 0, self.map.draw_string(self.pack), + self.color_pair(1)) for e in self.map.entities: - self.pad.addstr(e.y, e.x, self.pack.PLAYER) + self.pad.addstr(e.y, self.pack.tile_width * e.x, + self.pack[e.name.upper()], self.color_pair(2)) def display(self) -> None: - y, x = self.map.currenty, self.map.currentx + y, x = self.map.currenty, self.pack.tile_width * self.map.currentx deltay, deltax = (self.height // 2) + 1, (self.width // 2) + 1 pminrow, pmincol = y - deltay, x - deltax sminrow, smincol = max(-pminrow, 0), max(-pmincol, 0) deltay, deltax = self.height - deltay, self.width - deltax smaxrow = self.map.height - (y + deltay) + self.height - 1 smaxrow = min(smaxrow, self.height - 1) - smaxcol = self.map.width - (x + deltax) + self.width - 1 + smaxcol = self.pack.tile_width * self.map.width - \ + (x + deltax) + self.width - 1 smaxcol = min(smaxcol, self.width - 1) pminrow = max(0, min(self.map.height, pminrow)) - pmincol = max(0, min(self.map.width, pmincol)) + pmincol = max(0, min(self.pack.tile_width * self.map.width, pmincol)) self.pad.clear() self.update_pad() self.pad.refresh(pminrow, pmincol, sminrow, smincol, smaxrow, smaxcol) diff --git a/dungeonbattle/display/menudisplay.py b/dungeonbattle/display/menudisplay.py index aeed447..973dd31 100644 --- a/dungeonbattle/display/menudisplay.py +++ b/dungeonbattle/display/menudisplay.py @@ -1,3 +1,5 @@ +from typing import List + from dungeonbattle.menus import Menu, MainMenu from .display import Display @@ -11,7 +13,6 @@ class MenuDisplay(Display): def update_menu(self, menu: Menu) -> None: self.menu = menu - self.values = [str(a) for a in menu.values] self.trueheight = len(self.values) self.truewidth = max([len(a) for a in self.values]) @@ -22,7 +23,7 @@ class MenuDisplay(Display): def update_pad(self) -> None: for i in range(self.trueheight): - self.pad.addstr(i, 0, " ") + self.pad.addstr(i, 0, " " + self.values[i]) # set a marker on the selected line self.pad.addstr(self.menu.position, 0, ">") @@ -54,6 +55,10 @@ class MenuDisplay(Display): def preferred_height(self) -> int: return self.trueheight + 2 + @property + def values(self) -> List[str]: + return [str(a) for a in self.menu.values] + class MainMenuDisplay(Display): def __init__(self, menu: MainMenu, *args): diff --git a/dungeonbattle/display/statsdisplay.py b/dungeonbattle/display/statsdisplay.py index bf204dd..70c6f0c 100644 --- a/dungeonbattle/display/statsdisplay.py +++ b/dungeonbattle/display/statsdisplay.py @@ -1,3 +1,5 @@ +import curses + from .display import Display from dungeonbattle.entities.player import Player @@ -9,6 +11,7 @@ class StatsDisplay(Display): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.pad = self.newpad(self.rows, self.cols) + self.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) def update_player(self, p: Player) -> None: self.player = p @@ -33,9 +36,17 @@ class StatsDisplay(Display): string3 = string3 + " " self.pad.addstr(2, 0, string3) + inventory_str = "Inventaire : " + "".join( + self.pack[item.name.upper()] for item in self.player.inventory) + self.pad.addstr(3, 0, inventory_str) + + if self.player.dead: + self.pad.addstr(4, 0, "VOUS ÊTES MORT", + curses.A_BOLD | curses.A_BLINK | curses.A_STANDOUT + | self.color_pair(3)) + def display(self) -> None: self.pad.clear() self.update_pad() self.pad.refresh(0, 0, self.y, self.x, - 2 + self.y, - self.width + self.x) + 4 + self.y, self.width + self.x) diff --git a/dungeonbattle/display/texturepack.py b/dungeonbattle/display/texturepack.py index 6929a9c..0ae8f56 100644 --- a/dungeonbattle/display/texturepack.py +++ b/dungeonbattle/display/texturepack.py @@ -1,7 +1,16 @@ +import curses +from typing import Any + + class TexturePack: _packs = dict() name: str + tile_width: int + tile_fg_color: int + tile_bg_color: int + entity_fg_color: int + entity_bg_color: int EMPTY: str WALL: str FLOOR: str @@ -15,23 +24,52 @@ class TexturePack: self.__dict__.update(**kwargs) TexturePack._packs[name] = self + def __getitem__(self, item: str) -> Any: + return self.__dict__[item] + @classmethod def get_pack(cls, name: str) -> "TexturePack": return cls._packs[name.lower()] + @classmethod + def get_next_pack_name(cls, name: str) -> str: + return "squirrel" if name == "ascii" else "ascii" + TexturePack.ASCII_PACK = TexturePack( name="ascii", + tile_width=1, + tile_fg_color=curses.COLOR_WHITE, + tile_bg_color=curses.COLOR_BLACK, + entity_fg_color=curses.COLOR_WHITE, + entity_bg_color=curses.COLOR_BLACK, EMPTY=' ', WALL='#', FLOOR='.', PLAYER='@', + HEDGEHOG='*', + HEART='❤', + BOMB='o', + RABBIT='Y', + BEAVER='_', + TEDDY_BEAR='8', ) TexturePack.SQUIRREL_PACK = TexturePack( name="squirrel", - EMPTY=' ', - WALL='█', - FLOOR='.', - PLAYER='🐿️', + tile_width=2, + tile_fg_color=curses.COLOR_WHITE, + tile_bg_color=curses.COLOR_BLACK, + entity_fg_color=curses.COLOR_WHITE, + entity_bg_color=curses.COLOR_WHITE, + EMPTY=' ', + WALL='🧱', + FLOOR='██', + PLAYER='🐿 ️', + HEDGEHOG='🦔', + HEART='💜', + BOMB='💣', + RABBIT='🐇', + BEAVER='🦫', + TEDDY_BEAR='🧸', ) diff --git a/dungeonbattle/entities/items.py b/dungeonbattle/entities/items.py index 3edec91..4cfd26b 100644 --- a/dungeonbattle/entities/items.py +++ b/dungeonbattle/entities/items.py @@ -1,22 +1,46 @@ +from typing import Optional + +from .player import Player from ..interfaces import Entity, FightingEntity, Map class Item(Entity): held: bool + held_by: Optional["Player"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.held = False def drop(self, y: int, x: int) -> None: - self.held = False + if self.held: + self.held_by.inventory.remove(self) + self.held = False + self.held_by = None + self.map.add_entity(self) self.move(y, x) - def hold(self) -> None: + def hold(self, player: "Player") -> None: self.held = True + self.held_by = player + self.map.remove_entity(self) + player.inventory.append(self) + + +class Heart(Item): + name: str = "heart" + healing: int = 5 + + def hold(self, player: "Player") -> None: + """ + When holding a heart, heal the player and don't put item in inventory. + """ + player.health = min(player.maxhealth, player.health + self.healing) + self.map.remove_entity(self) class Bomb(Item): + name: str = "bomb" damage: int = 5 exploding: bool diff --git a/dungeonbattle/entities/monsters.py b/dungeonbattle/entities/monsters.py index 59db0e7..327521f 100644 --- a/dungeonbattle/entities/monsters.py +++ b/dungeonbattle/entities/monsters.py @@ -1,11 +1,58 @@ +from random import choice + +from .player import Player from ..interfaces import FightingEntity, Map class Monster(FightingEntity): def act(self, m: Map) -> None: - pass + """ + By default, a monster will move randomly where it is possible + And if a player is close to the monster, the monster run on the player. + """ + target = None + for entity in m.entities: + if self.distance_squared(entity) <= 25 and \ + isinstance(entity, Player): + target = entity + break + + # A Dijkstra algorithm has ran that targets the player. + # With that way, monsters can simply follow the path. + # If they can't move and they are already close to the player, + # They hit. + if target and (self.y, self.x) in target.paths: + # Move to target player + next_y, next_x = target.paths[(self.y, self.x)] + moved = self.check_move(next_y, next_x, True) + if not moved and self.distance_squared(target) <= 1: + self.hit(target) + else: + for _ in range(100): + if choice([self.move_up, self.move_down, + self.move_left, self.move_right])(): + break -class Squirrel(Monster): +class Beaver(Monster): + name = "beaver" + maxhealth = 30 + strength = 2 + + +class Hedgehog(Monster): + name = "hedgehog" maxhealth = 10 strength = 3 + + +class Rabbit(Monster): + name = "rabbit" + maxhealth = 15 + strength = 1 + + +class TeddyBear(Monster): + name = "teddy_bear" + maxhealth = 50 + strength = 0 diff --git a/dungeonbattle/entities/player.py b/dungeonbattle/entities/player.py index 9f01d0d..c1bde5e 100644 --- a/dungeonbattle/entities/player.py +++ b/dungeonbattle/entities/player.py @@ -1,7 +1,11 @@ +from random import randint +from typing import Dict, Tuple + from ..interfaces import FightingEntity class Player(FightingEntity): + name = "player" maxhealth: int = 20 strength: int = 5 intelligence: int = 1 @@ -11,25 +15,88 @@ class Player(FightingEntity): level: int = 1 current_xp: int = 0 max_xp: int = 10 + inventory: list + paths: Dict[Tuple[int, int], Tuple[int, int]] - def move_up(self) -> bool: - return self.check_move(self.y - 1, self.x, True) + def __init__(self): + super().__init__() + self.inventory = list() - def move_down(self) -> bool: - return self.check_move(self.y + 1, self.x, True) - - def move_left(self) -> bool: - return self.check_move(self.y, self.x - 1, True) - - def move_right(self) -> bool: - return self.check_move(self.y, self.x + 1, True) + def move(self, y: int, x: int) -> None: + """ + When the player moves, move the camera of the map. + """ + super().move(y, x) + self.map.currenty = y + self.map.currentx = x + self.recalculate_paths() def level_up(self) -> None: + """ + Add levels to the player as much as it is possible. + """ while self.current_xp > self.max_xp: self.level += 1 self.current_xp -= self.max_xp self.max_xp = self.level * 10 + self.health = self.maxhealth + # TODO Remove it, that's only fun + self.map.spawn_random_entities(randint(3 * self.level, + 10 * self.level)) def add_xp(self, xp: int) -> None: + """ + Add some experience to the player. + If the required amount is reached, level up. + """ self.current_xp += xp self.level_up() + + # noinspection PyTypeChecker,PyUnresolvedReferences + def check_move(self, y: int, x: int, move_if_possible: bool = False) \ + -> bool: + """ + If the player tries to move but a fighting entity is there, + the player fights this entity. + It rewards some XP if it is dead. + """ + # Don't move if we are dead + if self.dead: + return False + for entity in self.map.entities: + if entity.y == y and entity.x == x: + if entity.is_fighting_entity(): + self.hit(entity) + if entity.dead: + self.add_xp(randint(3, 7)) + return True + elif entity.is_item(): + entity.hold(self) + return super().check_move(y, x, move_if_possible) + + def recalculate_paths(self, max_distance: int = 8) -> None: + """ + Use Dijkstra algorithm to calculate best paths + for monsters to go to the player. + """ + queue = [(self.y, self.x)] + visited = [] + distances = {(self.y, self.x): 0} + predecessors = {} + while queue: + y, x = queue.pop(0) + visited.append((y, x)) + if distances[(y, x)] >= max_distance: + continue + for diff_y, diff_x in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + new_y, new_x = y + diff_y, x + diff_x + if not 0 <= new_y < self.map.height or \ + not 0 <= new_x < self.map.width or \ + not self.map.tiles[y][x].can_walk() or \ + (new_y, new_x) in visited or \ + (new_y, new_x) in queue: + continue + predecessors[(new_y, new_x)] = (y, x) + distances[(new_y, new_x)] = distances[(y, x)] + 1 + queue.append((new_y, new_x)) + self.paths = predecessors diff --git a/dungeonbattle/enums.py b/dungeonbattle/enums.py new file mode 100644 index 0000000..2a6b993 --- /dev/null +++ b/dungeonbattle/enums.py @@ -0,0 +1,48 @@ +from enum import Enum, auto +from typing import Optional + +from dungeonbattle.settings import Settings + + +class DisplayActions(Enum): + REFRESH = auto() + UPDATE = auto() + + +class GameMode(Enum): + MAINMENU = auto() + PLAY = auto() + SETTINGS = auto() + INVENTORY = auto() + + +class KeyValues(Enum): + UP = auto() + DOWN = auto() + LEFT = auto() + RIGHT = auto() + ENTER = auto() + SPACE = auto() + + @staticmethod + def translate_key(key: str, settings: Settings) -> Optional["KeyValues"]: + """ + Translate the raw string key into an enum value that we can use. + """ + if key in (settings.KEY_DOWN_SECONDARY, + settings.KEY_DOWN_PRIMARY): + return KeyValues.DOWN + elif key in (settings.KEY_LEFT_PRIMARY, + settings.KEY_LEFT_SECONDARY): + return KeyValues.LEFT + elif key in (settings.KEY_RIGHT_PRIMARY, + settings.KEY_RIGHT_SECONDARY): + return KeyValues.RIGHT + elif key in (settings.KEY_UP_PRIMARY, + settings.KEY_UP_SECONDARY): + return KeyValues.UP + elif key == settings.KEY_ENTER: + return KeyValues.ENTER + elif key == ' ': + return KeyValues.SPACE + return None diff --git a/dungeonbattle/game.py b/dungeonbattle/game.py index 3dc60cc..39e7b48 100644 --- a/dungeonbattle/game.py +++ b/dungeonbattle/game.py @@ -1,34 +1,18 @@ -import sys -from typing import Any +from random import randint +from typing import Any, Optional from .entities.player import Player +from .enums import GameMode, KeyValues, DisplayActions from .interfaces import Map from .settings import Settings -from enum import Enum, auto from . import menus from typing import Callable -class GameMode(Enum): - MAINMENU = auto() - PLAY = auto() - SETTINGS = auto() - INVENTORY = auto() - - -class KeyValues(Enum): - UP = auto() - DOWN = auto() - LEFT = auto() - RIGHT = auto() - ENTER = auto() - SPACE = auto() - - class Game: map: Map player: Player - display_refresh: Callable[[], None] + display_actions: Callable[[DisplayActions], None] def __init__(self) -> None: """ @@ -36,21 +20,22 @@ class Game: """ self.state = GameMode.MAINMENU self.main_menu = menus.MainMenu() + self.settings_menu = menus.SettingsMenu() self.settings = Settings() self.settings.load_settings() self.settings.write_settings() + self.settings_menu.update_values(self.settings) def new_game(self) -> None: """ Create a new game on the screen. """ # TODO generate a new map procedurally - self.map = Map.load("resources/example_map.txt") - self.map.currenty = 1 - self.map.currentx = 6 + self.map = Map.load("resources/example_map_2.txt") self.player = Player() - self.player.move(1, 6) self.map.add_entity(self.player) + self.player.move(self.map.start_y, self.map.start_x) + self.map.spawn_random_entities(randint(3, 10)) @staticmethod def load_game(filename: str) -> None: @@ -63,35 +48,16 @@ class Game: We wait for a player action, then we do what that should be done when the given key got pressed. """ - while True: + while True: # pragma no cover screen.clear() screen.refresh() - self.display_refresh() + self.display_actions(DisplayActions.REFRESH) key = screen.getkey() - self.handle_key_pressed(self.translate_key(key)) + self.handle_key_pressed( + KeyValues.translate_key(key, self.settings), key) - def translate_key(self, key: str) -> KeyValues: - """ - Translate the raw string key into an enum value that we can use. - """ - if key in (self.settings.KEY_DOWN_SECONDARY, - self.settings.KEY_DOWN_PRIMARY): - return KeyValues.DOWN - elif key in (self.settings.KEY_LEFT_PRIMARY, - self.settings.KEY_LEFT_SECONDARY): - return KeyValues.LEFT - elif key in (self.settings.KEY_RIGHT_PRIMARY, - self.settings.KEY_RIGHT_SECONDARY): - return KeyValues.RIGHT - elif key in (self.settings.KEY_UP_PRIMARY, - self.settings.KEY_UP_SECONDARY): - return KeyValues.UP - elif key == self.settings.KEY_ENTER: - return KeyValues.ENTER - elif key == ' ': - return KeyValues.SPACE - - def handle_key_pressed(self, key: KeyValues) -> None: + def handle_key_pressed(self, key: Optional[KeyValues], raw_key: str = '')\ + -> None: """ Indicates what should be done when the given key is pressed, according to the current game state. @@ -99,46 +65,26 @@ class Game: if self.state == GameMode.PLAY: self.handle_key_pressed_play(key) elif self.state == GameMode.MAINMENU: - self.handle_key_pressed_main_menu(key) + self.main_menu.handle_key_pressed(key, self) elif self.state == GameMode.SETTINGS: - self.handle_key_pressed_settings(key) - self.display_refresh() + self.settings_menu.handle_key_pressed(key, raw_key, self) + self.display_actions(DisplayActions.REFRESH) def handle_key_pressed_play(self, key: KeyValues) -> None: """ In play mode, arrows or zqsd should move the main character. """ if key == KeyValues.UP: - self.player.move_up() + if self.player.move_up(): + self.map.tick() elif key == KeyValues.DOWN: - self.player.move_down() + if self.player.move_down(): + self.map.tick() elif key == KeyValues.LEFT: - self.player.move_left() + if self.player.move_left(): + self.map.tick() elif key == KeyValues.RIGHT: - self.player.move_right() + if self.player.move_right(): + self.map.tick() elif key == KeyValues.SPACE: self.state = GameMode.MAINMENU - - def handle_key_pressed_main_menu(self, key: KeyValues) -> None: - """ - In the main menu, we can navigate through options. - """ - if key == KeyValues.DOWN: - self.main_menu.go_down() - if key == KeyValues.UP: - self.main_menu.go_up() - if key == KeyValues.ENTER: - option = self.main_menu.validate() - if option == menus.MainMenuValues.START: - self.state = GameMode.PLAY - elif option == menus.MainMenuValues.SETTINGS: - self.state = GameMode.SETTINGS - elif option == menus.MainMenuValues.EXIT: - sys.exit(0) - - def handle_key_pressed_settings(self, key: KeyValues) -> None: - """ - For now, in the settings mode, we can only go backwards. - """ - if key == KeyValues.SPACE: - self.state = GameMode.MAINMENU diff --git a/dungeonbattle/interfaces.py b/dungeonbattle/interfaces.py index bbcd25e..b057400 100644 --- a/dungeonbattle/interfaces.py +++ b/dungeonbattle/interfaces.py @@ -1,5 +1,8 @@ #!/usr/bin/env python from enum import Enum, auto +from math import sqrt +from random import choice, randint +from typing import List from dungeonbattle.display.texturepack import TexturePack @@ -11,15 +14,21 @@ class Map: """ width: int height: int - tiles: list + start_y: int + start_x: int + tiles: List[List["Tile"]] + entities: List["Entity"] # coordinates of the point that should be # on the topleft corner of the screen currentx: int currenty: int - def __init__(self, width: int, height: int, tiles: list): + def __init__(self, width: int, height: int, tiles: list, + start_y: int, start_x: int): self.width = width self.height = height + self.start_y = start_y + self.start_x = start_x self.tiles = tiles self.entities = [] @@ -30,8 +39,22 @@ class Map: self.entities.append(entity) entity.map = self + def remove_entity(self, entity: "Entity") -> None: + """ + Unregister an entity from the map. + """ + self.entities.remove(entity) + + def is_free(self, y: int, x: int) -> bool: + """ + Indicates that the case at the coordinates (y, x) is empty. + """ + return 0 <= y < self.height and 0 <= x < self.width and \ + self.tiles[y][x].can_walk() and \ + not any(entity.x == x and entity.y == y for entity in self.entities) + @staticmethod - def load(filename: str): + def load(filename: str) -> "Map": """ Read a file that contains the content of a map, and build a Map object. """ @@ -40,18 +63,20 @@ class Map: return Map.load_from_string(file) @staticmethod - def load_from_string(content: str): + def load_from_string(content: str) -> "Map": """ Load a map represented by its characters and build a Map object. """ lines = content.split("\n") - lines = [line for line in lines if line] + first_line = lines[0] + start_y, start_x = map(int, first_line.split(" ")) + lines = [line for line in lines[1:] if line] height = len(lines) width = len(lines[0]) tiles = [[Tile.from_ascii_char(c) for x, c in enumerate(line)] for y, line in enumerate(lines)] - return Map(width, height, tiles) + return Map(width, height, tiles, start_y, start_x) def draw_string(self, pack: TexturePack) -> str: """ @@ -61,6 +86,28 @@ class Map: return "\n".join("".join(tile.char(pack) for tile in line) for line in self.tiles) + def spawn_random_entities(self, count: int) -> None: + """ + Put randomly {count} hedgehogs on the map, where it is available. + """ + for _ in range(count): + y, x = 0, 0 + while True: + y, x = randint(0, self.height - 1), randint(0, self.width - 1) + tile = self.tiles[y][x] + if tile.can_walk(): + break + entity = choice(Entity.get_all_entity_classes())() + entity.move(y, x) + self.add_entity(entity) + + def tick(self) -> None: + """ + Trigger all entity events. + """ + for entity in self.entities: + entity.act(self) + class Tile(Enum): EMPTY = auto() @@ -84,7 +131,7 @@ class Tile(Enum): """ Check if an entity (player or not) can move in this tile. """ - return not self.is_wall() + return not self.is_wall() and self != Tile.EMPTY class Entity: @@ -99,14 +146,31 @@ class Entity: def check_move(self, y: int, x: int, move_if_possible: bool = False)\ -> bool: - tile = self.map.tiles[y][x] - if tile.can_walk() and move_if_possible: + free = self.map.is_free(y, x) + if free and move_if_possible: self.move(y, x) - return tile.can_walk() + return free - def move(self, y: int, x: int) -> None: + def move(self, y: int, x: int) -> bool: self.y = y self.x = x + return True + + def move_up(self, force: bool = False) -> bool: + return self.move(self.y - 1, self.x) if force else \ + self.check_move(self.y - 1, self.x, True) + + def move_down(self, force: bool = False) -> bool: + return self.move(self.y + 1, self.x) if force else \ + self.check_move(self.y + 1, self.x, True) + + def move_left(self, force: bool = False) -> bool: + return self.move(self.y, self.x - 1) if force else \ + self.check_move(self.y, self.x - 1, True) + + def move_right(self, force: bool = False) -> bool: + return self.move(self.y, self.x + 1) if force else \ + self.check_move(self.y, self.x + 1, True) def act(self, m: Map) -> None: """ @@ -115,6 +179,33 @@ class Entity: """ pass + def distance_squared(self, other: "Entity") -> int: + """ + Get the square of the distance to another entity. + Useful to check distances since square root takes time. + """ + return (self.y - other.y) ** 2 + (self.x - other.x) ** 2 + + def distance(self, other: "Entity") -> float: + """ + Get the cartesian distance to another entity. + """ + return sqrt(self.distance_squared(other)) + + def is_fighting_entity(self) -> bool: + return isinstance(self, FightingEntity) + + def is_item(self) -> bool: + from dungeonbattle.entities.items import Item + return isinstance(self, Item) + + @staticmethod + def get_all_entity_classes(): + from dungeonbattle.entities.items import Heart, Bomb + from dungeonbattle.entities.monsters import Beaver, Hedgehog, \ + Rabbit, TeddyBear + return [Beaver, Bomb, Heart, Hedgehog, Rabbit, TeddyBear] + class FightingEntity(Entity): maxhealth: int @@ -142,3 +233,4 @@ class FightingEntity(Entity): def die(self) -> None: self.dead = True + self.map.remove_entity(self) diff --git a/dungeonbattle/menus.py b/dungeonbattle/menus.py index 68eeab7..1990b27 100644 --- a/dungeonbattle/menus.py +++ b/dungeonbattle/menus.py @@ -1,5 +1,10 @@ +import sys from enum import Enum -from typing import Any +from typing import Any, Optional + +from .display.texturepack import TexturePack +from .enums import GameMode, KeyValues, DisplayActions +from .settings import Settings class Menu: @@ -30,6 +35,84 @@ class MainMenuValues(Enum): class MainMenu(Menu): values = [e for e in MainMenuValues] + def handle_key_pressed(self, key: KeyValues, game: Any) -> None: + """ + In the main menu, we can navigate through options. + """ + if key == KeyValues.DOWN: + self.go_down() + if key == KeyValues.UP: + self.go_up() + if key == KeyValues.ENTER: + option = self.validate() + if option == MainMenuValues.START: + game.state = GameMode.PLAY + elif option == MainMenuValues.SETTINGS: + game.state = GameMode.SETTINGS + elif option == MainMenuValues.EXIT: + sys.exit(0) + + +class SettingsMenu(Menu): + waiting_for_key: bool = False + + def update_values(self, settings: Settings) -> None: + self.values = [] + for i, key in enumerate(settings.settings_keys): + s = settings.get_comment(key) + s += " : " + if self.waiting_for_key and i == self.position: + s += "?" + else: + s += getattr(settings, key).replace("\n", "\\n") + s += 8 * " " # Write over old text + self.values.append(s) + self.values.append("") + self.values.append("Changer le pack de textures n'aura effet") + self.values.append("qu'après avoir relancé le jeu.") + self.values.append("") + self.values.append("Retour (espace)") + + def handle_key_pressed(self, key: Optional[KeyValues], raw_key: str, + game: Any) -> None: + """ + Update settings + """ + if not self.waiting_for_key: + # Navigate normally through the menu. + if key == KeyValues.SPACE or \ + key == KeyValues.ENTER and \ + self.position == len(self.values) - 1: + # Go back + game.display_actions(DisplayActions.UPDATE) + game.state = GameMode.MAINMENU + if key == KeyValues.DOWN: + self.go_down() + if key == KeyValues.UP: + self.go_up() + if key == KeyValues.ENTER and self.position < len(self.values) - 3: + # Change a setting + option = list(game.settings.settings_keys)[self.position] + if option == "TEXTURE_PACK": + game.settings.TEXTURE_PACK = \ + TexturePack.get_next_pack_name( + game.settings.TEXTURE_PACK) + game.settings.write_settings() + self.update_values(game.settings) + else: + self.waiting_for_key = True + self.update_values(game.settings) + else: + option = list(game.settings.settings_keys)[self.position] + # Don't use an already mapped key + if any(getattr(game.settings, opt) == raw_key + for opt in game.settings.settings_keys if opt != option): + return + setattr(game.settings, option, raw_key) + game.settings.write_settings() + self.waiting_for_key = False + self.update_values(game.settings) + class ArbitraryMenu(Menu): def __init__(self, values: list): diff --git a/dungeonbattle/term_manager.py b/dungeonbattle/term_manager.py index ab7f4dd..a425272 100644 --- a/dungeonbattle/term_manager.py +++ b/dungeonbattle/term_manager.py @@ -13,6 +13,8 @@ class TermManager: # pragma: no cover curses.cbreak() # make cursor invisible curses.curs_set(False) + # Enable colors + curses.start_color() def __enter__(self): return self diff --git a/dungeonbattle/tests/entities_test.py b/dungeonbattle/tests/entities_test.py index 00ea33b..d2c8171 100644 --- a/dungeonbattle/tests/entities_test.py +++ b/dungeonbattle/tests/entities_test.py @@ -1,7 +1,7 @@ import unittest -from dungeonbattle.entities.items import Bomb, Item -from dungeonbattle.entities.monsters import Squirrel +from dungeonbattle.entities.items import Bomb, Heart, Item +from dungeonbattle.entities.monsters import Hedgehog from dungeonbattle.entities.player import Player from dungeonbattle.interfaces import Entity, Map @@ -12,6 +12,9 @@ class TestEntities(unittest.TestCase): Load example map that can be used in tests. """ self.map = Map.load("resources/example_map.txt") + self.player = Player() + self.map.add_entity(self.player) + self.player.move(self.map.start_y, self.map.start_x) def test_basic_entities(self) -> None: """ @@ -23,12 +26,17 @@ class TestEntities(unittest.TestCase): self.assertEqual(entity.x, 64) self.assertIsNone(entity.act(self.map)) + other_entity = Entity() + other_entity.move(45, 68) + self.assertEqual(entity.distance_squared(other_entity), 25) + self.assertEqual(entity.distance(other_entity), 5) + def test_fighting_entities(self) -> None: """ Test some random stuff with fighting entities. """ - entity = Squirrel() - self.assertIsNone(entity.act(self.map)) + entity = Hedgehog() + self.map.add_entity(entity) self.assertEqual(entity.maxhealth, 10) self.assertEqual(entity.maxhealth, entity.health) self.assertEqual(entity.strength, 3) @@ -41,35 +49,85 @@ class TestEntities(unittest.TestCase): self.assertIsNone(entity.hit(entity)) self.assertTrue(entity.dead) + entity = Hedgehog() + self.map.add_entity(entity) + entity.move(15, 44) + # Move randomly + self.map.tick() + self.assertFalse(entity.y == 15 and entity.x == 44) + + # Move to the player + entity.move(3, 6) + self.map.tick() + self.assertTrue(entity.y == 2 and entity.x == 6) + + # Hedgehog should fight + old_health = self.player.health + self.map.tick() + self.assertTrue(entity.y == 2 and entity.x == 6) + self.assertEqual(old_health - entity.strength, self.player.health) + + # Fight the hedgehog + old_health = entity.health + self.player.move_down() + self.assertEqual(entity.health, old_health - self.player.strength) + self.assertFalse(entity.dead) + old_health = entity.health + self.player.move_down() + self.assertEqual(entity.health, old_health - self.player.strength) + self.assertTrue(entity.dead) + self.assertGreaterEqual(self.player.current_xp, 3) + def test_items(self) -> None: """ Test some random stuff with items. """ item = Item() + self.map.add_entity(item) self.assertFalse(item.held) - item.hold() + item.hold(self.player) self.assertTrue(item.held) - item.drop(42, 42) - self.assertEqual(item.y, 42) - self.assertEqual(item.x, 42) + item.drop(2, 6) + self.assertEqual(item.y, 2) + self.assertEqual(item.x, 6) + + # Pick up item + self.player.move_down() + self.assertTrue(item.held) + self.assertEqual(item.held_by, self.player) + self.assertIn(item, self.player.inventory) + self.assertNotIn(item, self.map.entities) def test_bombs(self) -> None: """ Test some random stuff with bombs. """ item = Bomb() - squirrel = Squirrel() + hedgehog = Hedgehog() self.map.add_entity(item) - self.map.add_entity(squirrel) - squirrel.health = 2 - squirrel.move(41, 42) + self.map.add_entity(hedgehog) + hedgehog.health = 2 + hedgehog.move(41, 42) item.act(self.map) - self.assertFalse(squirrel.dead) + self.assertFalse(hedgehog.dead) item.drop(42, 42) self.assertEqual(item.y, 42) self.assertEqual(item.x, 42) item.act(self.map) - self.assertTrue(squirrel.dead) + self.assertTrue(hedgehog.dead) + + def test_hearts(self) -> None: + """ + Test some random stuff with hearts. + """ + item = Heart() + self.map.add_entity(item) + item.move(2, 6) + self.player.health -= 2 * item.healing + self.player.move_down() + self.assertNotIn(item, self.map.entities) + self.assertEqual(self.player.health, + self.player.maxhealth - item.healing) def test_players(self) -> None: """ diff --git a/dungeonbattle/tests/game_test.py b/dungeonbattle/tests/game_test.py index 2498b48..80720ee 100644 --- a/dungeonbattle/tests/game_test.py +++ b/dungeonbattle/tests/game_test.py @@ -4,8 +4,10 @@ import unittest from dungeonbattle.bootstrap import Bootstrap from dungeonbattle.display.display import Display from dungeonbattle.display.display_manager import DisplayManager +from dungeonbattle.entities.player import Player from dungeonbattle.game import Game, KeyValues, GameMode from dungeonbattle.menus import MainMenuValues +from dungeonbattle.settings import Settings class TestGame(unittest.TestCase): @@ -16,7 +18,7 @@ class TestGame(unittest.TestCase): self.game = Game() self.game.new_game() display = DisplayManager(None, self.game) - self.game.display_refresh = display.refresh + self.game.display_actions = display.handle_display_action def test_load_game(self) -> None: self.assertRaises(NotImplementedError, Game.load_game, "game.save") @@ -35,25 +37,39 @@ class TestGame(unittest.TestCase): """ Test key bindings. """ - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_UP_PRIMARY), KeyValues.UP) - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_UP_SECONDARY), KeyValues.UP) - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_DOWN_PRIMARY), KeyValues.DOWN) - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_DOWN_SECONDARY), KeyValues.DOWN) - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_LEFT_PRIMARY), KeyValues.LEFT) - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_LEFT_SECONDARY), KeyValues.LEFT) - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_RIGHT_PRIMARY), KeyValues.RIGHT) - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_RIGHT_SECONDARY), KeyValues.RIGHT) - self.assertEqual(self.game.translate_key( - self.game.settings.KEY_ENTER), KeyValues.ENTER) - self.assertEqual(self.game.translate_key(' '), KeyValues.SPACE) + self.game.settings = Settings() + + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_UP_PRIMARY, self.game.settings), + KeyValues.UP) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_UP_SECONDARY, self.game.settings), + KeyValues.UP) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_DOWN_PRIMARY, self.game.settings), + KeyValues.DOWN) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_DOWN_SECONDARY, self.game.settings), + KeyValues.DOWN) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_LEFT_PRIMARY, self.game.settings), + KeyValues.LEFT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_LEFT_SECONDARY, self.game.settings), + KeyValues.LEFT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_RIGHT_PRIMARY, self.game.settings), + KeyValues.RIGHT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_RIGHT_SECONDARY, self.game.settings), + KeyValues.RIGHT) + self.assertEqual(KeyValues.translate_key( + self.game.settings.KEY_ENTER, self.game.settings), + KeyValues.ENTER) + self.assertEqual(KeyValues.translate_key(' ', self.game.settings), + KeyValues.SPACE) + self.assertEqual(KeyValues.translate_key('plop', self.game.settings), + None) def test_key_press(self) -> None: """ @@ -90,6 +106,11 @@ class TestGame(unittest.TestCase): self.game.handle_key_pressed(KeyValues.ENTER) self.assertEqual(self.game.state, GameMode.PLAY) + # Kill entities + for entity in self.game.map.entities.copy(): + if not isinstance(entity, Player): + self.game.map.remove_entity(entity) + y, x = self.game.player.y, self.game.player.x self.game.handle_key_pressed(KeyValues.DOWN) new_y, new_x = self.game.player.y, self.game.player.x @@ -116,3 +137,80 @@ class TestGame(unittest.TestCase): self.game.handle_key_pressed(KeyValues.SPACE) self.assertEqual(self.game.state, GameMode.MAINMENU) + + def test_settings_menu(self) -> None: + """ + Ensure that the settings menu is working properly. + """ + self.game.settings = Settings() + + # Open settings menu + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.state, GameMode.SETTINGS) + + # Define the "move up" key to 'w' + self.assertFalse(self.game.settings_menu.waiting_for_key) + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertTrue(self.game.settings_menu.waiting_for_key) + self.game.handle_key_pressed(None, 'w') + self.assertFalse(self.game.settings_menu.waiting_for_key) + self.assertEqual(self.game.settings.KEY_UP_PRIMARY, 'w') + + # Navigate to "move left" + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.UP) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + + # Define the "move up" key to 'a' + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertTrue(self.game.settings_menu.waiting_for_key) + # Can't used a mapped key + self.game.handle_key_pressed(None, 's') + self.assertTrue(self.game.settings_menu.waiting_for_key) + self.game.handle_key_pressed(None, 'a') + self.assertFalse(self.game.settings_menu.waiting_for_key) + self.assertEqual(self.game.settings.KEY_LEFT_PRIMARY, 'a') + + # Navigate to "texture pack" + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + + # Change texture pack + self.assertEqual(self.game.settings.TEXTURE_PACK, "ascii") + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.settings.TEXTURE_PACK, "squirrel") + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.settings.TEXTURE_PACK, "ascii") + + # Navigate to "back" button + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + self.game.handle_key_pressed(KeyValues.DOWN) + + self.game.handle_key_pressed(KeyValues.ENTER) + self.assertEqual(self.game.state, GameMode.MAINMENU) + + def test_dead_screen(self) -> None: + """ + Kill player and render dead screen. + """ + self.game.state = GameMode.PLAY + # Kill player + self.game.player.take_damage(self.game.player, + self.game.player.health + 2) + y, x = self.game.player.y, self.game.player.x + for key in [KeyValues.UP, KeyValues.DOWN, + KeyValues.LEFT, KeyValues.RIGHT]: + self.game.handle_key_pressed(key) + new_y, new_x = self.game.player.y, self.game.player.x + self.assertEqual(new_y, y) + self.assertEqual(new_x, x) diff --git a/dungeonbattle/tests/interfaces_test.py b/dungeonbattle/tests/interfaces_test.py index c36e895..b487eac 100644 --- a/dungeonbattle/tests/interfaces_test.py +++ b/dungeonbattle/tests/interfaces_test.py @@ -9,7 +9,7 @@ class TestInterfaces(unittest.TestCase): """ Create a map and check that it is well parsed. """ - m = Map.load_from_string(".#\n#.\n") + m = Map.load_from_string("0 0\n.#\n#.\n") self.assertEqual(m.width, 2) self.assertEqual(m.height, 2) self.assertEqual(m.draw_string(TexturePack.ASCII_PACK), ".#\n#.") @@ -31,5 +31,5 @@ class TestInterfaces(unittest.TestCase): self.assertFalse(Tile.EMPTY.is_wall()) self.assertTrue(Tile.FLOOR.can_walk()) self.assertFalse(Tile.WALL.can_walk()) - self.assertTrue(Tile.EMPTY.can_walk()) + self.assertFalse(Tile.EMPTY.can_walk()) self.assertRaises(ValueError, Tile.from_ascii_char, 'unknown') diff --git a/dungeonbattle/tests/screen.py b/dungeonbattle/tests/screen.py index b9ec851..6eb2cd0 100644 --- a/dungeonbattle/tests/screen.py +++ b/dungeonbattle/tests/screen.py @@ -3,7 +3,7 @@ class FakePad: In order to run tests, we simulate a fake curses pad that accepts functions but does nothing with them. """ - def addstr(self, y: int, x: int, message: str) -> None: + def addstr(self, y: int, x: int, message: str, color: int = 0) -> None: pass def refresh(self, pminrow: int, pmincol: int, sminrow: int, diff --git a/dungeonbattle/texturepack.py b/dungeonbattle/texturepack.py deleted file mode 100644 index e69de29..0000000 diff --git a/resources/example_map.txt b/resources/example_map.txt index 4111fae..5aaade9 100644 --- a/resources/example_map.txt +++ b/resources/example_map.txt @@ -1,3 +1,4 @@ +1 6 ####### ############# #.....# #...........# #.....# #####...........# diff --git a/resources/example_map_2.txt b/resources/example_map_2.txt new file mode 100644 index 0000000..8864f04 --- /dev/null +++ b/resources/example_map_2.txt @@ -0,0 +1,41 @@ +1 17 + ########### ######### + #.........# #.......# + #.........# ############.......# + #.........###############..........#.......############## + #.........#........................#....................# + #.........#.............#..........#.......#............# + ########.########.............#..................#............# + #.........# #.............####.#######.......#............# + #.........# #.............##.........###################### + #.........# #####.##########.........# ########### + #.........# #......# #.........# #.........# + ########.##########......# #.........# #.........# + #...........##......# #.........# #.........# + #...........##......# #.........# #.........# + #...........##......# #.........# ################.###### + #...........##......# #.........# #.................############ + #...........##......# ########.########.......#.........#..........# + #...........##......# #...............#.......#.........#..........# + #...........######### #...............#.......#.........#..........# + #...........# #...............#.......#....................# + #####.####### #.......................#.........#..........# + #.........# #...............###################..........# + #.........############ #...............# #..........# + #.........#..........# #...............# ############ + #....................#####.###########.############# + ########.#########...................# #.............# + #........# #..........#........# #.............######### + #........# ######.##########........# #.............#.......# + #........# #..........# #........# #.....................# + #........# #..........# #........# #.............#.......# + #........# #..........# #........# #.............#.......# + #........# #..........# #........# #.............#.......# + #........# #..........#########.##### #.............#.......# + #........# #..........#.........# ##########.############.####### + #........# #..........#.........# #..............# #..........# + ########## #..........#.........# #..............# #..........# + ############.........# #..............# #..........# + #.........# #..............# #..........# + ########### #..............# #..........# + ################ ############ diff --git a/tox.ini b/tox.ini index 098a080..1e43e33 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [tox] envlist = - py38 - py39 + py3 linters skipsdist = True