Compare commits

..

No commits in common. "main" and "geometry-tuples" have entirely different histories.

62 changed files with 532 additions and 57587 deletions

129
.gitignore vendored
View file

@ -1,129 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

2
.pep8
View file

@ -1,2 +0,0 @@
[pycodestyle]
max_line_length = 120

596
.pylintrc
View file

@ -1,596 +0,0 @@
[MASTER]
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold to be exceeded before program exits with error.
fail-under=10.0
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regex patterns to the ignore-list. The
# regex matches against paths and can be in Posix or Windows format.
ignore-paths=
# Files or directories matching the regex patterns are skipped. The regex
# matches against base names, not paths. The default value ignores emacs file
# locks
ignore-patterns=^\.#
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=1
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.10
# Discover python modules and packages in the file system subtree.
recursive=no
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
#msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
output-format=text
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. Available dictionaries: none. To make it work,
# install the 'python-enchant' package.
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear and the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
#notes-rgx=
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether missing members accessed in mixin class should be ignored. A
# class is considered mixin if its name matches the mixin-class-rgx option.
ignore-mixin-members=yes
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins ignore-mixin-
# members is set to 'yes'
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=map
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored. Default to name
# with leading underscore.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=200
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=no
# Signatures are removed from the similarity computation
ignore-signatures=no
# Minimum lines number of a similarity.
min-similarity-lines=4
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=ai,
bg,
dx,
dy,
fg,
hp,
pt,
i,
j,
k,
x,
y,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=.*Action,
ActionResult,
Direction
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "BaseException, Exception".
overgeneral-exceptions=BaseException,
Exception

15
.vscode/launch.json vendored
View file

@ -5,21 +5,10 @@
"version": "0.2.0",
"configurations": [
{
"name": "ErynRL",
"name": "Python: Module",
"type": "python",
"request": "launch",
"module": "erynrl",
"args": [],
"justMyCode": true
},
{
"name": "ErynRL (Sandbox)",
"type": "python",
"request": "launch",
"module": "erynrl",
"args": [
"--sandbox"
],
"module": "roguebasin",
"justMyCode": true
}
]

View file

@ -1,9 +0,0 @@
{
"python.linting.pylintEnabled": true,
"python.linting.enabled": true,
"python.testing.pytestArgs": [
"test"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

View file

@ -1,12 +1,11 @@
VENV_DIR=.venv
.PHONY: venv
venv:
python3 -m venv ${VENV_DIR}
.PHONY: env
venv: env
python3 -m venv env
deps: ${VENV_DIR}/bin/pip requirements.txt
${VENV_DIR}/bin/pip install -r requirements.txt
deps: env/bin/pip
./env/bin/pip install -r requirements.txt
freeze: ${VENV_DIR}/bin/pip
${VENV_DIR}/bin/pip freeze > requirements.txt
freeze:
./env/bin/pip freeze > requirements.txt

View file

@ -1,6 +1,4 @@
# Going Rogue
An experiment building a Roguelike with libtcod and Python
# Experiments with `libtcod`
`libtcod` is a library that provides a bunch of useful routines for building
Roguelikes. There are C++ and Python interfaces.

View file

@ -1,53 +0,0 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
import argparse
import tcod
def parse_args(argv, *a, **kw):
parser = argparse.ArgumentParser(*a, **kw)
parser.add_argument('width', type=int)
parser.add_argument('height', type=int)
args = parser.parse_args(argv)
return args
def main(argv):
args = parse_args(argv[1:], prog=argv[0])
bsp = tcod.bsp.BSP(0, 0, args.width, args.height)
bsp.split_recursive(
depth=3,
min_width=5, min_height=5,
max_vertical_ratio=1.5, max_horizontal_ratio=1.5
)
node_names = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
current_node_name_index = 0
print('digraph {')
for node in bsp.post_order():
try:
node_name = getattr(node, 'viz_name')
except AttributeError:
node_name = node_names[current_node_name_index]
setattr(node, 'viz_name', node_name)
bounds = (node.x, node.y, node.width, node.height)
print(f' {node_name} [label=\"{current_node_name_index}: {bounds}\"]')
current_node_name_index += 1
if node.children:
node_name = getattr(node, 'viz_name')
left_child_name = getattr(node.children[0], 'viz_name')
right_child_name = getattr(node.children[1], 'viz_name')
print(f' {node_name} -> {left_child_name}')
print(f' {node_name} -> {right_child_name}')
print('}')
if __name__ == '__main__':
import sys
result = main(sys.argv)
sys.exit(0 if not result else result)

44
ca.py
View file

@ -1,44 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Run the cellular atomaton from ErynRL and print the results.
'''
import argparse
from erynrl import log
from erynrl.geometry import Point, Rect, Size
from erynrl.map.generator.cellular_atomata import CellularAtomataMapGenerator
def parse_args(argv, *a, **kw):
'''Parse command line arguments'''
parser = argparse.ArgumentParser(*a, **kw)
parser.add_argument('--rounds', type=int, default=5)
args = parser.parse_args(argv)
return args
def main(argv):
'''The script'''
args = parse_args(argv[1:], prog=argv[0])
log.init()
bounds = Rect(Point(), Size(60, 20))
config = CellularAtomataMapGenerator.Configuration()
config.number_of_rounds = args.rounds
gen = CellularAtomataMapGenerator(bounds, config)
gen.generate()
print(gen)
if __name__ == '__main__':
import sys
result = main(sys.argv)
sys.exit(0 if not result else result)

View file

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

View file

@ -1,78 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''Main module'''
import argparse
import sys
import tcod
from . import log
from .configuration import Configuration, FontConfiguration, FontConfigurationError
from .engine import Engine
from .geometry import Size
from .interface import Interface
TITLE = 'ErynRL'
def parse_args(argv, *a, **kw):
parser = argparse.ArgumentParser(*a, **kw)
parser.add_argument('--debug', action='store_true', default=True)
parser.add_argument('--font')
parser.add_argument('--sandbox', action='store_true', default=False)
args = parser.parse_args(argv)
return args
def main(argv):
'''
Beginning of the game
Parameters
----------
argv : List[str]
A standard argument list, most likely you'll get this from sys.argv
'''
args = parse_args(argv[1:], prog=argv[0])
log.init()
try:
font = args.font
if font:
font_config = FontConfiguration.with_filename(font)
else:
font_config = FontConfiguration.default_configuration()
except FontConfigurationError as error:
log.ROOT.error('Unable to create a font configuration: %s', error)
return -1
configuration = Configuration(
console_font_configuration=font_config,
map_size=Size(80, 40),
sandbox=args.sandbox)
engine = Engine(configuration)
interface = Interface(configuration.console_size, engine)
tileset = configuration.console_font_configuration.tileset
with tcod.context.new(
columns=interface.console.width,
rows=interface.console.height,
tileset=tileset,
title=TITLE) as context:
interface.run_event_loop(context)
return 0
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()

View file

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

View file

@ -1,48 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
from typing import TYPE_CHECKING
from ..object import Actor
from .result import ActionResult
if TYPE_CHECKING:
from ..engine import Engine
class Action:
'''An action with no specific actor'''
def __init__(self, actor: Actor):
super().__init__()
self.actor = actor
# pylint: disable=unused-argument
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.
'''
return self.success()
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 self.__class__.__name__
def __repr__(self):
return f'{self.__class__.__name__}()'

View file

@ -1,215 +0,0 @@
'''
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
WaitAction
'''
import random
from typing import TYPE_CHECKING
from .. import items
from .. import log
from ..geometry import Vector
from ..object import Actor, Item
from .action import Action
from .result import ActionResult
if TYPE_CHECKING:
from ..engine import Engine
class MoveAction(Action):
'''An abstract Action that requires a direction to complete.'''
def __init__(self, actor: Actor, direction: Vector):
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 : Vector
The direction to test
'''
def perform(self, engine: 'Engine') -> ActionResult:
new_position = self.actor.position + self.direction
position_is_in_bounds = engine.map.point_is_in_bounds(new_position)
position_is_walkable = engine.map.point_is_walkable(new_position)
for ent in engine.entities:
if new_position != ent.position or not ent.blocks_movement:
continue
entity_occupying_position = ent
break
else:
entity_occupying_position = None
log.ACTIONS.info(
'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()
# TODO: I'm passing entity_occupying_position into the ActionResult below, but the type checker doesn't
# understand that the entity is an Actor. I think I need some additional checks here.
if entity_occupying_position:
assert 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:
actor = self.actor
assert actor.fighter
new_position = actor.position + self.direction
log.ACTIONS.debug('Moving %s to %s', self.actor, new_position)
actor.position = new_position
try:
should_recover_hit_points = actor.fighter.passively_recover_hit_points(5)
if should_recover_hit_points:
return ActionResult(self, alternate=HealAction(actor, random.randint(1, 3)))
except AttributeError:
pass
return self.success()
class MeleeAction(MoveAction):
'''Perform a melee attack on another Actor'''
def __init__(self, actor: Actor, direction: Vector, target: Actor):
super().__init__(actor, direction)
self.target = target
def perform(self, engine: 'Engine') -> ActionResult:
assert self.actor.fighter and self.target.fighter
fighter = self.actor.fighter
target_fighter = self.target.fighter
try:
damage = fighter.attack_power - target_fighter.defense
if damage > 0 and self.target:
log.ACTIONS.debug('%s attacks %s for %d damage!', self.actor, self.target, damage)
self.target.fighter.hit_points -= damage
if self.actor == engine.hero:
engine.message_log.add_message(
f'You attack the {self.target.name} for {damage} damage!',
fg=(127, 255, 127))
elif self.target == engine.hero:
engine.message_log.add_message(
f'The {self.actor.name} attacks you for {damage} damage!',
fg=(255, 127, 127))
else:
log.ACTIONS.debug('%s attacks %s but does no damage!', self.actor, self.target)
if self.target.fighter.is_dead:
log.ACTIONS.info('%s is dead!', self.target)
return ActionResult(self, alternate=DieAction(self.target))
except AttributeError:
return self.failure()
else:
return self.success()
class WaitAction(Action):
'''Wait a turn'''
def perform(self, engine: 'Engine') -> ActionResult:
log.ACTIONS.debug('%s is waiting a turn', self.actor)
if self.actor == engine.hero:
assert self.actor.fighter
fighter = self.actor.fighter
should_recover_hit_points = fighter.passively_recover_hit_points(20)
if should_recover_hit_points:
return ActionResult(self, alternate=HealAction(self.actor, random.randint(1, 3)))
return self.success()
class DieAction(Action):
'''Kill an Actor'''
def perform(self, engine: 'Engine') -> ActionResult:
engine.kill_actor(self.actor)
if self.actor == engine.hero:
engine.message_log.add_message('You die...', fg=(255, 127, 127))
else:
engine.message_log.add_message(f'The {self.actor.name} dies', fg=(127, 255, 127))
if self.actor.yields_corpse_on_death:
log.ACTIONS.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()
class HealAction(Action):
'''Heal a target actor some number of hit points'''
def __init__(self, actor: 'Actor', hit_points_to_recover: int):
super().__init__(actor)
self.hit_points_to_recover = hit_points_to_recover
def perform(self, engine: 'Engine') -> ActionResult:
fighter = self.actor.fighter
if not fighter:
log.ACTIONS.error('Attempted to heal %s but it has no hit points', self.actor)
return self.failure()
fighter.hit_points += self.hit_points_to_recover
return self.success()

View file

@ -1,47 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from .action import Action
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})'

View file

@ -1,138 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
import random
from typing import TYPE_CHECKING, List, Optional
import numpy as np
import tcod
from . import log
from .actions.action import Action
from .actions.game import BumpAction, WaitAction
from .components import Component
from .geometry import Direction, Point
from .object import Entity
if TYPE_CHECKING:
from .engine import Engine
# pylint: disable=too-few-public-methods
class AI(Component):
'''An abstract class providing AI for an entity.'''
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):
'''Entity AI for a hostile enemy.
The entity will wander around until it sees the hero, at which point it will
beeline for her.
'''
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.AI.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.AI.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.AI.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)
try:
point_is_walkable = engine.map.point_is_walkable(new_position)
except ValueError:
point_is_walkable = False
if not overlaps_existing_entity and point_is_walkable:
if engine.map.visible[tuple(self.entity.position)]:
log.AI.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.AI.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]

View file

@ -1,117 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
import random
from enum import Enum
from typing import Optional, Tuple
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
# TODO: Factor this out into a dedicated Clock class
self.__ticks_since_last_passive_heal = 0
self.__ticks_for_next_passive_heal = 0
self._reset_passive_heal_clock()
@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
def passively_recover_hit_points(self, number_of_ticks: int) -> bool:
'''
Check the passive healing clock to see if this fighter should recover hit points. If not, increment the
counter.
Arguments
---------
number_of_ticks : int
The number of ticks to increment the clock
'''
if self.hit_points == self.maximum_hit_points:
self.__ticks_since_last_passive_heal = 0
if self.__ticks_since_last_passive_heal < self.__ticks_for_next_passive_heal:
self.__ticks_since_last_passive_heal += number_of_ticks
return False
self._reset_passive_heal_clock()
return True
def _reset_passive_heal_clock(self) -> None:
self.__ticks_since_last_passive_heal = 0
self.__ticks_for_next_passive_heal = random.randint(30, 70)
class Renderable(Component):
class Order(Enum):
'''
These values indicate the order that an entity with a Renderable
component should be rendered. Higher values are rendered later and
therefore on top of items with lower orderings.
'''
ITEM = 1000
ACTOR = 2000
HERO = 3000
def __init__(
self,
symbol: str,
order: Order = Order.ACTOR,
fg: Optional[Tuple[int, int, int]] = None,
bg: Optional[Tuple[int, int, int]] = None):
if len(symbol) != 1:
raise ValueError(f'Symbol string "{symbol}" must be of length 1')
self.symbol = symbol
'''The symbol that represents this renderable on the map'''
self.order = order
'''
Specifies the layer at which this entity is rendered. Higher values are
rendered later, and thus on top of lower values.
'''
self.foreground = fg
'''The foreground color of the entity'''
self.background = bg
'''The background color of the entity'''
def __repr__(self):
return f'{self.__class__.__name__}("{self.symbol}", {self.order}, {self.foreground}, {self.background})'

View file

@ -1,188 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Game configuration parameters.
'''
import os.path as osp
import re
from dataclasses import dataclass
from enum import Enum
from os import PathLike
from typing import Iterable
import tcod.tileset
from . import log
from .geometry import Size
CONSOLE_SIZE = Size(80, 50)
MAP_SIZE = Size(100, 100)
FONT_CP437 = 'terminal16x16_gs_ro.png'
FONT_BDF = 'ter-u32n.bdf'
class FontConfigurationError(Exception):
'''Invalid font configration based on available parameters'''
@dataclass
class FontConfiguration:
'''Configuration of the font to use for rendering the game'''
filename: str | PathLike[str]
@staticmethod
def __find_fonts_directory():
'''Walk up the filesystem tree from this file to find a `fonts` directory.'''
def walk_up_directories_of_path(path):
while path and path != '/':
path = osp.dirname(path)
yield path
for parent_dir in walk_up_directories_of_path(__file__):
possible_fonts_dir = osp.join(parent_dir, 'fonts')
if osp.isdir(possible_fonts_dir):
log.ROOT.info('Found fonts dir %s', possible_fonts_dir)
break
else:
return None
return possible_fonts_dir
@staticmethod
def default_configuration():
'''Return a default configuration: a tilesheet font configuration using `fonts/terminal16x16_gs_ro.png`.'''
fonts_directory = FontConfiguration.__find_fonts_directory()
if not fonts_directory:
message = "Couldn't find a fonts directory"
log.ROOT.error('%s', message)
raise FontConfigurationError(message)
font = osp.join(fonts_directory, 'terminal16x16_gs_ro.png')
if not osp.isfile(font):
message = f"Font file {font} doesn't exist"
log.ROOT.error("%s", message)
raise FontConfigurationError(message)
return FontConfiguration.with_filename(font)
@staticmethod
def with_filename(filename: str | PathLike[str]) -> 'FontConfiguration':
'''Return a FontConfig subclass based on the path to the filename'''
_, extension = osp.splitext(filename)
match extension:
case ".bdf":
return BDFFontConfiguration(filename)
case ".ttf":
return TTFFontConfiguration(filename)
case ".png":
# Attempt to find the tilesheet dimensions in the filename.
try:
match = re.match(r'^.*\(\d+\)x\(\d+\).*$', extension)
if not match:
return TilesheetFontConfiguration(filename)
rows, columns = int(match.group(1)), int(match.group(2))
return TilesheetFontConfiguration(
filename=filename,
dimensions=Size(columns, rows))
except ValueError:
return TilesheetFontConfiguration(filename)
case _:
raise FontConfigurationError(f'Unable to determine font configuration from {filename}')
@property
def tileset(self) -> tcod.tileset.Tileset:
'''Returns a tcod tileset based on the parameters of this font config'''
raise NotImplementedError()
@dataclass
class BDFFontConfiguration(FontConfiguration):
'''A font configuration based on a BDF file.'''
@property
def tileset(self) -> tcod.tileset.Tileset:
return tcod.tileset.load_bdf(self.filename)
@dataclass
class TTFFontConfiguration(FontConfiguration):
'''
A font configuration based on a TTF file. Since TTFs are variable width, a fixed tile size needs to be specified.
'''
tile_size: Size = Size(16, 16)
@property
def tileset(self) -> tcod.tileset.Tileset:
return tcod.tileset.load_truetype_font(self.filename, *self.tile_size)
@dataclass
class TilesheetFontConfiguration(FontConfiguration):
'''
Configuration for tilesheets. Unlike other font configurations, tilesheets must have their dimensions specified as
the number of sprites per row and number of rows.
'''
class Layout(Enum):
'''The layout of the tilesheet'''
CP437 = 1
TCOD = 2
dimensions: Size = Size(16, 16)
layout: Layout | Iterable[int] = Layout.CP437
@property
def tilesheet(self) -> Iterable[int]:
'''A tilesheet mapping for the given layout'''
if not self.layout:
return tcod.tileset.CHARMAP_CP437
if isinstance(self.layout, Iterable):
return self.layout
match self.layout:
case TilesheetFontConfiguration.Layout.CP437:
return tcod.tileset.CHARMAP_CP437
case TilesheetFontConfiguration.Layout.TCOD:
return tcod.tileset.CHARMAP_TCOD
@property
def tileset(self) -> tcod.tileset.Tileset:
'''A tcod tileset with the given parameters'''
return tcod.tileset.load_tilesheet(
self.filename,
self.dimensions.width,
self.dimensions.height,
self.tilesheet)
@dataclass
class Configuration:
'''
Configuration of the game engine
### Attributes
console_font_configuration : FontConfiguration
A configuration object that defines the font to use for rendering the console
console_size : Size
The size of the console in tiles
map_size : Size
The size of the map in tiles
sandbox : bool
If this flag is toggled on, the map is rendered with no shroud
'''
console_font_configuration: FontConfiguration
console_size: Size = CONSOLE_SIZE
map_size: Size = MAP_SIZE
sandbox: bool = False

View file

@ -1,242 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''Defines the core game engine.'''
import random
from typing import MutableSet
import tcod
from . import log
from . import monsters
from .actions.action import Action
from .actions.result import ActionResult
from .ai import HostileEnemy
from .configuration import Configuration
from .events import EngineEventHandler, GameOverEventHandler
from .map import Map
from .map.generator import RoomsAndCorridorsGenerator
from .map.generator.cellular_atomata import CellularAtomataMapGenerator
from .map.generator.corridor import ElbowCorridorGenerator
from .map.generator.room import (
BSPRectMethod,
CellularAtomatonRoomMethod,
OrRoomMethod,
RoomGenerator,
RectangularRoomMethod)
from .messages import MessageLog
from .object import Actor, Entity, Hero, Monster
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, config: Configuration):
self.configuration = config
self.current_turn = 1
self.did_begin_turn = False
self.did_successfully_process_actions_for_turn = False
self.rng = tcod.random.Random()
self.message_log = MessageLog()
map_generator = RoomsAndCorridorsGenerator(
RoomGenerator(
RoomGenerator.Configuration(
rect_method=BSPRectMethod(
BSPRectMethod.Configuration(number_of_rooms=30)),
room_method=OrRoomMethod(
methods=[
(0.2, CellularAtomatonRoomMethod(CellularAtomataMapGenerator.Configuration())),
(0.8, RectangularRoomMethod())
]
)
)
),
ElbowCorridorGenerator())
self.map = Map(config, map_generator)
self.event_handler = EngineEventHandler(self)
self.entities: MutableSet[Entity] = set()
try:
hero_start_position = self.map.up_stairs[0]
except IndexError:
hero_start_position = self.map.random_walkable_position()
self.hero = Hero(position=hero_start_position)
self.entities.add(self.hero)
while len(self.entities) < 25:
should_spawn_monster_chance = random.random()
if should_spawn_monster_chance < 0.1:
continue
while True:
random_start_position = self.map.random_walkable_position()
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.ENGINE.info('Spawning %s', monster)
self.entities.add(monster)
self.update_field_of_view()
self.message_log.add_message('Greetings adventurer!', fg=(127, 127, 255), stack=False)
def process_input_action(self, action: Action):
'''Process an Action from player input'''
if not isinstance(action, Action):
action.perform(self)
return
self.begin_turn()
log.ACTIONS_TREE.info('Processing Hero Actions')
log.ACTIONS_TREE.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:
self.did_successfully_process_actions_for_turn = False
return
self.did_successfully_process_actions_for_turn = True
self.process_entity_actions()
self.update_field_of_view()
self.finish_turn()
def process_entity_actions(self):
'''Run AI for entities that have them, and process actions from those AIs'''
hero_position = self.hero.position
# 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.
entities = sorted(
self.entities,
key=lambda e: e.position.euclidean_distance_to(hero_position))
log.ACTIONS_TREE.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.map.visible[tuple(ent.position)]:
log.ACTIONS_TREE.info('%s-> %s', '|' if i < len(entities) - 1 else '`', ent)
action = ent_ai.act(engine=self)
if action:
self._perform_action_until_done(action)
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)
if log.ACTIONS_TREE.isEnabledFor(log.INFO) and self.map.visible[tuple(action.actor.position)]:
if result.alternate:
alternate_string = f'{result.alternate.__class__.__name__}[{result.alternate.actor}]'
else:
alternate_string = str(result.alternate)
log.ACTIONS_TREE.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:
assert result.alternate is not None, f'Action {result.action} incomplete but no alternate action given'
action = result.alternate
result = action.perform(self)
if log.ACTIONS_TREE.isEnabledFor(log.INFO) and self.map.visible[tuple(action.actor.position)]:
if result.alternate:
alternate_string = f'{result.alternate.__class__.__name__}[{result.alternate.actor}]'
else:
alternate_string = str(result.alternate)
log.ACTIONS_TREE.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 update_field_of_view(self):
'''Compute visible area of the map based on the player's position and point of view.'''
self.map.update_visible_tiles(self.hero.position, self.hero.sight_radius)
# Add visible tiles to the explored grid
self.map.explored |= self.map.visible
def begin_turn(self) -> None:
'''Begin the current turn'''
if self.did_begin_turn:
return
if log.ROOT.isEnabledFor(log.INFO):
dashes = '-' * 20
log.ROOT.info('%s Turn %d %s', dashes, self.current_turn, dashes)
self.did_begin_turn = True
def finish_turn(self) -> None:
'''Finish the current turn and prepare for the next turn'''
if not self.did_successfully_process_actions_for_turn:
return
log.ROOT.info('Completed turn %d successfully', self.current_turn)
self._prepare_for_next_turn()
def _prepare_for_next_turn(self) -> None:
self.current_turn += 1
self.did_begin_turn = False
self.did_successfully_process_actions_for_turn = False
def kill_actor(self, actor: Actor) -> None:
'''Kill an entity. Remove it from the game.'''
if actor == self.hero:
# When the hero dies, the game is over.
log.ACTIONS.info('Time to die.')
self.event_handler = GameOverEventHandler(self)
else:
log.ACTIONS.info('%s dies', actor)
self.entities.remove(actor)

View file

@ -1,60 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
from typing import Optional, TYPE_CHECKING
import tcod
import tcod.event as tev
from .actions.action import Action
from .actions.game import BumpAction, WaitAction
from .geometry import Direction
if TYPE_CHECKING:
from .engine import Engine
class EngineEventHandler(tev.EventDispatch[Action]):
'''Handles event on behalf of the game engine, dispatching Actions back to the engine.'''
def __init__(self, engine: 'Engine'):
super().__init__()
self.engine = engine
def ev_keydown(self, event: tev.KeyDown) -> Optional[Action]:
action: Optional[Action] = None
hero = self.engine.hero
is_shift_pressed = bool(event.mod & tcod.event.Modifier.SHIFT)
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.PERIOD:
if not is_shift_pressed:
action = WaitAction(hero)
return action
class GameOverEventHandler(tev.EventDispatch[Action]):
'''When the game is over (the hero dies, the player quits, etc), this event handler takes over.'''
def __init__(self, engine: 'Engine'):
super().__init__()
self.engine = engine

View file

@ -1,325 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''A bunch of geometric primitives'''
import math
from dataclasses import dataclass
from typing import Iterator, Optional, overload, Tuple
@dataclass
class Point:
'''A two-dimensional point, with coordinates in X and Y axes'''
x: int = 0
y: int = 0
@property
def numpy_index(self) -> Tuple[int, int]:
'''Convert this Point into a tuple suitable for indexing into a numpy map array'''
return (self.x, self.y)
@property
def neighbors(self) -> Iterator['Point']:
'''Iterator over the neighboring points of `self` in all eight directions.'''
for direction in Direction.all():
yield self + direction
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
'''
if self == other:
return False
return (self.x - 1 <= other.x <= self.x + 1) and (self.y - 1 <= other.y <= self.y + 1)
def direction_to_adjacent_point(self, other: 'Point') -> Optional['Vector']:
'''
Given a point directly adjacent to `self`, return a Vector indicating in
which direction it is adjacent.
'''
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)
def manhattan_distance_to(self, other: 'Point') -> int:
'''Compute the Manhattan distance to another Point'''
return abs(self.x - other.x) + abs(self.y - other.y)
def __add__(self, other: 'Vector') -> '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 __sub__(self, other: 'Vector') -> '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 __lt__(self, other: 'Point') -> bool:
return self.x < other.x and self.y < other.y
def __le__(self, other: 'Point') -> bool:
return self.x <= other.x and self.y <= other.y
def __gt__(self, other: 'Point') -> bool:
return self.x > other.x and self.y > other.y
def __ge__(self, other: 'Point') -> bool:
return self.x >= other.x and self.y >= other.y
def __iter__(self):
yield self.x
yield self.y
def __str__(self):
return f'(x:{self.x}, y:{self.y})'
@dataclass
class Vector:
'''A two-dimensional vector, representing change in position in X and Y axes'''
dx: int = 0
dy: int = 0
@classmethod
def from_point(cls, point: Point) -> 'Vector':
'''Create a Vector from a Point'''
return Vector(point.x, point.y)
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[Vector]:
'''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
class Size:
'''A two-dimensional size, representing size in X (width) and Y (height) axes'''
width: int = 0
height: int = 0
@property
def numpy_shape(self) -> Tuple[int, int]:
'''Return a tuple suitable for passing into numpy array initializers for specifying the shape of the array.'''
return (self.width, self.height)
def __iter__(self):
yield self.width
yield self.height
def __str__(self):
return f'(w:{self.width}, h:{self.height})'
@dataclass
class Rect:
'''
A two-dimensional rectangle defined by an origin point and size
'''
origin: Point
size: Size
@staticmethod
def from_raw_values(x: int, y: int, width: int, height: int):
'''Create a rect from raw (unpacked from their struct) values'''
return Rect(Point(x, y), Size(width, height))
@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 self.origin.x + self.size.width // 2
@property
def mid_y(self) -> int:
'''The y-value of the center point of this rectangle.'''
return 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 end_x(self) -> int:
'''X-value beyond the end of the rectangle.'''
return self.origin.x + self.size.width
@property
def end_y(self) -> int:
'''Y-value beyond the end of the rectangle.'''
return self.origin.y + self.size.height
@property
def width(self) -> int:
'''The width of the rectangle. A convenience property for accessing `self.size.width`.'''
return self.size.width
@property
def height(self) -> int:
'''The height of the rectangle. A convenience property for accessing `self.size.height`.'''
return self.size.height
@property
def midpoint(self) -> Point:
'''A Point in the middle of the Rect'''
return Point(self.mid_x, self.mid_y)
@property
def corners(self) -> Iterator[Point]:
'''An iterator over the corners of this rectangle'''
yield self.origin
yield Point(self.max_x, self.min_y)
yield Point(self.min_x, self.max_y)
yield Point(self.max_x, self.max_y)
@property
def edges(self) -> Iterator[int]:
'''
An iterator over the edges of this Rect in the order of: `min_x`, `max_x`, `min_y`, `max_y`.
'''
yield self.min_x
yield self.max_x
yield self.min_y
yield self.max_y
def intersects(self, other: 'Rect') -> bool:
'''Returns `True` if `other` intersects this Rect.'''
if other.min_x > self.max_x:
return False
if other.max_x < self.min_x:
return False
if other.min_y > self.max_y:
return False
if other.max_y < self.min_y:
return False
return True
def inset_rect(self, top: int = 0, right: int = 0, bottom: int = 0, left: int = 0) -> 'Rect':
'''
Create 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))
@overload
def __contains__(self, other: Point) -> bool:
...
@overload
def __contains__(self, other: 'Rect') -> bool:
...
def __contains__(self, other: 'Point | Rect') -> bool:
if isinstance(other, Point):
return self.__contains_point(other)
if isinstance(other, Rect):
return self.__contains_rect(other)
raise TypeError(f'{self.__class__.__name__} cannot contain value of type {other.__class__.__name__}')
def __contains_point(self, pt: Point) -> bool:
return self.min_x <= pt.x <= self.max_x and self.min_y <= pt.y <= self.max_y
def __contains_rect(self, other: 'Rect') -> bool:
return (self.min_x <= other.min_x
and self.max_x >= other.max_x
and self.min_y <= other.min_y
and self.max_y >= other.max_y)
def __iter__(self):
yield tuple(self.origin)
yield tuple(self.size)
def __str__(self):
return f'[{self.origin}, {self.size}]'

View file

@ -1,78 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
The game's graphical user interface
'''
from typing import NoReturn
from tcod import event as tev
from tcod.console import Console
from tcod.context import Context
from .events import InterfaceEventHandler
from .window.info import InfoWindow
from .window.map import MapWindow
from .window.message_log import MessageLogWindow
from ..engine import Engine
from ..geometry import Rect, Size
class Interface:
'''The game's user interface'''
def __init__(self, size: Size, engine: Engine):
self.engine = engine
self.console = Console(*size.numpy_shape, order='F')
self.map_window = MapWindow(
Rect.from_raw_values(0, 0, size.width, size.height - 5),
engine.map,
engine.hero)
self.info_window = InfoWindow(
Rect.from_raw_values(0, size.height - 5, 28, 5))
self.message_window = MessageLogWindow(
Rect.from_raw_values(28, size.height - 5, size.width - 28, 5),
engine.message_log)
self.event_handler = InterfaceEventHandler(self)
def update(self):
'''Update game state that the interface needs to render'''
self.info_window.turn_count = self.engine.current_turn
hero = self.engine.hero
self.info_window.update_hero(hero)
sorted_entities = sorted(filter(lambda e: e.renderable is not None, self.engine.entities),
key=lambda e: e.renderable.order.value)
self.map_window.entities = sorted_entities
def draw(self):
'''Draw the UI to the console'''
self.map_window.draw(self.console)
self.info_window.draw(self.console)
self.message_window.draw(self.console)
def run_event_loop(self, context: Context) -> NoReturn:
'''Run the event loop forever. This method never returns.'''
while True:
self.update()
self.console.clear()
self.draw()
context.present(self.console)
for event in tev.wait():
context.convert_event(event)
did_handle = self.event_handler.dispatch(event)
if did_handle:
continue
action = self.engine.event_handler.dispatch(event)
if not action:
# The engine didn't handle the event, so just drop it.
continue
self.engine.process_input_action(action)

View file

@ -1,45 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
# pylint: disable=too-few-public-methods
'''
A bunch of colors.
'''
from typing import Iterator, Tuple
Color = Tuple[int, int, int]
# Grayscale
BLACK = (0x00, 0x00, 0x00)
GREY12 = (0x20, 0x20, 0x20)
GREY25 = (0x40, 0x40, 0x40)
GREY50 = (0x80, 0x80, 0x80)
GREY75 = (0xC0, 0xC0, 0xC0)
WHITE = (0xFF, 0xFF, 0xFF)
# Primaries
BLUE = (0x00, 0x00, 0xFF)
CYAN = (0x00, 0xFF, 0xFF)
GREEN = (0x00, 0xFF, 0x00)
MAGENTA = (0xFF, 0x00, 0xFF)
RED = (0xFF, 0x00, 0x00)
YELLOW = (0xFF, 0xFF, 0x00)
ORANGE = (0xFF, 0x77, 0x00)
# Semantic
class HealthBar:
'''Semantic colors for the health bar'''
FULL = GREEN
GOOD = GREEN
OKAY = YELLOW
LOW = ORANGE
CRITICAL = RED
@staticmethod
def bar_colors() -> Iterator[Tuple[float, Color]]:
'''Return an iterator of colors that a Bar class can use'''
yield (0.1, HealthBar.CRITICAL)
yield (0.25, HealthBar.LOW)
yield (0.75, HealthBar.OKAY)
yield (0.9, HealthBar.GOOD)
yield (1.0, HealthBar.FULL)

View file

@ -1,55 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''Defines event handling mechanisms.'''
from typing import NoReturn, TYPE_CHECKING
from tcod import event as tev
if TYPE_CHECKING:
from . import Interface
class InterfaceEventHandler(tev.EventDispatch[bool]):
'''The event handler for the user interface.'''
def __init__(self, interface: 'Interface'):
super().__init__()
self.interface = interface
self._handlers = []
self._refresh_handlers()
def _refresh_handlers(self):
self._handlers = [
self.interface.map_window.event_handler,
self.interface.message_window.event_handler,
self.interface.info_window.event_handler,
]
def ev_keydown(self, event: tev.KeyDown) -> bool:
return self._handle_event(event)
def ev_keyup(self, event: tev.KeyUp) -> bool:
return self._handle_event(event)
def ev_mousemotion(self, event: tev.MouseMotion) -> bool:
return self._handle_event(event)
def ev_mousebuttondown(self, event: tev.MouseButtonDown) -> bool:
return self._handle_event(event)
def ev_mousebuttonup(self, event: tev.MouseButtonUp) -> bool:
return self._handle_event(event)
def ev_quit(self, event: tev.Quit) -> NoReturn:
# TODO: Maybe show a "do you want to quit?" alert?
# TODO: Probably inform the engine that we're shutting down.
raise SystemExit()
def _handle_event(self, event: tev.Event) -> bool:
for handler in self._handlers:
if handler and handler.dispatch(event):
return True
return False

View file

@ -1,62 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
from typing import List, Optional, Tuple
from . import color
from ..geometry import Point
class PercentageBar:
'''A bar that expresses a percentage.'''
def __init__(self, *, position: Point, width: int, colors: Optional[List[Tuple[float, color.Color]]] = None):
'''
Instantiate a new Bar
Arguments
---------
position : Point
The position within a console to render this bar
width : int
The length of the bar in tiles
colors : List[Tuple[float, color.Color]]
A list of two-tuples specifying a percentage and color to draw the bar. If the bar is less than or equal to
the specified percentage, that color will be chosen. For example, if the bar is 45% filled, and this colors
array is specified:
```
[(0.25, RED), (0.5, ORANGE), (0.75, YELLOW), (1.0, GREEN)]
```
The bar will be painted `ORANGE` because 45% is greater than 25% and less than 50%.
'''
self.position = position
self.width = width
self.colors = sorted(colors, key=lambda c: c[0]) if colors is not None else []
self._percent_filled = 1.0
@property
def percent_filled(self) -> float:
'''The percentage of this bar that should be filled, as a value between 0.0 and 1.0.'''
return self._percent_filled
@percent_filled.setter
def percent_filled(self, value):
self._percent_filled = min(1, max(0, value))
def render_to_console(self, console):
'''Draw this bar to the console'''
# Draw the background first
console.draw_rect(x=self.position.x, y=self.position.y, width=self.width, height=1, ch=1, bg=color.GREY12)
percent_filled = self._percent_filled
if percent_filled > 0:
for color_spec in self.colors:
if percent_filled <= color_spec[0]:
bar_color = color_spec[1]
break
else:
bar_color = color.GREY50
filled_width = round(self._percent_filled * self.width)
console.draw_rect(x=self.position.x, y=self.position.y, width=filled_width, height=1, ch=1, bg=bar_color)

View file

@ -1,97 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Declares the Window class.
'''
from typing import Generic, Optional, TypeVar
from tcod import event as tev
from tcod.console import Console
from ...geometry import Point, Rect, Vector
WindowT = TypeVar('WindowT', bound='Window')
class Window:
'''A user interface window. It can be framed and it can handle events.'''
class EventHandler(tev.EventDispatch[bool], Generic[WindowT]):
'''
Handles events for a Window. Event dispatch methods return True if the event
was handled and no further action is needed.
'''
def __init__(self, window: WindowT):
super().__init__()
self.window = window
def mouse_point_for_event(self, event: tev.MouseState) -> Point:
'''
Return the mouse point in tiles for a window event. Raises a ValueError
if the event is not a mouse event.
'''
if not isinstance(event, tev.MouseState):
raise ValueError("Can't get mouse point for non-mouse event")
return Point(event.tile.x, event.tile.y)
def ev_keydown(self, event: tev.KeyDown) -> bool:
return False
def ev_keyup(self, event: tev.KeyUp) -> bool:
return False
def ev_mousemotion(self, event: tev.MouseMotion) -> bool:
mouse_point = self.mouse_point_for_event(event)
if mouse_point not in self.window.bounds:
return False
return False
def __init__(self, bounds: Rect, *, framed: bool = True, event_handler: Optional['EventHandler'] = None):
self.bounds = bounds
'''The window's bounds in console coordinates'''
self.is_framed = framed
'''A `bool` indicating whether the window has a frame'''
self.event_handler = event_handler or self.__class__.EventHandler(self)
'''The window's event handler'''
@property
def drawable_bounds(self) -> Rect:
'''
A rectangle in console coordinates defining the area of the window that
is drawable, inset by the window's frame if it has one.
'''
if self.is_framed:
return self.bounds.inset_rect(1, 1, 1, 1)
return self.bounds
def convert_console_point_to_window(self, point: Point, *, use_drawable_bounds: bool = False) -> Optional[Point]:
'''
Converts a point in console coordinates to window coordinates. If the
point is out of bounds of the window, return None.
'''
bounds = self.drawable_bounds if use_drawable_bounds else self.bounds
if point in bounds:
return point - Vector.from_point(bounds.origin)
return None
def draw(self, console: Console):
'''Draw the window to the conole'''
if self.is_framed:
console.draw_frame(
self.bounds.origin.x,
self.bounds.origin.y,
self.bounds.size.width,
self.bounds.size.height)
drawable_bounds = self.drawable_bounds
console.draw_rect(drawable_bounds.min_x, drawable_bounds.min_y,
drawable_bounds.width, drawable_bounds.height,
ord(' '), (255, 255, 255), (0, 0, 0))

View file

@ -1,47 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Declares the InfoWindow.
'''
from tcod.console import Console
from . import Window
from ..color import HealthBar
from ..percentage_bar import PercentageBar
from ...geometry import Point, Rect
from ...object import Hero
class InfoWindow(Window):
'''A window that displays information about the player'''
def __init__(self, bounds: Rect):
super().__init__(bounds, framed=True)
self.turn_count: int = 0
drawable_area = self.drawable_bounds
self.hit_points_bar = PercentageBar(
position=Point(drawable_area.min_x + 6, drawable_area.min_y),
width=20,
colors=list(HealthBar.bar_colors()))
def update_hero(self, hero: Hero):
'''Update internal state for the hero'''
assert hero.fighter
fighter = hero.fighter
hp, max_hp = fighter.hit_points, fighter.maximum_hit_points
self.hit_points_bar.percent_filled = hp / max_hp
def draw(self, console: Console):
super().draw(console)
drawable_bounds = self.drawable_bounds
console.print(x=drawable_bounds.min_x + 2, y=drawable_bounds.min_y, string='HP:')
self.hit_points_bar.render_to_console(console)
if self.turn_count:
console.print(x=drawable_bounds.min_x, y=drawable_bounds.min_y + 1, string=f'Turn: {self.turn_count}')

View file

@ -1,209 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Declares the MapWindow class.
'''
from typing import List
import numpy as np
import tcod.event as tev
from tcod.console import Console
from . import Window
from ... import log
from ...geometry import Point, Rect, Vector
from ...map import Map
from ...object import Entity, Hero
class MapWindow(Window):
'''A Window that displays a game map'''
class EventHandler(Window.EventHandler['MapWindow']):
'''An event handler for the MapWindow.'''
def ev_mousemotion(self, event: tev.MouseMotion) -> bool:
mouse_point = self.mouse_point_for_event(event)
converted_point = self.window.convert_console_point_to_window(mouse_point, use_drawable_bounds=True)
if not converted_point:
return False
hero = self.window.hero
if not hero:
return False
map_point = self.window.convert_console_point_to_map(mouse_point)
log.UI.info('Mouse moved; finding path from hero to %s', map_point)
map_ = self.window.map
path = map_.find_walkable_path_from_point_to_point(hero.position, map_point)
map_.highlight_points(path)
return False
def ev_mousebuttondown(self, event: tev.MouseButtonDown) -> bool:
mouse_point = self.mouse_point_for_event(event)
converted_point = self.window.convert_console_point_to_window(mouse_point, use_drawable_bounds=True)
if not converted_point:
return False
map_point = self.window.convert_console_point_to_map(mouse_point)
log.UI.info('Mouse button down at %s', map_point)
return False
def __init__(self, bounds: Rect, map: Map, hero: Hero, **kwargs):
super().__init__(bounds, event_handler=self.__class__.EventHandler(self), **kwargs)
self.map = map
'''The game map'''
self.visible_map_bounds = map.bounds
'''A rectangle in map coordinates defining the visible area of the map in the window'''
self.hero = hero
'''The hero entity'''
self.entities: List[Entity] = []
'''A list of all game entities to render on the map'''
self._draw_bounds = self.drawable_bounds
'''
A rectangle in console coordinates where the map will actually be drawn.
This area should always be entirely contained within the window's
drawable bounds.
'''
def convert_console_point_to_map(self, point: Point) -> Point:
'''
Convert a point in console coordinates to a point relative to the map's
origin point.
'''
return point - Vector.from_point(self._draw_bounds.origin) + Vector.from_point(self.visible_map_bounds.origin)
def _update_visible_map_bounds(self) -> Rect:
'''
Figure out what portion of the map is visible. This method attempts to
keep the hero centered in the map viewport, while not overscrolling the
map in either direction.
'''
bounds = self.drawable_bounds
map_bounds = self.map.bounds
viewport_is_wider_than_map = bounds.width > map_bounds.width
viewport_is_taller_than_map = bounds.height > map_bounds.height
if viewport_is_wider_than_map and viewport_is_taller_than_map:
# The whole map fits within the window's drawable bounds
return map_bounds
# Attempt to keep the player centered in the viewport.
hero_point = self.hero.position
if viewport_is_wider_than_map:
x = 0
width = map_bounds.width
else:
half_width = bounds.width // 2
x = min(max(0, hero_point.x - half_width), map_bounds.end_x - bounds.width)
width = bounds.width
if viewport_is_taller_than_map:
y = 0
height = map_bounds.height
else:
half_height = bounds.height // 2
y = min(max(0, hero_point.y - half_height), map_bounds.end_y - bounds.height)
height = bounds.height
return Rect.from_raw_values(x, y, width, height)
def _update_draw_bounds(self):
'''
The area where the map should actually be drawn, accounting for the size
of the viewport (`drawable_bounds`) and the size of the map (`self.map.bounds`).
'''
visible_map_bounds = self.visible_map_bounds
drawable_bounds = self.drawable_bounds
viewport_is_wider_than_map = drawable_bounds.width >= visible_map_bounds.width
viewport_is_taller_than_map = drawable_bounds.height >= visible_map_bounds.height
if viewport_is_wider_than_map:
# Center the map horizontally in the viewport
x = drawable_bounds.min_x + (drawable_bounds.width - visible_map_bounds.width) // 2
width = visible_map_bounds.width
else:
x = drawable_bounds.min_x
width = drawable_bounds.width
if viewport_is_taller_than_map:
# Center the map vertically in the viewport
y = drawable_bounds.min_y + (drawable_bounds.height - visible_map_bounds.height) // 2
height = visible_map_bounds.height
else:
y = drawable_bounds.min_y
height = drawable_bounds.height
draw_bounds = Rect.from_raw_values(x, y, width, height)
assert draw_bounds in self.drawable_bounds
return draw_bounds
def draw(self, console: Console):
super().draw(console)
self.visible_map_bounds = self._update_visible_map_bounds()
self._draw_bounds = self._update_draw_bounds()
self._draw_map(console)
self._draw_entities(console)
def _draw_map(self, console: Console):
drawable_map_bounds = self.visible_map_bounds
map_slice = np.s_[
drawable_map_bounds.min_x: drawable_map_bounds.max_x + 1,
drawable_map_bounds.min_y: drawable_map_bounds.max_y + 1]
console_draw_bounds = self._draw_bounds
console_slice = np.s_[
console_draw_bounds.min_x: console_draw_bounds.max_x + 1,
console_draw_bounds.min_y: console_draw_bounds.max_y + 1]
console.tiles_rgb[console_slice] = self.map.composited_tiles[map_slice]
def _draw_entities(self, console: Console):
visible_map_bounds = self.visible_map_bounds
map_bounds_vector = Vector.from_point(self.visible_map_bounds.origin)
draw_bounds_vector = Vector.from_point(self._draw_bounds.origin)
for ent in self.entities:
entity_position = ent.position
# Only draw entities that are within the visible map bounds
if entity_position not in visible_map_bounds:
continue
# Only draw entities that are in the field of view
if not self.map.point_is_visible(entity_position):
continue
renderable = ent.renderable
if not renderable:
continue
# Entity positions are relative to the (0, 0) point of the Map. In
# order to render them in the correct position in the console, we
# need to transform them into viewport-relative coordinates.
map_tile_at_entity_position = self.map.composited_tiles[entity_position.numpy_index]
position = ent.position - map_bounds_vector + draw_bounds_vector
console.print(
x=position.x,
y=position.y,
string=renderable.symbol,
fg=renderable.foreground,
bg=tuple(map_tile_at_entity_position['bg'][:3]))

View file

@ -1,23 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Declares the MessageLogWindow.
'''
from tcod.console import Console
from . import Window
from ...geometry import Rect
from ...messages import MessageLog
class MessageLogWindow(Window):
'''A window that displays a list of messages'''
def __init__(self, bounds: Rect, message_log: MessageLog):
super().__init__(bounds, framed=True)
self.message_log = message_log
def draw(self, console: Console):
super().draw(console)
self.message_log.render_to_console(console, self.drawable_bounds)

View file

@ -1,38 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
from dataclasses import dataclass
from typing import Optional, 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: Optional[Tuple[int, int, int]] = None
Corpse = Item(
'%',
name="Corpse",
description="The corpse of a once-living being",
foreground_color=(128, 128, 255))

View file

@ -1,98 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Initializes and sets up logging for the game.
'''
import json
import logging
import logging.config
import os.path
from typing import Iterator, Optional
# These are re-imports so clients of this module don't have to also import logging
# pylint: disable=unused-import
from logging import CRITICAL, DEBUG, ERROR, FATAL, INFO, NOTSET, WARN, WARNING
def _log_name(*components):
return '.'.join(['erynrl'] + list(components))
ROOT = logging.getLogger(_log_name())
AI = logging.getLogger(_log_name('ai'))
ACTIONS = logging.getLogger(_log_name('actions'))
ACTIONS_TREE = logging.getLogger(_log_name('actions', 'tree'))
ENGINE = logging.getLogger(_log_name('engine'))
EVENTS = logging.getLogger(_log_name('events'))
UI = logging.getLogger(_log_name('ui'))
MAP = logging.getLogger(_log_name('map'))
MAP_BSP = logging.getLogger(_log_name('map', 'bsp'))
MAP_CELL_ATOM = logging.getLogger(_log_name('map', 'cellular'))
def walk_up_directories_of_path(path: str) -> Iterator[str]:
'''
Walk up a path, yielding each directory, until the root of the filesystem is
found.
### Parameters
`path`: `str`
The starting path
### Returns
Yields each ancestor directory until the root directory of the filesystem is
reached.
'''
while path and path != '/':
if os.path.isdir(path):
yield path
path = os.path.dirname(path)
def find_logging_config() -> Optional[str]:
'''
Walk up the filesystem from this script to find a logging_config.json
### Returns
The path to a logging configuration file, or `None` if no such file was found
'''
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):
ROOT.info('Found logging config file %s', possible_logging_config_file)
break
else:
return None
return possible_logging_config_file
def init(config_file: Optional[str] = None):
'''
Set up the logging system by (preferrably) reading a logging configuration file.
### Parameters
`config_file`: Optional[str]
Path to a file containing a Python logging configuration in JSON
'''
logging_config_path = config_file if config_file else find_logging_config()
if logging_config_path and os.path.isfile(logging_config_path):
ROOT.info('Found logging configuration at %s', 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)
else:
ROOT.info(
"Couldn't find logging configuration at %s; using default configuration",
logging_config_path)
root_logger = logging.getLogger('')
root_logger.setLevel(logging.DEBUG)
stderr_handler = logging.StreamHandler()
stderr_handler.setFormatter(logging.Formatter("%(asctime)s %(name)s: %(message)s"))
root_logger.addHandler(stderr_handler)

View file

@ -1,132 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
This module defines the level map, a number of basic building blocks (Rooms, etc), and objects that generate various
parts of a map.
'''
import random
from typing import Iterable, List
import numpy as np
import tcod
from ..configuration import Configuration
from ..geometry import Point, Rect, Size
from .generator import MapGenerator
from .room import Corridor, Room
from .tile import Empty, Shroud
class Map:
'''A level map'''
def __init__(self, config: Configuration, generator: MapGenerator):
self.configuration = config
map_size = config.map_size
self._bounds = Rect(Point(), map_size)
shape = map_size.numpy_shape
self.tiles = np.full(shape, fill_value=Empty, order='F')
self.highlighted = np.full(shape, fill_value=False, order='F')
# Map tiles that are currently visible to the player
self.visible = np.full(shape, fill_value=False, order='F')
# Map tiles that the player has explored
should_mark_all_tiles_explored = config.sandbox
self.explored = np.full(shape, fill_value=should_mark_all_tiles_explored, order='F')
self.__walkable_points = None
generator.generate(self)
# Map Features
self.rooms: List[Room] = []
self.corridors: List[Corridor] = []
self.up_stairs = generator.up_stairs
self.down_stairs = generator.down_stairs
@property
def bounds(self) -> Rect:
'''The bounds of the map'''
return self._bounds
@property
def size(self) -> Size:
'''The size of the map'''
return self.configuration.map_size
@property
def composited_tiles(self) -> np.ndarray:
# TODO: Hold onto the result here so that this doen't have to be done every time this property is called.
return np.select(
condlist=[
self.highlighted,
self.visible,
self.explored],
choicelist=[
self.tiles['highlighted'],
self.tiles['light'],
self.tiles['dark']],
default=Shroud)
def update_visible_tiles(self, point: Point, radius: int):
field_of_view = tcod.map.compute_fov(self.tiles['transparent'], tuple(point), radius=radius)
# The player's computed field of view
self.visible[:] = field_of_view
def random_walkable_position(self) -> Point:
'''Return a random walkable point on the map.'''
if not self.__walkable_points:
self.__walkable_points = [Point(x, y) for x, y in np.ndindex(
self.tiles.shape) if self.tiles[x, y]['walkable']]
return random.choice(self.__walkable_points)
def point_is_in_bounds(self, point: Point) -> bool:
'''Return True if the given point is inside the bounds of the map'''
return 0 <= point.x < self.size.width and 0 <= point.y < self.size.height
def point_is_walkable(self, point: Point) -> bool:
'''Return True if the tile at the given point is walkable'''
if not self.point_is_in_bounds(point):
raise ValueError(f'Point {point!s} is not in bounds')
return self.tiles[point.numpy_index]['walkable']
def point_is_visible(self, point: Point) -> bool:
'''Return True if the point is visible to the player'''
if not self.point_is_in_bounds(point):
raise ValueError(f'Point {point!s} is not in bounds')
return self.visible[point.numpy_index]
def point_is_explored(self, point: Point) -> bool:
'''Return True if the tile at the given point has been explored by the player'''
if not self.point_is_in_bounds(point):
raise ValueError(f'Point {point!s} is not in bounds')
return self.explored[point.numpy_index]
def highlight_points(self, points: Iterable[Point]):
'''Update the highlight graph with the list of points to highlight.'''
self.highlighted.fill(False)
for pt in points:
self.highlighted[pt.numpy_index] = True
def find_walkable_path_from_point_to_point(self, point_a: Point, point_b: Point) -> Iterable[Point]:
'''
Find a path between point A and point B using tcod's A* implementation.
'''
a_star = tcod.path.AStar(self.tiles['walkable'])
path = a_star.get_path(point_a.x, point_a.y, point_b.x, point_b.y)
return map(lambda t: Point(t[0], t[1]), path)
def __str__(self):
string = ''
tiles = self.tiles['light']['ch']
for row in tiles:
string += ''.join(chr(n) for n in row) + '\n'
return string

View file

@ -1,56 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
This module defines a bunch of mechanisms for generating maps.
'''
from typing import List, TYPE_CHECKING
from .corridor import CorridorGenerator
from .room import RoomGenerator
from ...geometry import Point
if TYPE_CHECKING:
from .. import Map
class MapGenerator:
'''Abstract base class defining an interface for generating a map and applying it to a set of tiles.'''
@property
def up_stairs(self) -> List[Point]:
'''The location of any routes to a higher floor of the dungeon.'''
raise NotImplementedError()
@property
def down_stairs(self) -> List[Point]:
'''The location of any routes to a lower floor of the dungeon.'''
raise NotImplementedError()
def generate(self, map: 'Map'):
'''Generate a map and place it in `tiles`'''
raise NotImplementedError()
class RoomsAndCorridorsGenerator(MapGenerator):
'''
Generates a classic "rooms and corridors" style map with the given room and corridor generators.
'''
def __init__(self, room_generator: RoomGenerator, corridor_generator: CorridorGenerator):
self.room_generator = room_generator
self.corridor_generator = corridor_generator
@property
def up_stairs(self) -> List[Point]:
return self.room_generator.up_stairs
@property
def down_stairs(self) -> List[Point]:
return self.room_generator.down_stairs
def generate(self, map: 'Map'):
self.room_generator.generate(map)
self.room_generator.apply(map)
self.corridor_generator.generate(map)
self.corridor_generator.apply(map)

View file

@ -1,133 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
import random
from dataclasses import dataclass
from typing import Optional, TYPE_CHECKING
import numpy as np
from ... import log
from ...geometry import Point, Rect, Vector
from ..tile import Empty, Floor, Wall, tile_datatype
if TYPE_CHECKING:
from .. import Map
class CellularAtomataMapGenerator:
'''
A map generator that utilizes a cellular atomaton to place floors and walls.
'''
@dataclass
class Configuration:
'''
Configuration of a cellular atomaton map generator.
### Attributes
`fill_percentage` : `float`
The percentage of tiles to fill with Floor when the map is seeded.
`number_of_rounds` : `int`
The number of rounds to run the atomaton. 5 is a good default. More
rounds results in smoother output; fewer rounds results in more
jagged, random output.
'''
fill_percentage: float = 0.5
number_of_rounds: int = 5
def __init__(self, bounds: Rect, config: Optional[Configuration] = None):
'''
Initializer
### Parameters
`bounds` : `Rect`
A rectangle representing the bounds of the cellular atomaton
`config` : `Optional[Configuration]`
A configuration object specifying parameters for the atomaton. If
None, the instance will use a default configuration.
'''
self.bounds = bounds
self.configuration = config if config else CellularAtomataMapGenerator.Configuration()
self.tiles = np.full((bounds.size.height, bounds.size.width), fill_value=Empty, dtype=tile_datatype, order='C')
def generate(self):
'''
Run the cellular atomaton on a grid of `self.bounds.size` shape.
First fill the grid with random Floor and Wall tiles according to
`self.configuration.fill_percentage`, then run the simulation for
`self.configuration.number_of_rounds` rounds.
'''
self._fill()
self._run_atomaton()
def apply(self, map: 'Map'):
origin = self.bounds.origin
for y, x in np.ndindex(self.tiles.shape):
map_pt = origin + Vector(x, y)
tile = self.tiles[y, x]
if tile == Floor:
map.tiles[map_pt.numpy_index] = tile
def _fill(self):
fill_percentage = self.configuration.fill_percentage
for y, x in np.ndindex(self.tiles.shape):
self.tiles[y, x] = Floor if random.random() < fill_percentage else Empty
def _run_atomaton(self):
alternate_tiles = np.full((self.bounds.size.height, self.bounds.size.width),
fill_value=Empty, dtype=tile_datatype, order='C')
number_of_rounds = self.configuration.number_of_rounds
if number_of_rounds < 1:
raise ValueError('Refusing to run cellular atomaton for less than 1 round')
log.MAP_CELL_ATOM.info(
'Running cellular atomaton over %s for %d round%s',
self.bounds,
number_of_rounds,
'' if number_of_rounds == 1 else 's')
for i in range(number_of_rounds):
if i % 2 == 0:
from_tiles = self.tiles
to_tiles = alternate_tiles
else:
from_tiles = alternate_tiles
to_tiles = self.tiles
self._do_round(from_tiles, to_tiles)
# If we ended on a round where alternate_tiles was the "to" tile grid
# above, save it back to self.tiles.
if number_of_rounds % 2 == 0:
self.tiles = alternate_tiles
def _do_round(self, from_tiles: np.ndarray, to_tiles: np.ndarray):
for y, x in np.ndindex(from_tiles.shape):
pt = Point(x, y)
# Start with 1 because the point is its own neighbor
number_of_neighbors = 1
for neighbor in pt.neighbors:
try:
if from_tiles[neighbor.y, neighbor.x] == Floor:
number_of_neighbors += 1
except IndexError:
pass
idx = (pt.y, pt.x)
tile_is_alive = from_tiles[idx] == Floor
if tile_is_alive and number_of_neighbors >= 5:
# Survival
to_tiles[idx] = Floor
elif not tile_is_alive and number_of_neighbors >= 5:
# Birth
to_tiles[idx] = Floor
else:
to_tiles[idx] = Empty
def __str__(self):
return '\n'.join(''.join(chr(i['light']['ch']) for i in row) for row in self.tiles)

View file

@ -1,124 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Defines an abstract CorridorGenerator and several concrete subclasses. These classes generate corridors between rooms.
'''
import random
from itertools import pairwise
from typing import List, TYPE_CHECKING
import tcod
from ... import log
from ...geometry import Point
from ..room import Corridor, Room
from ..tile import Empty, Floor, Wall
if TYPE_CHECKING:
from .. import Map
class CorridorGenerator:
'''
Corridor generators produce corridors between rooms.
'''
def generate(self, map: 'Map') -> bool:
'''Generate corridors given a list of rooms.'''
raise NotImplementedError()
def apply(self, map: 'Map'):
'''Apply corridors to a tile grid.'''
raise NotImplementedError()
def _sorted_rooms(self, rooms: List[Room]) -> List[Room]:
return sorted(rooms, key=lambda r: r.bounds.origin)
class ElbowCorridorGenerator(CorridorGenerator):
'''
Generators corridors using a simple "elbow" algorithm:
```
For each pair of rooms:
1. Find the midpoint of the bounding rect of each room
2. For each pair of rooms:
1. Calculate an elbow point by taking the x coordinate of one room
and the y coordinate of the other room, choosing which x and which
y at random.
2. Draw a path from the midpoint of the first room to the elbow point
3. Draw a path from the elbow point to the midpoint of the second room
```
'''
def __init__(self):
self.corridors: List[Corridor] = []
def generate(self, map: 'Map') -> bool:
rooms = map.rooms
if len(rooms) < 2:
return True
sorted_rooms = self._sorted_rooms(rooms)
for left_room, right_room in pairwise(sorted_rooms):
corridor = self._generate_corridor_between(left_room, right_room)
self.corridors.append(corridor)
return True
def _generate_corridor_between(self, left_room, right_room) -> Corridor:
left_room_bounds = left_room.bounds
right_room_bounds = right_room.bounds
log.MAP.debug(' left: %s, %s', left_room, left_room_bounds)
log.MAP.debug('right: %s, %s', right_room, 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
horizontal_first = random.random() < 0.5
if horizontal_first:
corner = Point(end_point.x, start_point.y)
else:
corner = Point(start_point.x, end_point.y)
log.MAP.debug(
'Digging a tunnel between %s and %s with corner %s (%s)',
start_point,
end_point,
corner,
'horizontal' if horizontal_first else 'vertical')
log.MAP.debug('|-> start: %s', left_room_bounds)
log.MAP.debug('`-> end: %s', right_room_bounds)
corridor: List[Point] = []
for x, y in tcod.los.bresenham(tuple(start_point), tuple(corner)).tolist():
corridor.append(Point(x, y))
for x, y in tcod.los.bresenham(tuple(corner), tuple(end_point)).tolist():
corridor.append(Point(x, y))
return Corridor(points=corridor)
def apply(self, map: 'Map'):
tiles = map.tiles
map.corridors = self.corridors
for corridor in self.corridors:
for pt in corridor:
tiles[pt.x, pt.y] = Floor
for neighbor in pt.neighbors:
if not (0 <= neighbor.x < tiles.shape[0] and 0 <= neighbor.y < tiles.shape[1]):
continue
if tiles[neighbor.x, neighbor.y] == Empty:
tiles[neighbor.x, neighbor.y] = Wall
class NetHackCorridorGenerator(CorridorGenerator):
'''A corridor generator that produces doors and corridors that look like Nethack's Dungeons of Doom levels.'''

View file

@ -1,380 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
import math
import random
from dataclasses import dataclass
from typing import Iterable, Iterator, List, Optional, Tuple, TYPE_CHECKING
import numpy as np
import tcod
from ... import log
from ...geometry import Point, Rect, Size
from ..room import FreeformRoom, RectangularRoom, Room
from ..tile import Empty, Floor, StairsDown, StairsUp, Wall, tile_datatype
from .cellular_atomata import CellularAtomataMapGenerator
if TYPE_CHECKING:
from .. import Map
class RoomGenerator:
'''Abstract room generator class.'''
@dataclass
class Configuration:
'''
Configuration of a RoomGenerator
### Attributes
rect_method : RectMethod
A RectMethod object to produce rectangles
room_method : RoomMethod
A RoomMethod object to produce rooms from rectangles
'''
rect_method: 'RectMethod'
room_method: 'RoomMethod'
def __init__(self, config: Configuration):
self.configuration = config
self.rooms: List[Room] = []
self.up_stairs: List[Point] = []
self.down_stairs: List[Point] = []
def generate(self, map: 'Map'):
'''Generate rooms and stairs'''
rect_method = self.configuration.rect_method
room_method = self.configuration.room_method
for rect in rect_method.generate(map):
room = room_method.room_in_rect(rect)
if not room:
break
self.rooms.append(room)
if len(self.rooms) == 0:
return
self._generate_stairs()
def apply(self, map: 'Map'):
'''Apply the generated rooms to a tile array'''
self._apply(map)
self._apply_stairs(map)
def _apply(self, map: 'Map'):
'''
Apply the generated list of rooms to an array of tiles. Subclasses must implement this.
Arguments
---------
map: Map
The game map to apply the generated room to
'''
tiles = map.tiles
map.rooms = self.rooms
for room in self.rooms:
for pt in room.floor_points:
tiles[pt.numpy_index] = Floor
for room in self.rooms:
for pt in room.wall_points:
idx = pt.numpy_index
if tiles[idx] != Empty:
continue
tiles[idx] = Wall
def _generate_stairs(self):
up_stair_room = random.choice(self.rooms)
down_stair_room = None
if len(self.rooms) >= 2:
while down_stair_room is None or down_stair_room == up_stair_room:
down_stair_room = random.choice(self.rooms)
else:
down_stair_room = up_stair_room
self.up_stairs.append(random.choice(list(up_stair_room.walkable_tiles)))
self.down_stairs.append(random.choice(list(down_stair_room.walkable_tiles)))
def _apply_stairs(self, map: 'Map'):
tiles = map.tiles
map.up_stairs = self.up_stairs
map.down_stairs = self.down_stairs
for pt in self.up_stairs:
tiles[pt.numpy_index] = StairsUp
for pt in self.down_stairs:
tiles[pt.numpy_index] = StairsDown
class RectMethod:
'''An abstract class defining a method for generating rooms.'''
def generate(self, map: 'Map') -> Iterator[Rect]:
'''Generate rects to place rooms in until there are no more.'''
raise NotImplementedError()
class OneBigRoomRectMethod(RectMethod):
'''
A room generator method that yields one large rectangle centered in the
bounds defined by the zero origin and `self.size`.
'''
@dataclass
class Configuration:
'''
Configuration for a OneBigRoom room generator method.
### Attributes
width_percentage : float
The percentage of overall width to make the room
height_percentage : float
The percentage of overall height to make the room
'''
width_percentage: float = 0.5
height_percentage: float = 0.5
def __init__(self, config: Optional[Configuration] = None):
super().__init__()
self.configuration = config or self.__class__.Configuration()
def generate(self, map: 'Map') -> Iterator[Rect]:
map_size = map.bounds.size
width = map_size.width
height = map_size.height
size = Size(math.floor(width * self.configuration.width_percentage),
math.floor(height * self.configuration.height_percentage))
origin = Point((width - size.width) // 2, (height - size.height) // 2)
yield Rect(origin, size)
class RandomRectMethod(RectMethod):
NUMBER_OF_ATTEMPTS_PER_RECT = 30
@dataclass
class Configuration:
number_of_rooms: int = 30
minimum_room_size: Size = Size(7, 7)
maximum_room_size: Size = Size(20, 20)
def __init__(self, config: Optional[Configuration] = None):
self.configuration = config or self.__class__.Configuration()
self._rects: List[Rect] = []
def generate(self, map: 'Map') -> Iterator[Rect]:
minimum_room_size = self.configuration.minimum_room_size
maximum_room_size = self.configuration.maximum_room_size
width_range = (minimum_room_size.width, maximum_room_size.width)
height_range = (minimum_room_size.height, maximum_room_size.height)
map_size = map.size
while len(self._rects) < self.configuration.number_of_rooms:
for _ in range(self.__class__.NUMBER_OF_ATTEMPTS_PER_RECT):
size = Size(random.randint(*width_range), random.randint(*height_range))
origin = Point(random.randint(0, map_size.width - size.width),
random.randint(0, map_size.height - size.height))
candidate_rect = Rect(origin, size)
overlaps_any_existing_room = any(candidate_rect.intersects(r) for r in self._rects)
if not overlaps_any_existing_room:
break
else:
return
self._rects.append(candidate_rect)
yield candidate_rect
class BSPRectMethod(RectMethod):
'''
Generate rectangles with Binary Space Partitioning.
'''
@dataclass
class Configuration:
'''
Configuration for the binary space partitioning (BSP) Rect method.
### Attributes
number_of_rooms : int
The maximum number of rooms to produce
maximum_room_size : Size
The maximum size of any room
minimum_room_size : Size
The minimum size of any room
room_size_ratio : Tuple[float, float]
A pair of floats indicating the maximum proportion the sides of a
BSP node can have to each other.
The first value is the horizontal ratio. BSP nodes will never have a
horizontal size (width) bigger than `room_size_ratio[0]` times the
vertical size.
The second value is the vertical ratio. BSP nodes will never have a
vertical size (height) larger than `room_size_ratio[1]` times the
horizontal size.
The closer these values are to 1.0, the more square the BSP nodes
will be.
'''
number_of_rooms: int = 30
minimum_room_size: Size = Size(7, 7)
maximum_room_size: Size = Size(20, 20)
room_size_ratio: Tuple[float, float] = (1.1, 1.1)
def __init__(self, config: Optional[Configuration] = None):
self.configuration = config or self.__class__.Configuration()
def generate(self, map: 'Map') -> Iterator[Rect]:
nodes_with_rooms = set()
minimum_room_size = self.configuration.minimum_room_size
maximum_room_size = self.configuration.maximum_room_size
map_size = map.size
# Recursively divide the map into squares of various sizes to place rooms in.
bsp = tcod.bsp.BSP(x=0, y=0, width=map_size.width, height=map_size.height)
# Add 2 to the minimum width and height to account for walls
bsp.split_recursive(
depth=6,
min_width=minimum_room_size.width,
min_height=minimum_room_size.height,
max_horizontal_ratio=self.configuration.room_size_ratio[0],
max_vertical_ratio=self.configuration.room_size_ratio[1])
log.MAP_BSP.info('Generating room rects via BSP')
# Visit all nodes in a level before visiting any of their children
for bsp_node in bsp.level_order():
node_width = bsp_node.w
node_height = bsp_node.h
if node_width > maximum_room_size.width or node_height > maximum_room_size.height:
log.MAP_BSP.debug('Node with size (%s, %s) exceeds maximum size %s',
node_width, node_height, maximum_room_size)
continue
if len(nodes_with_rooms) >= self.configuration.number_of_rooms:
# Made as many rooms as we're allowed. We're done.
log.MAP_BSP.debug("Generated enough rooms (more than %d); we're done",
self.configuration.number_of_rooms)
return
if any(node in nodes_with_rooms for node in self.__all_parents_of_node(bsp_node)):
# Already made a room for one of this node's parents
log.MAP_BSP.debug('Already made a room for parent of %s', bsp_node)
continue
try:
probability_of_room = max(
1.0 / (node_width - minimum_room_size.width),
1.0 / (node_height - minimum_room_size.height))
except ZeroDivisionError:
probability_of_room = 1.0
log.MAP_BSP.info('Probability of generating room for %s: %f', bsp_node, probability_of_room)
if random.random() <= probability_of_room:
log.MAP_BSP.info('Yielding room for node %s', bsp_node)
nodes_with_rooms.add(bsp_node)
yield self.__rect_from_bsp_node(bsp_node)
log.MAP_BSP.info('Finished BSP room rect generation, yielded %d rooms', len(nodes_with_rooms))
def __rect_from_bsp_node(self, bsp_node: tcod.bsp.BSP) -> Rect:
return Rect.from_raw_values(bsp_node.x, bsp_node.y, bsp_node.w, bsp_node.h)
def __all_parents_of_node(self, node: tcod.bsp.BSP | None) -> Iterable[tcod.bsp.BSP]:
while node:
yield node
node = node.parent
class RoomMethod:
'''An abstract class defining a method for generating rooms.'''
def room_in_rect(self, rect: Rect) -> Optional[Room]:
'''Create a Room inside the given Rect.'''
raise NotImplementedError()
class RectangularRoomMethod(RoomMethod):
def room_in_rect(self, rect: Rect) -> Optional[Room]:
return RectangularRoom(rect)
class CellularAtomatonRoomMethod(RoomMethod):
def __init__(self, cellular_atomaton_config: CellularAtomataMapGenerator.Configuration):
self.cellular_atomaton_configuration = cellular_atomaton_config
def room_in_rect(self, rect: Rect) -> Optional[Room]:
# The cellular atomaton doesn't generate any walls, just floors and
# emptiness. Inset it by 1 all the way around so that we can draw walls
# around it.
atomaton_rect = rect.inset_rect(1, 1, 1, 1)
room_generator = CellularAtomataMapGenerator(atomaton_rect, self.cellular_atomaton_configuration)
room_generator.generate()
# Create a new tile array and copy the result of the atomaton into it,
# then draw walls everywhere that neighbors a floor tile.
width = rect.width
height = rect.height
room_tiles = np.full((height, width), fill_value=Empty, dtype=tile_datatype, order='C')
room_tiles[1:height - 1, 1:width - 1] = room_generator.tiles
for y, x in np.ndindex(room_tiles.shape):
if room_tiles[y, x] == Floor:
continue
for neighbor in Point(x, y).neighbors:
try:
if room_tiles[neighbor.y, neighbor.x] != Floor:
continue
room_tiles[y, x] = Wall
break
except IndexError:
pass
return FreeformRoom(rect, room_tiles)
class OrRoomMethod(RoomMethod):
'''
A room generator method that picks between several RoomMethods at random
based on a set of probabilities.
'''
def __init__(self, methods: Iterable[Tuple[float, RoomMethod]]):
assert sum(m[0] for m in methods) == 1.0
self.methods = methods
def room_in_rect(self, rect: Rect) -> Optional[Room]:
factor = random.random()
threshold = 0
for method in self.methods:
threshold += method[0]
if factor <= threshold:
return method[1].room_in_rect(rect)
return None

View file

@ -1,15 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Utilities for maps.
'''
import numpy as np
from .tile import Empty
from ..geometry import Size
def make_grid(size: Size, fill: np.ndarray = Empty) -> np.ndarray:
'''Make a numpy array of the given size filled with `fill` tiles.'''
return np.full(size.numpy_shape, fill_value=fill, order='F')

View file

@ -1,138 +0,0 @@
# Eryn Wells <eryn@erynwels.me>
'''
Implements an abstract Room class, and subclasses that implement it. Rooms are basic components of maps.
'''
from typing import Iterable, Iterator, List, Optional
import numpy as np
from ..geometry import Point, Rect, Vector
from .tile import Floor, Wall
class Room:
'''An abstract room. It can be any size or shape.'''
def __init__(self, bounds: Rect):
self.bounds: Rect = bounds
@property
def center(self) -> Point:
'''The center of the room, truncated according to integer math rules'''
return self.bounds.midpoint
@property
def wall_points(self) -> Iterable[Point]:
'''An iterator over all the points that make up the walls of this room.'''
raise NotImplementedError()
@property
def floor_points(self) -> Iterable[Point]:
'''An iterator over all the points that make of the floor of this room'''
raise NotImplementedError()
@property
def walkable_tiles(self) -> Iterable[Point]:
'''An iterator over all the points that are walkable in this room.'''
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.
'''
@property
def walkable_tiles(self) -> Iterable[Point]:
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 wall_points(self) -> Iterable[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 x in range(min_x, max_x + 1):
yield Point(x, min_y)
yield Point(x, max_y)
for y in range(min_y + 1, max_y):
yield Point(min_x, y)
yield Point(max_x, y)
@property
def floor_points(self) -> Iterable[Point]:
inset_bounds = self.bounds.inset_rect(1, 1, 1, 1)
min_y = inset_bounds.min_y
max_y = inset_bounds.max_y
min_x = inset_bounds.min_x
max_x = inset_bounds.max_x
for x in range(min_x, max_x + 1):
for y in range(min_y, max_y + 1):
yield Point(x, y)
def __repr__(self) -> str:
return f'{self.__class__.__name__}({self.bounds})'
class FreeformRoom(Room):
def __init__(self, bounds: Rect, tiles: np.ndarray):
super().__init__(bounds)
self.tiles = tiles
@property
def floor_points(self) -> Iterable[Point]:
room_origin_vector = Vector.from_point(self.bounds.origin)
for y, x in np.ndindex(self.tiles.shape):
if self.tiles[y, x] == Floor:
yield Point(x, y) + room_origin_vector
@property
def wall_points(self) -> Iterable[Point]:
room_origin_vector = Vector.from_point(self.bounds.origin)
for y, x in np.ndindex(self.tiles.shape):
if self.tiles[y, x] == Wall:
yield Point(x, y) + room_origin_vector
@property
def walkable_tiles(self) -> Iterable[Point]:
room_origin_vector = Vector.from_point(self.bounds.origin)
for y, x in np.ndindex(self.tiles.shape):
if self.tiles[y, x]['walkable']:
yield Point(x, y) + room_origin_vector
def __str__(self):
return '\n'.join(''.join(chr(i['light']['ch']) for i in row) for row in self.tiles)
class Corridor:
'''
A corridor is a list of points connecting two endpoints
'''
def __init__(self, points: Optional[List[Point]] = None):
self.points: List[Point] = points or []
@property
def length(self) -> int:
'''The length of this corridor'''
return len(self.points)
def __iter__(self) -> Iterator[Point]:
for pt in self.points:
yield pt

View file

@ -1,73 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Map tiles and tile related things.
Maps are represented with 2-dimensional numpy arrays with the `dtype`s defined
here. Tiles are instances of those dtypes.
'''
from typing import Tuple
import numpy as np
graphic_datatype = np.dtype([
# Character, a Unicode codepoint represented as an int32
('ch', np.int32),
# Foreground color, three bytes
('fg', '4B'),
# Background color, three bytes
('bg', '4B'),
])
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),
# A graphic struct (as above) defining the look of this tile when it's highlighted
('highlighted', graphic_datatype),
])
def tile(*,
walkable: int,
transparent: int,
dark: Tuple[int, Tuple[int, int, int, int], Tuple[int, int, int, int]],
light: Tuple[int, Tuple[int, int, int, int], Tuple[int, int, int, int]],
highlighted: Tuple[int, Tuple[int, int, int, int], Tuple[int, int, int, int]]) -> np.ndarray:
return np.array((walkable, transparent, dark, light, highlighted), dtype=tile_datatype)
# An overlay color for tiles that are not visible and have not been explored
Shroud = np.array((ord(' '), (255, 255, 255, 255), (0, 0, 0, 0)), dtype=graphic_datatype)
Empty = tile(
walkable=False, transparent=False,
dark=(ord('#'), (20, 20, 20, 255), (0, 0, 0, 0)),
light=(ord('#'), (20, 20, 20, 255), (0, 0, 0, 0)),
highlighted=(ord('#'), (20, 20, 20, 255), (30, 30, 30, 255)))
Floor = tile(
walkable=True, transparent=True,
dark=(ord('·'), (80, 80, 100, 255), (50, 50, 50, 255)),
light=(ord('·'), (100, 100, 120, 255), (80, 80, 100, 255)),
highlighted=(ord('·'), (100, 100, 120, 255), (80, 80, 150, 255)))
StairsUp = tile(
walkable=True, transparent=True,
dark=(ord('<'), (80, 80, 100, 255), (50, 50, 50, 255)),
light=(ord('<'), (100, 100, 120, 255), (80, 80, 100, 255)),
highlighted=(ord('<'), (100, 100, 120, 255), (80, 80, 150, 255)))
StairsDown = tile(
walkable=True, transparent=True,
dark=(ord('>'), (80, 80, 100, 255), (50, 50, 50, 255)),
light=(ord('>'), (100, 100, 120, 255), (80, 80, 100, 255)),
highlighted=(ord('>'), (100, 100, 120, 255), (80, 80, 150, 255)))
Wall = tile(
walkable=False, transparent=False,
dark=(ord('#'), (80, 80, 80, 255), (0, 0, 0, 255)),
light=(ord('#'), (100, 100, 100, 255), (20, 20, 20, 255)),
highlighted=(ord('#'), (100, 100, 100, 255), (20, 20, 20, 255)))

View file

@ -1,93 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''
Defines the classes the support the in-game message log. Messages are recorded to the log as game actions are handled. A
short buffer of messages is displayed in the game's HUD, and a full list of messages can be viewed by the player at any
time.
'''
import textwrap
from typing import List, Optional, Reversible, Tuple
import tcod
from .geometry import Rect
class Message:
'''A message in the message log
Attributes
----------
text : str
The text of the message
foreground : Tuple[int, int, int]
The foreground color to render the message with
count : int
The number of times this message has stacked
'''
def __init__(self, text: str, fg: Optional[Tuple[int, int, int]] = None):
self.text = text
self.foreground = fg
self.count = 1
@property
def full_text(self) -> str:
'''The full text of the message, including a count of repeats, if present'''
if self.count == 1:
return self.text
return f'{self.text} (x{self.count})'
def __str__(self) -> str:
return self.text
def __repr__(self) -> str:
return f'{self.__class__.__name__}({repr(self.text)}, fg={self.foreground})'
class MessageLog:
'''A buffer of messages sent to the player by the game'''
def __init__(self):
self.messages: List[Message] = []
def add_message(self, text: str, fg: Optional[Tuple[int, int, int]] = None, stack: bool = True):
'''
Add a message to the buffer
Parameters
----------
text : str
The text of the message
fg : Tuple[int, int, int], optional
A foreground color to render the text
stack : bool
If True and the previous message in the buffer is the same as the text given, increment the count of that
message rather than adding a new message to the buffer
'''
if stack and self.messages and self.messages[-1].text == text:
self.messages[-1].count += 1
else:
self.messages.append(Message(text, fg))
def render_to_console(self, console: tcod.console.Console, rect: Rect):
'''Render this message log to the given console in the given rect'''
self.render_messages(console, rect, self.messages)
@staticmethod
def render_messages(console: tcod.console.Console, rect: Rect, messages: Reversible[Message]):
'''Render a list of messages to the console in the given rect'''
y_offset = min(rect.size.height, len(messages)) - 1
for message in reversed(messages):
wrapped_text = textwrap.wrap(message.full_text, rect.size.width)
for line in wrapped_text:
console.print(x=rect.min_x, y=rect.min_y + y_offset, string=line, fg=message.foreground)
y_offset -= 1
if y_offset < 0:
break
if y_offset < 0:
break

View file

@ -1,52 +0,0 @@
# 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 Optional, 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: Optional[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)

View file

@ -1,187 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
'''Defines a number of high-level game objects. The parent class of all game objects is the Entity class.'''
from typing import TYPE_CHECKING, Optional, Type
import tcod
from . import items
from .components import Fighter, Renderable
from .geometry import Point
from .monsters import Species
if TYPE_CHECKING:
from .ai import AI
class Entity:
'''A single-tile drawable entity with a symbol and position
### Attributes
identifier : int
A numerical value that uniquely identifies this entity across the entire
game
position : Point
The Entity's location on the map
blocks_movement : bool
True if this Entity blocks other Entities from moving through its
position
'''
# A monotonically increasing identifier to help differentiate between
# entities that otherwise look identical
__next_identifier = 1
def __init__(
self,
*,
position: Optional[Point] = None,
blocks_movement: Optional[bool] = True,
renderable: Optional[Renderable] = None):
self.identifier = Entity.__next_identifier
self.position = position if position else Point()
self.renderable = renderable
self.blocks_movement = blocks_movement
Entity.__next_identifier += 1
def __str__(self):
return f'{self.__class__.__name__}!{self.identifier}'
def __repr__(self):
return f'{self.__class__.__name__}(position={self.position!r}, blocks_movement={self.blocks_movement}, renderable={self.renderable!r})'
class Actor(Entity):
'''
An actor is an abstract class that defines an object that can act in the
game world. Entities that are actors will be allowed an opportunity to
perform an action during each game turn.
### Attributes
ai : AI, optional
If an entity can act on its own behalf, an instance of an AI class
fighter : Fighter, optional
If an entity can fight or take damage, an instance of the Fighter class.
This is where hit points, attack power, defense power, etc live.
'''
def __init__(
self,
*,
position: Optional[Point] = None,
blocks_movement: Optional[bool] = True,
renderable: Optional[Renderable] = None,
ai: Optional['AI'] = None,
fighter: Optional[Fighter] = None):
super().__init__(
position=position,
blocks_movement=blocks_movement,
renderable=renderable)
# Components
self.ai = ai
self.fighter = fighter
@property
def name(self) -> str:
'''The name of this actor. This is a player-visible string.'''
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__}(position={self.position!r}, fighter={self.fighter!r}, ai={self.ai!r}, renderable={self.renderable!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),
renderable=Renderable('@', Renderable.Order.HERO, tuple(tcod.white)))
@property
def name(self) -> str:
return 'Hero'
@property
def sight_radius(self) -> int:
# TODO: Make this configurable
return 0
def __str__(self) -> str:
assert self.fighter
return f'Hero!{self.identifier} at {self.position} with {self.fighter.hit_points}/{self.fighter.maximum_hit_points} hp'
class Monster(Actor):
'''An instance of a Species'''
def __init__(self, species: Species, ai_class: Type['AI'], position: Optional[Point] = None):
fighter = Fighter(
maximum_hit_points=species.maximum_hit_points,
attack_power=species.attack_power,
defense=species.defense)
super().__init__(
ai=ai_class(self),
position=position,
fighter=fighter,
renderable=Renderable(
symbol=species.symbol,
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:
assert self.fighter
return f'{self.name}!{self.identifier} 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: Optional[Point] = None, name: Optional[str] = None):
super().__init__(position=position,
blocks_movement=False,
renderable=Renderable(
symbol=kind.symbol,
order=Renderable.Order.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

File diff suppressed because it is too large Load diff

View file

@ -1,14 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"editor.formatOnSave": true,
"python.analysis.typeCheckingMode": "basic",
"files.exclude": {
".venv/": true
}
}
}

View file

@ -1,85 +0,0 @@
{
"version": 1,
"formatters": {
"default": {
"format": "%(asctime)s %(name)s: %(message)s",
"datefmt": "%Y-%m-%d %I:%M:%S"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "DEBUG",
"stream": "ext://sys.stdout"
}
},
"loggers": {
"erynrl": {
"level": "INFO",
"handlers": [
"console"
],
"propagate": false
},
"erynrl.ai": {
"level": "ERROR",
"handlers": [
"console"
],
"propagate": false
},
"erynrl.actions": {
"level": "ERROR",
"handlers": [
"console"
]
},
"erynrl.actions.movement": {
"level": "ERROR",
"handlers": [
"console"
]
},
"erynrl.actions.tree": {
"level": "ERROR",
"handlers": [
"console"
],
"propagate": false
},
"erynrl.events": {
"level": "WARN",
"handlers": [
"console"
],
"propagate": false
},
"erynrl.map": {
"level": "DEBUG",
"handlers": [
"console"
],
"propagate": false
},
"erynrl.ui": {
"level": "INFO",
"handlers": [
"console"
],
"propagate": false
},
"erynrl.visible": {
"level": "WARN",
"handlers": [
"console"
]
}
},
"root": {
"level": "DEBUG",
"handlers": [
"console"
]
}
}

View file

@ -1,23 +1,5 @@
astroid==2.14.1
attrs==22.2.0
autopep8==2.0.1
cffi==1.15.0
dill==0.3.6
exceptiongroup==1.1.0
iniconfig==2.0.0
isort==5.12.0
lazy-object-proxy==1.9.0
mccabe==0.7.0
numpy==1.22.3
packaging==23.0
platformdirs==3.0.0
pluggy==1.0.0
pycodestyle==2.10.0
pycparser==2.21
pylint==2.16.1
pytest==7.2.1
tcod==13.6.1
tomli==2.0.1
tomlkit==0.11.6
typing_extensions==4.2.0
wrapt==1.14.1

15
roguebasin/__main__.py Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
from . import actions
from . import events
from . import geometry
from . import main
from . import map
from . import object
from . import tile
if __name__ == '__main__':
import sys
result = main.main(sys.argv)
sys.exit(0 if not result else result)

51
roguebasin/actions.py Normal file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
import logging
from .geometry import Vector
LOG = logging.getLogger('events')
class Action:
def perform(self, engine: 'Engine', entity: 'Entity') -> None:
'''
Perform this action. This is an abstract method that all subclasses
should implement.
'''
raise NotImplementedError()
def __repr__(self):
return f'{self.__class__.__name__}()'
class ExitAction(Action):
def perform(self, engine: 'Engine', entity: 'Entity') -> None:
raise SystemExit()
class RegenerateRoomsAction(Action):
def perform(self, engine: 'Engine', entity: 'Entity') -> None:
...
class MovePlayerAction(Action):
class Direction:
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)
def __init__(self, direction: Direction):
self.direction = direction
def perform(self, engine: 'Engine', entity: 'Entity') -> None:
new_player_position = entity.position + self.direction
position_is_in_bounds = engine.map.tile_is_in_bounds(new_player_position)
position_is_walkable = engine.map.tile_is_walkable(new_player_position)
overlaps_another_entity = any(new_player_position == ent.position for ent in engine.entities if ent is not entity)
LOG.debug(f'Attempting to move player to {new_player_position} (in_bounds:{position_is_in_bounds} walkable:{position_is_walkable} overlaps:{overlaps_another_entity})')
if position_is_in_bounds and position_is_walkable and not overlaps_another_entity:
entity.position = new_player_position

52
roguebasin/engine.py Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
import logging
import tcod
from .actions import ExitAction, MovePlayerAction, RegenerateRoomsAction
from .events import EventHandler
from .geometry import Point, Size
from .map import Map
from .object import Entity
from typing import AbstractSet
LOG = logging.getLogger('engine')
EVENT_LOG = logging.getLogger('events')
class Configuration:
def __init__(self, map_size: Size):
self.map_size = map_size
self.random_seed = None
class Engine:
def __init__(self, event_handler: EventHandler, configuration: Configuration):
self.event_handler = event_handler
self.configuration = configuration
self.rng = tcod.random.Random(seed=configuration.random_seed)
map_size = configuration.map_size
self.map = Map(map_size)
first_room = self.map.generator.rooms[0]
player_start_position = first_room.center
self.player = Entity('@', position=player_start_position, fg=tcod.white)
self.entities: AbstractSet[Entity] = {self.player}
for _ in range(self.rng.randint(1, 15)):
position = Point(self.rng.randint(0, map_size.width), self.rng.randint(0, map_size.height))
self.entities.add(Entity('@', position=position, fg=tcod.yellow))
def handle_event(self, event: tcod.event.Event):
action = self.event_handler.dispatch(event)
if not action:
return
action.perform(self, self.player)
def print_to_console(self, console):
self.map.print_to_console(console)
for ent in self.entities:
ent.print_to_console(console)

28
roguebasin/events.py Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
import tcod
from .actions import Action, ExitAction, MovePlayerAction, RegenerateRoomsAction
from typing import Optional
class EventHandler(tcod.event.EventDispatch[Action]):
def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
return ExitAction()
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
action: Optional[Action] = None
sym = event.sym
if sym == tcod.event.KeySym.h:
action = MovePlayerAction(MovePlayerAction.Direction.West)
elif sym == tcod.event.KeySym.j:
action = MovePlayerAction(MovePlayerAction.Direction.South)
elif sym == tcod.event.KeySym.k:
action = MovePlayerAction(MovePlayerAction.Direction.North)
elif sym == tcod.event.KeySym.l:
action = MovePlayerAction(MovePlayerAction.Direction.East)
elif sym == tcod.event.KeySym.SPACE:
action = RegenerateRoomsAction()
return action

96
roguebasin/geometry.py Normal file
View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
from dataclasses import dataclass
from typing import Any, Tuple, overload
@dataclass(frozen=True)
class Point:
x: int = 0
y: int = 0
@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:
dx: int = 0
dy: int = 0
def __iter__(self):
yield self.dx
yield self.dy
def __str__(self):
return f'(δx:{self.x}, δy:{self.y})'
@dataclass(frozen=True)
class Size:
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:
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:
return Point(self.mid_x, self.mid_y)
def __iter__(self):
yield tuple(self.origin)
yield tuple(self.size)
def __str__(self):
return f'[{self.origin}, {self.size}]'

82
roguebasin/main.py Normal file
View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
'''
New script.
'''
import argparse
import logging
import os.path
import random
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):
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 find_fonts_directory():
'''Walk up the filesystem tree from this script to find a fonts/ directory.'''
parent_dir = os.path.dirname(__file__)
while parent_dir and parent_dir != '/':
possible_fonts_dir = os.path.join(parent_dir, 'fonts')
LOG.debug(f'Checking for fonts dir at {possible_fonts_dir}')
if os.path.isdir(possible_fonts_dir):
LOG.info(f'Found fonts dir: {possible_fonts_dir}')
return possible_fonts_dir
parent_dir = os.path.dirname(parent_dir)
else:
return None
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(f"Font file {font} doesn't exist")
return -1
tileset = tcod.tileset.load_tilesheet(font, 16, 16, tcod.tileset.CHARMAP_CP437)
console = tcod.Console(CONSOLE_WIDTH, CONSOLE_HEIGHT, order='F')
event_handler = EventHandler()
configuration = Configuration(map_size=Size(MAP_WIDTH, MAP_HEIGHT))
engine = Engine(event_handler, configuration)
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)
for event in tcod.event.wait():
engine.handle_event(event)

133
roguebasin/map.py Normal file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
import logging
import numpy as np
import tcod
from .geometry import Point, Rect, Size
from .tile import Floor, Wall
from typing import List, Optional
LOG = logging.getLogger('map')
class Map:
def __init__(self, size: Size):
self.size = size
self.generator = RoomsAndCorridorsGenerator(size=size)
self.tiles = self.generator.generate()
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:
size = self.size
console.tiles_rgb[0:size.width, 0:size.height] = self.tiles["dark"]
class MapGenerator:
def __init__(self, *, size: Size):
self.size = size
def generate(self) -> np.ndarray:
'''
Generate a tile grid
Subclasses should implement this and fill in their specific map
generation algorithm.
'''
raise NotImplementedError()
class RoomsAndCorridorsGenerator(MapGenerator):
'''Generate a rooms-and-corridors style map with BSP.'''
class Configuration:
def __init__(self, min_room_size: Size):
self.minimum_room_size = min_room_size
DefaultConfiguration = Configuration(
min_room_size=Size(8, 8)
)
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['RectanularRoom'] = []
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
# 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,
min_width=minimum_room_size.width, min_height=minimum_room_size.height,
max_horizontal_ratio=1.5, max_vertical_ratio=1.5)
tiles = np.full(tuple(self.size), fill_value=Wall, order='F')
# Generate the rooms
rooms: List['RectangularRoom'] = []
# For nicer debug logging
indent = 0
for node in bsp.pre_order():
node_bounds = self.__rect_from_bsp_node(node)
if node.children:
if LOG.getEffectiveLevel() == logging.DEBUG:
LOG.debug(f'{" " * indent}{node_bounds}')
indent += 2
# TODO: Connect the two child rooms
else:
LOG.debug(f'{" " * indent}{node_bounds} (room) {node}')
size = Size(self.rng.randint(5, min(15, max(5, node.width - 2))),
self.rng.randint(5, min(15, max(5, node.height - 2))))
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(f'{" " * indent}`-> {bounds}')
room = RectangularRoom(bounds)
rooms.append(room)
if LOG.getEffectiveLevel() == logging.DEBUG:
indent -= 2
self.rooms = rooms
for room in rooms:
bounds = room.bounds
tiles[bounds.min_x:bounds.max_x, bounds.min_y:bounds.max_y] = Floor
self.tiles = tiles
return tiles
def generate_tunnel(self, start_room_bounds: Rect, end_room_bounds: Rect):
pass
def __rect_from_bsp_node(self, node: tcod.bsp.BSP) -> Rect:
return Rect(Point(node.x, node.y), Size(node.width, node.height))
class RectangularRoom:
def __init__(self, bounds: Rect):
self.bounds = bounds
@property
def center(self) -> Point:
return self.bounds.midpoint
def __repr__(self) -> str:
return f'{self.__class__.__name__}({self.bounds})'

27
roguebasin/object.py Normal file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
import tcod
from .geometry import Point
from typing import Optional
class Entity:
'''A single-tile drawable entity with a symbol and position.'''
def __init__(self, symbol: str, *,
position: Optional[Point] = None,
fg: Optional[tcod.Color] = None,
bg: Optional[tcod.Color] = None):
self.position = position if position else Point()
self.foreground = fg if fg else tcod.white
self.background = bg
self.symbol = symbol
def print_to_console(self, console: tcod.Console) -> None:
console.print(x=self.position.x, y=self.position.y, string=self.symbol, fg=self.foreground, bg=self.background)
def __str__(self):
return f'{self.symbol}[{self.position}]'
def __repr__(self):
return f'{self.__class__.__name__}({self.symbol}, position={self.position}, fg={self.foreground}, bg={self.background})'

29
roguebasin/tile.py Normal file
View file

@ -0,0 +1,29 @@
#!/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),
])
def tile(*, walkable: int, transparent: int, dark: Tuple[int, Tuple[int, int, int], Tuple[int, int ,int]]) -> np.ndarray:
return np.array((walkable, transparent, dark), dtype=tile_datatype)
Floor = tile(walkable=True, transparent=True, dark=(ord(' '), (255, 255, 255), (50, 50, 150)))
Wall = tile(walkable=False, transparent=False, dark=(ord(' '), (255, 255, 255), (0, 0, 150)))

8
tcod.code-workspace Normal file
View file

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

View file

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

View file

@ -1,52 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
from erynrl.geometry import Point
def test_point_neighbors():
'''Check that Point.neighbors returns all neighbors'''
test_point = Point(5, 5)
expected_neighbors = set([
Point(4, 4),
Point(5, 4),
Point(6, 4),
Point(4, 5),
# Point(5, 5),
Point(6, 5),
Point(4, 6),
Point(5, 6),
Point(6, 6),
])
neighbors = set(test_point.neighbors)
for pt in expected_neighbors:
assert pt in neighbors
assert expected_neighbors - neighbors == set(), \
f"Found some points that didn't belong in the set of neighbors of {test_point}"
def test_point_manhattan_distance():
'''Check that the Manhattan Distance calculation on Points is correct'''
point_a = Point(3, 2)
point_b = Point(8, 5)
assert point_a.manhattan_distance_to(point_b) == 8
def test_point_is_adjacent_to():
'''Check that Point.is_adjacent_to correctly computes adjacency'''
test_point = Point(5, 5)
assert not test_point.is_adjacent_to(test_point), \
f"{test_point!s} should not be considered adjacent to itself"
for neighbor in test_point.neighbors:
assert test_point.is_adjacent_to(neighbor), \
f"Neighbor {neighbor!s} that was not considered adjacent to {test_point!s}"
assert not test_point.is_adjacent_to(Point(3, 5))
assert not test_point.is_adjacent_to(Point(7, 5))
assert not test_point.is_adjacent_to(Point(5, 3))
assert not test_point.is_adjacent_to(Point(5, 7))

View file

@ -1,18 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
from erynrl.geometry import Point, Rect, Size
def test_rect_corners():
rect = Rect(Point(5, 5), Size(5, 5))
corners = set(rect.corners)
expected_corners = set([
Point(5, 5),
Point(9, 5),
Point(5, 9),
Point(9, 9)
])
assert corners == expected_corners

View file

@ -1,34 +0,0 @@
# Eryn Wells <eryn@erynwells.me>
from erynrl.geometry import Point, Rect, Size
from erynrl.map.room import RectangularRoom
def test_rectangular_room_wall_points():
'''Check that RectangularRoom.wall_points returns the correct set of points'''
rect = Rect(Point(5, 5), Size(5, 5))
room = RectangularRoom(rect)
expected_points = set([
Point(5, 5),
Point(6, 5),
Point(7, 5),
Point(8, 5),
Point(9, 5),
Point(9, 6),
Point(9, 7),
Point(9, 8),
Point(9, 9),
Point(8, 9),
Point(7, 9),
Point(6, 9),
Point(5, 9),
Point(5, 8),
Point(5, 7),
Point(5, 6),
])
for pt in room.wall_points:
expected_points.remove(pt)
assert len(expected_points) == 0