Compare commits

..

236 commits

Author SHA1 Message Date
a650e1db49 Clean up the context.new call in __main__ 2023-03-11 01:11:17 -08:00
327cc90b2e Remove QuitAction and ActionWithActor!
Move quit event handling to the interface and flatten the Action class
hierarchy. There are no longer any actions that don't take an Actor. This has
the happy side effect of resolving some pylint errors too. :)
2023-03-11 01:11:09 -08:00
02ed3d1e4a Add a title to the window :) 2023-03-11 01:00:09 -08:00
def79386d8 Move the renderable part of Entity to a Renderable component
Move symbol, render order, foreground, and background properties on Entity
to a new Component called Renderable.
2023-03-11 00:58:58 -08:00
01b549bc6e Remove the RegenerateRoomsAction 2023-03-11 00:57:48 -08:00
f78bc39e3b Clean up a few type checker things 2023-03-11 00:57:15 -08:00
b0d91c9c5d Refactor how maps, rooms, and corridors are generated
- Rect and Room method objects no longer need to know the map size up front
- The Map object has lists of interesting map features (I don't like this)
- Room and corridor generators take the map itself as an argument to their
  generate and apply methods
- Create a Corridor object to hold a list of points
- Add a bunch of documentation here and there
2023-03-11 00:06:47 -08:00
e1523cd9c0 Some logging level adjustments
Move some loggers up to INFO. Disable propagation on erynrl.ui.
2023-03-10 23:54:36 -08:00
078520678d Figure out (finally!) the mouse coordinates in the MapWindow 2023-03-10 23:53:08 -08:00
1018febeab Fix a bug in Rect.__contains_point!
It was comparing point.y with the Rect's min_x.
2023-03-10 22:56:50 -08:00
879e0c680d Pylint: allow masking the 'map' builtin 2023-03-10 22:56:00 -08:00
eda44a8792 Rename the Map's Point question methods to use "point" instead of "tile" 2023-03-08 08:57:20 -08:00
7ee790e25e Remove all the old mouse point stuff from Engine 2023-03-07 22:27:21 -08:00
2d82d9834f Do pathfinding from the hero to the mouse point
It works finally! And uses A*!
2023-03-07 22:18:31 -08:00
a8bbc47668 Move all interface Windows to their own modules in interface.window 2023-03-07 21:44:19 -08:00
1e678ff47d Promote .ui and .visible logging to WARN 2023-03-07 21:30:04 -08:00
003aedf30e Restructure event handling
Events start in the Interface. The interface gets first crack at any incoming
events. If the interface doesn't handle the event, it is given to the
engine. The engine has an EngineEventHandler that yields actions just
like the event handler prior to this change.

The interface's event handler passes events to each window in the
interface. Windows can choose to handle events however they like, and
they return a bool indicating whether the event was fully handled.
2023-03-07 21:29:28 -08:00
ee1c6f2222 Remove the RandomRectRoomGenerator 2023-03-06 19:33:50 -08:00
fd068268f5 Move the BSP implementation to BSPRectMethod 2023-03-06 19:33:34 -08:00
040803fe61 Add Rect.from_raw_values 2023-03-06 19:23:43 -08:00
edbc76d2ff Catch the ValueError when an AI asks if an out-of-bounds tile is walkable 2023-03-05 18:48:51 -08:00
635aea5e3b Add cellular atomata to the map generator finally!
Use the new map generator mechanism to generate rooms via cellular
atomata. Create a new CellularAtomatonRoomMethod class that uses
the Cellular Atomton class to create a room. Add a FreefromRoom class
that draws a room based on an ndarray of tiles.

Along the way I discovered I have misunderstood how numpy arrays
organize rows and columns. The numpy array creation routines take an
'order' argument that specifies whether arrays should be in C order (row
major) or Fortran order (column major). Fortran order lets you index
arrays with a more natural [x, y] coordinate order, and that's what the
tutorials I've read have shown. So I've been using that. When I was
developing the Cellular Atomaton, I wrote some code that assumed row-
major order. I think I want to move everything to row-major / C-style,
but that will take a bit more time.
2023-03-05 18:40:02 -08:00
e6327deeef Geometry classes are no longer frozen 2023-03-05 16:53:34 -08:00
dd8b0364e0 Break RoomGenerators into Rect and Room methods
A RoomGenerator is now made up of two "method" classes that do separate
things:

1. A RectMethod takes the size of the area to generate and creates an
   iterable stream of Rects to fill that area.
2. A RoomMethod takes a Rect and creates a room inside of it.

These two components are composable in interesting ways, and allow a
more data-driven approach to map generation, though I don't yet have the
ability to make this mechansim entirely data-driven.
2023-03-05 16:53:00 -08:00
c17258bd73 Some x/y -> numpy_index changes 2023-03-05 14:26:52 -08:00
744c63bc85 Sort rooms before generating corridors between them 2023-03-05 14:26:20 -08:00
e3864d8468 Implement LE, LT, GE, GT on Point 2023-03-05 14:24:44 -08:00
af3d93ba11 Fix up how Maps are rendered in MapWindows
There was a bug in how MapWindow was calculating the numpy array slices
when drawing the map. Redo how this works so that MapWindow can draw
maps of arbitrary size and center maps that are smaller than the window's
drawable area.
2023-03-05 13:37:51 -08:00
42cfb78ba3 Raise exceptions for out-of-bounds points that are passed into Map helper methods 2023-03-05 13:36:32 -08:00
4b09d467d1 Document map.grid 2023-03-05 13:36:04 -08:00
84e51a17ff Specify map size of 80x24 2023-03-05 13:35:47 -08:00
368f780fcd Enable UI debug logging 2023-03-05 13:35:35 -08:00
85928a938d Some geometry updates
- Add Point.numpy_index to simplify converting Points to indexes
- Update the doc string of Point.direction_to_adjacent_point
- Add a Rect.__contains__ implementation for another Rect
- Refactor the contains implementations above into helper methods
2023-03-05 13:35:25 -08:00
c488ef9c2b Add doc string for Configuration attributes 2023-03-05 10:49:02 -08:00
e2553cca3b Clean up RoomGenerator.Configuration 2023-03-04 11:08:11 -08:00
b9c45f44b2 Clean up the doc comment on Entity 2023-03-04 11:07:32 -08:00
a83b85b7a0 Add one stray import to map.generator.room 2023-02-20 18:02:21 -08:00
a542bb956a Add CellularAtomataMapGenerator
First pass at a cellular atomata map generator.

Add map.grid and a make_grid function to make it easier to make
numpy arrays for Map purposes.

Add ca.py to test the generator.
2023-02-20 18:02:01 -08:00
be7198b16d Update the doc string of Rect.inset_rect 2023-02-20 18:00:41 -08:00
6aefff838d Implement Rect.__contains__ on a Point
Returns True if the Rect contains the point.
2023-02-20 18:00:29 -08:00
b8e7e3d059 Clean up log module documentation 2023-02-20 17:11:55 -08:00
09480e7499 Fix up some imports in map.room 2023-02-19 19:13:31 -08:00
706a244382 Fix up some imports in map.generator.room 2023-02-19 19:13:19 -08:00
445c600bf9 Add a doc string to Rect.corners 2023-02-19 19:08:32 -08:00
22ad73af0b Fix Rect.corners based on the unit test 2023-02-19 19:06:24 -08:00
47014d4e6e Move field of view updates to Map.update_field_of_view 2023-02-19 18:37:14 -08:00
9bd287dc9f Add Map.__str__ 2023-02-19 18:34:59 -08:00
21c3b5d94f Small bit of reformatting and type checking 2023-02-19 18:22:45 -08:00
885868f39e Add a test for RectangularRoom.wall_points 2023-02-19 18:18:40 -08:00
5470ea697c Add a test for Rect.corners 2023-02-19 18:18:09 -08:00
64844d124b A couple fixes in items.py
Reformat the items in items.py
Fix the type declaration of Item.background_color
2023-02-18 22:57:37 -08:00
30727ccac1 Add a test for Point.is_adjacent_to 2023-02-18 22:55:41 -08:00
37ffa423b6 Pass the whole Map into MapGenerator.generate 2023-02-18 22:55:20 -08:00
7428d95126 Fix the implementation of Point.is_adjacent_to 2023-02-18 22:51:58 -08:00
06d34a527b Add Point.manhattan_distance_to
Returns the manhattan distance to another Point.
2023-02-18 22:51:42 -08:00
462eebd95c Add Rect.corners
An iterator over the corners of the rectangle
2023-02-18 22:51:19 -08:00
306d6fd13f Add pytest unit tests 2023-02-18 10:49:35 -08:00
8fc5206e95 Ignore .venv directory in the VSCode workspace 2023-02-18 10:40:55 -08:00
292fa852f9 Add non-Sandbox launch target 2023-02-18 10:40:36 -08:00
d5e8891545 Add back some imports to map/room.py 2023-02-15 08:36:43 -08:00
e377b3d7b6 Change the name of the Sandbox scheme to indicate it's the Sandbox scheme 2023-02-15 08:26:01 -08:00
4050ac5c6f Add a bunch of doc strings and header comments to files 2023-02-15 08:25:40 -08:00
633580e27a Add Rect.edges
Iterates the minimum and maximum x and y edges for the Rect.
2023-02-15 08:22:32 -08:00
e6c4717e80 Let the Hero specify its own sight radius 2023-02-15 08:21:49 -08:00
77fc9467bc Fix accidentally breaking QuitAction
When I added some type safety to Engine.process_input_action, I just
dropped any action without an actor.
2023-02-12 19:48:45 -08:00
356e205f2c Implement viewport tracking for the MapWindow
As the player moves the hero, the MapWindow will try to keep the
hero in the middle of the view.
2023-02-12 19:47:27 -08:00
402e910915 Add Map.bounds 2023-02-12 19:45:12 -08:00
0a6ff23dcd Bigger maps! 2023-02-12 19:44:52 -08:00
b5f25822df Add a UI log 2023-02-12 19:44:41 -08:00
36206b5cc0 Clean up __add__ on Point; add __sub__
The dunder add method only ever had one overload, so you don't need to
declare an overload
2023-02-12 19:44:28 -08:00
7e00f58a40 Refactor Action into Action and ActionWithActor
The base class Actor doesn't declare a (optional) actor attribute.
The ActionWithActor has a non-optional actor attribute.

This makes the type checker happier, and means we can have some actions
that don't have actors.
2023-02-12 16:34:37 -08:00
8efd3ce207 Refactor map rendering
- Move all map rendering to a new MapWindow class
- Clip map rendering to the bounds of the Window
- Pass in Entities list from the Engine and render them relative to the window

The map doesn't scroll yet, so it'll always be clipped on the bottom and
right.
2023-02-12 15:55:01 -08:00
ec28f984da Add a couple geometry methods
- Vector.from_point to convert a point to a vector
- Rect.width and Rect.height convenience properties
2023-02-12 15:52:26 -08:00
b0b75f7e76 Use RandomRect room generator and Elbow corridor generator for the map 2023-02-12 14:32:32 -08:00
8aa329d368 Increase sight radius to 30 (from 8) 2023-02-12 14:30:53 -08:00
1aa6d14540 Fix some type checker errors by moving an assert above where the property is accessed 2023-02-12 14:30:40 -08:00
a709f3fba5 Detect when Shift is pressed and don't return a WaitAction when Shift+. is pressed 2023-02-12 14:29:45 -08:00
253db06683 Add the --sandbox argument to the default run config 2023-02-12 14:29:11 -08:00
2851ce36a0 Update virtualenv requirements 2023-02-12 14:28:57 -08:00
d8275725b8 Allow many more (30) attempts at generating a random rect for the RandomRectRoomGenerator 2023-02-12 14:28:42 -08:00
cf31bcc272 Create a more semantic Size.numpy_shape property 2023-02-12 14:28:14 -08:00
84f7bdb947 Little grammar fix in RenderOrder's doc comment 2023-02-12 14:24:56 -08:00
06ae79ccd0 Redo the configuration metchanism
- Allow passing a font on the command line via --font
- Move the engine configuration to its own module
- Redo entirely the font configuration: move it to the configuration module
- Pass the configuration object to the Map in place of the size argument
2023-02-12 14:24:36 -08:00
6780b0495c Move all the interface stuff to interface.Interface
Draw three windows with frames:
- map window
- info window (hit point bar; turn count)
- message window

Clean up the UI code in the Engine.
2023-02-11 01:21:52 -08:00
df4df06013 Fix this import in ai.py 2023-02-10 22:37:26 -08:00
2f9864edd8 Start the hero on the down stairs of the level 2023-02-10 22:37:26 -08:00
6c9d01771f Draw a path from the hero to the current mouse point
Add a highlight grid to Map, with locations set to True if that point should be
drawn with a "highlight" treatment.

Add the highlight graphic_dtype to all Tiles.
2023-02-10 22:37:21 -08:00
f05dfdef55 PEP8 formatter changes 2023-02-10 21:25:00 -08:00
727a0737c6 Use integer division for Rect.mid_y and Rect.mid_x 2023-02-10 21:13:49 -08:00
7b0b7ff5b6 Add Rect.intersects 2023-02-10 21:13:31 -08:00
0d0c5a2b35 Remove this unused DefaultConfiguration class variable 2023-02-10 21:13:16 -08:00
85b059dbd4 Add two new room generators
- OneBigRoomGenerator
- RandomRectRoomGenerator
2023-02-10 21:12:42 -08:00
9d00f3b638 Promote BSPRoomGenerator.Configuration to RoomGenerator.Configuration
This configuration object can actually apply to all room generators.

Notably: copy the default configuration object before setting it to self.configuration.
2023-02-10 21:12:12 -08:00
643ab0990b logging_config changes
Add erynrl.map to the config.
Something reformatted the logging config.
2023-02-10 21:10:21 -08:00
c59dc1b907 Break up room and corridor generation into generate and apply phases
- Generate creates rooms and corridors, and apply applies them to a tile grid.
- Add up and down stairs generation to the Room Generators.
- Clean up Room.wall_points and Room.floor_points to make it easier to
  write a generic apply() method on RoomGenerator
2023-02-10 21:07:50 -08:00
d4c4b5d879 Reformat some heckin' long initializers in object.py 2023-02-09 21:08:11 -08:00
d8dfe5c497 Fix some PEP8 formatting issues 2023-02-09 21:07:42 -08:00
350876347b Correct some Direction type annotations
They should always have been Vectors.
2023-02-09 21:05:05 -08:00
771926088c Remove an unused import 2023-02-09 21:04:26 -08:00
fff3260d01 Give MapGenerator up_stairs and down_stairs properties 2023-02-09 21:00:51 -08:00
d9aa8097c5 Add StairsUp and StairsDown tile types 2023-02-09 20:57:51 -08:00
ac5efa7518 PEP8 formatting in map.tile 2023-02-09 20:57:37 -08:00
aacc5d56ca "strict" type checking is too much 🙃 2023-02-09 20:55:03 -08:00
8e9b130ba7 Fix the ordering of imports in map/__init__.py 2023-02-09 20:54:40 -08:00
dabc9e70dd PEP8 formatter changes 2023-02-09 16:12:46 -08:00
391f84b21b Fix up the type annotations for geometry.Direction 2023-02-09 16:09:58 -08:00
175f94798b Enable strict type checking in the workspace 2023-02-09 16:09:06 -08:00
d8cb6b4242 Tell the linter to allow pt as a variable name 2023-02-09 16:07:45 -08:00
9a04692539 Refactor map generator package
- Move room generators to map.generators.room
- Move corridor generators to map.generators.corridor

Generators have a generate() method that generates the things they place,
and an apply() method that applies their objects to a grid of tiles.
2023-02-09 16:07:29 -08:00
843aa2823f Some messing around with fonts and BDF files from long ago 2023-02-08 08:36:44 -08:00
1df7cea2ad Move map modules to their own directory 2023-02-08 08:36:00 -08:00
a4f4584ffd Enable pep8 formatting on save 2023-02-08 08:35:06 -08:00
1b37807710 Add a pep8 code style configuration file 2023-02-08 08:34:46 -08:00
90994cafd7 Import Action and ActionResult from actions submodules 2022-05-29 21:24:31 -07:00
05eb5c4ade Add some docstrings to ai.py 2022-05-28 09:02:52 -07:00
ae1c7f5ce2 Refactor events into their own package
Most of the existing actions are game actions (they control the player character)
so they live in actions.game. Eventually, there will be modules for different
kinds of actions that only apply to, e.g. modal UI.
2022-05-28 08:52:54 -07:00
46af8863b1 Use a try/catch to check for the presence of actor/target.fighter in MeleeAction 2022-05-16 20:51:53 -07:00
99838cbd00 Convert the passive healing clock to a more granular tick mechanism
Instead of counting turns, count clock ticks. A WaitAction adds 10 ticks to the
passive healing clock and a WalkAction adds 5. So, you will heal while walking
but at a slower rate.
2022-05-16 20:50:23 -07:00
31bec25dcf Add a docstring to engine.Configuration 2022-05-16 20:49:42 -07:00
4124d1ae4e Clean up some pylint warnings in interface/color.py 2022-05-16 20:49:28 -07:00
d5f6cbe73a Clean up some pylint warnings in messages.py
Add a module doc string
Remove an extraneous else:
2022-05-16 20:49:01 -07:00
e5485300ef Rename interface.bar.Bar -> interface.percentage_bar.PercentageBar 2022-05-16 16:47:21 -07:00
6e0112fd59 Add some documentation to the things in object.py 2022-05-16 16:40:45 -07:00
18a068cff6 Add some more basic colors and some semantic colors for the Health Bar 2022-05-16 16:40:29 -07:00
11aee12320 Add colors attribute to Bar class
This list lets you specify a set of colors that the bar should be painted with
depending on the percentage the bar is filled
2022-05-16 16:40:04 -07:00
6073454ed3 Add line 1 comment to interface/__init__.py 2022-05-16 16:39:19 -07:00
85569595a9 FIX THE COLLISION DETECTION BUG 2022-05-15 19:58:39 -07:00
d4e4684694 Show Entity under mouse cursor in a line above the hit points 2022-05-15 16:50:24 -07:00
4e585a2650 Implement passive healing 2022-05-15 16:19:03 -07:00
ccd2e04d0e Doc strings in Messages module 2022-05-15 16:18:42 -07:00
8239b22157 Allow fg and bg as variable names 2022-05-15 16:03:47 -07:00
9f27899572 Print some messages when damage and death happen! 2022-05-15 13:58:37 -07:00
72cbd15fb0 Render the MessageLog and clean up the interface (lots of math errors here) 2022-05-15 13:58:26 -07:00
08ef1af4e4 Add a MessageLog that keeps a record of in-game events 2022-05-15 13:58:06 -07:00
ff6763d354 Add a unique identifier to all entities (a monotonically increasing integer) 2022-05-15 13:57:32 -07:00
8fed219af0 Add erynrl log config 2022-05-15 13:13:22 -07:00
090272854d Add a turn count that increments after successfully handling actions for that turn 2022-05-15 13:13:12 -07:00
5a9df0a322 Add my first interface element: a Bar!
It renders a bar of a certain width and a percentage full. Use it to render HP.

Add a new Python package called interface. The Bar class lives there. Also add a
bunch of color defintions to a module called interface.color.
2022-05-15 00:12:05 -07:00
388754e5dd When the Hero dies, swap MainGameEventHandler for GameOverEventHandler 2022-05-14 23:43:38 -07:00
e5b3cbd2cd Prepare for a GameOver state
- Factor a bunch of event handling into a base EventHandler class
- Rename the previous event handler MainGameEventHandler
- Add a GameOverEventHandler that only responds to Exit actions
2022-05-14 23:43:04 -07:00
5b0b33782f Move handling hero actions and entity actions to the Engine 2022-05-14 23:41:43 -07:00
c44c4e7bc6 Move the event loop to Engine.run_event_loop() 2022-05-14 23:39:03 -07:00
bd5e1bc3c1 Rename the VS Code Workspace (again) 2022-05-13 08:18:46 -07:00
2b367c7bb6 Quit the game on Escape 2022-05-12 20:40:46 -07:00
ee915bd7c1 Add a doc string and a terminal newline 2022-05-12 20:40:40 -07:00
ce63c825b0 Move all the logging to log.py and prefix all the log names with "erynrl" 2022-05-12 20:40:15 -07:00
5d4e0cff3d Rename the launch config 2022-05-12 20:29:44 -07:00
f6fe9d0f09 Move the roguebasin package to erynrl 2022-05-12 09:05:27 -07:00
cc6c701c59 Move first steps scripts to their own directory 2022-05-12 09:02:53 -07:00
1244c97493 Move the VS Code workspace to LYARLFGG 2022-05-12 09:02:15 -07:00
0e917ac111 Move the ai log config to ERROR level 2022-05-12 08:59:36 -07:00
7a5f131973 Log AI for entities that are visible to the hero to the ai logger 2022-05-12 08:59:24 -07:00
95cc3034b8 Clean up unused imports in ai.py 2022-05-12 08:58:51 -07:00
da3d30872b Add logging action handling in a tree-like fashion
These logs are available in the actions.tree logger. They'll print a helpful
list of actions and their results in a tree-like way.

For example:

```
2022-05-12 08:57:57 actions.tree: Processing Hero Actions
2022-05-12 08:57:57 actions.tree: |-> @[(x:4, y:6)][30/30]
2022-05-12 08:57:57 actions.tree: |   |-> BumpAction toward (δx:-1, δy:1) by @[(x:4, y:6)][30/30] => success=False done=False alternate=WalkAction[@]
2022-05-12 08:57:57 actions.tree: |   `-> WalkAction toward (δx:-1, δy:1) by @[(x:3, y:7)][30/30] => success=True done=True alternate=None
2022-05-12 08:57:57 actions.tree: Processing Entity Actions
2022-05-12 08:57:57 actions.tree: |-> Orc with 10/10 hp at (x:4, y:5)
2022-05-12 08:57:57 actions.tree: |   |-> BumpAction toward (δx:-1, δy:1) by Orc with 10/10 hp at (x:4, y:5) => success=False done=False alternate=WalkAction[o]
2022-05-12 08:57:57 actions.tree: |   `-> WalkAction toward (δx:-1, δy:1) by Orc with 10/10 hp at (x:3, y:6) => success=True done=True alternate=None
2022-05-12 08:57:57 actions.tree: |-> Orc with 10/10 hp at (x:5, y:5)
2022-05-12 08:57:57 actions.tree: |   |-> BumpAction toward (δx:-1, δy:1) by Orc with 10/10 hp at (x:5, y:5) => success=False done=False alternate=WalkAction[o]
2022-05-12 08:57:57 actions.tree: |   `-> WalkAction toward (δx:-1, δy:1) by Orc with 10/10 hp at (x:4, y:6) => success=True done=True alternate=None
```
2022-05-12 08:56:15 -07:00
d236b827cd Move most of the basic actions logs to debug (probably just for now) 2022-05-12 08:55:22 -07:00
4084d98efd Clean up Action processing a little bit
- Sort entities by their Euclidean distance to the hero so actions from entities
  near the hero are processed first
- Fewer local variables for cleaner reading
- Pass hero into the RegerateRoomsAction, which was causing a pylint error
2022-05-12 08:53:26 -07:00
70c17b6235 Condense the declaration of engine.hero; add a FIXME 2022-05-12 08:49:46 -07:00
e4f8aa5e80 Clean up the __str__ for a few Action subclasses 2022-05-12 08:48:28 -07:00
6f1d68db20 Update the logging config 2022-05-12 08:46:59 -07:00
e9db004a7a Add Point.euclidean_distance_to()
Does what it says on the tin.
2022-05-12 08:46:45 -07:00
99ca090448 Remove a newline 2022-05-11 08:08:47 -07:00
a4adbcca85 Add RenderOrder to Entity
This enum dictates what order an entity
will be rendered in.
2022-05-11 07:59:54 -07:00
7820adf057 Generate entities in rooms at different locations
Prior to this change, entities in rooms would always be spawned on the
same tile.
2022-05-11 07:57:28 -07:00
c9b86271d3 Remove these two logging messages
They aren't serving a good purpose.
2022-05-11 07:55:47 -07:00
2762933c83 Configure logging with logging_config.json
See https://docs.python.org/3/library/logging.config.html for details on
the schema for this file.
2022-05-11 07:52:35 -07:00
8849a9de73 Allow 'hp' as a valid variable name 2022-05-11 07:50:35 -07:00
bc46856117 Every action needs an actor, even ExitAction 2022-05-08 23:45:20 -07:00
17bad9fd4d Don't try to Melee entities that don't block movement 2022-05-08 23:43:33 -07:00
7d871e52a9 Copy the entities set into a list before iterating it so there's no risk of modifying the array while iterating 2022-05-08 23:43:08 -07:00
cef1ad25cb Small cleanup of log statement 2022-05-08 23:42:36 -07:00
c0c8584f45 Implement is_adjacent_to and direction_to_adjacent_point on Point 2022-05-08 23:42:24 -07:00
2266511ec5 Implement attacking and reducing hit points
Attacks are computed with attack_power and defense. When an Actor dies,
a DieAction is produced and possibly also a DropItemAction.
2022-05-08 23:41:54 -07:00
aae1251660 Rename Action.entity -> Action.actor 2022-05-08 23:40:33 -07:00
b604ff30ec Implement a basic AI for HostileMonster
This AI will walk randomly around the dungeon (pausing periodically) and
if the Hero comes into view, will b-line and attack
2022-05-08 23:38:48 -07:00
1f750a0c7c Add an Item subclass of Entity for instances of items on the map 2022-05-08 23:37:31 -07:00
e8b2729353 Add blocks_movement to the Entity class 2022-05-08 23:36:56 -07:00
a13ef89832 Add an Item type class and a Corpse item type 2022-05-08 23:36:13 -07:00
021b82c93a Add an Actor subclass of Entity
Make Hero and Monster subclasses of Actor
2022-05-08 23:35:47 -07:00
7653df1e3f Add Action.success() and Action.failure() helper methods to produce results for straight success and failure 2022-05-08 23:34:09 -07:00
46e1a42060 Let Entity.ai produce its own Actions! 2022-05-08 10:03:28 -07:00
6bb5d819bf Lots of comment and type documentation in object.py 2022-05-08 09:56:21 -07:00
e1562b2b2b Add a Fighter component to the Hero 2022-05-08 09:55:56 -07:00
ee0e4b1dba Instantiate Monsters with a HostileEnemy AI 2022-05-08 09:55:10 -07:00
550cde6a8f Allow "ai" as a variable name 2022-05-08 09:54:24 -07:00
687511d69e Add an ai attribute to Entity 2022-05-08 09:54:08 -07:00
49b48ec7a8 Add a HostileEnemy AI component 2022-05-08 09:48:22 -07:00
cf9ec2d17e Move Monster to the object module 2022-05-08 09:48:05 -07:00
cf0b120fad Add a Fighter component that tracks hit points, attack power, defense, etc 2022-05-08 09:46:32 -07:00
f5a8a55182 Add WaitAction to the class hierarchy 2022-05-08 08:55:08 -07:00
a9ebc38078 Update the Makefile to use .venv as the virtual env directory 2022-05-08 08:54:54 -07:00
4f7e477b24 Add a WaitAction and trigger it with . 2022-05-07 22:34:43 -07:00
85423e739c Remove the shbang from map.py 2022-05-07 17:44:30 -07:00
eea49ed3c1 Resolve all the pylint warnings in geometry 2022-05-07 12:37:35 -07:00
427e7c8e84 Remove some unused imports 2022-05-07 12:28:02 -07:00
8b3c0137a5 Refactor event handling into EventHandler
Move all the event handling code from Engine to EventHandler. EventHandler has a
reference to Engine and can deal with entities from its methods.

Refactor Action to take an optional Entity in its initializer. Some actions
don't require an Entity, but many do/will.
2022-05-07 12:25:46 -07:00
d75c9faea3 Little bits of cleanup 2022-05-07 12:25:44 -07:00
15e188b9f2 Convert the keysym matching to match/case from if/elif 2022-05-07 11:57:08 -07:00
7b747fb4d3 Doc comments and stuff 2022-05-07 11:56:55 -07:00
54568d70c2 Add a sight_radius parameter to the Species dataclass 2022-05-07 11:43:02 -07:00
04aa61fe4b Doc strings and import reordering per pylint 2022-05-07 11:42:48 -07:00
d0a2e2c2ef Clean up imports and terminal newlines in files according to pylint 2022-05-07 11:22:54 -07:00
372cd5f295 Generate a .pylintrc and tweak it slightly for this project 2022-05-07 11:20:57 -07:00
3510bab79a Document return value of MapGenerator.generate 2022-05-07 11:20:41 -07:00
4002b64640 Attack!!!
Refactor MovePlayerAction into a few different Action subclasses. Move direction
to a parent MoveAction, and create three new subclasses of MoveAction:

    - BumpAction: perform the test that an action can be performed in the given direction
    - WalkAction, take a step in the given direction
    - MeleeAction, attack another Entity in the given direction

Add an ActionResult class that communicates the result of performing an Action.

    - ActionResult.succeeded indicates whether the action succeeded.
    - ActionResult.done indicates if the action is fully complete or requires followup.
    - ActionResult.alternate specifies the follow-up action to perform.

Convert all the key handling actions to BumpActions.

In the Engine's event handler method, loop until an action is completed,
performing specified alternate actions until the result object indicates the
action is done.
2022-05-07 11:16:17 -07:00
57bbb2c3fc Some more fixes from the linter 2022-05-07 09:57:39 -07:00
7720bc525a Address some linter issues; add doc strings
- Clean up some import ordering
- Write some Numpy style doc strings for classes and functions
2022-05-07 08:55:10 -07:00
f3d5e273db Remove this unimplemented method 2022-05-07 08:54:46 -07:00
16b4b64099 Clean up the logging; use % formats instead of f-strings 2022-05-07 08:53:58 -07:00
a1c1609908 Enable linting 2022-05-07 08:51:55 -07:00
ded318e659 Reorganize the package
- Create an __init__.py, which makes this directory a Python package.
- Replace the contents of __main__.py with the contents of main.py
2022-05-07 08:51:44 -07:00
d99c97408c Generate orcs and trolls randomly throughout the dungeon 2022-05-06 21:16:39 -07:00
08b1841bdf Add monsters module and define Species, Monster, Orc, and Troll 2022-05-06 21:16:19 -07:00
1e69667ba8 Create a Hero class for the character the player moves; stop using tcod.Color for foreground and background; use tuples instead 2022-05-06 21:16:00 -07:00
5f6247ef13 I decided: every dungeon map has rooms 2022-05-06 21:14:42 -07:00
c2c67ae9ef Move logging hero movement into the if block where it actually does the move 2022-05-06 21:13:37 -07:00
00462f6005 Change Engine.Configuration to a clas and convert moved_entities to a set 2022-05-06 14:34:26 -07:00
6a431ee574 Tweaking the room size ratios 2022-05-05 08:55:49 -07:00
1d4882a8ac Do not allow entities to move into squares previously moved entities have moved to 2022-05-05 08:45:30 -07:00
a43e403e9c Let NPCs randomly walk 2022-05-05 08:38:06 -07:00
1cd45d366b Add Direction.all() that returns an iterator that produces all the Direction values 2022-05-05 08:37:48 -07:00
1247617b87 Add diagonal movement 2022-05-04 09:25:35 -07:00
084385f8f2 Add a map shroud over tiles and compute field of view based on player position!!! 2022-05-04 09:22:40 -07:00
25aa5506c8 Convert the generator.Configuration class to a dataclass 2022-05-04 09:22:06 -07:00
0b577ad5ea Merge branch 'diggin-tunnels' 2022-05-03 19:05:57 -07:00
39832c7f74 A little script to visualize how BSP works; emits a DOT graph 2022-05-03 19:05:53 -07:00
c638ee506a Carve walls and floors out of Empty tiles
Walls fill all the Empty tiles around Floors, including rooms and hallways.
2022-05-03 19:04:59 -07:00
7c5c3c57ec Fill the map with Empty tiles instead of Wall tiles 2022-05-03 19:03:31 -07:00
fcfab9fc1b Add Rect.inset_rect 2022-05-03 19:02:39 -07:00
09bdf8a7a6 Place NPCs randomly in a generated room 2022-05-03 19:00:45 -07:00
58e732c923 Clean up and document room rect generation 2022-05-03 18:25:30 -07:00
7ba33cc6eb Fix this type annotation 2022-05-03 18:24:14 -07:00
50d6550e17 Parameterize maximum room size; make minimum_room_size the actual floor size, not counting walls 2022-05-03 18:23:48 -07:00
e1044f3a73 Move MovePlayerAction.Direction to geometry.Direction 2022-05-03 18:21:24 -07:00
8aa7cd30ae Merge remote-tracking branch 'origin/main' 2022-05-03 07:17:39 -07:00
558bd86b16
Initial commit 2022-05-03 07:15:41 -07:00
010c67fd78 Attempt #1 to dig tunnels -- it does not work 2022-05-01 18:03:47 -07:00
62 changed files with 57587 additions and 532 deletions

129
.gitignore vendored Normal file
View 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
View file

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

596
.pylintrc Normal file
View 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
View file

@ -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
View 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
}

View file

@ -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

View file

@ -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
View 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
View 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
View file

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

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

View file

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

48
erynrl/actions/action.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}]'

View 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
View 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)

View 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

View 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)

View 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))

View 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}')

View 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]))

View 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
View 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
View 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
View 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

View 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)

View 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)

View 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.'''

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

View 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
View 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"
]
}
}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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}]'

View file

@ -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)

View file

@ -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})'

View file

@ -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})'

View file

@ -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)))

View file

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

1
test/__init__.py Normal file
View file

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

View 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))

View 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
View 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