Move the roguebasin package to erynrl

This commit is contained in:
Eryn Wells 2022-05-12 09:05:27 -07:00
parent cc6c701c59
commit f6fe9d0f09
14 changed files with 2 additions and 2 deletions

1
erynrl/__init__.py Normal file
View file

@ -0,0 +1 @@
# Eryn Wells <eryn@erynwells.me>

111
erynrl/__main__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)))