diff --git a/erynrl/__main__.py b/erynrl/__main__.py index aab55ea..19d8985 100644 --- a/erynrl/__main__.py +++ b/erynrl/__main__.py @@ -1,45 +1,24 @@ # Eryn Wells +'''Main module''' + import argparse -import os.path import sys import tcod from . import log -from .engine import Configuration, Engine -from .geometry import Size - -CONSOLE_WIDTH, CONSOLE_HEIGHT = 80, 50 -MAP_WIDTH, MAP_HEIGHT = 80, 45 -FONT_CP437 = 'terminal16x16_gs_ro.png' -FONT_BDF = 'ter-u32n.bdf' +from .configuration import Configuration, FontConfiguration, FontConfigurationError, MAP_SIZE, CONSOLE_SIZE +from .engine import Engine def parse_args(argv, *a, **kw): parser = argparse.ArgumentParser(*a, **kw) parser.add_argument('--debug', action='store_true', default=True) + parser.add_argument('--font') + parser.add_argument('--sandbox', action='store_true', default=False) args = parser.parse_args(argv) return args -def walk_up_directories_of_path(path): - while path and path != '/': - path = os.path.dirname(path) - yield path - - -def find_fonts_directory(): - '''Walk up the filesystem tree from this script to find a fonts/ directory.''' - for parent_dir in walk_up_directories_of_path(__file__): - possible_fonts_dir = os.path.join(parent_dir, 'fonts') - if os.path.isdir(possible_fonts_dir): - log.ROOT.info('Found fonts dir %s', possible_fonts_dir) - break - else: - return None - - return possible_fonts_dir - - def main(argv): ''' Beginning of the game @@ -53,25 +32,31 @@ def main(argv): log.init() - fonts_directory = find_fonts_directory() - if not fonts_directory: - log.ROOT.error("Couldn't find a fonts/ directory") + try: + font = args.font + if font: + font_config = FontConfiguration.with_filename(font) + else: + font_config = FontConfiguration.default_configuration() + except FontConfigurationError as error: + log.ROOT.error('Unable to create a default font configuration: %s', error) return -1 - font = os.path.join(fonts_directory, FONT_BDF) - if not os.path.isfile(font): - log.ROOT.error("Font file %s doesn't exist", font) - return -1 + configuration = Configuration( + console_size=CONSOLE_SIZE, + console_font_config=font_config, + map_size=MAP_SIZE, + sandbox=args.sandbox) - tileset = tcod.tileset.load_bdf(font) - console = tcod.Console(CONSOLE_WIDTH, CONSOLE_HEIGHT, order='F') - - configuration = Configuration(map_size=Size(MAP_WIDTH, MAP_HEIGHT)) engine = Engine(configuration) + tileset = configuration.console_font_config.tileset + console = tcod.Console(*configuration.console_size.numpy_shape, order='F') with tcod.context.new(columns=console.width, rows=console.height, tileset=tileset) as context: engine.run_event_loop(context, console) + return 0 + def run_until_exit(): ''' diff --git a/erynrl/configuration.py b/erynrl/configuration.py new file mode 100644 index 0000000..384aa93 --- /dev/null +++ b/erynrl/configuration.py @@ -0,0 +1,175 @@ +# Eryn Wells + +''' +Game configuration parameters. +''' + +import os.path as osp +import re +from dataclasses import dataclass +from enum import Enum +from os import PathLike +from typing import Iterable + +import tcod.tileset + +from . import log +from .geometry import Size + + +CONSOLE_SIZE = Size(80, 50) +MAP_SIZE = Size(80, 45) +FONT_CP437 = 'terminal16x16_gs_ro.png' +FONT_BDF = 'ter-u32n.bdf' + + +class FontConfigurationError(Exception): + '''Invalid font configration based on available parameters''' + + +@dataclass +class FontConfiguration: + '''Configuration of the font to use for rendering the game''' + + filename: str | PathLike[str] + + @staticmethod + def __find_fonts_directory(): + '''Walk up the filesystem tree from this file to find a `fonts` directory.''' + + def walk_up_directories_of_path(path): + while path and path != '/': + path = osp.dirname(path) + yield path + + for parent_dir in walk_up_directories_of_path(__file__): + possible_fonts_dir = osp.join(parent_dir, 'fonts') + if osp.isdir(possible_fonts_dir): + log.ROOT.info('Found fonts dir %s', possible_fonts_dir) + break + else: + return None + + return possible_fonts_dir + + @staticmethod + def default_configuration(): + '''Return a default configuration: a tilesheet font configuration using `fonts/terminal16x16_gs_ro.png`.''' + + fonts_directory = FontConfiguration.__find_fonts_directory() + if not fonts_directory: + message = "Couldn't find a fonts directory" + log.ROOT.error('%s', message) + raise FontConfigurationError(message) + + font = osp.join(fonts_directory, 'terminal16x16_gs_ro.png') + if not osp.isfile(font): + message = f"Font file {font} doesn't exist" + log.ROOT.error("%s", message) + raise FontConfigurationError(message) + + return FontConfiguration.with_filename(font) + + @staticmethod + def with_filename(filename: str | PathLike[str]) -> 'FontConfiguration': + '''Return a FontConfig subclass based on the path to the filename''' + _, extension = osp.splitext(filename) + + match extension: + case ".bdf": + return BDFFontConfiguration(filename) + case ".ttf": + return TTFFontConfiguration(filename) + case ".png": + # Attempt to find the tilesheet dimensions in the filename. + try: + match = re.match(r'^.*\(\d+\)x\(\d+\).*$', extension) + if not match: + return TilesheetFontConfiguration(filename) + + rows, columns = int(match.group(1)), int(match.group(2)) + return TilesheetFontConfiguration( + filename=filename, + dimensions=Size(columns, rows)) + except ValueError: + return TilesheetFontConfiguration(filename) + case _: + raise FontConfigurationError(f'Unable to determine font configuration from {filename}') + + @property + def tileset(self) -> tcod.tileset.Tileset: + '''Returns a tcod tileset based on the parameters of this font config''' + raise NotImplementedError() + + +@dataclass +class BDFFontConfiguration(FontConfiguration): + '''A font configuration based on a BDF file.''' + + @property + def tileset(self) -> tcod.tileset.Tileset: + return tcod.tileset.load_bdf(self.filename) + + +@dataclass +class TTFFontConfiguration(FontConfiguration): + ''' + A font configuration based on a TTF file. Since TTFs are variable width, a fixed tile size needs to be specified. + ''' + + tile_size: Size = Size(16, 16) + + @property + def tileset(self) -> tcod.tileset.Tileset: + return tcod.tileset.load_truetype_font(self.filename, *self.tile_size) + + +@dataclass +class TilesheetFontConfiguration(FontConfiguration): + ''' + Configuration for tilesheets. Unlike other font configurations, tilesheets must have their dimensions specified as + the number of sprites per row and number of rows. + ''' + + class Layout(Enum): + '''The layout of the tilesheet''' + CP437 = 1 + TCOD = 2 + + dimensions: Size = Size(16, 16) + layout: Layout | Iterable[int] = Layout.CP437 + + @property + def tilesheet(self) -> Iterable[int]: + '''A tilesheet mapping for the given layout''' + if not self.layout: + return tcod.tileset.CHARMAP_CP437 + + if isinstance(self.layout, Iterable): + return self.layout + + match self.layout: + case TilesheetFontConfiguration.Layout.CP437: + return tcod.tileset.CHARMAP_CP437 + case TilesheetFontConfiguration.Layout.TCOD: + return tcod.tileset.CHARMAP_TCOD + + @property + def tileset(self) -> tcod.tileset.Tileset: + '''A tcod tileset with the given parameters''' + return tcod.tileset.load_tilesheet( + self.filename, + self.dimensions.width, + self.dimensions.height, + self.tilesheet) + + +@dataclass +class Configuration: + '''Configuration of the game engine''' + console_size: Size + console_font_config: FontConfiguration + + map_size: Size + + sandbox: bool = False diff --git a/erynrl/engine.py b/erynrl/engine.py index 6779aef..bf18c53 100644 --- a/erynrl/engine.py +++ b/erynrl/engine.py @@ -3,7 +3,6 @@ '''Defines the core game engine.''' import random -from dataclasses import dataclass from typing import TYPE_CHECKING, List, MutableSet, NoReturn, Optional import tcod @@ -13,8 +12,9 @@ from . import monsters from .actions.action import Action from .actions.result import ActionResult from .ai import HostileEnemy +from .configuration import Configuration from .events import GameOverEventHandler, MainGameEventHandler -from .geometry import Point, Rect, Size +from .geometry import Point, Size from .interface import Interface from .map import Map from .map.generator import RoomsAndCorridorsGenerator @@ -27,12 +27,6 @@ if TYPE_CHECKING: from .events import EventHandler -@dataclass -class Configuration: - '''Configuration of the game engine''' - map_size: Size - - class Engine: '''The main game engine. @@ -52,8 +46,8 @@ class Engine: A random number generator ''' - def __init__(self, configuration: Configuration): - self.configuration = configuration + def __init__(self, config: Configuration): + self.configuration = config self.current_turn = 1 self.did_begin_turn = False @@ -62,9 +56,8 @@ class Engine: self.rng = tcod.random.Random() self.message_log = MessageLog() - map_size = configuration.map_size - map_generator = RoomsAndCorridorsGenerator(BSPRoomGenerator(size=map_size), ElbowCorridorGenerator()) - self.map = Map(map_size, map_generator) + map_size = config.map_size + self.map = Map(config, map_generator) self.event_handler: 'EventHandler' = MainGameEventHandler(self) diff --git a/erynrl/map/__init__.py b/erynrl/map/__init__.py index 4f19d84..c98862b 100644 --- a/erynrl/map/__init__.py +++ b/erynrl/map/__init__.py @@ -12,29 +12,39 @@ import numpy as np import numpy.typing as npt import tcod +from ..engine import Configuration from ..geometry import Point, Rect, Size from .generator import MapGenerator from .tile import Empty, Shroud class Map: - def __init__(self, size: Size, generator: MapGenerator): - self.size = size + def __init__(self, config: Configuration, generator: MapGenerator): + self.configuration = config - self.tiles = np.full(tuple(size), fill_value=Empty, order='F') + map_size = config.map_size + shape = tuple(map_size) + + self.tiles = np.full(shape, fill_value=Empty, order='F') generator.generate(self.tiles) self.up_stairs = generator.up_stairs self.down_stairs = generator.down_stairs - self.highlighted = np.full(tuple(self.size), fill_value=False, order='F') + self.highlighted = np.full(shape, fill_value=False, order='F') # Map tiles that are currently visible to the player - self.visible = np.full(tuple(self.size), fill_value=False, order='F') + self.visible = np.full(shape, fill_value=False, order='F') # Map tiles that the player has explored - self.explored = np.full(tuple(self.size), fill_value=False, order='F') + should_mark_all_tiles_explored = config.sandbox + self.explored = np.full(shape, fill_value=should_mark_all_tiles_explored, order='F') self.__walkable_points = None + @property + def size(self) -> Size: + '''The size of the map''' + return self.configuration.map_size + def random_walkable_position(self) -> Point: '''Return a random walkable point on the map.''' if not self.__walkable_points: