Merge branch 'map_generation' into 'master'

Map generation

Closes #5

See merge request ynerant/squirrel-battle!35
This commit is contained in:
ynerant 2021-01-10 23:54:28 +01:00
commit e9374c5e6b
11 changed files with 562 additions and 46 deletions

View File

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

View File

@ -2,17 +2,17 @@
####### #############
#.H...# #...........#
#.....# #####...........#
#.....# #............H..#
#.....# #...&........H..#
#.##### #.###...........#
#.# #.# #...........#
#.# #.# #############
#.# #.#
#.#### #.#
#....# #.#
####.###################.#
####&###################&#
#.....................# #################
#.....................# #...............#
#.....................#######...............#
#...........................................#
#.....................&.....&...............#
#.....................#######...............#
####################### #################

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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