Move the roguebasin package to erynrl
This commit is contained in:
		
							parent
							
								
									cc6c701c59
								
							
						
					
					
						commit
						f6fe9d0f09
					
				
					 14 changed files with 2 additions and 2 deletions
				
			
		
							
								
								
									
										1
									
								
								erynrl/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								erynrl/__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
							
								
								
									
										111
									
								
								erynrl/__main__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								erynrl/__main__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,111 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
import argparse
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import logging.config
 | 
			
		||||
import os.path
 | 
			
		||||
import sys
 | 
			
		||||
import tcod
 | 
			
		||||
from .engine import Configuration, Engine
 | 
			
		||||
from .events import EventHandler
 | 
			
		||||
from .geometry import Size
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger('main')
 | 
			
		||||
 | 
			
		||||
CONSOLE_WIDTH, CONSOLE_HEIGHT = 80, 50
 | 
			
		||||
MAP_WIDTH, MAP_HEIGHT = 80, 45
 | 
			
		||||
 | 
			
		||||
FONT = 'terminal16x16_gs_ro.png'
 | 
			
		||||
 | 
			
		||||
def parse_args(argv, *a, **kw):
 | 
			
		||||
    parser = argparse.ArgumentParser(*a, **kw)
 | 
			
		||||
    parser.add_argument('--debug', action='store_true', default=True)
 | 
			
		||||
    args = parser.parse_args(argv)
 | 
			
		||||
    return args
 | 
			
		||||
 | 
			
		||||
def init_logging(args):
 | 
			
		||||
    '''Set up the logging system by (preferrably) reading a logging configuration file.'''
 | 
			
		||||
    logging_config_path = find_logging_config()
 | 
			
		||||
    if logging_config_path:
 | 
			
		||||
        with open(logging_config_path, encoding='utf-8') as logging_config_file:
 | 
			
		||||
            logging_config = json.load(logging_config_file)
 | 
			
		||||
            logging.config.dictConfig(logging_config)
 | 
			
		||||
        LOG.info('Found logging configuration at %s', logging_config_path)
 | 
			
		||||
    else:
 | 
			
		||||
        root_logger = logging.getLogger('')
 | 
			
		||||
        root_logger.setLevel(logging.DEBUG if args.debug else logging.INFO)
 | 
			
		||||
 | 
			
		||||
        stderr_handler = logging.StreamHandler()
 | 
			
		||||
        stderr_handler.setFormatter(logging.Formatter("%(asctime)s %(name)s: %(message)s"))
 | 
			
		||||
 | 
			
		||||
        root_logger.addHandler(stderr_handler)
 | 
			
		||||
 | 
			
		||||
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.info('Found fonts dir %s', possible_fonts_dir)
 | 
			
		||||
            break
 | 
			
		||||
    else:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    return possible_fonts_dir
 | 
			
		||||
 | 
			
		||||
def find_logging_config():
 | 
			
		||||
    '''Walk up the filesystem from this script to find a logging_config.json'''
 | 
			
		||||
    for parent_dir in walk_up_directories_of_path(__file__):
 | 
			
		||||
        possible_logging_config_file = os.path.join(parent_dir, 'logging_config.json')
 | 
			
		||||
        if os.path.isfile(possible_logging_config_file):
 | 
			
		||||
            LOG.info('Found logging config file %s', possible_logging_config_file)
 | 
			
		||||
            break
 | 
			
		||||
    else:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    return possible_logging_config_file
 | 
			
		||||
 | 
			
		||||
def main(argv):
 | 
			
		||||
    args = parse_args(argv[1:], prog=argv[0])
 | 
			
		||||
 | 
			
		||||
    init_logging(args)
 | 
			
		||||
 | 
			
		||||
    fonts_directory = find_fonts_directory()
 | 
			
		||||
    if not fonts_directory:
 | 
			
		||||
        LOG.error("Couldn't find a fonts/ directory")
 | 
			
		||||
        return -1
 | 
			
		||||
 | 
			
		||||
    font = os.path.join(fonts_directory, FONT)
 | 
			
		||||
    if not os.path.isfile(font):
 | 
			
		||||
        LOG.error("Font file %s doesn't exist", font)
 | 
			
		||||
        return -1
 | 
			
		||||
 | 
			
		||||
    tileset = tcod.tileset.load_tilesheet(font, 16, 16, tcod.tileset.CHARMAP_CP437)
 | 
			
		||||
    console = tcod.Console(CONSOLE_WIDTH, CONSOLE_HEIGHT, order='F')
 | 
			
		||||
 | 
			
		||||
    configuration = Configuration(map_size=Size(MAP_WIDTH, MAP_HEIGHT))
 | 
			
		||||
    engine = Engine(configuration)
 | 
			
		||||
    event_handler = EventHandler(engine)
 | 
			
		||||
 | 
			
		||||
    with tcod.context.new(columns=console.width, rows=console.height, tileset=tileset) as context:
 | 
			
		||||
        while True:
 | 
			
		||||
            console.clear()
 | 
			
		||||
            engine.print_to_console(console)
 | 
			
		||||
            context.present(console)
 | 
			
		||||
 | 
			
		||||
            event_handler.wait_for_events()
 | 
			
		||||
 | 
			
		||||
def run_until_exit():
 | 
			
		||||
    '''
 | 
			
		||||
    Run main() and call sys.exit when it finishes. In practice, this function will never return. The game engine has
 | 
			
		||||
    other mechanisms for exiting.
 | 
			
		||||
    '''
 | 
			
		||||
    result = main(sys.argv)
 | 
			
		||||
    sys.exit(0 if not result else result)
 | 
			
		||||
 | 
			
		||||
run_until_exit()
 | 
			
		||||
							
								
								
									
										245
									
								
								erynrl/actions.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								erynrl/actions.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,245 @@
 | 
			
		|||
#!/usr/bin/env python3
 | 
			
		||||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
'''
 | 
			
		||||
This module defines all of the actions that can be performed by the game. These actions can come from the player (e.g.
 | 
			
		||||
via keyboard input), or from non-player entities (e.g. AI deciboard input), or from non-player entities (e.g. AI
 | 
			
		||||
decisions).
 | 
			
		||||
 | 
			
		||||
Class Hierarchy
 | 
			
		||||
---------------
 | 
			
		||||
 | 
			
		||||
Action : Base class of all actions
 | 
			
		||||
    MoveAction : Base class for all actions that are performed with a direction
 | 
			
		||||
        BumpAction
 | 
			
		||||
        WalkAction
 | 
			
		||||
        MeleeAction
 | 
			
		||||
    ExitAction
 | 
			
		||||
    WaitAction
 | 
			
		||||
'''
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Optional, TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from . import items
 | 
			
		||||
from .geometry import Direction
 | 
			
		||||
from .object import Actor, Item
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from .engine import Engine
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger('actions')
 | 
			
		||||
 | 
			
		||||
class ActionResult:
 | 
			
		||||
    '''The result of an Action.
 | 
			
		||||
 | 
			
		||||
    `Action.perform()` returns an instance of this class to inform the caller of the result
 | 
			
		||||
 | 
			
		||||
    Attributes
 | 
			
		||||
    ----------
 | 
			
		||||
    action : Action
 | 
			
		||||
        The Action that was performed
 | 
			
		||||
    success : bool, optional
 | 
			
		||||
        True if the action succeeded
 | 
			
		||||
    done : bool, optional
 | 
			
		||||
        True if the action is complete, and no follow-up action is needed
 | 
			
		||||
    alternate : Action, optional
 | 
			
		||||
        An alternate action to perform if this action failed
 | 
			
		||||
    '''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, action: 'Action', *,
 | 
			
		||||
                 success: Optional[bool] = None,
 | 
			
		||||
                 done: Optional[bool] = None,
 | 
			
		||||
                 alternate: Optional['Action'] = None):
 | 
			
		||||
        self.action = action
 | 
			
		||||
        self.alternate = alternate
 | 
			
		||||
 | 
			
		||||
        if success is not None:
 | 
			
		||||
            self.success = success
 | 
			
		||||
        elif alternate:
 | 
			
		||||
            self.success = False
 | 
			
		||||
        else:
 | 
			
		||||
            self.success = True
 | 
			
		||||
 | 
			
		||||
        if done is not None:
 | 
			
		||||
            self.done = done
 | 
			
		||||
        elif self.success:
 | 
			
		||||
            self.done = True
 | 
			
		||||
        else:
 | 
			
		||||
            self.done = not alternate
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f'{self.__class__.__name__}({self.action!r}, success={self.success}, done={self.done}, alternate={self.alternate!r})'
 | 
			
		||||
 | 
			
		||||
class Action:
 | 
			
		||||
    '''An action that an Entity should perform.'''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, actor: Optional[Actor]):
 | 
			
		||||
        self.actor = actor
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        '''Perform this action.
 | 
			
		||||
 | 
			
		||||
        Parameters
 | 
			
		||||
        ----------
 | 
			
		||||
        engine : Engine
 | 
			
		||||
            The game engine
 | 
			
		||||
 | 
			
		||||
        Returns
 | 
			
		||||
        -------
 | 
			
		||||
        ActionResult
 | 
			
		||||
            A result object reflecting how the action was handled, and what follow-up actions, if any, are needed to
 | 
			
		||||
            complete the action.
 | 
			
		||||
        '''
 | 
			
		||||
        raise NotImplementedError()
 | 
			
		||||
 | 
			
		||||
    def failure(self) -> ActionResult:
 | 
			
		||||
        '''Create an ActionResult indicating failure with no follow-up'''
 | 
			
		||||
        return ActionResult(self, success=False)
 | 
			
		||||
 | 
			
		||||
    def success(self) -> ActionResult:
 | 
			
		||||
        '''Create an ActionResult indicating success with no follow-up'''
 | 
			
		||||
        return ActionResult(self, success=True)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f'{self.__class__.__name__} for {self.actor!s}'
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f'{self.__class__.__name__}({self.actor!r})'
 | 
			
		||||
 | 
			
		||||
class ExitAction(Action):
 | 
			
		||||
    '''Exit the game.'''
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        raise SystemExit()
 | 
			
		||||
 | 
			
		||||
class RegenerateRoomsAction(Action):
 | 
			
		||||
    '''Regenerate the dungeon map'''
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        return ActionResult(self, success=False)
 | 
			
		||||
 | 
			
		||||
# pylint: disable=abstract-method
 | 
			
		||||
class MoveAction(Action):
 | 
			
		||||
    '''An abstract Action that requires a direction to complete.'''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, actor: Actor, direction: Direction):
 | 
			
		||||
        super().__init__(actor)
 | 
			
		||||
        self.direction = direction
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f'{self.__class__.__name__}({self.actor!r}, {self.direction!r})'
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f'{self.__class__.__name__} toward {self.direction} by {self.actor!s}'
 | 
			
		||||
 | 
			
		||||
class BumpAction(MoveAction):
 | 
			
		||||
    '''Attempt to perform a movement action in a direction.
 | 
			
		||||
 | 
			
		||||
    This action tests if an action in the direction is possible and returns the action that can be completed.
 | 
			
		||||
 | 
			
		||||
    Attributes
 | 
			
		||||
    ----------
 | 
			
		||||
    direction : Direction
 | 
			
		||||
        The direction to test
 | 
			
		||||
    '''
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        new_position = self.actor.position + self.direction
 | 
			
		||||
 | 
			
		||||
        position_is_in_bounds = engine.map.tile_is_in_bounds(new_position)
 | 
			
		||||
        position_is_walkable = engine.map.tile_is_walkable(new_position)
 | 
			
		||||
 | 
			
		||||
        for ent in engine.entities:
 | 
			
		||||
            if new_position != ent.position:
 | 
			
		||||
                continue
 | 
			
		||||
            entity_occupying_position = ent
 | 
			
		||||
            break
 | 
			
		||||
        else:
 | 
			
		||||
            entity_occupying_position = None
 | 
			
		||||
 | 
			
		||||
        LOG.debug('Bumping %s into %s (in_bounds:%s walkable:%s overlaps:%s)',
 | 
			
		||||
                  self.actor,
 | 
			
		||||
                  new_position,
 | 
			
		||||
                  position_is_in_bounds,
 | 
			
		||||
                  position_is_walkable,
 | 
			
		||||
                  entity_occupying_position)
 | 
			
		||||
 | 
			
		||||
        if not position_is_in_bounds or not position_is_walkable:
 | 
			
		||||
            return self.failure()
 | 
			
		||||
 | 
			
		||||
        if entity_occupying_position and entity_occupying_position.blocks_movement:
 | 
			
		||||
            return ActionResult(self, alternate=MeleeAction(self.actor, self.direction, entity_occupying_position))
 | 
			
		||||
 | 
			
		||||
        return ActionResult(self, alternate=WalkAction(self.actor, self.direction))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WalkAction(MoveAction):
 | 
			
		||||
    '''Walk one step in the given direction.'''
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        new_position = self.actor.position + self.direction
 | 
			
		||||
 | 
			
		||||
        LOG.debug('Moving %s to %s', self.actor, new_position)
 | 
			
		||||
        self.actor.position = new_position
 | 
			
		||||
 | 
			
		||||
        return self.success()
 | 
			
		||||
 | 
			
		||||
class MeleeAction(MoveAction):
 | 
			
		||||
    '''Perform a melee attack on another Actor'''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, actor: Actor, direction: Direction, target: Actor):
 | 
			
		||||
        super().__init__(actor, direction)
 | 
			
		||||
        self.target = target
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        if not self.target:
 | 
			
		||||
            return self.failure()
 | 
			
		||||
 | 
			
		||||
        if not self.actor.fighter or not self.target.fighter:
 | 
			
		||||
            return self.failure()
 | 
			
		||||
 | 
			
		||||
        damage = self.actor.fighter.attack_power - self.target.fighter.defense
 | 
			
		||||
        if damage > 0 and self.target:
 | 
			
		||||
            LOG.debug('%s attacks %s for %d damage!', self.actor, self.target, damage)
 | 
			
		||||
            self.target.fighter.hit_points -= damage
 | 
			
		||||
        else:
 | 
			
		||||
            LOG.debug('%s attacks %s but does no damage!', self.actor, self.target)
 | 
			
		||||
 | 
			
		||||
        if self.target.fighter.is_dead:
 | 
			
		||||
            LOG.info('%s is dead!', self.target)
 | 
			
		||||
            return ActionResult(self, alternate=DieAction(self.target))
 | 
			
		||||
 | 
			
		||||
        return self.success()
 | 
			
		||||
 | 
			
		||||
class WaitAction(Action):
 | 
			
		||||
    '''Wait a turn'''
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        LOG.debug('%s is waiting a turn', self.actor)
 | 
			
		||||
        return self.success()
 | 
			
		||||
 | 
			
		||||
class DieAction(Action):
 | 
			
		||||
    '''Kill an Actor'''
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        LOG.debug('%s dies', self.actor)
 | 
			
		||||
        engine.entities.remove(self.actor)
 | 
			
		||||
 | 
			
		||||
        if self.actor.yields_corpse_on_death:
 | 
			
		||||
            LOG.debug('%s leaves a corpse behind', self.actor)
 | 
			
		||||
            corpse = Item(kind=items.Corpse, name=f'{self.actor.name} Corpse', position=self.actor.position)
 | 
			
		||||
            return ActionResult(self, alternate=DropItemAction(self.actor, corpse))
 | 
			
		||||
 | 
			
		||||
        return self.success()
 | 
			
		||||
 | 
			
		||||
class DropItemAction(Action):
 | 
			
		||||
    '''Drop an item'''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, actor: 'Actor', item: 'Item'):
 | 
			
		||||
        super().__init__(actor)
 | 
			
		||||
        self.item = item
 | 
			
		||||
 | 
			
		||||
    def perform(self, engine: 'Engine') -> ActionResult:
 | 
			
		||||
        engine.entities.add(self.item)
 | 
			
		||||
        return self.success()
 | 
			
		||||
							
								
								
									
										123
									
								
								erynrl/ai.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								erynrl/ai.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,123 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
import random
 | 
			
		||||
from typing import TYPE_CHECKING, List, Optional
 | 
			
		||||
 | 
			
		||||
import numpy as np
 | 
			
		||||
import tcod
 | 
			
		||||
 | 
			
		||||
from .actions import Action, BumpAction, WaitAction
 | 
			
		||||
from .components import Component
 | 
			
		||||
from .geometry import Direction, Point
 | 
			
		||||
from .object import Entity
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from .engine import Engine
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger('ai')
 | 
			
		||||
 | 
			
		||||
class AI(Component):
 | 
			
		||||
    def __init__(self, entity: Entity) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.entity = entity
 | 
			
		||||
 | 
			
		||||
    def act(self, engine: 'Engine') -> Optional[Action]:
 | 
			
		||||
        '''Produce an action to perform'''
 | 
			
		||||
        raise NotImplementedError()
 | 
			
		||||
 | 
			
		||||
class HostileEnemy(AI):
 | 
			
		||||
    def act(self, engine: 'Engine') -> Optional[Action]:
 | 
			
		||||
        visible_tiles = tcod.map.compute_fov(
 | 
			
		||||
            engine.map.tiles['transparent'],
 | 
			
		||||
            pov=tuple(self.entity.position),
 | 
			
		||||
            radius=self.entity.sight_radius)
 | 
			
		||||
 | 
			
		||||
        if engine.map.visible[tuple(self.entity.position)]:
 | 
			
		||||
            LOG.debug("AI for %s", self.entity)
 | 
			
		||||
 | 
			
		||||
        hero_position = engine.hero.position
 | 
			
		||||
        hero_is_visible = visible_tiles[hero_position.x, hero_position.y]
 | 
			
		||||
 | 
			
		||||
        if hero_is_visible:
 | 
			
		||||
            path_to_hero = self.get_path_to(hero_position, engine)
 | 
			
		||||
            assert len(path_to_hero) > 0, f'{self.entity} attempting to find a path to hero while on top of the hero!'
 | 
			
		||||
 | 
			
		||||
            entity_position = self.entity.position
 | 
			
		||||
 | 
			
		||||
            if engine.map.visible[tuple(self.entity.position)]:
 | 
			
		||||
                LOG.debug('|-> Path to hero %s', path_to_hero)
 | 
			
		||||
 | 
			
		||||
            next_position = path_to_hero.pop(0) if len(path_to_hero) > 1 else hero_position
 | 
			
		||||
            direction_to_next_position = entity_position.direction_to_adjacent_point(next_position)
 | 
			
		||||
 | 
			
		||||
            if engine.map.visible[tuple(self.entity.position)]:
 | 
			
		||||
                LOG.info('`-> Hero is visible to %s, bumping %s (%s)', self.entity, direction_to_next_position, next_position)
 | 
			
		||||
 | 
			
		||||
            return BumpAction(self.entity, direction_to_next_position)
 | 
			
		||||
        else:
 | 
			
		||||
            move_or_wait_chance = random.random()
 | 
			
		||||
            if move_or_wait_chance <= 0.7:
 | 
			
		||||
                # Pick a random adjacent tile to move to
 | 
			
		||||
                directions = list(Direction.all())
 | 
			
		||||
                while len(directions) > 0:
 | 
			
		||||
                    direction = random.choice(directions)
 | 
			
		||||
                    directions.remove(direction)
 | 
			
		||||
                    new_position = self.entity.position + direction
 | 
			
		||||
                    overlaps_existing_entity = any(new_position == ent.position for ent in engine.entities)
 | 
			
		||||
                    tile_is_walkable = engine.map.tile_is_walkable(new_position)
 | 
			
		||||
                    if not overlaps_existing_entity and tile_is_walkable:
 | 
			
		||||
                        if engine.map.visible[tuple(self.entity.position)]:
 | 
			
		||||
                            LOG.info('Hero is NOT visible to %s, bumping %s randomly', self.entity, direction)
 | 
			
		||||
                        action = BumpAction(self.entity, direction)
 | 
			
		||||
                        break
 | 
			
		||||
                else:
 | 
			
		||||
                    # If this entity somehow can't move anywhere, just wait
 | 
			
		||||
                    if engine.map.visible[tuple(self.entity.position)]:
 | 
			
		||||
                        LOG.info("Hero is NOT visible to %s and it can't move anywhere, waiting", self.entity)
 | 
			
		||||
                    action = WaitAction(self.entity)
 | 
			
		||||
 | 
			
		||||
                return action
 | 
			
		||||
            else:
 | 
			
		||||
                return WaitAction(self.entity)
 | 
			
		||||
 | 
			
		||||
    def get_path_to(self, point: Point, engine: 'Engine') -> List[Point]:
 | 
			
		||||
        '''Compute a path to the given position.
 | 
			
		||||
 | 
			
		||||
        Copied from the Roguelike tutorial. :)
 | 
			
		||||
 | 
			
		||||
        Arguments
 | 
			
		||||
        ---------
 | 
			
		||||
        point : Point
 | 
			
		||||
            The target point
 | 
			
		||||
        engine : Engine
 | 
			
		||||
            The game engine
 | 
			
		||||
 | 
			
		||||
        Returns
 | 
			
		||||
        -------
 | 
			
		||||
        List[Point]
 | 
			
		||||
            An array of Points representing a path from the Entity's position to the target point
 | 
			
		||||
        '''
 | 
			
		||||
        # Copy the walkable array
 | 
			
		||||
        cost = np.array(engine.map.tiles['walkable'], dtype=np.int8)
 | 
			
		||||
 | 
			
		||||
        for ent in engine.entities:
 | 
			
		||||
            # Check that an entity blocks movement and the cost isn't zero (blocking)
 | 
			
		||||
            position = ent.position
 | 
			
		||||
            if ent.blocks_movement and cost[position.x, position.y]:
 | 
			
		||||
                # Add to the cost of a blocked position. A lower number means more enemies will crowd behind each other
 | 
			
		||||
                # in hallways. A higher number means enemies will take longer paths in order to surround the player.
 | 
			
		||||
                cost[position.x, position.y] += 10
 | 
			
		||||
 | 
			
		||||
        # Create a graph from the cost array and pass that graph to a new pathfinder.
 | 
			
		||||
        graph = tcod.path.SimpleGraph(cost=cost, cardinal=2, diagonal=3)
 | 
			
		||||
        pathfinder = tcod.path.Pathfinder(graph)
 | 
			
		||||
 | 
			
		||||
        # Set the starting position
 | 
			
		||||
        pathfinder.add_root(tuple(self.entity.position))
 | 
			
		||||
 | 
			
		||||
        # Compute the path to the destination and remove the starting point.
 | 
			
		||||
        path: List[List[int]] = pathfinder.path_to(tuple(point))[1:].tolist()
 | 
			
		||||
 | 
			
		||||
        # Convert from List[List[int]] to List[Tuple[int, int]].
 | 
			
		||||
        return [Point(index[0], index[1]) for index in path]
 | 
			
		||||
							
								
								
									
										44
									
								
								erynrl/components.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								erynrl/components.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,44 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
# pylint: disable=too-few-public-methods
 | 
			
		||||
class Component:
 | 
			
		||||
    '''A base, abstract Component that implement some aspect of an Entity's behavior.'''
 | 
			
		||||
 | 
			
		||||
class Fighter(Component):
 | 
			
		||||
    '''A Fighter is an Entity that can fight. That is, it has hit points (health), attack, and defense.
 | 
			
		||||
 | 
			
		||||
    Attributes
 | 
			
		||||
    ----------
 | 
			
		||||
    maximum_hit_points : int
 | 
			
		||||
        Maximum number of hit points the Fighter can have. In almost every case, a Fighter will be spawned with this
 | 
			
		||||
        many hit points.
 | 
			
		||||
    attack_power : int
 | 
			
		||||
        The amount of damage the Figher can do.
 | 
			
		||||
    defense : int
 | 
			
		||||
        The amount of damage the Fighter can deflect or resist.
 | 
			
		||||
    hit_points : int
 | 
			
		||||
        The current number of hit points remaining. When this reaches 0, the Fighter dies.
 | 
			
		||||
    '''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *, maximum_hit_points: int, attack_power: int, defense: int, hit_points: Optional[int] = None):
 | 
			
		||||
        self.maximum_hit_points = maximum_hit_points
 | 
			
		||||
        self.__hit_points = hit_points if hit_points else maximum_hit_points
 | 
			
		||||
        # TODO: Rename these two attributes something better
 | 
			
		||||
        self.attack_power = attack_power
 | 
			
		||||
        self.defense = defense
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def hit_points(self) -> int:
 | 
			
		||||
        '''Number of hit points remaining. When a Fighter reaches 0 hit points, they die.'''
 | 
			
		||||
        return self.__hit_points
 | 
			
		||||
 | 
			
		||||
    @hit_points.setter
 | 
			
		||||
    def hit_points(self, value: int) -> None:
 | 
			
		||||
        self.__hit_points = min(self.maximum_hit_points, max(0, value))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_dead(self) -> bool:
 | 
			
		||||
        '''True if the Fighter has died, i.e. reached 0 hit points'''
 | 
			
		||||
        return self.__hit_points == 0
 | 
			
		||||
							
								
								
									
										96
									
								
								erynrl/engine.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								erynrl/engine.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,96 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
'''Defines the core game engine.'''
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
import random
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from typing import MutableSet
 | 
			
		||||
 | 
			
		||||
import tcod
 | 
			
		||||
 | 
			
		||||
from . import monsters
 | 
			
		||||
from .ai import HostileEnemy
 | 
			
		||||
from .geometry import Size
 | 
			
		||||
from .map import Map
 | 
			
		||||
from .object import Entity, Hero, Monster
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger('engine')
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class Configuration:
 | 
			
		||||
    map_size: Size
 | 
			
		||||
 | 
			
		||||
class Engine:
 | 
			
		||||
    '''The main game engine.
 | 
			
		||||
 | 
			
		||||
    This class provides the event handling, map drawing, and maintains the list of entities.
 | 
			
		||||
 | 
			
		||||
    Attributes
 | 
			
		||||
    ----------
 | 
			
		||||
    configuration : Configuration
 | 
			
		||||
        Defines the basic configuration for the game
 | 
			
		||||
    entities : MutableSet[Entity]
 | 
			
		||||
        A set of all the entities on the current map, including the Hero
 | 
			
		||||
    hero : Hero
 | 
			
		||||
        The hero, the Entity controlled by the player
 | 
			
		||||
    map : Map
 | 
			
		||||
        A map of the current level
 | 
			
		||||
    rng : tcod.random.Random
 | 
			
		||||
        A random number generator
 | 
			
		||||
    '''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, configuration: Configuration):
 | 
			
		||||
        self.configuration = configuration
 | 
			
		||||
 | 
			
		||||
        self.rng = tcod.random.Random()
 | 
			
		||||
        self.map = Map(configuration.map_size)
 | 
			
		||||
        self.hero = Hero(position=self.map.generator.rooms[0].center)
 | 
			
		||||
 | 
			
		||||
        self.entities: MutableSet[Entity] = {self.hero}
 | 
			
		||||
        for room in self.map.rooms:
 | 
			
		||||
            should_spawn_monster_chance = random.random()
 | 
			
		||||
            if should_spawn_monster_chance < 0.4:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            floor = list(room.walkable_tiles)
 | 
			
		||||
            for _ in range(2):
 | 
			
		||||
                while True:
 | 
			
		||||
                    random_start_position = random.choice(floor)
 | 
			
		||||
                    if not any(ent.position == random_start_position for ent in self.entities):
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
                spawn_monster_chance = random.random()
 | 
			
		||||
                if spawn_monster_chance > 0.8:
 | 
			
		||||
                    monster = Monster(monsters.Troll, ai_class=HostileEnemy, position=random_start_position)
 | 
			
		||||
                else:
 | 
			
		||||
                    monster = Monster(monsters.Orc, ai_class=HostileEnemy, position=random_start_position)
 | 
			
		||||
 | 
			
		||||
                LOG.info('Spawning %s', monster)
 | 
			
		||||
                self.entities.add(monster)
 | 
			
		||||
 | 
			
		||||
        self.update_field_of_view()
 | 
			
		||||
 | 
			
		||||
    def print_to_console(self, console):
 | 
			
		||||
        '''Print the whole game to the given console.'''
 | 
			
		||||
        self.map.print_to_console(console)
 | 
			
		||||
 | 
			
		||||
        hp, max_hp = self.hero.fighter.hit_points, self.hero.fighter.maximum_hit_points
 | 
			
		||||
        console.print(x=1, y=47, string=f'HP: {hp}/{max_hp}')
 | 
			
		||||
 | 
			
		||||
        for ent in sorted(self.entities, key=lambda e: e.render_order.value):
 | 
			
		||||
            # Only print entities that are in the field of view
 | 
			
		||||
            if not self.map.visible[tuple(ent.position)]:
 | 
			
		||||
                continue
 | 
			
		||||
            ent.print_to_console(console)
 | 
			
		||||
 | 
			
		||||
    def update_field_of_view(self) -> None:
 | 
			
		||||
        '''Compute visible area of the map based on the player's position and point of view.'''
 | 
			
		||||
        # FIXME: Move this to the Map class
 | 
			
		||||
        self.map.visible[:] = tcod.map.compute_fov(
 | 
			
		||||
            self.map.tiles['transparent'],
 | 
			
		||||
            tuple(self.hero.position),
 | 
			
		||||
            radius=8)
 | 
			
		||||
 | 
			
		||||
        # Visible tiles should be added to the explored list
 | 
			
		||||
        self.map.explored |= self.map.visible
 | 
			
		||||
							
								
								
									
										145
									
								
								erynrl/events.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								erynrl/events.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,145 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
'''Defines event handling mechanisms.'''
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Optional, TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
import tcod
 | 
			
		||||
 | 
			
		||||
from .actions import Action, ActionResult, ExitAction, RegenerateRoomsAction, BumpAction, WaitAction
 | 
			
		||||
from .geometry import Direction
 | 
			
		||||
from .object import Actor
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from .engine import Engine
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger('events')
 | 
			
		||||
ACTIONS_TREE_LOG = logging.getLogger('actions.tree')
 | 
			
		||||
 | 
			
		||||
class EventHandler(tcod.event.EventDispatch[Action]):
 | 
			
		||||
    '''Handler of `tcod` events'''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, engine: 'Engine'):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.engine = engine
 | 
			
		||||
 | 
			
		||||
    def wait_for_events(self):
 | 
			
		||||
        '''Wait for events and handle them.'''
 | 
			
		||||
        for event in tcod.event.wait():
 | 
			
		||||
            self.handle_event(event)
 | 
			
		||||
 | 
			
		||||
    def handle_event(self, event: tcod.event.Event) -> None:
 | 
			
		||||
        '''Handle the given event. Transform that event into an Action via an EventHandler and perform it.'''
 | 
			
		||||
        action = self.dispatch(event)
 | 
			
		||||
 | 
			
		||||
        # Unhandled event. Ignore it.
 | 
			
		||||
        if not action:
 | 
			
		||||
            LOG.debug('Unhandled event: %s', event)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        ACTIONS_TREE_LOG.info('Processing Hero Actions')
 | 
			
		||||
        ACTIONS_TREE_LOG.info('|-> %s', action.actor)
 | 
			
		||||
 | 
			
		||||
        result = self.perform_action_until_done(action)
 | 
			
		||||
 | 
			
		||||
        # Player's action failed, don't proceed with turn.
 | 
			
		||||
        if not result.success and result.done:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Copy the list so we only act on the entities that exist at the start of this turn. Sort it by Euclidean
 | 
			
		||||
        # distance to the Hero, so entities closer to the hero act first.
 | 
			
		||||
        hero_position = self.engine.hero.position
 | 
			
		||||
        entities = sorted(
 | 
			
		||||
            self.engine.entities,
 | 
			
		||||
            key=lambda e: e.position.euclidean_distance_to(hero_position))
 | 
			
		||||
 | 
			
		||||
        ACTIONS_TREE_LOG.info('Processing Entity Actions')
 | 
			
		||||
 | 
			
		||||
        for i, ent in enumerate(entities):
 | 
			
		||||
            if not isinstance(ent, Actor):
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            ent_ai = ent.ai
 | 
			
		||||
            if not ent_ai:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if self.engine.map.visible[tuple(ent.position)]:
 | 
			
		||||
                ACTIONS_TREE_LOG.info('%s-> %s', '|' if i < len(entities) - 1 else '`', ent)
 | 
			
		||||
 | 
			
		||||
            action = ent_ai.act(self.engine)
 | 
			
		||||
            self.perform_action_until_done(action)
 | 
			
		||||
 | 
			
		||||
        self.engine.update_field_of_view()
 | 
			
		||||
 | 
			
		||||
    def perform_action_until_done(self, action: Action) -> ActionResult:
 | 
			
		||||
        '''Perform the given action and any alternate follow-up actions until the action chain is done.'''
 | 
			
		||||
        result = action.perform(self.engine)
 | 
			
		||||
 | 
			
		||||
        if ACTIONS_TREE_LOG.isEnabledFor(logging.INFO) and self.engine.map.visible[tuple(action.actor.position)]:
 | 
			
		||||
            if result.alternate:
 | 
			
		||||
                alternate_string = f'{result.alternate.__class__.__name__}[{result.alternate.actor.symbol}]'
 | 
			
		||||
            else:
 | 
			
		||||
                alternate_string = str(result.alternate)
 | 
			
		||||
            ACTIONS_TREE_LOG.info('|   %s-> %s => success=%s done=%s alternate=%s',
 | 
			
		||||
                '|' if not result.success or not result.done else '`',
 | 
			
		||||
                action,
 | 
			
		||||
                result.success,
 | 
			
		||||
                result.done,
 | 
			
		||||
                alternate_string)
 | 
			
		||||
 | 
			
		||||
        while not result.done:
 | 
			
		||||
            action = result.alternate
 | 
			
		||||
            assert action is not None, f'Action {result.action} incomplete but no alternate action given'
 | 
			
		||||
 | 
			
		||||
            result = action.perform(self.engine)
 | 
			
		||||
 | 
			
		||||
            if ACTIONS_TREE_LOG.isEnabledFor(logging.INFO) and self.engine.map.visible[tuple(action.actor.position)]:
 | 
			
		||||
                if result.alternate:
 | 
			
		||||
                    alternate_string = f'{result.alternate.__class__.__name__}[{result.alternate.actor.symbol}]'
 | 
			
		||||
                else:
 | 
			
		||||
                    alternate_string = str(result.alternate)
 | 
			
		||||
                ACTIONS_TREE_LOG.info('|   %s-> %s => success=%s done=%s alternate=%s',
 | 
			
		||||
                    '|' if not result.success or not result.done else '`',
 | 
			
		||||
                    action,
 | 
			
		||||
                    result.success,
 | 
			
		||||
                    result.done,
 | 
			
		||||
                    alternate_string)
 | 
			
		||||
 | 
			
		||||
            if result.success:
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
 | 
			
		||||
        return ExitAction(self.engine.hero)
 | 
			
		||||
 | 
			
		||||
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
 | 
			
		||||
        action: Optional[Action] = None
 | 
			
		||||
 | 
			
		||||
        hero = self.engine.hero
 | 
			
		||||
 | 
			
		||||
        sym = event.sym
 | 
			
		||||
        match sym:
 | 
			
		||||
            case tcod.event.KeySym.b:
 | 
			
		||||
                action = BumpAction(hero, Direction.SouthWest)
 | 
			
		||||
            case tcod.event.KeySym.h:
 | 
			
		||||
                action = BumpAction(hero, Direction.West)
 | 
			
		||||
            case tcod.event.KeySym.j:
 | 
			
		||||
                action = BumpAction(hero, Direction.South)
 | 
			
		||||
            case tcod.event.KeySym.k:
 | 
			
		||||
                action = BumpAction(hero, Direction.North)
 | 
			
		||||
            case tcod.event.KeySym.l:
 | 
			
		||||
                action = BumpAction(hero, Direction.East)
 | 
			
		||||
            case tcod.event.KeySym.n:
 | 
			
		||||
                action = BumpAction(hero, Direction.SouthEast)
 | 
			
		||||
            case tcod.event.KeySym.u:
 | 
			
		||||
                action = BumpAction(hero, Direction.NorthEast)
 | 
			
		||||
            case tcod.event.KeySym.y:
 | 
			
		||||
                action = BumpAction(hero, Direction.NorthWest)
 | 
			
		||||
            case tcod.event.KeySym.SPACE:
 | 
			
		||||
                action = RegenerateRoomsAction(hero)
 | 
			
		||||
            case tcod.event.KeySym.PERIOD:
 | 
			
		||||
                action = WaitAction(hero)
 | 
			
		||||
 | 
			
		||||
        return action
 | 
			
		||||
							
								
								
									
										183
									
								
								erynrl/geometry.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								erynrl/geometry.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,183 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
'''A bunch of geometric primitives'''
 | 
			
		||||
 | 
			
		||||
import math
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from typing import Any, Iterator, Optional, overload
 | 
			
		||||
 | 
			
		||||
@dataclass(frozen=True)
 | 
			
		||||
class Point:
 | 
			
		||||
    '''A two-dimensional point, with coordinates in X and Y axes'''
 | 
			
		||||
 | 
			
		||||
    x: int = 0
 | 
			
		||||
    y: int = 0
 | 
			
		||||
 | 
			
		||||
    def is_adjacent_to(self, other: 'Point') -> bool:
 | 
			
		||||
        '''Check if this point is adjacent to, but not overlapping the given point
 | 
			
		||||
 | 
			
		||||
        Parameters
 | 
			
		||||
        ----------
 | 
			
		||||
        other : Point
 | 
			
		||||
            The point to check
 | 
			
		||||
 | 
			
		||||
        Returns
 | 
			
		||||
        -------
 | 
			
		||||
        bool
 | 
			
		||||
            True if this point is adjacent to the other point
 | 
			
		||||
        '''
 | 
			
		||||
        return (self.x in (other.x - 1, other.x + 1)) and (self.y in (other.y -1, other.y + 1))
 | 
			
		||||
 | 
			
		||||
    def direction_to_adjacent_point(self, other: 'Point') -> Optional['Direction']:
 | 
			
		||||
        for direction in Direction.all():
 | 
			
		||||
            if (self + direction) != other:
 | 
			
		||||
                continue
 | 
			
		||||
            return direction
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def euclidean_distance_to(self, other: 'Point') -> float:
 | 
			
		||||
        '''Compute the Euclidean distance to another Point'''
 | 
			
		||||
        return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
 | 
			
		||||
 | 
			
		||||
    @overload
 | 
			
		||||
    def __add__(self, other: 'Vector') -> 'Point':
 | 
			
		||||
        ...
 | 
			
		||||
 | 
			
		||||
    def __add__(self, other: Any) -> 'Point':
 | 
			
		||||
        if not isinstance(other, Vector):
 | 
			
		||||
            raise TypeError('Only Vector can be added to a Point')
 | 
			
		||||
        return Point(self.x + other.dx, self.y + other.dy)
 | 
			
		||||
 | 
			
		||||
    def __iter__(self):
 | 
			
		||||
        yield self.x
 | 
			
		||||
        yield self.y
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f'(x:{self.x}, y:{self.y})'
 | 
			
		||||
 | 
			
		||||
@dataclass(frozen=True)
 | 
			
		||||
class Vector:
 | 
			
		||||
    '''A two-dimensional vector, representing change in position in X and Y axes'''
 | 
			
		||||
 | 
			
		||||
    dx: int = 0
 | 
			
		||||
    dy: int = 0
 | 
			
		||||
 | 
			
		||||
    def __iter__(self):
 | 
			
		||||
        yield self.dx
 | 
			
		||||
        yield self.dy
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f'(δx:{self.dx}, δy:{self.dy})'
 | 
			
		||||
 | 
			
		||||
class Direction:
 | 
			
		||||
    '''A collection of simple uint vectors in each of the eight major compass directions. This is a namespace, not a class.'''
 | 
			
		||||
 | 
			
		||||
    North = Vector(0, -1)
 | 
			
		||||
    NorthEast = Vector(1, -1)
 | 
			
		||||
    East = Vector(1, 0)
 | 
			
		||||
    SouthEast = Vector(1, 1)
 | 
			
		||||
    South = Vector(0, 1)
 | 
			
		||||
    SouthWest = Vector(-1, 1)
 | 
			
		||||
    West = Vector(-1, 0)
 | 
			
		||||
    NorthWest = Vector(-1, -1)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def all(cls) -> Iterator['Direction']:
 | 
			
		||||
        '''Iterate through all directions, starting with North and proceeding clockwise'''
 | 
			
		||||
        yield Direction.North
 | 
			
		||||
        yield Direction.NorthEast
 | 
			
		||||
        yield Direction.East
 | 
			
		||||
        yield Direction.SouthEast
 | 
			
		||||
        yield Direction.South
 | 
			
		||||
        yield Direction.SouthWest
 | 
			
		||||
        yield Direction.West
 | 
			
		||||
        yield Direction.NorthWest
 | 
			
		||||
 | 
			
		||||
@dataclass(frozen=True)
 | 
			
		||||
class Size:
 | 
			
		||||
    '''A two-dimensional size, representing size in X (width) and Y (height) axes'''
 | 
			
		||||
 | 
			
		||||
    width: int = 0
 | 
			
		||||
    height: int = 0
 | 
			
		||||
 | 
			
		||||
    def __iter__(self):
 | 
			
		||||
        yield self.width
 | 
			
		||||
        yield self.height
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f'(w:{self.width}, h:{self.height})'
 | 
			
		||||
 | 
			
		||||
@dataclass(frozen=True)
 | 
			
		||||
class Rect:
 | 
			
		||||
    '''A two-dimensional rectangle, defined by an origin point and size'''
 | 
			
		||||
 | 
			
		||||
    origin: Point
 | 
			
		||||
    size: Size
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def min_x(self) -> int:
 | 
			
		||||
        '''Minimum x-value that is still within the bounds of this rectangle. This is the origin's x-value.'''
 | 
			
		||||
        return self.origin.x
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def min_y(self) -> int:
 | 
			
		||||
        '''Minimum y-value that is still within the bounds of this rectangle. This is the origin's y-value.'''
 | 
			
		||||
        return self.origin.y
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def mid_x(self) -> int:
 | 
			
		||||
        '''The x-value of the center point of this rectangle.'''
 | 
			
		||||
        return int(self.origin.x + self.size.width / 2)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def mid_y(self) -> int:
 | 
			
		||||
        '''The y-value of the center point of this rectangle.'''
 | 
			
		||||
        return int(self.origin.y + self.size.height / 2)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def max_x(self) -> int:
 | 
			
		||||
        '''Maximum x-value that is still within the bounds of this rectangle.'''
 | 
			
		||||
        return self.origin.x + self.size.width - 1
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def max_y(self) -> int:
 | 
			
		||||
        '''Maximum y-value that is still within the bounds of this rectangle.'''
 | 
			
		||||
        return self.origin.y + self.size.height - 1
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def midpoint(self) -> Point:
 | 
			
		||||
        '''A Point in the middle of the Rect'''
 | 
			
		||||
        return Point(self.mid_x, self.mid_y)
 | 
			
		||||
 | 
			
		||||
    def inset_rect(self, top: int = 0, right: int = 0, bottom: int = 0, left: int = 0) -> 'Rect':
 | 
			
		||||
        '''
 | 
			
		||||
        Return a new Rect inset from this rect by the specified values. Arguments are listed in clockwise order around
 | 
			
		||||
        the permeter. This method doesn't validate the returned Rect, or transform it to a canonical representation with
 | 
			
		||||
        the origin at the top-left.
 | 
			
		||||
 | 
			
		||||
        Parameters
 | 
			
		||||
        ----------
 | 
			
		||||
        top : int
 | 
			
		||||
            Amount to inset from the top
 | 
			
		||||
        right : int
 | 
			
		||||
            Amount to inset from the right
 | 
			
		||||
        bottom : int
 | 
			
		||||
            Amount to inset from the bottom
 | 
			
		||||
        left : int
 | 
			
		||||
            Amount to inset from the left
 | 
			
		||||
 | 
			
		||||
        Returns
 | 
			
		||||
        -------
 | 
			
		||||
        Rect
 | 
			
		||||
            A new Rect, inset from `self` by the given amount on each side
 | 
			
		||||
        '''
 | 
			
		||||
        return Rect(Point(self.origin.x + left, self.origin.y + top),
 | 
			
		||||
                    Size(self.size.width - right - left, self.size.height - top - bottom))
 | 
			
		||||
 | 
			
		||||
    def __iter__(self):
 | 
			
		||||
        yield tuple(self.origin)
 | 
			
		||||
        yield tuple(self.size)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f'[{self.origin}, {self.size}]'
 | 
			
		||||
							
								
								
									
										35
									
								
								erynrl/items.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								erynrl/items.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
@dataclass(frozen=True)
 | 
			
		||||
class Item:
 | 
			
		||||
    '''A record of a kind of item
 | 
			
		||||
 | 
			
		||||
    This class follows the "type class" pattern. It represents a kind of item, not a specific instance of that item.
 | 
			
		||||
    (See `object.Item` for that.)
 | 
			
		||||
 | 
			
		||||
    Attributes
 | 
			
		||||
    ----------
 | 
			
		||||
    symbol : str
 | 
			
		||||
        The symbol used to render this item on the map
 | 
			
		||||
    foreground_color : Tuple[int, int, int]
 | 
			
		||||
        The foreground color used to render this item on the map
 | 
			
		||||
    background_color : Tuple[int, int, int], optional
 | 
			
		||||
        The background color used to render this item on the map
 | 
			
		||||
    name : str
 | 
			
		||||
        The name of this item
 | 
			
		||||
    description : str
 | 
			
		||||
        A description of this item
 | 
			
		||||
    '''
 | 
			
		||||
    symbol: str
 | 
			
		||||
    name: str
 | 
			
		||||
    description: str
 | 
			
		||||
    foreground_color: Tuple[int, int, int]
 | 
			
		||||
    background_color: Tuple[int, int, int] = None
 | 
			
		||||
 | 
			
		||||
Corpse = Item('%',
 | 
			
		||||
              name="Corpse",
 | 
			
		||||
              description="The corpse of a once-living being",
 | 
			
		||||
              foreground_color=(128, 128, 255))
 | 
			
		||||
							
								
								
									
										262
									
								
								erynrl/map.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										262
									
								
								erynrl/map.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,262 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
import random
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from typing import Iterator, List, Optional
 | 
			
		||||
 | 
			
		||||
import numpy as np
 | 
			
		||||
import tcod
 | 
			
		||||
 | 
			
		||||
from .geometry import Direction, Point, Rect, Size
 | 
			
		||||
from .tile import Empty, Floor, Shroud, Wall
 | 
			
		||||
 | 
			
		||||
LOG = logging.getLogger('map')
 | 
			
		||||
 | 
			
		||||
class Map:
 | 
			
		||||
    def __init__(self, size: Size):
 | 
			
		||||
        self.size = size
 | 
			
		||||
 | 
			
		||||
        self.generator = RoomsAndCorridorsGenerator(size=size)
 | 
			
		||||
        self.tiles = self.generator.generate()
 | 
			
		||||
 | 
			
		||||
        # Map tiles that are currently visible to the player
 | 
			
		||||
        self.visible = np.full(tuple(self.size), fill_value=False, order='F')
 | 
			
		||||
        # Map tiles that the player has explored
 | 
			
		||||
        self.explored = np.full(tuple(self.size), fill_value=False, order='F')
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def rooms(self) -> List['Room']:
 | 
			
		||||
        return self.generator.rooms
 | 
			
		||||
 | 
			
		||||
    def random_walkable_position(self) -> Point:
 | 
			
		||||
        # TODO: Include hallways
 | 
			
		||||
        random_room: RectangularRoom = random.choice(self.rooms)
 | 
			
		||||
        floor: List[Point] = list(random_room.walkable_tiles)
 | 
			
		||||
        random_position_in_room = random.choice(floor)
 | 
			
		||||
        return random_position_in_room
 | 
			
		||||
 | 
			
		||||
    def tile_is_in_bounds(self, point: Point) -> bool:
 | 
			
		||||
        return 0 <= point.x < self.size.width and 0 <= point.y < self.size.height
 | 
			
		||||
 | 
			
		||||
    def tile_is_walkable(self, point: Point) -> bool:
 | 
			
		||||
        return self.tiles[point.x, point.y]['walkable']
 | 
			
		||||
 | 
			
		||||
    def print_to_console(self, console: tcod.Console) -> None:
 | 
			
		||||
        '''Render the map to the console.'''
 | 
			
		||||
        size = self.size
 | 
			
		||||
 | 
			
		||||
        # If a tile is in the visible array, draw it with the "light" color. If it's not, but it's in the explored
 | 
			
		||||
        # array, draw it with the "dark" color. Otherwise, draw it as Empty.
 | 
			
		||||
        console.tiles_rgb[0:size.width, 0:size.height] = np.select(
 | 
			
		||||
            condlist=[self.visible, self.explored],
 | 
			
		||||
            choicelist=[self.tiles['light'], self.tiles['dark']],
 | 
			
		||||
            default=Shroud)
 | 
			
		||||
 | 
			
		||||
class MapGenerator:
 | 
			
		||||
    def __init__(self, *, size: Size):
 | 
			
		||||
        self.size = size
 | 
			
		||||
        self.rooms: List['Room'] = []
 | 
			
		||||
 | 
			
		||||
    def generate(self) -> np.ndarray:
 | 
			
		||||
        '''
 | 
			
		||||
        Generate a tile grid
 | 
			
		||||
 | 
			
		||||
        Subclasses should implement this and fill in their specific map
 | 
			
		||||
        generation algorithm.
 | 
			
		||||
 | 
			
		||||
        Returns
 | 
			
		||||
        -------
 | 
			
		||||
        np.ndarray
 | 
			
		||||
            A two-dimensional array of tiles. Dimensions should match the given size.
 | 
			
		||||
        '''
 | 
			
		||||
        raise NotImplementedError()
 | 
			
		||||
 | 
			
		||||
class RoomsAndCorridorsGenerator(MapGenerator):
 | 
			
		||||
    '''Generate a rooms-and-corridors style map with BSP.'''
 | 
			
		||||
 | 
			
		||||
    @dataclass
 | 
			
		||||
    class Configuration:
 | 
			
		||||
        minimum_room_size: Size
 | 
			
		||||
        maximum_room_size: Size
 | 
			
		||||
 | 
			
		||||
    DefaultConfiguration = Configuration(
 | 
			
		||||
        minimum_room_size=Size(5, 5),
 | 
			
		||||
        maximum_room_size=Size(15, 15),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *, size: Size, config: Optional[Configuration] = None):
 | 
			
		||||
        super().__init__(size=size)
 | 
			
		||||
        self.configuration = config if config else RoomsAndCorridorsGenerator.DefaultConfiguration
 | 
			
		||||
 | 
			
		||||
        self.rng: tcod.random.Random = tcod.random.Random()
 | 
			
		||||
 | 
			
		||||
        self.rooms: List['RectangularRoom'] = []
 | 
			
		||||
        self.tiles: Optional[np.ndarray] = None
 | 
			
		||||
 | 
			
		||||
    def generate(self) -> np.ndarray:
 | 
			
		||||
        if self.tiles:
 | 
			
		||||
            return self.tiles
 | 
			
		||||
 | 
			
		||||
        minimum_room_size = self.configuration.minimum_room_size
 | 
			
		||||
        maximum_room_size = self.configuration.maximum_room_size
 | 
			
		||||
 | 
			
		||||
        # Recursively divide the map into squares of various sizes to place rooms in.
 | 
			
		||||
        bsp = tcod.bsp.BSP(x=0, y=0, width=self.size.width, height=self.size.height)
 | 
			
		||||
        bsp.split_recursive(
 | 
			
		||||
            depth=4,
 | 
			
		||||
            # Add 2 to the minimum width and height to account for walls
 | 
			
		||||
            min_width=minimum_room_size.width + 2, min_height=minimum_room_size.height + 2,
 | 
			
		||||
            max_horizontal_ratio=3, max_vertical_ratio=3)
 | 
			
		||||
 | 
			
		||||
        tiles = np.full(tuple(self.size), fill_value=Empty, order='F')
 | 
			
		||||
 | 
			
		||||
        # Generate the rooms
 | 
			
		||||
        rooms: List['RectangularRoom'] = []
 | 
			
		||||
 | 
			
		||||
        room_attrname = f'{__class__.__name__}.room'
 | 
			
		||||
 | 
			
		||||
        for node in bsp.post_order():
 | 
			
		||||
            node_bounds = self.__rect_from_bsp_node(node)
 | 
			
		||||
 | 
			
		||||
            if node.children:
 | 
			
		||||
                LOG.debug(node_bounds)
 | 
			
		||||
 | 
			
		||||
                left_room: RectangularRoom = getattr(node.children[0], room_attrname)
 | 
			
		||||
                right_room: RectangularRoom = getattr(node.children[1], room_attrname)
 | 
			
		||||
 | 
			
		||||
                left_room_bounds = left_room.bounds
 | 
			
		||||
                right_room_bounds = right_room.bounds
 | 
			
		||||
 | 
			
		||||
                LOG.debug(' left: %s, %s', node.children[0], left_room_bounds)
 | 
			
		||||
                LOG.debug('right: %s, %s', node.children[1], right_room_bounds)
 | 
			
		||||
 | 
			
		||||
                start_point = left_room_bounds.midpoint
 | 
			
		||||
                end_point = right_room_bounds.midpoint
 | 
			
		||||
 | 
			
		||||
                # Randomly choose whether to move horizontally then vertically or vice versa
 | 
			
		||||
                if random.random() < 0.5:
 | 
			
		||||
                    corner = Point(end_point.x, start_point.y)
 | 
			
		||||
                else:
 | 
			
		||||
                    corner = Point(start_point.x, end_point.y)
 | 
			
		||||
 | 
			
		||||
                LOG.debug('Digging a tunnel between %s and %s with corner %s', start_point, end_point, corner)
 | 
			
		||||
                LOG.debug('|-> start: %s', left_room_bounds)
 | 
			
		||||
                LOG.debug('`->   end: %s', right_room_bounds)
 | 
			
		||||
 | 
			
		||||
                for x, y in tcod.los.bresenham(tuple(start_point), tuple(corner)).tolist():
 | 
			
		||||
                    tiles[x, y] = Floor
 | 
			
		||||
                for x, y in tcod.los.bresenham(tuple(corner), tuple(end_point)).tolist():
 | 
			
		||||
                    tiles[x, y] = Floor
 | 
			
		||||
            else:
 | 
			
		||||
                LOG.debug('%s (room) %s', node_bounds, node)
 | 
			
		||||
 | 
			
		||||
                # Generate a room size between minimum_room_size and maximum_room_size. The minimum value is
 | 
			
		||||
                # straight-forward, but the maximum value needs to be clamped between minimum_room_size and the size of
 | 
			
		||||
                # the node.
 | 
			
		||||
                width_range = (minimum_room_size.width, min(maximum_room_size.width, max(minimum_room_size.width, node.width - 2)))
 | 
			
		||||
                height_range = (minimum_room_size.height, min(maximum_room_size.height, max(minimum_room_size.height, node.height - 2)))
 | 
			
		||||
 | 
			
		||||
                size = Size(self.rng.randint(*width_range), self.rng.randint(*height_range))
 | 
			
		||||
                origin = Point(node.x + self.rng.randint(1, max(1, node.width - size.width - 1)),
 | 
			
		||||
                               node.y + self.rng.randint(1, max(1, node.height - size.height - 1)))
 | 
			
		||||
                bounds = Rect(origin, size)
 | 
			
		||||
 | 
			
		||||
                LOG.debug('`-> %s', bounds)
 | 
			
		||||
 | 
			
		||||
                room = RectangularRoom(bounds)
 | 
			
		||||
                setattr(node, room_attrname, room)
 | 
			
		||||
                rooms.append(room)
 | 
			
		||||
 | 
			
		||||
                if not hasattr(node.parent, room_attrname):
 | 
			
		||||
                    setattr(node.parent, room_attrname, room)
 | 
			
		||||
                elif random.random() < 0.5:
 | 
			
		||||
                    setattr(node.parent, room_attrname, room)
 | 
			
		||||
 | 
			
		||||
            # Pass up a random child room so that parent nodes can connect subtrees to each other.
 | 
			
		||||
            parent = node.parent
 | 
			
		||||
            if parent:
 | 
			
		||||
                node_room = getattr(node, room_attrname)
 | 
			
		||||
                if not hasattr(node.parent, room_attrname):
 | 
			
		||||
                    setattr(node.parent, room_attrname, node_room)
 | 
			
		||||
                elif random.random() < 0.5:
 | 
			
		||||
                    setattr(node.parent, room_attrname, node_room)
 | 
			
		||||
 | 
			
		||||
        self.rooms = rooms
 | 
			
		||||
 | 
			
		||||
        for room in rooms:
 | 
			
		||||
            for wall_position in room.walls:
 | 
			
		||||
                if tiles[wall_position.x, wall_position.y] != Floor:
 | 
			
		||||
                    tiles[wall_position.x, wall_position.y] = Wall
 | 
			
		||||
 | 
			
		||||
            bounds = room.bounds
 | 
			
		||||
            # The range of a numpy array slice is [a, b).
 | 
			
		||||
            floor_rect = bounds.inset_rect(top=1, right=1, bottom=1, left=1)
 | 
			
		||||
            tiles[floor_rect.min_x:floor_rect.max_x + 1, floor_rect.min_y:floor_rect.max_y + 1] = Floor
 | 
			
		||||
 | 
			
		||||
        for y in range(self.size.height):
 | 
			
		||||
            for x in range(self.size.width):
 | 
			
		||||
                pos = Point(x, y)
 | 
			
		||||
                if tiles[x, y] != Floor:
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                neighbors = (pos + direction for direction in Direction.all())
 | 
			
		||||
                for neighbor in neighbors:
 | 
			
		||||
                    if tiles[neighbor.x, neighbor.y] != Empty:
 | 
			
		||||
                        continue
 | 
			
		||||
                    tiles[neighbor.x, neighbor.y] = Wall
 | 
			
		||||
 | 
			
		||||
        self.tiles = tiles
 | 
			
		||||
 | 
			
		||||
        return tiles
 | 
			
		||||
 | 
			
		||||
    def __rect_from_bsp_node(self, node: tcod.bsp.BSP) -> Rect:
 | 
			
		||||
        '''Create a Rect from the given BSP node object'''
 | 
			
		||||
        return Rect(Point(node.x, node.y), Size(node.width, node.height))
 | 
			
		||||
 | 
			
		||||
class Room:
 | 
			
		||||
    '''An abstract room. It can be any size or shape.'''
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def walkable_tiles(self) -> Iterator[Point]:
 | 
			
		||||
        raise NotImplementedError()
 | 
			
		||||
 | 
			
		||||
class RectangularRoom(Room):
 | 
			
		||||
    '''A rectangular room defined by a Rect.
 | 
			
		||||
 | 
			
		||||
    Attributes
 | 
			
		||||
    ----------
 | 
			
		||||
    bounds : Rect
 | 
			
		||||
        A rectangle that defines the room. This rectangle includes the tiles used for the walls, so the floor is 1 tile
 | 
			
		||||
        inset from the bounds.
 | 
			
		||||
    '''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, bounds: Rect):
 | 
			
		||||
        self.bounds = bounds
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def center(self) -> Point:
 | 
			
		||||
        '''The center of the room, truncated according to integer math rules'''
 | 
			
		||||
        return self.bounds.midpoint
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def walkable_tiles(self) -> Rect:
 | 
			
		||||
        floor_rect = self.bounds.inset_rect(top=1, right=1, bottom=1, left=1)
 | 
			
		||||
        for y in range(floor_rect.min_y, floor_rect.max_y + 1):
 | 
			
		||||
            for x in range(floor_rect.min_x, floor_rect.max_x + 1):
 | 
			
		||||
                yield Point(x, y)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def walls(self) -> Iterator[Point]:
 | 
			
		||||
        bounds = self.bounds
 | 
			
		||||
        min_y = bounds.min_y
 | 
			
		||||
        max_y = bounds.max_y
 | 
			
		||||
        min_x = bounds.min_x
 | 
			
		||||
        max_x = bounds.max_x
 | 
			
		||||
        for y in range(min_y, max_y + 1):
 | 
			
		||||
            for x in range(min_x, max_x + 1):
 | 
			
		||||
                if y == min_y or y == max_y or x == min_x or x == max_x:
 | 
			
		||||
                    yield Point(x, y)
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        return f'{self.__class__.__name__}({self.bounds})'
 | 
			
		||||
							
								
								
									
										49
									
								
								erynrl/monsters.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								erynrl/monsters.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,49 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
'''Defines the Species type, which represents a class of monsters, and all the monster types the hero can encounter in
 | 
			
		||||
the dungeon.'''
 | 
			
		||||
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
# pylint: disable=too-many-instance-attributes
 | 
			
		||||
@dataclass(frozen=True)
 | 
			
		||||
class Species:
 | 
			
		||||
    '''A kind of monster.
 | 
			
		||||
 | 
			
		||||
    Attributes
 | 
			
		||||
    ----------
 | 
			
		||||
    name : str
 | 
			
		||||
        A friendly, user-visiable name for the monster
 | 
			
		||||
    symbol : str
 | 
			
		||||
        The symbol used to render the monster on the map
 | 
			
		||||
    maximum_hit_points : int
 | 
			
		||||
        The maximum number of hit points the monster can be spawned with
 | 
			
		||||
    sight_radius : int
 | 
			
		||||
        The number of tiles this monster can see
 | 
			
		||||
    foreground_color : Tuple[int, int, int]
 | 
			
		||||
        The foreground color used to render the monster on the map
 | 
			
		||||
    background_color : Tuple[int, int, int], optional
 | 
			
		||||
        The background color used to render the monster on the map; if none is given, the tile color specified by the
 | 
			
		||||
        map will be used.
 | 
			
		||||
    '''
 | 
			
		||||
    name: str
 | 
			
		||||
    symbol: str
 | 
			
		||||
    maximum_hit_points: int
 | 
			
		||||
    sight_radius: int
 | 
			
		||||
    # TODO: Rename these two attributes something better
 | 
			
		||||
    attack_power: int
 | 
			
		||||
    defense: int
 | 
			
		||||
    foreground_color: Tuple[int, int, int]
 | 
			
		||||
    background_color: Tuple[int, int, int] = None
 | 
			
		||||
 | 
			
		||||
Orc = Species(name='Orc', symbol='o',
 | 
			
		||||
              foreground_color=(63, 127, 63),
 | 
			
		||||
              maximum_hit_points=10,
 | 
			
		||||
              sight_radius=4,
 | 
			
		||||
              attack_power=4, defense=1)
 | 
			
		||||
Troll = Species(name='Troll', symbol='T',
 | 
			
		||||
                foreground_color=(0, 127, 0),
 | 
			
		||||
                maximum_hit_points=16,
 | 
			
		||||
                sight_radius=4,
 | 
			
		||||
                attack_power=3, defense=0)
 | 
			
		||||
							
								
								
									
										173
									
								
								erynrl/object.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								erynrl/object.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,173 @@
 | 
			
		|||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from typing import TYPE_CHECKING, Optional, Tuple, Type
 | 
			
		||||
 | 
			
		||||
import tcod
 | 
			
		||||
 | 
			
		||||
from . import items
 | 
			
		||||
from .components import Fighter
 | 
			
		||||
from .geometry import Point
 | 
			
		||||
from .monsters import Species
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from .ai import AI
 | 
			
		||||
 | 
			
		||||
class RenderOrder(Enum):
 | 
			
		||||
    '''
 | 
			
		||||
    These values indicate the order that an Entity should be rendered. Higher values are rendered later and therefore on
 | 
			
		||||
    top of items at with lower orderings.
 | 
			
		||||
    '''
 | 
			
		||||
    ITEM = 1000
 | 
			
		||||
    ACTOR = 2000
 | 
			
		||||
    HERO = 3000
 | 
			
		||||
 | 
			
		||||
class Entity:
 | 
			
		||||
    '''A single-tile drawable entity with a symbol and position
 | 
			
		||||
 | 
			
		||||
    Attributes
 | 
			
		||||
    ----------
 | 
			
		||||
    position : Point
 | 
			
		||||
        The Entity's location on the map
 | 
			
		||||
    foreground : Tuple[int, int, int]
 | 
			
		||||
        The foreground color used to render this Entity
 | 
			
		||||
    background : Tuple[int, int, int], optional
 | 
			
		||||
        The background color used to render this Entity
 | 
			
		||||
    symbol : str
 | 
			
		||||
        A single character string that represents this character on the map
 | 
			
		||||
    ai : Type[AI], optional
 | 
			
		||||
        If an entity can act on its own behalf, an instance of an AI class
 | 
			
		||||
    blocks_movement : bool
 | 
			
		||||
        True if this Entity blocks other Entities from moving through its position
 | 
			
		||||
    '''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, symbol: str, *,
 | 
			
		||||
                 position: Optional[Point] = None,
 | 
			
		||||
                 blocks_movement: Optional[bool] = True,
 | 
			
		||||
                 render_order: RenderOrder = RenderOrder.ITEM,
 | 
			
		||||
                 fg: Optional[Tuple[int, int, int]] = None,
 | 
			
		||||
                 bg: Optional[Tuple[int, int, int]] = None):
 | 
			
		||||
        self.position = position if position else Point()
 | 
			
		||||
        self.foreground = fg if fg else (255, 255, 255)
 | 
			
		||||
        self.background = bg
 | 
			
		||||
        self.symbol = symbol
 | 
			
		||||
        self.blocks_movement = blocks_movement
 | 
			
		||||
        self.render_order = render_order
 | 
			
		||||
 | 
			
		||||
    def print_to_console(self, console: tcod.Console) -> None:
 | 
			
		||||
        '''Render this Entity to the console'''
 | 
			
		||||
        console.print(x=self.position.x, y=self.position.y, string=self.symbol, fg=self.foreground, bg=self.background)
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f'{self.symbol}[{self.position}]'
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        return f'{self.__class__.__name__}({self.symbol!r}, position={self.position!r}, fg={self.foreground!r}, bg={self.background!r})'
 | 
			
		||||
 | 
			
		||||
class Actor(Entity):
 | 
			
		||||
    def __init__(self, symbol: str, *,
 | 
			
		||||
                 position: Optional[Point] = None,
 | 
			
		||||
                 blocks_movement: Optional[bool] = True,
 | 
			
		||||
                 render_order: RenderOrder = RenderOrder.ACTOR,
 | 
			
		||||
                 ai: Optional[Type['AI']] = None,
 | 
			
		||||
                 fighter: Optional[Fighter] = None,
 | 
			
		||||
                 fg: Optional[Tuple[int, int, int]] = None,
 | 
			
		||||
                 bg: Optional[Tuple[int, int, int]] = None):
 | 
			
		||||
        super().__init__(symbol, position=position, blocks_movement=blocks_movement, fg=fg, bg=bg, render_order=render_order)
 | 
			
		||||
 | 
			
		||||
        # Components
 | 
			
		||||
        self.ai = ai
 | 
			
		||||
        self.fighter = fighter
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return 'Actor'
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def sight_radius(self) -> int:
 | 
			
		||||
        '''The number of tiles this entity can see around itself'''
 | 
			
		||||
        return 0
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def yields_corpse_on_death(self) -> bool:
 | 
			
		||||
        '''True if this Actor should produce a corpse when it dies'''
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        return f'{self.__class__.__name__}({self.symbol!r}, position={self.position!r}, fighter={self.fighter!r}, ai={self.ai!r}, fg={self.foreground!r}, bg={self.background!r})'
 | 
			
		||||
 | 
			
		||||
class Hero(Actor):
 | 
			
		||||
    '''The hero, the player character'''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, position: Point):
 | 
			
		||||
        super().__init__('@',
 | 
			
		||||
             position=position,
 | 
			
		||||
             fighter=Fighter(maximum_hit_points=30, attack_power=5, defense=2),
 | 
			
		||||
             render_order=RenderOrder.HERO,
 | 
			
		||||
             fg=tuple(tcod.white))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return 'Hero'
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def sight_radius(self) -> int:
 | 
			
		||||
        # TODO: Make this configurable
 | 
			
		||||
        return 8
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f'{self.symbol}[{self.position}][{self.fighter.hit_points}/{self.fighter.maximum_hit_points}]'
 | 
			
		||||
 | 
			
		||||
class Monster(Actor):
 | 
			
		||||
    '''An instance of a Species'''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, species: Species, ai_class: Type['AI'], position: Point = None):
 | 
			
		||||
        fighter = Fighter(
 | 
			
		||||
            maximum_hit_points=species.maximum_hit_points,
 | 
			
		||||
            attack_power=species.attack_power,
 | 
			
		||||
            defense=species.defense)
 | 
			
		||||
 | 
			
		||||
        super().__init__(
 | 
			
		||||
            species.symbol,
 | 
			
		||||
            ai=ai_class(self),
 | 
			
		||||
            position=position,
 | 
			
		||||
            fighter=fighter,
 | 
			
		||||
            fg=species.foreground_color,
 | 
			
		||||
            bg=species.background_color)
 | 
			
		||||
 | 
			
		||||
        self.species = species
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return self.species.name
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def sight_radius(self) -> int:
 | 
			
		||||
        return self.species.sight_radius
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def yields_corpse_on_death(self) -> bool:
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f'{self.name} with {self.fighter.hit_points}/{self.fighter.maximum_hit_points} hp at {self.position}'
 | 
			
		||||
 | 
			
		||||
class Item(Entity):
 | 
			
		||||
    '''An instance of an Item'''
 | 
			
		||||
 | 
			
		||||
    def __init__(self, kind: items.Item, position: Point = None, name: str = None):
 | 
			
		||||
        super().__init__(kind.symbol,
 | 
			
		||||
                         position=position,
 | 
			
		||||
                         blocks_movement=False,
 | 
			
		||||
                         render_order=RenderOrder.ITEM,
 | 
			
		||||
                         fg=kind.foreground_color,
 | 
			
		||||
                         bg=kind.background_color)
 | 
			
		||||
        self.kind = kind
 | 
			
		||||
        self._name = name
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        '''The name of the item'''
 | 
			
		||||
        if self._name:
 | 
			
		||||
            return self._name
 | 
			
		||||
        return self.kind.name
 | 
			
		||||
							
								
								
									
										45
									
								
								erynrl/tile.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								erynrl/tile.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,45 @@
 | 
			
		|||
#!/usr/bin/env python3
 | 
			
		||||
# Eryn Wells <eryn@erynwells.me>
 | 
			
		||||
 | 
			
		||||
import numpy as np
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
graphic_datatype = np.dtype([
 | 
			
		||||
    # Character, a Unicode codepoint represented as an int32
 | 
			
		||||
    ('ch', np.int32),
 | 
			
		||||
    # Foreground color, three bytes
 | 
			
		||||
    ('fg', '3B'),
 | 
			
		||||
    # Background color, three bytes
 | 
			
		||||
    ('bg', '3B'),
 | 
			
		||||
])
 | 
			
		||||
 | 
			
		||||
tile_datatype = np.dtype([
 | 
			
		||||
    # Bool indicating whether this tile can be traversed
 | 
			
		||||
    ('walkable', np.bool),
 | 
			
		||||
    # Bool indicating whether this tile is transparent
 | 
			
		||||
    ('transparent', np.bool),
 | 
			
		||||
    # A graphic struct (as above) defining the look of this tile when it's not visible
 | 
			
		||||
    ('dark', graphic_datatype),
 | 
			
		||||
    # A graphic struct (as above) defining the look of this tile when it's visible
 | 
			
		||||
    ('light', graphic_datatype),
 | 
			
		||||
])
 | 
			
		||||
 | 
			
		||||
def tile(*,
 | 
			
		||||
         walkable: int,
 | 
			
		||||
         transparent: int,
 | 
			
		||||
         dark: Tuple[int, Tuple[int, int, int], Tuple[int, int ,int]],
 | 
			
		||||
         light: Tuple[int, Tuple[int, int, int], Tuple[int, int ,int]]) -> np.ndarray:
 | 
			
		||||
    return np.array((walkable, transparent, dark, light), dtype=tile_datatype)
 | 
			
		||||
 | 
			
		||||
# An overlay color for tiles that are not visible and have not been explored
 | 
			
		||||
Shroud = np.array((ord(' '), (255, 255, 255), (0, 0, 0)), dtype=graphic_datatype)
 | 
			
		||||
 | 
			
		||||
Empty = tile(walkable=False, transparent=False,
 | 
			
		||||
             dark=(ord(' '), (255, 255, 255), (0, 0, 0)),
 | 
			
		||||
             light=(ord(' '), (255, 255, 255), (0, 0, 0)))
 | 
			
		||||
Floor = tile(walkable=True, transparent=True,
 | 
			
		||||
             dark=(ord('·'), (80, 80, 100), (50, 50, 50)),
 | 
			
		||||
             light=(ord('·'), (100, 100, 120), (80, 80, 100)))
 | 
			
		||||
Wall = tile(walkable=False, transparent=False,
 | 
			
		||||
            dark=(ord(' '), (255, 255, 255), (0, 0, 150)),
 | 
			
		||||
            light=(ord(' '), (255, 255, 255), (50, 50, 200)))
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue