Compare commits
236 commits
geometry-t
...
main
Author | SHA1 | Date | |
---|---|---|---|
a650e1db49 | |||
327cc90b2e | |||
02ed3d1e4a | |||
def79386d8 | |||
01b549bc6e | |||
f78bc39e3b | |||
b0d91c9c5d | |||
e1523cd9c0 | |||
078520678d | |||
1018febeab | |||
879e0c680d | |||
eda44a8792 | |||
7ee790e25e | |||
2d82d9834f | |||
a8bbc47668 | |||
1e678ff47d | |||
003aedf30e | |||
ee1c6f2222 | |||
fd068268f5 | |||
040803fe61 | |||
edbc76d2ff | |||
635aea5e3b | |||
e6327deeef | |||
dd8b0364e0 | |||
c17258bd73 | |||
744c63bc85 | |||
e3864d8468 | |||
af3d93ba11 | |||
42cfb78ba3 | |||
4b09d467d1 | |||
84e51a17ff | |||
368f780fcd | |||
85928a938d | |||
c488ef9c2b | |||
e2553cca3b | |||
b9c45f44b2 | |||
a83b85b7a0 | |||
a542bb956a | |||
be7198b16d | |||
6aefff838d | |||
b8e7e3d059 | |||
09480e7499 | |||
706a244382 | |||
445c600bf9 | |||
22ad73af0b | |||
47014d4e6e | |||
9bd287dc9f | |||
21c3b5d94f | |||
885868f39e | |||
5470ea697c | |||
64844d124b | |||
30727ccac1 | |||
37ffa423b6 | |||
7428d95126 | |||
06d34a527b | |||
462eebd95c | |||
306d6fd13f | |||
8fc5206e95 | |||
292fa852f9 | |||
d5e8891545 | |||
e377b3d7b6 | |||
4050ac5c6f | |||
633580e27a | |||
e6c4717e80 | |||
77fc9467bc | |||
356e205f2c | |||
402e910915 | |||
0a6ff23dcd | |||
b5f25822df | |||
36206b5cc0 | |||
7e00f58a40 | |||
8efd3ce207 | |||
ec28f984da | |||
b0b75f7e76 | |||
8aa329d368 | |||
1aa6d14540 | |||
a709f3fba5 | |||
253db06683 | |||
2851ce36a0 | |||
d8275725b8 | |||
cf31bcc272 | |||
84f7bdb947 | |||
06ae79ccd0 | |||
6780b0495c | |||
df4df06013 | |||
2f9864edd8 | |||
6c9d01771f | |||
f05dfdef55 | |||
727a0737c6 | |||
7b0b7ff5b6 | |||
0d0c5a2b35 | |||
85b059dbd4 | |||
9d00f3b638 | |||
643ab0990b | |||
c59dc1b907 | |||
d4c4b5d879 | |||
d8dfe5c497 | |||
350876347b | |||
771926088c | |||
fff3260d01 | |||
d9aa8097c5 | |||
ac5efa7518 | |||
aacc5d56ca | |||
8e9b130ba7 | |||
dabc9e70dd | |||
391f84b21b | |||
175f94798b | |||
d8cb6b4242 | |||
9a04692539 | |||
843aa2823f | |||
1df7cea2ad | |||
a4f4584ffd | |||
1b37807710 | |||
90994cafd7 | |||
05eb5c4ade | |||
ae1c7f5ce2 | |||
46af8863b1 | |||
99838cbd00 | |||
31bec25dcf | |||
4124d1ae4e | |||
d5f6cbe73a | |||
e5485300ef | |||
6e0112fd59 | |||
18a068cff6 | |||
11aee12320 | |||
6073454ed3 | |||
85569595a9 | |||
d4e4684694 | |||
4e585a2650 | |||
ccd2e04d0e | |||
8239b22157 | |||
9f27899572 | |||
72cbd15fb0 | |||
08ef1af4e4 | |||
ff6763d354 | |||
8fed219af0 | |||
090272854d | |||
5a9df0a322 | |||
388754e5dd | |||
e5b3cbd2cd | |||
5b0b33782f | |||
c44c4e7bc6 | |||
bd5e1bc3c1 | |||
2b367c7bb6 | |||
ee915bd7c1 | |||
ce63c825b0 | |||
5d4e0cff3d | |||
f6fe9d0f09 | |||
cc6c701c59 | |||
1244c97493 | |||
0e917ac111 | |||
7a5f131973 | |||
95cc3034b8 | |||
da3d30872b | |||
d236b827cd | |||
4084d98efd | |||
70c17b6235 | |||
e4f8aa5e80 | |||
6f1d68db20 | |||
e9db004a7a | |||
99ca090448 | |||
a4adbcca85 | |||
7820adf057 | |||
c9b86271d3 | |||
2762933c83 | |||
8849a9de73 | |||
bc46856117 | |||
17bad9fd4d | |||
7d871e52a9 | |||
cef1ad25cb | |||
c0c8584f45 | |||
2266511ec5 | |||
aae1251660 | |||
b604ff30ec | |||
1f750a0c7c | |||
e8b2729353 | |||
a13ef89832 | |||
021b82c93a | |||
7653df1e3f | |||
46e1a42060 | |||
6bb5d819bf | |||
e1562b2b2b | |||
ee0e4b1dba | |||
550cde6a8f | |||
687511d69e | |||
49b48ec7a8 | |||
cf9ec2d17e | |||
cf0b120fad | |||
f5a8a55182 | |||
a9ebc38078 | |||
4f7e477b24 | |||
85423e739c | |||
eea49ed3c1 | |||
427e7c8e84 | |||
8b3c0137a5 | |||
d75c9faea3 | |||
15e188b9f2 | |||
7b747fb4d3 | |||
54568d70c2 | |||
04aa61fe4b | |||
d0a2e2c2ef | |||
372cd5f295 | |||
3510bab79a | |||
4002b64640 | |||
57bbb2c3fc | |||
7720bc525a | |||
f3d5e273db | |||
16b4b64099 | |||
a1c1609908 | |||
ded318e659 | |||
d99c97408c | |||
08b1841bdf | |||
1e69667ba8 | |||
5f6247ef13 | |||
c2c67ae9ef | |||
00462f6005 | |||
6a431ee574 | |||
1d4882a8ac | |||
a43e403e9c | |||
1cd45d366b | |||
1247617b87 | |||
084385f8f2 | |||
25aa5506c8 | |||
0b577ad5ea | |||
39832c7f74 | |||
c638ee506a | |||
7c5c3c57ec | |||
fcfab9fc1b | |||
09bdf8a7a6 | |||
58e732c923 | |||
7ba33cc6eb | |||
50d6550e17 | |||
e1044f3a73 | |||
8aa7cd30ae | |||
558bd86b16 | |||
010c67fd78 |
62 changed files with 57587 additions and 532 deletions
129
.gitignore
vendored
Normal file
129
.gitignore
vendored
Normal file
|
@ -0,0 +1,129 @@
|
|||
# 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
Normal file
2
.pep8
Normal file
|
@ -0,0 +1,2 @@
|
|||
[pycodestyle]
|
||||
max_line_length = 120
|
596
.pylintrc
Normal file
596
.pylintrc
Normal file
|
@ -0,0 +1,596 @@
|
|||
[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
|
17
.vscode/launch.json
vendored
17
.vscode/launch.json
vendored
|
@ -5,11 +5,22 @@
|
|||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Module",
|
||||
"name": "ErynRL",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "roguebasin",
|
||||
"module": "erynrl",
|
||||
"args": [],
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "ErynRL (Sandbox)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "erynrl",
|
||||
"args": [
|
||||
"--sandbox"
|
||||
],
|
||||
"justMyCode": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"python.linting.pylintEnabled": true,
|
||||
"python.linting.enabled": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"test"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
15
Makefile
15
Makefile
|
@ -1,11 +1,12 @@
|
|||
|
||||
VENV_DIR=.venv
|
||||
|
||||
.PHONY: env
|
||||
venv: env
|
||||
python3 -m venv env
|
||||
.PHONY: venv
|
||||
venv:
|
||||
python3 -m venv ${VENV_DIR}
|
||||
|
||||
deps: env/bin/pip
|
||||
./env/bin/pip install -r requirements.txt
|
||||
deps: ${VENV_DIR}/bin/pip requirements.txt
|
||||
${VENV_DIR}/bin/pip install -r requirements.txt
|
||||
|
||||
freeze:
|
||||
./env/bin/pip freeze > requirements.txt
|
||||
freeze: ${VENV_DIR}/bin/pip
|
||||
${VENV_DIR}/bin/pip freeze > requirements.txt
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
# Experiments with `libtcod`
|
||||
# Going Rogue
|
||||
|
||||
An experiment building a Roguelike with libtcod and Python
|
||||
|
||||
`libtcod` is a library that provides a bunch of useful routines for building
|
||||
Roguelikes. There are C++ and Python interfaces.
|
||||
|
|
53
bsp_visualizer.py
Normal file
53
bsp_visualizer.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
#!/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
Normal file
44
ca.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
# 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)
|
1
erynrl/__init__.py
Normal file
1
erynrl/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Eryn Wells <eryn@erynwells.me>
|
78
erynrl/__main__.py
Normal file
78
erynrl/__main__.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
# 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()
|
1
erynrl/actions/__init__.py
Normal file
1
erynrl/actions/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Eryn Wells <eryn@erynwells.me>
|
48
erynrl/actions/action.py
Normal file
48
erynrl/actions/action.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
# 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__}()'
|
215
erynrl/actions/game.py
Normal file
215
erynrl/actions/game.py
Normal file
|
@ -0,0 +1,215 @@
|
|||
'''
|
||||
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()
|
47
erynrl/actions/result.py
Normal file
47
erynrl/actions/result.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
# 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})'
|
138
erynrl/ai.py
Normal file
138
erynrl/ai.py
Normal file
|
@ -0,0 +1,138 @@
|
|||
# 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]
|
117
erynrl/components.py
Normal file
117
erynrl/components.py
Normal file
|
@ -0,0 +1,117 @@
|
|||
# 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})'
|
188
erynrl/configuration.py
Normal file
188
erynrl/configuration.py
Normal file
|
@ -0,0 +1,188 @@
|
|||
# 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
|
242
erynrl/engine.py
Normal file
242
erynrl/engine.py
Normal file
|
@ -0,0 +1,242 @@
|
|||
# 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)
|
60
erynrl/events.py
Normal file
60
erynrl/events.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
# 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
|
325
erynrl/geometry.py
Normal file
325
erynrl/geometry.py
Normal file
|
@ -0,0 +1,325 @@
|
|||
# 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}]'
|
78
erynrl/interface/__init__.py
Normal file
78
erynrl/interface/__init__.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
# 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)
|
45
erynrl/interface/color.py
Normal file
45
erynrl/interface/color.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
# 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)
|
55
erynrl/interface/events.py
Normal file
55
erynrl/interface/events.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
# 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
|
62
erynrl/interface/percentage_bar.py
Normal file
62
erynrl/interface/percentage_bar.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
# 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)
|
97
erynrl/interface/window/__init__.py
Normal file
97
erynrl/interface/window/__init__.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
# 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))
|
47
erynrl/interface/window/info.py
Normal file
47
erynrl/interface/window/info.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
# 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}')
|
209
erynrl/interface/window/map.py
Normal file
209
erynrl/interface/window/map.py
Normal file
|
@ -0,0 +1,209 @@
|
|||
# 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]))
|
23
erynrl/interface/window/message_log.py
Normal file
23
erynrl/interface/window/message_log.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# 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)
|
38
erynrl/items.py
Normal file
38
erynrl/items.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
# 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))
|
98
erynrl/log.py
Normal file
98
erynrl/log.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
# 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)
|
132
erynrl/map/__init__.py
Normal file
132
erynrl/map/__init__.py
Normal file
|
@ -0,0 +1,132 @@
|
|||
# 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
|
56
erynrl/map/generator/__init__.py
Normal file
56
erynrl/map/generator/__init__.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
# 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)
|
133
erynrl/map/generator/cellular_atomata.py
Normal file
133
erynrl/map/generator/cellular_atomata.py
Normal file
|
@ -0,0 +1,133 @@
|
|||
# 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)
|
124
erynrl/map/generator/corridor.py
Normal file
124
erynrl/map/generator/corridor.py
Normal file
|
@ -0,0 +1,124 @@
|
|||
# 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.'''
|
380
erynrl/map/generator/room.py
Normal file
380
erynrl/map/generator/room.py
Normal file
|
@ -0,0 +1,380 @@
|
|||
# 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
|
15
erynrl/map/grid.py
Normal file
15
erynrl/map/grid.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
# 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')
|
138
erynrl/map/room.py
Normal file
138
erynrl/map/room.py
Normal file
|
@ -0,0 +1,138 @@
|
|||
# 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
|
73
erynrl/map/tile.py
Normal file
73
erynrl/map/tile.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
# 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)))
|
93
erynrl/messages.py
Normal file
93
erynrl/messages.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
# 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
|
52
erynrl/monsters.py
Normal file
52
erynrl/monsters.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
# 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)
|
187
erynrl/object.py
Normal file
187
erynrl/object.py
Normal file
|
@ -0,0 +1,187 @@
|
|||
# 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
|
52912
fonts/ter-u32n.bdf
Normal file
52912
fonts/ter-u32n.bdf
Normal file
File diff suppressed because it is too large
Load diff
14
going_rogue.code-workspace
Normal file
14
going_rogue.code-workspace
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"files.exclude": {
|
||||
".venv/": true
|
||||
}
|
||||
}
|
||||
}
|
85
logging_config.json
Normal file
85
logging_config.json
Normal file
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,5 +1,23 @@
|
|||
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
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
#!/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)
|
|
@ -1,51 +0,0 @@
|
|||
#!/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
|
|
@ -1,52 +0,0 @@
|
|||
#!/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)
|
|
@ -1,28 +0,0 @@
|
|||
#!/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
|
|
@ -1,96 +0,0 @@
|
|||
#!/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}]'
|
|
@ -1,82 +0,0 @@
|
|||
#!/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)
|
|
@ -1,133 +0,0 @@
|
|||
#!/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})'
|
|
@ -1,27 +0,0 @@
|
|||
#!/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})'
|
|
@ -1,29 +0,0 @@
|
|||
#!/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)))
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
1
test/__init__.py
Normal file
1
test/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Eryn Wells <eryn@erynwells.me>
|
52
test/test_geometry_point.py
Normal file
52
test/test_geometry_point.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
# 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))
|
18
test/test_geometry_rect.py
Normal file
18
test/test_geometry_rect.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# 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
|
34
test/test_map_room.py
Normal file
34
test/test_map_room.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
# 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
|
Loading…
Add table
Add a link
Reference in a new issue