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