diff --git a/docs/rules.rst b/docs/rules.rst index 77cfc6b..4b86976 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -11,8 +11,9 @@ prêt à tout pour s'en sortir. Sa vision de rongeur lui permet d'observer l'intégralité de la carte_, et à l'aide d'objets_, il va pouvoir affronter les monstres_ présents dans le donjon et gagner en expérience et en force. -Le jeu fonctionne par niveau. À chaque niveau ``n`` du joueur, entre ``3n`` et -``7n`` entités apparaissent aléatoirement sur la carte. +Le jeu fonctionne par étage. À chaque étage, différents monstres sont présents, +et à l'aide d'objets, il pourra progresser dans le donjon et descendre de plus +en plus bas. En tuant des ennemis, ce qu'il parvient à faire en fonçant directement sur eux ayant mangé trop de noisettes (ou étant armé d'un couteau), l'écureuil va diff --git a/squirrelbattle/assets/example_map.txt b/squirrelbattle/assets/example_map.txt index be2e798..68c3ae1 100644 --- a/squirrelbattle/assets/example_map.txt +++ b/squirrelbattle/assets/example_map.txt @@ -2,17 +2,17 @@ ####### ############# #.H...# #...........# #.....# #####...........# - #.....# #............H..# + #.....# #...&........H..# #.##### #.###...........# #.# #.# #...........# #.# #.# ############# #.# #.# #.#### #.# #....# #.# - ####.###################.# + ####&###################&# #.....................# ################# #.....................# #...............# #.....................#######...............# - #...........................................# + #.....................&.....&...............# #.....................#######...............# ####################### ################# diff --git a/squirrelbattle/display/texturepack.py b/squirrelbattle/display/texturepack.py index 16cad4f..92df405 100644 --- a/squirrelbattle/display/texturepack.py +++ b/squirrelbattle/display/texturepack.py @@ -82,6 +82,7 @@ TexturePack.ASCII_PACK = TexturePack( BOW=')', CHEST='□', CHESTPLATE='(', + DOOR='&', EAGLE='µ', EMPTY=' ', EXPLOSION='%', @@ -124,6 +125,8 @@ TexturePack.SQUIRREL_PACK = TexturePack( BOW='🏹', CHEST='🧰', CHESTPLATE='🦺', + DOOR=('🚪', curses.COLOR_WHITE, (1000, 1000, 1000), + curses.COLOR_WHITE, (1000, 1000, 1000)), EAGLE='🦅', EMPTY=' ', EXPLOSION='💥', diff --git a/squirrelbattle/entities/player.py b/squirrelbattle/entities/player.py index 9613618..12dfe1e 100644 --- a/squirrelbattle/entities/player.py +++ b/squirrelbattle/entities/player.py @@ -6,7 +6,7 @@ from random import randint from typing import Dict, Optional, Tuple from .items import Item -from ..interfaces import FightingEntity, InventoryHolder +from ..interfaces import FightingEntity, InventoryHolder, Tile from ..translations import gettext as _ @@ -108,9 +108,6 @@ class Player(InventoryHolder, FightingEntity): self.charisma += 1 if self.level % 10 == 0 and self.critical < 95: self.critical += (100 - self.charisma) // 30 - # TODO Remove it, that's only for fun - self.map.spawn_random_entities(randint(3 * self.level, - 10 * self.level)) def add_xp(self, xp: int) -> None: """ @@ -140,6 +137,12 @@ class Player(InventoryHolder, FightingEntity): return True elif entity.is_item(): entity.hold(self) + tile = self.map.tiles[y][x] + if tile == Tile.DOOR and move_if_possible: + # Open door + self.map.tiles[y][x] = Tile.FLOOR + self.map.compute_visibility(y, x, self.vision) + return super().check_move(y, x, move_if_possible) return super().check_move(y, x, move_if_possible) def save_state(self) -> dict: diff --git a/squirrelbattle/game.py b/squirrelbattle/game.py index 61ea75c..7d1bc4c 100644 --- a/squirrelbattle/game.py +++ b/squirrelbattle/game.py @@ -5,7 +5,6 @@ import curses import json from json import JSONDecodeError import os -from random import randint import sys from typing import Any, List, Optional @@ -13,6 +12,7 @@ from . import menus from .entities.player import Player from .enums import DisplayActions, GameMode, KeyValues from .interfaces import Logs, Map +from .mapgeneration import broguelike from .resources import ResourceManager from .settings import Settings from .translations import gettext as _, Translator @@ -55,16 +55,14 @@ class Game: """ Creates a new game on the screen. """ - # TODO generate a new map procedurally self.maps = [] self.map_index = 0 - self.map = Map.load(ResourceManager.get_asset_path("example_map.txt")) + self.map = broguelike.Generator().run() self.map.logs = self.logs self.logs.clear() self.player = Player() 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)) self.inventory_menu.update_player(self.player) @property @@ -201,9 +199,9 @@ class Game: self.map_index = 0 return while self.map_index >= len(self.maps): - # TODO: generate a new map - self.maps.append(Map.load(ResourceManager.get_asset_path( - "example_map_2.txt"))) + m = broguelike.Generator().run() + m.logs = self.logs + self.maps.append(m) new_map = self.map new_map.floor = self.map_index old_map.remove_entity(self.player) @@ -420,10 +418,13 @@ class Game: try: self.map_index = d["map_index"] self.maps = [Map().load_state(map_dict) for map_dict in d["maps"]] + for i, m in enumerate(self.maps): + m.floor = i + m.logs = self.logs except KeyError as error: self.message = _("Some keys are missing in your save file.\n" "Your save seems to be corrupt. It got deleted.")\ - + f"\n{error}" + + f"\n{error}" os.unlink(ResourceManager.get_config_path("save.json")) self.display_actions(DisplayActions.UPDATE) return diff --git a/squirrelbattle/interfaces.py b/squirrelbattle/interfaces.py index 8c0a80a..18ac895 100644 --- a/squirrelbattle/interfaces.py +++ b/squirrelbattle/interfaces.py @@ -4,9 +4,10 @@ from copy import deepcopy from enum import auto, Enum from functools import reduce +from itertools import product from math import ceil, sqrt from queue import PriorityQueue -from random import choice, choices, randint +from random import choice, randint from typing import Any, Dict, List, Optional, Tuple from .display.texturepack import TexturePack @@ -88,6 +89,8 @@ class Map: self.height = height self.start_y = start_y self.start_x = start_x + self.currenty = start_y + self.currentx = start_x self.tiles = tiles or [] self.visibility = [[False for _ in range(len(self.tiles[0]))] for _ in range(len(self.tiles))] @@ -178,22 +181,6 @@ 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: - """ - Puts randomly {count} entities on the map, only on empty ground tiles. - """ - for _ignored 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 = choices(Entity.get_all_entity_classes(), - weights=Entity.get_weights(), k=1)[0]() - entity.move(y, x) - self.add_entity(entity) - def is_visible_from(self, starty: int, startx: int, desty: int, destx: int, max_range: int) -> bool: oldvisibility = deepcopy(self.visibility) @@ -373,6 +360,27 @@ class Map: return self + @staticmethod + def neighbourhood(grid: List[List["Tile"]], y: int, x: int, + large: bool = False, oob: bool = False) \ + -> List[List[int]]: + """ + Returns up to 8 nearby coordinates, in a 3x3 square around the input + coordinate if large is set to True, or in a 5-square cross by default. + Does not return coordinates if they are out of bounds. + """ + height, width = len(grid), len(grid[0]) + neighbours = [] + if large: + dyxs = [[dy, dx] for dy, dx in product([-1, 0, 1], [-1, 0, 1])] + dyxs = dyxs[:5] + dyxs[6:] + else: + dyxs = [[0, -1], [0, 1], [-1, 0], [1, 0]] + for dy, dx in dyxs: + if oob or (0 <= y + dy < height and 0 <= x + dx < width): + neighbours.append([y + dy, x + dx]) + return neighbours + class Tile(Enum): """ @@ -382,6 +390,7 @@ class Tile(Enum): WALL = auto() FLOOR = auto() LADDER = auto() + DOOR = auto() @staticmethod def from_ascii_char(ch: str) -> "Tile": @@ -422,7 +431,7 @@ class Tile(Enum): """ Is this Tile a wall? """ - return self == Tile.WALL + return self == Tile.WALL or self == Tile.DOOR def is_ladder(self) -> bool: """ diff --git a/squirrelbattle/mapgeneration/__init__.py b/squirrelbattle/mapgeneration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squirrelbattle/mapgeneration/broguelike.py b/squirrelbattle/mapgeneration/broguelike.py new file mode 100644 index 0000000..f746f37 --- /dev/null +++ b/squirrelbattle/mapgeneration/broguelike.py @@ -0,0 +1,423 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from random import choice, choices, randint, random, shuffle +from typing import List, Tuple + +from ..interfaces import Entity, Map, Tile + +DEFAULT_PARAMS = { + "width": 120, + "height": 35, + "tries": 300, + "max_rooms": 20, + "max_room_tries": 15, + "cross_room": 1, + "corridor_chance": .6, + "min_v_corr": 2, + "max_v_corr": 6, + "min_h_corr": 4, + "max_h_corr": 12, + "large_circular_room": .10, + "circular_holes": .5, + "loop_tries": 40, + "loop_max": 5, + "loop_threshold": 15, + "spawn_per_region": [1, 2], +} + + +def dist(level: List[List[Tile]], y1: int, x1: int, y2: int, x2: int) -> int: + """ + Compute the minimum walking distance between points (y1, x1) and (y2, x2) + on a Tile grid + """ + # simple breadth first search + copy = [[t for t in row] for row in level] + dist = -1 + queue, next_queue = [[y1, x1]], [0] + while next_queue: + next_queue = [] + dist += 1 + while queue: + y, x = queue.pop() + copy[y][x] = Tile.EMPTY + if y == y2 and x == x2: + return dist + for y, x in Map.neighbourhood(copy, y, x): + if copy[y][x].can_walk(): + next_queue.append([y, x]) + queue = next_queue + return -1 + + +class Generator: + def __init__(self, params: dict = None): + self.params = params or DEFAULT_PARAMS + self.spawn_areas = [] + self.queued_area = None + + @staticmethod + def room_fits(level: List[List[Tile]], y: int, x: int, + room: List[List[Tile]], door_y: int, door_x: int, + dy: int, dx: int) -> bool: + """ + Using point (door_y, door_x) in the room as a reference and placing it + over point (y, x) in the level, returns whether or not the room fits + here + """ + lh, lw = len(level), len(level[0]) + rh, rw = len(room), len(room[0]) + if not(0 < y + dy < lh and 0 < x + dx < lw): + return False + # door must be placed on an empty tile, and point into a floor tile + if level[y][x] != Tile.EMPTY or level[y + dy][x + dx] != Tile.FLOOR: + return False + # now we verify floor tiles in both grids do not overlap + for ry in range(rh): + for rx in range(rw): + if room[ry][rx] == Tile.FLOOR: + ly, lx = y + ry - door_y, x + rx - door_x + # tile must be in bounds and empty + if not(0 <= ly < lh and 0 <= lx < lw) or \ + level[ly][lx] == Tile.FLOOR: + return False + # so do all neighbouring tiles bc we may + # need to place walls there eventually + for ny, nx in Map.neighbourhood(level, ly, lx, + large=True, oob=True): + if not(0 <= ny < lh and 0 <= nx < lw) or \ + level[ny][nx] != Tile.EMPTY: + return False + return True + + @staticmethod + def place_room(level: List[List[Tile]], y: int, x: int, + room: List[List[Tile]], door_y: int, door_x: int) -> None: + """ + Mutates level in place to add the room. Placement is determined by + making (door_y, door_x) in the room correspond with (y, x) in the level + """ + rh, rw = len(room), len(room[0]) + level[y][x] = Tile.DOOR + for ry in range(rh): + for rx in range(rw): + if room[ry][rx] == Tile.FLOOR: + level[y - door_y + ry][x - door_x + rx] = Tile.FLOOR + + @staticmethod + def add_loop(level: List[List[Tile]], y: int, x: int) -> bool: + """ + Try to add a corridor between two far apart floor tiles, passing + through point (y, x). + """ + h, w = len(level), len(level[0]) + + if level[y][x] != Tile.EMPTY: + return False + + # loop over both directions, trying to place both veritcal + # and horizontal corridors + for dx, dy in [[0, 1], [1, 0]]: + # then we find two floor tiles, one on each side of (y, x) + # exiting if we don't find two (reach the edge of the map before) + y1, x1, y2, x2 = y, x, y, x + while x1 >= 0 and y1 >= 0 and level[y1][x1] == Tile.EMPTY: + y1, x1 = y1 - dy, x1 - dx + while x2 < w and y2 < h and level[y2][x2] == Tile.EMPTY: + y2, x2 = y2 + dy, x2 + dx + if not(0 <= x1 <= x2 < w and 0 <= y1 <= y2 < h): + continue + + def verify_sides() -> bool: + # switching up dy and dx here pivots the axis, so + # (y+dx, x+dy) and (y-dx, x-dy) are the tiles adjacent to + # (y, x), but not on the original axis + for delta_x, delta_y in [[dy, dx], [-dy, -dx]]: + for i in range(1, y2 - y1 + x2 - x1): + if not (0 <= y1 + delta_y + i * dy < h + and 0 <= x1 + delta_x + i * dx < w) or \ + level[y1 + delta_y + i * dy][x1 + delta_x + + i * dx]\ + .can_walk(): + return False + return True + # if adding the path would make the two tiles significantly closer + # and its sides don't touch already placed terrain, build it + if dist(level, y1, x1, y2, x2) < 20 and verify_sides(): + y, x = y1 + dy, x1 + dx + while level[y][x] == Tile.EMPTY: + level[y][x] = Tile.FLOOR + y, x = y + dy, x + dx + return True + return False + + @staticmethod + def place_walls(level: List[List[Tile]]) -> None: + """ + Place wall tiles on every empty tile that is adjacent (in the largest + sense), to a floor tile + """ + h, w = len(level), len(level[0]) + for y in range(h): + for x in range(w): + if not level[y][x].is_wall(): + for ny, nx in Map.neighbourhood(level, y, x, large=True): + if level[ny][nx] == Tile.EMPTY: + level[ny][nx] = Tile.WALL + + def corr_meta_info(self) -> Tuple[int, int, int, int]: + """ + Return info about the extra grid space that should be allocated for the + room, and where the room should be built along this extra grid space. + Because grids are usually thight around the room, this gives us extra + place to add a corridor later. Corridor length and orientation is + implicitly derived from this info. + + h_sup and w_sup represent the extra needed space along each axis, + and h_off and w_off are the offset at which to build the room + """ + if random() < self.params["corridor_chance"]: + h_sup = randint(self.params["min_v_corr"], + self.params["max_v_corr"]) if random() < .5 else 0 + # we only allow extra space allocation along one axis, + # because there won't more than one exit corridor + w_sup = 0 if h_sup else randint(self.params["min_h_corr"], + self.params["max_h_corr"]) + # implicitly choose which direction along the axis + # the corridor will be pointing to + h_off = h_sup if random() < .5 else 0 + w_off = w_sup if random() < .5 else 0 + return h_sup, w_sup, h_off, w_off + return 0, 0, 0, 0 + + @staticmethod + def build_door(room: List[List[Tile]], y: int, x: int, + dy: int, dx: int, length: int) -> bool: + """ + Tries to build the exit from the room at given coordinates + Depending on parameter length, it will either attempt to build a + simple door, or a long corridor. Return value is a boolean + signifying whether or not the exit was successfully built + """ + rh, rw = len(room), len(room[0]) + # verify we are pointing away from a floor tile + if not(0 <= y - dy < rh and 0 <= x - dx < rw) \ + or room[y - dy][x - dx] != Tile.FLOOR: + return False + # verify there's no other floor tile around us + for ny, nx in [[y + dy, x + dx], [y - dx, x - dy], + [y + dx, x + dy]]: + if 0 <= ny < rh and 0 <= nx < rw \ + and room[ny][nx] != Tile.EMPTY: + return False + # see if the path ahead is clear. needed in the case of non convex room + for i in range(length + 1): + if room[y + i * dy][x + i * dx] != Tile.EMPTY: + return False + for i in range(length): + room[y + i * dy][x + i * dx] = Tile.FLOOR + return True + + @staticmethod + def attach_door(room: List[List[Tile]], h_sup: int, w_sup: int, + h_off: int, w_off: int) -> Tuple[int, int, int, int]: + """ + Attach an exit to the room. If extra space was allocated to + the grid, make sure a corridor is properly built + """ + length = h_sup + w_sup + dy, dx = 0, 0 + if length > 0: + if h_sup: + dy = -1 if h_off else 1 + else: + dx = -1 if w_off else 1 + else: + # determine door direction for rooms without corridors + if random() < .5: + dy = -1 if random() < .5 else 1 + else: + dx = -1 if random() < .5 else 1 + + # loop over all possible positions in a random order + rh, rw = len(room), len(room[0]) + yxs = [i for i in range(rh * rw)] + shuffle(yxs) + for pos in yxs: + y, x = pos // rw, pos % rw + if room[y][x] == Tile.EMPTY and \ + Generator.build_door(room, y, x, dy, dx, length): + break + else: # pragma: no cover + return None, None, None, None + + return y + length * dy, x + length * dx, dy, dx + + def create_circular_room(self, spawnable: bool = True) \ + -> Tuple[List[List[Tile]], int, int, int, int]: + """ + Create and return as a tile grid a room that is circular in shape, and + may have a center, also circular hole + Also return door info so we know how to place the room in the level + """ + if random() < self.params["large_circular_room"]: + r = randint(5, 10) + else: + r = randint(2, 4) + + room = [] + + h_sup, w_sup, h_off, w_off = self.corr_meta_info() + + height = 2 * r + 2 + width = 2 * r + 2 + make_hole = r > 6 and random() < self.params["circular_holes"] + r2 = 0 + if make_hole: + r2 = randint(3, r - 3) + for i in range(height + h_sup): + room.append([]) + d = (i - h_off - height // 2) ** 2 + for j in range(width + w_sup): + if d + (j - w_off - width // 2) ** 2 < r ** 2 and \ + (not make_hole + or d + (j - w_off - width // 2) ** 2 >= r2 ** 2): + room[-1].append(Tile.FLOOR) + else: + room[-1].append(Tile.EMPTY) + + # log all placed tiles as spawn positions + if spawnable: + self.register_spawn_area(room) + + # attach exit + door_y, door_x, dy, dx = self.attach_door(room, h_sup, w_sup, + h_off, w_off) + + return room, door_y, door_x, dy, dx + + def create_random_room(self, spawnable: bool = True) \ + -> Tuple[List[list], int, int, int, int]: + """ + Randomly select a room shape and return one such room along with its + door info. Set spawnable to False is the room should be marked as a + potential spawning region on the map + """ + return self.create_circular_room() + + def register_spawn_area(self, area: List[List[Tile]]) -> None: + """ + Register all floor positions relative to the input grid + for later use + """ + spawn_positions = [] + for y, line in enumerate(area): + for x, tile in enumerate(line): + if tile == Tile.FLOOR: + spawn_positions.append([y, x]) + self.queued_area = spawn_positions + + def update_spawnable(self, y: int, x: int) -> None: + """ + Convert previous spawn positions relative to the room grid to actual + actual spawn positions on the level grid, using the position of the + top left corner of the room on the level, then log them as a + spawnable region + """ + if self.queued_area is not None: + translated_area = [[y + ry, x + rx] for ry, rx in self.queued_area] + self.spawn_areas.append(translated_area) + self.queued_area = None + + def populate(self, rv: Map) -> None: + """ + Populate every spawnable area with some randomly chosen, randomly + placed entity + """ + min_c, max_c = self.params["spawn_per_region"] + for region in self.spawn_areas: + entity_count = randint(min_c, max_c) + for _dummy in range(entity_count): + entity = choices(Entity.get_all_entity_classes(), + weights=Entity.get_weights(), k=1)[0]() + y, x = choice(region) + entity.move(y, x) + rv.add_entity(entity) + + def run(self) -> Map: + """ + Using procedural generation, build and return a full map, populated + with entities + """ + height, width = self.params["height"], self.params["width"] + level = [width * [Tile.EMPTY] for _ignored in range(height)] + + # the starting room must have no corridor + mem, self.params["corridor_chance"] = self.params["corridor_chance"], 0 + starting_room, _, _, _, _ = self.create_random_room(spawnable=False) + dim_v, dim_h = len(starting_room), len(starting_room[0]) + # because Generator.room_fits checks that the exit door is correctly + # placed, but the starting room has no exit door, we find a positoin + # manually + pos_y, pos_x = randint(0, height - dim_v - 1),\ + randint(0, width - dim_h - 1) + self.place_room(level, pos_y, pos_x, starting_room, 0, 0) + # remove the door that was placed + if starting_room[0][0] != Tile.FLOOR: + level[pos_y][pos_x] = Tile.EMPTY + self.params["corridor_chance"] = mem + + # find a starting position for the player + sy, sx = randint(0, height - 1), randint(0, width - 1) + while level[sy][sx] != Tile.FLOOR: + sy, sx = randint(0, height - 1), randint(0, width - 1) + level[sy][sx] = Tile.LADDER + + # now we loop until we're bored, or we've added enough rooms + tries, rooms_built = 0, 0 + while tries < self.params["tries"] \ + and rooms_built < self.params["max_rooms"]: + + # build a room, try to fit it everywhere in a random order, and + # place it at the first possible position + room, door_y, door_x, dy, dx = self.create_random_room() + positions = [i for i in range(height * width)] + shuffle(positions) + for pos in positions: + y, x = pos // width, pos % width + if self.room_fits(level, y, x, room, door_y, door_x, dy, dx): + self.update_spawnable(y - door_y, x - door_x) + self.place_room(level, y, x, room, door_y, door_x) + rooms_built += 1 + break + tries += 1 + + # post-processing + self.place_walls(level) + + # because when a room is placed, it leads to exactly one previously + # placed room, the level has a tree like structure with the starting + # room as the root + # to avoid boring player backtracking, we add some cycles to the room + # graph in post processing by placing additional corridors + tries, loops = 0, 0 + while tries < self.params["loop_tries"] and \ + loops < self.params["loop_max"]: + tries += 1 + y, x = randint(0, height - 1), randint(0, width - 1) + loops += self.add_loop(level, y, x) + + # place an exit ladder + y, x = randint(0, height - 1), randint(0, width - 1) + while level[y][x] != Tile.FLOOR or \ + any([level[j][i].is_wall() for j, i + in Map.neighbourhood(level, y, x, large=True)]): + y, x = randint(0, height - 1), randint(0, width - 1) + level[y][x] = Tile.LADDER + + # spawn entities + rv = Map(width, height, level, sy, sx) + self.populate(rv) + + return rv diff --git a/squirrelbattle/tests/entities_test.py b/squirrelbattle/tests/entities_test.py index 83a74dc..9d50f58 100644 --- a/squirrelbattle/tests/entities_test.py +++ b/squirrelbattle/tests/entities_test.py @@ -134,13 +134,13 @@ class TestEntities(unittest.TestCase): self.map.remove_entity(entity2) # Test following the player and finding the player as target - self.player.move(5, 5) - fam.move(4, 5) + self.player.move(6, 5) + fam.move(5, 5) fam.target = None self.player.move_down() self.map.tick(self.player) self.assertTrue(fam.target == self.player) - self.assertEqual(fam.y, 5) + self.assertEqual(fam.y, 6) self.assertEqual(fam.x, 5) # Test random move diff --git a/squirrelbattle/tests/game_test.py b/squirrelbattle/tests/game_test.py index 032ff64..4f80977 100644 --- a/squirrelbattle/tests/game_test.py +++ b/squirrelbattle/tests/game_test.py @@ -16,7 +16,7 @@ from ..entities.monsters import GiantSeaEagle, Rabbit from ..entities.player import Player from ..enums import DisplayActions, GameMode, KeyValues from ..game import Game -from ..interfaces import Map +from ..interfaces import Map, Tile from ..menus import MainMenuValues from ..resources import ResourceManager from ..settings import Settings @@ -224,6 +224,12 @@ class TestGame(unittest.TestCase): self.game.map.remove_entity(entity) y, x = self.game.player.y, self.game.player.x + + # Ensure that the neighborhood is walkable + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + self.game.map.tiles[y + dy][x + dx] = Tile.FLOOR + self.game.handle_key_pressed(KeyValues.DOWN) new_y, new_x = self.game.player.y, self.game.player.x self.assertEqual(new_y, y + 1) @@ -518,7 +524,7 @@ class TestGame(unittest.TestCase): self.game.state = GameMode.PLAY sunflower = Sunflower() - sunflower.move(2, 6) + sunflower.move(self.game.player.y + 1, self.game.player.x) self.game.map.add_entity(sunflower) # Does nothing @@ -549,15 +555,15 @@ class TestGame(unittest.TestCase): for msg in Sunflower().dialogue_option)) # Test all directions to detect the friendly entity - self.game.player.move(3, 6) + self.game.player.move(sunflower.y + 1, sunflower.x) self.game.handle_key_pressed(KeyValues.CHAT) self.game.handle_key_pressed(KeyValues.UP) self.assertEqual(len(self.game.logs.messages), 3) - self.game.player.move(2, 7) + self.game.player.move(sunflower.y, sunflower.x + 1) self.game.handle_key_pressed(KeyValues.CHAT) self.game.handle_key_pressed(KeyValues.LEFT) self.assertEqual(len(self.game.logs.messages), 4) - self.game.player.move(2, 5) + self.game.player.move(sunflower.y, sunflower.x - 1) self.game.handle_key_pressed(KeyValues.CHAT) self.game.handle_key_pressed(KeyValues.RIGHT) self.assertEqual(len(self.game.logs.messages), 5) @@ -569,7 +575,7 @@ class TestGame(unittest.TestCase): self.game.state = GameMode.PLAY merchant = Merchant() - merchant.move(2, 6) + merchant.move(self.game.player.y + 1, self.game.player.x) self.game.map.add_entity(merchant) # Does nothing @@ -715,6 +721,7 @@ class TestGame(unittest.TestCase): self.game.player.inventory.clear() ring = RingCritical() ring.hold(self.game.player) + self.game.display_actions(DisplayActions.REFRESH) old_critical = self.game.player.critical self.game.handle_key_pressed(KeyValues.EQUIP) self.assertEqual(self.game.player.critical, @@ -758,8 +765,6 @@ class TestGame(unittest.TestCase): self.game.handle_key_pressed(KeyValues.LADDER) self.assertEqual(self.game.map_index, 1) self.assertEqual(self.game.player.map.floor, 1) - self.assertEqual(self.game.player.y, 1) - self.assertEqual(self.game.player.x, 17) self.game.display_actions(DisplayActions.UPDATE) # Move up @@ -940,3 +945,18 @@ class TestGame(unittest.TestCase): # Exit the menu self.game.handle_key_pressed(KeyValues.SPACE) self.assertEqual(self.game.state, GameMode.PLAY) + + def test_doors(self) -> None: + """ + Check that the user can open doors. + """ + self.game.state = GameMode.PLAY + + self.game.player.move(9, 8) + self.assertEqual(self.game.map.tiles[10][8], Tile.DOOR) + # Open door + self.game.handle_key_pressed(KeyValues.DOWN) + self.assertEqual(self.game.map.tiles[10][8], Tile.FLOOR) + self.assertEqual(self.game.player.y, 10) + self.assertEqual(self.game.player.x, 8) + self.game.display_actions(DisplayActions.REFRESH) diff --git a/squirrelbattle/tests/mapgeneration_test.py b/squirrelbattle/tests/mapgeneration_test.py new file mode 100644 index 0000000..5fa19fd --- /dev/null +++ b/squirrelbattle/tests/mapgeneration_test.py @@ -0,0 +1,56 @@ +# Copyright (C) 2020 by ÿnérant, eichhornchen, nicomarg, charlse +# SPDX-License-Identifier: GPL-3.0-or-later + +from random import randint +from typing import List +import unittest + +from ..display.texturepack import TexturePack +from ..interfaces import Map, Tile +from ..mapgeneration import broguelike + + +class TestBroguelike(unittest.TestCase): + def setUp(self) -> None: + self.generator = broguelike.Generator() + self.stom = lambda x: Map.load_from_string("0 0\n" + x) + self.mtos = lambda x: x.draw_string(TexturePack.ASCII_PACK) + + def test_dist(self) -> None: + m = self.stom(".. ..\n ... ") + distance = broguelike.dist(m.tiles, 0, 0, 0, 4) + self.assertEqual(distance, 6) + m = self.stom(". .") + distance = broguelike.dist(m.tiles, 0, 0, 0, 2) + self.assertEqual(distance, -1) + + def is_connex(self, grid: List[List[Tile]]) -> bool: + h, w = len(grid), len(grid[0]) + y, x = -1, -1 + while not grid[y][x].can_walk(): + y, x = randint(0, h - 1), randint(0, w - 1) + queue = Map.neighbourhood(grid, y, x) + while queue: + y, x = queue.pop() + if grid[y][x].can_walk() or grid[y][x] == Tile.DOOR: + grid[y][x] = Tile.WALL + queue += Map.neighbourhood(grid, y, x) + return not any([t.can_walk() or t == Tile.DOOR + for row in grid for t in row]) + + def test_build_doors(self) -> None: + m = self.stom(". .\n. .\n. .\n") + self.assertFalse(self.generator.build_door(m.tiles, 1, 1, 0, 1, 2)) + + def test_connexity(self) -> None: + m = self.generator.run() + self.assertTrue(self.is_connex(m.tiles)) + + def test_loops(self) -> None: + m = self.stom(3 * ".. ..\n") + self.generator.add_loop(m.tiles, 1, 3) + s = self.mtos(m) + self.assertEqual(s, ".. ..\n.......\n.. ..") + self.assertFalse(self.generator.add_loop(m.tiles, 0, 0)) + m = self.stom("...\n. .\n...") + self.assertFalse(self.generator.add_loop(m.tiles, 1, 1))