Merge branch 'entities' into 'master'

Entities

See merge request ynerant/dungeon-battle!10
This commit is contained in:
ynerant 2020-11-13 14:10:40 +01:00
commit eca6b9af1f
24 changed files with 763 additions and 170 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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='🧸',
)

View File

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

View File

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

View File

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

48
dungeonbattle/enums.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
1 6
####### #############
#.....# #...........#
#.....# #####...........#

View File

@ -0,0 +1,41 @@
1 17
########### #########
#.........# #.......#
#.........# ############.......#
#.........###############..........#.......##############
#.........#........................#....................#
#.........#.............#..........#.......#............#
########.########.............#..................#............#
#.........# #.............####.#######.......#............#
#.........# #.............##.........######################
#.........# #####.##########.........# ###########
#.........# #......# #.........# #.........#
########.##########......# #.........# #.........#
#...........##......# #.........# #.........#
#...........##......# #.........# #.........#
#...........##......# #.........# ################.######
#...........##......# #.........# #.................############
#...........##......# ########.########.......#.........#..........#
#...........##......# #...............#.......#.........#..........#
#...........######### #...............#.......#.........#..........#
#...........# #...............#.......#....................#
#####.####### #.......................#.........#..........#
#.........# #...............###################..........#
#.........############ #...............# #..........#
#.........#..........# #...............# ############
#....................#####.###########.#############
########.#########...................# #.............#
#........# #..........#........# #.............#########
#........# ######.##########........# #.............#.......#
#........# #..........# #........# #.....................#
#........# #..........# #........# #.............#.......#
#........# #..........# #........# #.............#.......#
#........# #..........# #........# #.............#.......#
#........# #..........#########.##### #.............#.......#
#........# #..........#.........# ##########.############.#######
#........# #..........#.........# #..............# #..........#
########## #..........#.........# #..............# #..........#
############.........# #..............# #..........#
#.........# #..............# #..........#
########### #..............# #..........#
################ ############

View File

@ -1,7 +1,6 @@
[tox]
envlist =
py38
py39
py3
linters
skipsdist = True