├── .editorconfig ├── .flake8 ├── .github └── workflows │ └── python-linters.yml ├── .idea ├── codeStyles │ └── codeStyleConfig.xml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── roguelike-tutorial.iml ├── vcs.xml └── watcherTasks.xml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE.txt ├── README.md ├── actions ├── __init__.py ├── ai.py └── common.py ├── actor.py ├── data └── cp437-14.png ├── effect.py ├── g.py ├── gamemap.py ├── graphic.py ├── inventory.py ├── items ├── __init__.py ├── other.py ├── potions.py └── scrolls.py ├── location.py ├── main.py ├── model.py ├── mypy.ini ├── procgen ├── __init__.py └── dungeon.py ├── pyproject.toml ├── races ├── __init__.py └── common.py ├── rendering.py ├── requirements.txt ├── states ├── __init__.py ├── ingame.py └── mainmenu.py └── tqueue.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [*.py] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [*.md] 17 | indent_style = space 18 | indent_size = 4 19 | 20 | [*.json] 21 | indent_style = space 22 | indent_size = 4 23 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 88 4 | select = B,C,E,F,W,T4,B9 5 | extend-exclude = .*,venv 6 | -------------------------------------------------------------------------------- /.github/workflows/python-linters.yml: -------------------------------------------------------------------------------- 1 | name: Python linters 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | runs-on: Ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: "3.x" 15 | - name: Install APT dependencies 16 | run: | 17 | sudo apt-get install libsdl2-dev 18 | - name: Install Pip packages 19 | run: | 20 | python -m pip install -U pip 21 | pip install flake8 mypy isort black --requirement requirements.txt 22 | - name: Flake8 23 | if: always() 24 | uses: liskin/gh-problem-matcher-wrap@v1 25 | with: 26 | linters: flake8 27 | run: flake8 . 28 | - name: MyPy 29 | if: always() 30 | uses: liskin/gh-problem-matcher-wrap@v1 31 | with: 32 | linters: mypy 33 | run: mypy --show-column-numbers . 34 | - name: isort 35 | if: always() 36 | uses: liskin/gh-problem-matcher-wrap@v1 37 | with: 38 | linters: isort 39 | run: isort --check . 40 | - name: Black 41 | run: | 42 | black --check . 43 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/roguelike-tutorial.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 24 | 25 | 36 | 44 | 45 | 56 | 64 | 65 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "ms-python.python", 7 | "editorconfig.editorconfig", 8 | ], 9 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 10 | "unwantedRecommendations": [] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Run main.py", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "main.py", 12 | "console": "integratedTerminal", 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.enabled": true, 3 | "python.linting.pylintEnabled": false, 4 | "python.linting.mypyEnabled": true, 5 | "python.linting.flake8Enabled": true, 6 | "python.formatting.provider": "black", 7 | "editor.formatOnSave": true, 8 | "editor.rulers": [ 9 | 88 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kyle Stewart 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HexDecimal/roguelike-tutorial 2 | 3 | An attempt at doing the tutorial without any deprecated features of python-tcod. 4 | 5 | https://old.reddit.com/r/roguelikedev/wiki/python_tutorial_series 6 | 7 | ## Windows 8 | 9 | Open a command line to the project directory and run `py -3 -m pip install -r requirements.txt` to install requirements. 10 | 11 | Double click `main.py` or run `py main.py` from the command line. 12 | 13 | ## Linux / MacOS 14 | 15 | Open a command line to the project directory. 16 | 17 | Run `pip3 install -r requirements.txt` to install requirements. 18 | 19 | Run `./main.py`. 20 | 21 | ![Roguelike Tutorial 2019][logo] 22 | 23 | [logo]: https://i.imgur.com/3MAzEp1.png "Roguelikedev Does The Complete Roguelike Tutorial 2019" 24 | -------------------------------------------------------------------------------- /actions/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Tuple 4 | 5 | if TYPE_CHECKING: 6 | from actor import Actor 7 | from gamemap import GameMap 8 | from items import Item 9 | from location import Location 10 | from model import Model 11 | 12 | 13 | class Impossible(Exception): 14 | """Exception raised when an action can not be performed. 15 | 16 | Includes the reason as the exception message. 17 | """ 18 | 19 | 20 | class Action: 21 | def __init__(self, actor: Actor): 22 | self.actor = actor 23 | 24 | def plan(self) -> Action: 25 | """Return the action to perform.""" 26 | return self 27 | 28 | def act(self) -> None: 29 | """Execute the action for this class.""" 30 | raise RuntimeError(f"{self.__class__} has no act implementation.") 31 | 32 | @property 33 | def location(self) -> Location: 34 | return self.actor.location 35 | 36 | @property 37 | def map(self) -> GameMap: 38 | return self.actor.location.map 39 | 40 | @property 41 | def model(self) -> Model: 42 | return self.actor.location.map.model 43 | 44 | def report(self, msg: str) -> None: 45 | return self.model.report(msg) 46 | 47 | 48 | class ActionWithPosition(Action): 49 | def __init__(self, actor: Actor, position: Tuple[int, int]): 50 | super().__init__(actor) 51 | self.target_pos = position 52 | 53 | 54 | class ActionWithDirection(ActionWithPosition): 55 | def __init__(self, actor: Actor, direction: Tuple[int, int]): 56 | position = actor.location.x + direction[0], actor.location.y + direction[1] 57 | super().__init__(actor, position) 58 | self.direction = direction 59 | 60 | 61 | class ActionWithEntity(Action): 62 | def __init__(self, actor: Actor, target: Actor): 63 | super().__init__(actor) 64 | self.target = target 65 | 66 | 67 | class ActionWithItem(Action): 68 | def __init__(self, actor: Actor, target: Item): 69 | super().__init__(actor) 70 | self.item = target 71 | -------------------------------------------------------------------------------- /actions/ai.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List, Optional, Tuple 4 | 5 | import numpy as np 6 | import tcod 7 | 8 | import actions.common 9 | from actions import Action, Impossible 10 | from states import ingame 11 | 12 | if TYPE_CHECKING: 13 | from actor import Actor 14 | 15 | 16 | class PathTo(Action): 17 | def __init__(self, actor: Actor, dest_xy: Tuple[int, int]) -> None: 18 | super().__init__(actor) 19 | self.subaction: Optional[Action] = None 20 | self.dest_xy = dest_xy 21 | self.path_xy: List[Tuple[int, int]] = self.compute_path() 22 | 23 | def compute_path(self) -> List[Tuple[int, int]]: 24 | map_ = self.actor.location.map 25 | walkable = np.copy(map_.tiles["move_cost"]) 26 | blocker_pos = [e.location.ij for e in map_.actors] 27 | blocker_index = tuple(np.transpose(blocker_pos)) 28 | walkable[blocker_index] = 50 29 | walkable.T[self.dest_xy] = 1 30 | graph = tcod.path.SimpleGraph(cost=walkable, cardinal=2, diagonal=3) 31 | pf = tcod.path.Pathfinder(graph) 32 | pf.add_root(self.actor.location.ij) 33 | return [(ij[1], ij[0]) for ij in pf.path_to(self.dest_xy[::-1])[1:].tolist()] 34 | 35 | def plan(self) -> Action: 36 | if not self.path_xy: 37 | raise Impossible("End of path reached.") 38 | self.subaction = actions.common.MoveTo(self.actor, self.path_xy[0]).plan() 39 | return self 40 | 41 | def act(self) -> None: 42 | assert self.subaction 43 | self.subaction.act() 44 | if self.path_xy[0] == self.actor.location.xy: 45 | self.path_xy.pop(0) 46 | 47 | 48 | class AI(Action): 49 | pass 50 | 51 | 52 | class BasicMonster(AI): 53 | def __init__(self, actor: Actor) -> None: 54 | super().__init__(actor) 55 | self.pathfinder: Optional[PathTo] = None 56 | 57 | def plan(self) -> Action: 58 | owner = self.actor 59 | map_ = owner.location.map 60 | if map_.visible[owner.location.ij]: 61 | self.pathfinder = PathTo(owner, map_.player.location.xy) 62 | if not self.pathfinder: 63 | return actions.common.Move(owner, (0, 0)).plan() 64 | if owner.location.distance_to(*map_.player.location.xy) <= 1: 65 | return actions.common.AttackPlayer(owner).plan() 66 | try: 67 | return self.pathfinder.plan() 68 | except Impossible: 69 | self.pathfinder = None 70 | return actions.common.Move(owner, (0, 0)).plan() 71 | 72 | 73 | class PlayerControl(AI): 74 | def act(self) -> None: 75 | ticket = self.actor.ticket 76 | while ticket is self.actor.ticket: 77 | next_action = ingame.PlayerReady(self.actor.location.map.model).loop() 78 | if next_action is None: 79 | continue 80 | try: 81 | next_action.plan().act() 82 | except Impossible as exc: 83 | self.report(exc.args[0]) 84 | -------------------------------------------------------------------------------- /actions/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from actions import ( 4 | Action, 5 | ActionWithDirection, 6 | ActionWithItem, 7 | ActionWithPosition, 8 | Impossible, 9 | ) 10 | 11 | 12 | class MoveTo(ActionWithPosition): 13 | """Move an entity to a position, interacting with obstacles.""" 14 | 15 | def plan(self) -> Action: 16 | if self.actor.location.distance_to(*self.target_pos) > 1: 17 | # Enforces that the actor is only moving a short distance. 18 | raise Impossible( 19 | "Can't move from %s to %s." % (self.actor.location.xy, self.target_pos) 20 | ) 21 | if self.actor.location.xy == self.target_pos: 22 | return self 23 | if self.map.fighter_at(*self.target_pos): 24 | return Attack(self.actor, self.target_pos).plan() 25 | if self.map.is_blocked(*self.target_pos): 26 | raise Impossible("That way is blocked.") 27 | return self 28 | 29 | def act(self) -> None: 30 | self.actor.location = self.map[self.target_pos] 31 | if self.actor.is_player(): 32 | self.map.update_fov() 33 | self.actor.reschedule(100) 34 | 35 | 36 | class Move(ActionWithDirection): 37 | """Move an entity in a direction, interaction with obstacles.""" 38 | 39 | def plan(self) -> Action: 40 | return MoveTo(self.actor, self.target_pos).plan() 41 | 42 | 43 | class MoveTowards(ActionWithPosition): 44 | """Move towards and possibly interact with destination.""" 45 | 46 | def plan(self) -> Action: 47 | dx = self.target_pos[0] - self.location.x 48 | dy = self.target_pos[1] - self.location.y 49 | distance = max(abs(dx), abs(dy)) 50 | dx = int(round(dx / distance)) 51 | dy = int(round(dy / distance)) 52 | return Move(self.actor, (dx, dy)).plan() 53 | 54 | 55 | class Attack(ActionWithPosition): 56 | """Make this entities Fighter attack another entity.""" 57 | 58 | def plan(self) -> Attack: 59 | if self.location.distance_to(*self.target_pos) > 1: 60 | raise Impossible("That space is too far away to attack.") 61 | return self 62 | 63 | def act(self) -> None: 64 | target = self.map.fighter_at(*self.target_pos) 65 | assert target 66 | 67 | damage = self.actor.fighter.power - target.fighter.defense 68 | 69 | if self.actor.is_player(): 70 | who_desc = f"You attack the {target.fighter.name}" 71 | else: 72 | who_desc = f"{self.actor.fighter.name} attacks {target.fighter.name}" 73 | 74 | if damage > 0: 75 | self.report(f"{who_desc} for {damage} hit points.") 76 | target.damage(damage) 77 | else: 78 | self.report(f"{who_desc} but does no damage.") 79 | self.actor.reschedule(100) 80 | 81 | 82 | class AttackPlayer(Action): 83 | """Move towards and attack the player.""" 84 | 85 | def plan(self) -> Action: 86 | return MoveTowards(self.actor, self.map.player.location.xy).plan() 87 | 88 | 89 | class Pickup(Action): 90 | def plan(self) -> Action: 91 | if not self.map.items.get(self.location.xy): 92 | raise Impossible("There is nothing to pick up.") 93 | return self 94 | 95 | def act(self) -> None: 96 | for item in self.map.items[self.location.xy]: 97 | self.report(f"{self.actor.fighter.name} pick up the {item.name}.") 98 | self.actor.inventory.take(item) 99 | return self.actor.reschedule(100) 100 | 101 | 102 | class ActivateItem(ActionWithItem): 103 | def plan(self) -> ActionWithItem: 104 | assert self.item in self.actor.inventory.contents 105 | return self.item.plan_activate(self) 106 | 107 | def act(self) -> None: 108 | assert self.item in self.actor.inventory.contents 109 | self.item.action_activate(self) 110 | self.actor.reschedule(100) 111 | 112 | 113 | class DropItem(ActionWithItem): 114 | def act(self) -> None: 115 | assert self.item in self.actor.inventory.contents 116 | self.item.lift() 117 | self.item.place(self.actor.location) 118 | self.report(f"You drop the {self.item.name}.") 119 | self.actor.reschedule(100) 120 | 121 | 122 | class DrinkItem(ActionWithItem): 123 | def act(self) -> None: 124 | assert self.item in self.actor.inventory.contents 125 | self.item.action_drink(self) 126 | self.actor.reschedule(100) 127 | 128 | 129 | class EatItem(ActionWithItem): 130 | def act(self) -> None: 131 | assert self.item in self.actor.inventory.contents 132 | self.item.action_eat(self) 133 | self.actor.reschedule(100) 134 | -------------------------------------------------------------------------------- /actor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import traceback 5 | from typing import TYPE_CHECKING, Optional, Type 6 | 7 | import items.other 8 | from actions import Impossible 9 | 10 | if TYPE_CHECKING: 11 | from actions import Action 12 | from inventory import Inventory 13 | from location import Location 14 | from races import Fighter 15 | from tqueue import Ticket, TurnQueue 16 | 17 | 18 | class Actor: 19 | def __init__(self, location: Location, fighter: Fighter, ai_cls: Type[Action]): 20 | self.location = location 21 | self.fighter = fighter 22 | location.map.actors.add(self) 23 | self.ticket: Optional[Ticket] = self.scheduler.schedule(0, self.act) 24 | self.ai = ai_cls(self) 25 | 26 | def act(self, scheduler: TurnQueue, ticket: Ticket) -> None: 27 | if ticket is not self.ticket: 28 | return scheduler.unschedule(ticket) 29 | try: 30 | action = self.ai.plan() 31 | except Impossible: 32 | print(f"Unresolved action with {self}!", file=sys.stderr) 33 | traceback.print_exc(file=sys.stderr) 34 | return self.reschedule(100) 35 | assert action is action.plan(), f"{action} was not fully resolved, {self}." 36 | action.act() 37 | 38 | @property 39 | def scheduler(self) -> TurnQueue: 40 | return self.location.map.model.scheduler 41 | 42 | def reschedule(self, interval: int) -> None: 43 | """Reschedule this actor to run after `interval` ticks.""" 44 | if self.ticket is None: 45 | # Actor has died during their own turn. 46 | assert not self.fighter.alive 47 | return 48 | self.ticket = self.scheduler.reschedule(self.ticket, interval) 49 | 50 | @property 51 | def inventory(self) -> Inventory: 52 | return self.fighter.inventory 53 | 54 | def is_player(self) -> bool: 55 | """Return True if this actor is the player.""" 56 | return self.location.map.player is self 57 | 58 | def is_visible(self) -> bool: 59 | """Return True if this actor is visible to the player.""" 60 | return bool(self.location.map.visible[self.location.ij]) 61 | 62 | def __repr__(self) -> str: 63 | return f"{self.__class__.__name__}({self.location!r}, {self.fighter!r})" 64 | 65 | def die(self) -> None: 66 | """Perform on death logic.""" 67 | assert self.fighter.alive 68 | self.fighter.alive = False 69 | if self.is_visible(): 70 | if self.is_player(): 71 | self.location.map.model.report("You die.") 72 | else: 73 | self.location.map.model.report(f"The {self.fighter.name} dies.") 74 | items.other.Corpse(self).place(self.location) 75 | # Drop all held items. 76 | for item in list(self.fighter.inventory.contents): 77 | item.lift() 78 | item.place(self.location) 79 | self.location.map.actors.remove(self) # Actually remove the actor. 80 | if self.scheduler.heap[0] is self.ticket: 81 | # If this actor killed itself during its turn then it must edit the queue. 82 | self.scheduler.unschedule(self.ticket) 83 | self.ticket = None # Disable AI. 84 | 85 | def damage(self, damage: int) -> None: 86 | """Damage a fighter and check for its death.""" 87 | assert damage >= 0 88 | self.fighter.hp -= damage 89 | if self.fighter.hp <= 0: 90 | self.die() 91 | -------------------------------------------------------------------------------- /data/cp437-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HexDecimal/roguelike-tutorial/3124a89a00d32996f208af3fe6042040c68b1f9e/data/cp437-14.png -------------------------------------------------------------------------------- /effect.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from actions import Action 7 | from actor import Actor 8 | 9 | 10 | class Effect: 11 | def apply(self, action: Action, entity: Actor) -> None: 12 | raise NotImplementedError() 13 | 14 | 15 | class Healing(Effect): 16 | def __init__(self, amount: int = 4): 17 | self.amount = amount 18 | 19 | def apply(self, action: Action, entity: Actor) -> None: 20 | if not entity.fighter: 21 | return 22 | fighter = entity.fighter 23 | fighter.hp = min(fighter.hp + self.amount, fighter.max_hp) 24 | action.report(f"{fighter.name} heal {self.amount} hp.") 25 | -------------------------------------------------------------------------------- /g.py: -------------------------------------------------------------------------------- 1 | """Python module to define and hold global variables.""" 2 | import tcod 3 | 4 | context: tcod.context.Context # Active context. 5 | console: tcod.console.Console # Active console. 6 | -------------------------------------------------------------------------------- /gamemap.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Set, Tuple 5 | 6 | import numpy as np 7 | import tcod 8 | 9 | from location import Location 10 | 11 | if TYPE_CHECKING: 12 | from actor import Actor 13 | from graphic import Graphic 14 | from items import Item 15 | from model import Model 16 | 17 | # Data types for handling game map tiles: 18 | tile_graphic = np.dtype([("ch", np.int32), ("fg", "3B"), ("bg", "3B")]) 19 | tile_dt = np.dtype( 20 | [ 21 | ("move_cost", np.uint8), 22 | ("transparent", bool), 23 | ("light", tile_graphic), 24 | ("dark", tile_graphic), 25 | ] 26 | ) 27 | 28 | 29 | class Camera(NamedTuple): 30 | """An object for tracking the camera position and for screen/world conversions. 31 | 32 | `x` and `y` are the camera center position. 33 | """ 34 | 35 | x: int 36 | y: int 37 | 38 | def get_left_top_pos(self, screen_shape: Tuple[int, int]) -> Tuple[int, int]: 39 | """Return the (left, top) position of the camera for a screen of this size.""" 40 | return self.x - screen_shape[0] // 2, self.y - screen_shape[1] // 2 41 | 42 | def get_views( 43 | self, world_shape: Tuple[int, int], screen_shape: Tuple[int, int] 44 | ) -> Tuple[Tuple[slice, slice], Tuple[slice, slice]]: 45 | """Return (screen_view, world_view) as 2D slices for use with NumPy. 46 | 47 | These views are used to slice their respective arrays. 48 | """ 49 | camera_left, camera_top = self.get_left_top_pos(screen_shape) 50 | 51 | screen_left = max(0, -camera_left) 52 | screen_top = max(0, -camera_top) 53 | 54 | world_left = max(0, camera_left) 55 | world_top = max(0, camera_top) 56 | 57 | screen_width = min(screen_shape[0] - screen_left, world_shape[0] - world_left) 58 | screen_height = min(screen_shape[1] - screen_top, world_shape[1] - world_top) 59 | 60 | screen_view: Tuple[slice, slice] = np.s_[ 61 | screen_top : screen_top + screen_height, 62 | screen_left : screen_left + screen_width, 63 | ] 64 | world_view: Tuple[slice, slice] = np.s_[ 65 | world_top : world_top + screen_height, 66 | world_left : world_left + screen_width, 67 | ] 68 | 69 | return screen_view, world_view 70 | 71 | 72 | class Tile(NamedTuple): 73 | """A NamedTuple type broadcastable to any tile_dt array.""" 74 | 75 | move_cost: int 76 | transparent: bool 77 | light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]] 78 | dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]] 79 | 80 | 81 | class MapLocation(Location): 82 | def __init__(self, gamemap: GameMap, x: int, y: int): 83 | self.map = gamemap 84 | self.x = x 85 | self.y = y 86 | 87 | 88 | class GameMap: 89 | """An object which holds the tile and entity data for a single floor.""" 90 | 91 | DARKNESS = np.asarray((0, (0, 0, 0), (0, 0, 0)), dtype=tile_graphic) 92 | 93 | player: Actor 94 | 95 | def __init__(self, model: Model, width: int, height: int): 96 | self.model = model 97 | self.width = width 98 | self.height = height 99 | self.shape = height, width 100 | self.tiles = np.zeros(self.shape, dtype=tile_dt) 101 | self.explored = np.zeros(self.shape, dtype=bool) 102 | self.visible = np.zeros(self.shape, dtype=bool) 103 | self.actors: Set[Actor] = set() 104 | self.items: Dict[Tuple[int, int], List[Item]] = {} 105 | self.camera_xy = (0, 0) # Camera center position. 106 | 107 | @property 108 | def camera(self) -> Camera: 109 | return Camera(*self.camera_xy) 110 | 111 | def is_blocked(self, x: int, y: int) -> bool: 112 | """Return True if this position is impassible.""" 113 | if not (0 <= x < self.width and 0 <= y < self.height): 114 | return True 115 | if not self.tiles[y, x]["move_cost"]: 116 | return True 117 | if any(actor.location.xy == (x, y) for actor in self.actors): 118 | return True 119 | 120 | return False 121 | 122 | def fighter_at(self, x: int, y: int) -> Optional[Actor]: 123 | """Return any fighter entity found at this position.""" 124 | for actor in self.actors: 125 | if actor.location.xy == (x, y): 126 | return actor 127 | return None 128 | 129 | def update_fov(self) -> None: 130 | """Update the field of view around the player.""" 131 | if not self.player.location: 132 | return 133 | self.visible = tcod.map.compute_fov( 134 | transparency=self.tiles["transparent"], 135 | pov=self.player.location.ij, 136 | radius=10, 137 | light_walls=True, 138 | algorithm=tcod.FOV_RESTRICTIVE, 139 | ) 140 | self.explored |= self.visible 141 | 142 | def render(self, console: tcod.console.Console) -> None: 143 | """Render this maps contents onto a console.""" 144 | screen_shape = console.width, console.height 145 | cam_x, cam_y = self.camera.get_left_top_pos(screen_shape) 146 | 147 | # Get the screen and world view slices. 148 | screen_view, world_view = self.camera.get_views( 149 | (self.width, self.height), screen_shape 150 | ) 151 | 152 | # Draw the console based on visible or explored areas. 153 | console.tiles_rgb[screen_view] = np.select( 154 | (self.visible[world_view], self.explored[world_view]), 155 | (self.tiles["light"][world_view], self.tiles["dark"][world_view]), 156 | self.DARKNESS, 157 | ) 158 | 159 | # Collect and filter the various entity objects. 160 | visible_objs: Dict[Tuple[int, int], List[Graphic]] = defaultdict(list) 161 | for obj in self.actors: 162 | obj_x, obj_y = obj.location.x - cam_x, obj.location.y - cam_y 163 | if not (0 <= obj_x < console.width and 0 <= obj_y < console.height): 164 | continue 165 | if not self.visible[obj.location.ij]: 166 | continue 167 | visible_objs[obj_y, obj_x].append(obj.fighter) 168 | for (item_x, item_y), items in self.items.items(): 169 | obj_x, obj_y = item_x - cam_x, item_y - cam_y 170 | if not (0 <= obj_x < console.width and 0 <= obj_y < console.height): 171 | continue 172 | if not self.visible[item_y, item_x]: 173 | continue 174 | visible_objs[obj_y, obj_x].extend(items) 175 | 176 | # Draw the visible entities. 177 | for ij, graphics in visible_objs.items(): 178 | graphic = min(graphics) 179 | console.tiles_rgb[["ch", "fg"]][ij] = graphic.char, graphic.color 180 | 181 | def __getitem__(self, key: Tuple[int, int]) -> MapLocation: 182 | """Return the MapLocation for an x,y index.""" 183 | return MapLocation(self, *key) 184 | -------------------------------------------------------------------------------- /graphic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Tuple 4 | 5 | 6 | class Graphic: 7 | name: str = "" 8 | char: int = ord("!") 9 | color: Tuple[int, int, int] = (255, 255, 255) 10 | render_order: int = 0 11 | 12 | def __lt__(self, other: Graphic) -> bool: 13 | """Sort Graphic instances by render_order.""" 14 | return self.render_order < other.render_order 15 | -------------------------------------------------------------------------------- /inventory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List 4 | 5 | if TYPE_CHECKING: 6 | from items import Item 7 | 8 | 9 | class Inventory: 10 | symbols = "abcdefghijklmnopqrstuvwxyz" 11 | capacity = len(symbols) 12 | 13 | def __init__(self) -> None: 14 | self.contents: List[Item] = [] 15 | 16 | def take(self, item: Item) -> None: 17 | """Take an item from its current location and put it in self.""" 18 | assert item.owner is not self 19 | item.lift() 20 | self.contents.append(item) 21 | item.owner = self 22 | -------------------------------------------------------------------------------- /items/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | import graphic 6 | from actions import Impossible 7 | 8 | if TYPE_CHECKING: 9 | from actions import ActionWithItem 10 | from inventory import Inventory 11 | from location import Location 12 | 13 | 14 | class Item(graphic.Graphic): 15 | render_order = 1 16 | 17 | def __init__(self) -> None: 18 | self.owner: Optional[Inventory] = None 19 | self.location: Optional[Location] = None 20 | 21 | def lift(self) -> None: 22 | """Remove this item from any of its containers.""" 23 | if self.owner: 24 | self.owner.contents.remove(self) 25 | self.owner = None 26 | if self.location: 27 | item_list = self.location.map.items[self.location.xy] 28 | item_list.remove(self) 29 | if not item_list: 30 | del self.location.map.items[self.location.xy] 31 | self.location = None 32 | 33 | def place(self, location: Location) -> None: 34 | """Place this item on the floor at the given location.""" 35 | assert not self.location, "This item already has a location." 36 | assert not self.owner, "Can't be placed because this item is currently owned." 37 | self.location = location 38 | items = location.map.items 39 | try: 40 | items[location.xy].append(self) 41 | except KeyError: 42 | items[location.xy] = [self] 43 | 44 | def plan_activate(self, action: ActionWithItem) -> ActionWithItem: 45 | """Item activated as part of an action. 46 | 47 | Assume that action has an actor which is holding this items entity. 48 | """ 49 | return action 50 | 51 | def action_activate(self, action: ActionWithItem) -> None: 52 | raise Impossible(f"You can do nothing with the {self.name}.") 53 | 54 | def consume(self, action: ActionWithItem) -> None: 55 | """Remove this item from the actors inventory.""" 56 | assert action.item is self 57 | action.item.lift() 58 | 59 | def action_drink(self, action: ActionWithItem) -> None: 60 | """Drink this item.""" 61 | raise Impossible("You can't drink that.") 62 | 63 | def action_eat(self, action: ActionWithItem) -> None: 64 | """Eat this item.""" 65 | raise Impossible("You can't eat that.") 66 | -------------------------------------------------------------------------------- /items/other.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import actions.common 6 | import effect 7 | from items import Item 8 | 9 | if TYPE_CHECKING: 10 | from actions import ActionWithItem 11 | from actor import Actor 12 | 13 | 14 | class Eatable(Item): 15 | def __init__(self, my_effect: effect.Effect): 16 | super().__init__() 17 | self.my_effect = my_effect 18 | 19 | def plan_activate(self, action: ActionWithItem) -> ActionWithItem: 20 | """Foods will forward to a food action.""" 21 | return actions.common.EatItem(action.actor, self) 22 | 23 | def action_eat(self, action: ActionWithItem) -> None: 24 | """Consume this food and active its effect.""" 25 | self.consume(action) 26 | self.my_effect.apply(action, action.actor) 27 | 28 | 29 | class Corpse(Eatable): 30 | char = ord("%") 31 | color = (127, 0, 0) 32 | render_order = 2 33 | 34 | def __init__(self, actor: Actor) -> None: 35 | super().__init__(effect.Healing(1)) 36 | self.name = f"{actor.fighter.name} Corpse" 37 | 38 | 39 | class FoodRation(Eatable): 40 | char = ord("%") 41 | color = (127, 0, 0) 42 | render_order = 2 43 | 44 | def __init__(self) -> None: 45 | super().__init__(effect.Healing(2)) 46 | self.name = "Food ration" 47 | -------------------------------------------------------------------------------- /items/potions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import actions.common 6 | import effect 7 | from items import Item 8 | 9 | if TYPE_CHECKING: 10 | from actions import ActionWithItem 11 | 12 | 13 | class Potion(Item): 14 | name = "Potion" 15 | char = ord("!") 16 | color = (255, 255, 255) 17 | 18 | def __init__(self, my_effect: effect.Effect): 19 | super().__init__() 20 | self.my_effect = my_effect 21 | 22 | def plan_activate(self, action: ActionWithItem) -> ActionWithItem: 23 | """Potions will forward to a drink action.""" 24 | return actions.common.DrinkItem(action.actor, self) 25 | 26 | def action_drink(self, action: ActionWithItem) -> None: 27 | """Consume this potion and active its effect.""" 28 | self.consume(action) 29 | self.my_effect.apply(action, action.actor) 30 | 31 | 32 | class HealingPotion(Potion): 33 | name = "Healing Potion" 34 | color = (64, 0, 64) 35 | 36 | def __init__(self) -> None: 37 | super().__init__(effect.Healing(4)) 38 | -------------------------------------------------------------------------------- /items/scrolls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | from typing import TYPE_CHECKING, Iterator 5 | 6 | import states.ingame 7 | from actions import Impossible 8 | from items import Item 9 | 10 | if TYPE_CHECKING: 11 | from actions import ActionWithItem 12 | from actor import Actor 13 | 14 | 15 | class Scroll(Item): 16 | name = "Scroll" 17 | char = ord("#") 18 | color = (255, 255, 255) 19 | 20 | 21 | class LightningScroll(Scroll): 22 | name = "Lightning Scroll" 23 | color = (255, 255, 32) 24 | damage = 20 25 | max_range = 3 26 | 27 | @staticmethod 28 | def iter_targets(action: ActionWithItem) -> Iterator[Actor]: 29 | for actor in action.map.actors: 30 | if actor is action.actor: 31 | continue 32 | if not action.map.visible[actor.location.ij]: 33 | continue 34 | yield actor 35 | 36 | @staticmethod 37 | def target_distance(owner: Actor, target: Actor) -> int: 38 | return owner.location.distance_to(*target.location.xy) 39 | 40 | def action_activate(self, action: ActionWithItem) -> None: 41 | targets = list(self.iter_targets(action)) 42 | if not targets: 43 | raise Impossible("No enemy is close enough to strike.") 44 | target = min(targets, key=functools.partial(self.target_distance, action.actor)) 45 | if self.target_distance(action.actor, target) > self.max_range: 46 | raise Impossible("The enemy is too far away to strike.") 47 | damage = self.damage 48 | action.report( 49 | f"A lighting bolt strikes the {target.fighter.name} for {damage} damage!" 50 | ) 51 | target.damage(damage) 52 | self.consume(action) 53 | 54 | 55 | class FireballScroll(Scroll): 56 | name = "Fireball Scroll" 57 | color = (255, 32, 32) 58 | damage = 12 59 | radius = 3 60 | 61 | def action_activate(self, action: ActionWithItem) -> None: 62 | selected_xy = states.ingame.PickLocation( 63 | action.model, "Select where to cast a fireball.", action.actor.location.xy 64 | ).loop() 65 | if not selected_xy: 66 | raise Impossible("Targeting canceled.") 67 | if not action.map.visible.T[selected_xy]: 68 | raise Impossible("You cannot target a tile outside your field of view") 69 | 70 | action.report( 71 | f"The fireball explodes, burning everything within {self.radius} tiles!" 72 | ) 73 | 74 | # Use a copy of the actors list since it may be edited during the loop. 75 | for actor in list(action.map.actors): 76 | if actor.location.distance_to(*selected_xy) > self.radius: 77 | continue 78 | action.report( 79 | f"The {actor.fighter.name} gets burned for {self.damage} hit points" 80 | ) 81 | actor.damage(self.damage) 82 | self.consume(action) 83 | 84 | 85 | class GenocideScroll(Scroll): 86 | name = "Genocide Scroll" 87 | color = (32, 32, 0) 88 | 89 | def action_activate(self, action: ActionWithItem) -> None: 90 | selected_xy = states.ingame.PickLocation( 91 | action.model, "Select who to genecide.", action.actor.location.xy 92 | ).loop() 93 | if not selected_xy: 94 | raise Impossible("Targeting canceled.") 95 | if not action.map.visible.T[selected_xy]: 96 | raise Impossible("You cannot target a enemy outside your field of view.") 97 | 98 | selected_actor = action.map.fighter_at(*selected_xy) 99 | if not selected_actor or selected_actor.is_player(): 100 | raise Impossible("No enemy selected to genocide.") 101 | 102 | type_fighter = type(selected_actor.fighter) 103 | # Use a copy of the actors list since it may be edited during the loop. 104 | for actor in list(action.map.actors): 105 | if isinstance(actor.fighter, type_fighter): 106 | actor.die() 107 | 108 | action.report(f"The {selected_actor.fighter.name} has been genocided") 109 | self.consume(action) 110 | 111 | 112 | class TeleportScroll(Scroll): 113 | name = "Teleport Scroll" 114 | color = (32, 32, 255) 115 | 116 | def action_activate(self, action: ActionWithItem) -> None: 117 | selected_xy = states.ingame.PickLocation( 118 | action.model, "Select where to cast a teleport.", action.actor.location.xy 119 | ).loop() 120 | if not selected_xy: 121 | raise Impossible("Targeting canceled.") 122 | if not action.map.visible.T[selected_xy]: 123 | raise Impossible("You cannot target a tile outside your field of view.") 124 | if action.map.is_blocked(*selected_xy): 125 | raise Impossible("This tile is blocked.") 126 | 127 | action.actor.location = action.map[selected_xy] 128 | action.map.update_fov() 129 | self.consume(action) 130 | -------------------------------------------------------------------------------- /location.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Tuple 4 | 5 | if TYPE_CHECKING: 6 | import gamemap 7 | 8 | 9 | class Location: 10 | map: gamemap.GameMap 11 | x: int 12 | y: int 13 | 14 | @property 15 | def xy(self) -> Tuple[int, int]: 16 | return self.x, self.y 17 | 18 | @xy.setter 19 | def xy(self, xy: Tuple[int, int]) -> None: 20 | self.x, self.y = xy 21 | 22 | @property 23 | def ij(self) -> Tuple[int, int]: 24 | return self.y, self.x 25 | 26 | def distance_to(self, x: int, y: int) -> int: 27 | """Return the approximate number of steps needed to reach x,y.""" 28 | return max(abs(self.x - x), abs(self.y - y)) 29 | 30 | def relative(self, x: int, y: int) -> Tuple[int, int]: 31 | """Return a coordinate relative to this entity.""" 32 | return self.x + x, self.y + y 33 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import warnings 4 | 5 | import tcod 6 | 7 | import g 8 | import states.mainmenu 9 | 10 | 11 | def main() -> None: 12 | screen_width = 720 13 | screen_height = 480 14 | 15 | tileset = tcod.tileset.load_tilesheet( 16 | "data/cp437-14.png", 32, 8, tcod.tileset.CHARMAP_CP437 17 | ) 18 | with tcod.context.new( 19 | width=screen_width, 20 | height=screen_height, 21 | tileset=tileset, 22 | title="libtcod tutorial revised", 23 | renderer=tcod.RENDERER_SDL2, 24 | vsync=True, 25 | ) as g.context: 26 | g.console = tcod.Console(*g.context.recommended_console_size()) 27 | states.mainmenu.MainMenu().loop() 28 | 29 | 30 | if __name__ == "__main__": 31 | if not sys.warnoptions: 32 | warnings.simplefilter("default") # Show all warnings once by default. 33 | main() 34 | -------------------------------------------------------------------------------- /model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List 4 | 5 | import states.ingame 6 | import tqueue 7 | 8 | if TYPE_CHECKING: 9 | from actor import Actor 10 | from gamemap import GameMap 11 | 12 | 13 | class Message: 14 | def __init__(self, text: str) -> None: 15 | self.text = text 16 | self.count = 1 17 | 18 | def __str__(self) -> str: 19 | if self.count > 1: 20 | return f"{self.text} (x{self.count})" 21 | return self.text 22 | 23 | 24 | class Model: 25 | """The model contains everything from a session which should be saved.""" 26 | 27 | active_map: GameMap 28 | 29 | def __init__(self) -> None: 30 | self.log: List[Message] = [] 31 | self.scheduler = tqueue.TurnQueue() 32 | 33 | @property 34 | def player(self) -> Actor: 35 | return self.active_map.player 36 | 37 | def report(self, text: str) -> None: 38 | print(text) 39 | if self.log and self.log[-1].text == text: 40 | self.log[-1].count += 1 41 | else: 42 | self.log.append(Message(text)) 43 | 44 | @property 45 | def is_player_dead(self) -> bool: 46 | """True if the player had died.""" 47 | return not self.player.fighter or self.player.fighter.hp <= 0 48 | 49 | def loop(self) -> None: 50 | while True: 51 | if self.is_player_dead: 52 | states.ingame.GameOver(self).loop() 53 | continue 54 | self.scheduler.invoke_next() 55 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.8 3 | warn_unused_configs = True 4 | disallow_subclassing_any = True 5 | disallow_any_generics = True 6 | disallow_untyped_calls = True 7 | disallow_untyped_defs = True 8 | disallow_incomplete_defs = True 9 | check_untyped_defs = True 10 | disallow_untyped_decorators = True 11 | no_implicit_optional = True 12 | warn_redundant_casts = True 13 | warn_unused_ignores = True 14 | warn_return_any = True 15 | implicit_reexport = False 16 | -------------------------------------------------------------------------------- /procgen/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HexDecimal/roguelike-tutorial/3124a89a00d32996f208af3fe6042040c68b1f9e/procgen/__init__.py -------------------------------------------------------------------------------- /procgen/dungeon.py: -------------------------------------------------------------------------------- 1 | """Dungeon level generator.""" 2 | from __future__ import annotations 3 | 4 | import random 5 | from typing import TYPE_CHECKING, Iterator, List, Tuple, Type 6 | 7 | import numpy as np 8 | import tcod 9 | 10 | import gamemap 11 | import items.other 12 | import items.potions 13 | import items.scrolls 14 | import races 15 | import races.common 16 | from actions import ai 17 | 18 | if TYPE_CHECKING: 19 | from model import Model 20 | 21 | WALL = gamemap.Tile( 22 | move_cost=0, 23 | transparent=False, 24 | light=(ord(" "), (255, 255, 255), (130, 110, 50)), 25 | dark=(ord(" "), (255, 255, 255), (0, 0, 100)), 26 | ) 27 | FLOOR = gamemap.Tile( 28 | move_cost=1, 29 | transparent=True, 30 | light=(ord(" "), (255, 255, 255), (200, 180, 50)), 31 | dark=(ord(" "), (255, 255, 255), (50, 50, 150)), 32 | ) 33 | 34 | 35 | class Room: 36 | """Holds data and methods used to generate rooms.""" 37 | 38 | def __init__(self, x: int, y: int, width: int, height: int): 39 | self.x1 = x 40 | self.y1 = y 41 | self.x2 = x + width 42 | self.y2 = y + height 43 | 44 | @property 45 | def outer(self) -> Tuple[slice, slice]: 46 | """Return the NumPy index for the whole room.""" 47 | index: Tuple[slice, slice] = np.s_[self.x1 : self.x2, self.y1 : self.y2] 48 | return index 49 | 50 | @property 51 | def inner(self) -> Tuple[slice, slice]: 52 | """Return the NumPy index for the inner room area.""" 53 | index: Tuple[slice, slice] = np.s_[ 54 | self.x1 + 1 : self.x2 - 1, self.y1 + 1 : self.y2 - 1 55 | ] 56 | return index 57 | 58 | @property 59 | def center(self) -> Tuple[int, int]: 60 | """Return the index for the rooms center coordinate.""" 61 | return (self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2 62 | 63 | def intersects(self, other: Room) -> bool: 64 | """Return True if this room intersects with another.""" 65 | return ( 66 | self.x1 <= other.x2 67 | and self.x2 >= other.x1 68 | and self.y1 <= other.y2 69 | and self.y2 >= other.y1 70 | ) 71 | 72 | def distance_to(self, other: Room) -> float: 73 | """Return an approximate distance from this room to another.""" 74 | x, y = self.center 75 | other_x, other_y = other.center 76 | return abs(other_x - x) + abs(other_y - y) 77 | 78 | def get_free_spaces( 79 | self, gamemap: gamemap.GameMap, number: int 80 | ) -> Iterator[Tuple[int, int]]: 81 | """Iterate over the x,y coordinates of up to `number` spaces.""" 82 | for _ in range(number): 83 | x = random.randint(self.x1 + 1, self.x2 - 2) 84 | y = random.randint(self.y1 + 1, self.y2 - 2) 85 | if gamemap.is_blocked(x, y): 86 | continue 87 | yield x, y 88 | 89 | def place_entities(self, gamemap: gamemap.GameMap) -> None: 90 | """Spawn entities within this room.""" 91 | monsters = random.randint(0, 3) 92 | items_spawned = random.randint(0, 2) 93 | for xy in self.get_free_spaces(gamemap, monsters): 94 | monster_cls: Type[races.Fighter] 95 | if random.randint(0, 100) < 80: 96 | monster_cls = races.common.Orc 97 | else: 98 | monster_cls = races.common.Troll 99 | monster_cls.spawn(gamemap[xy]) 100 | 101 | for xy in self.get_free_spaces(gamemap, items_spawned): 102 | item_cls = random.choice( 103 | [ 104 | items.other.FoodRation, 105 | items.potions.HealingPotion, 106 | items.scrolls.LightningScroll, 107 | items.scrolls.FireballScroll, 108 | items.scrolls.GenocideScroll, 109 | items.scrolls.TeleportScroll, 110 | ] 111 | ) 112 | item_cls().place(gamemap[xy]) 113 | 114 | 115 | def generate(model: Model, width: int = 80, height: int = 45) -> gamemap.GameMap: 116 | """Return a randomly generated GameMap.""" 117 | room_max_size = 10 118 | room_min_size = 6 119 | max_rooms = 30 120 | 121 | gm = gamemap.GameMap(model, width, height) 122 | gm.tiles[...] = WALL 123 | rooms: List[Room] = [] 124 | 125 | for i in range(max_rooms): 126 | # random width and height 127 | w = random.randint(room_min_size, room_max_size) 128 | h = random.randint(room_min_size, room_max_size) 129 | # random position without going out of the boundaries of the map 130 | x = random.randint(0, width - w) 131 | y = random.randint(0, height - h) 132 | new_room = Room(x, y, w, h) 133 | if any(new_room.intersects(other) for other in rooms): 134 | continue # This room intersects with a previous room. 135 | 136 | # Mark room inner area as open. 137 | gm.tiles.T[new_room.inner] = FLOOR 138 | if rooms: 139 | # Open a tunnel between rooms. 140 | if random.randint(0, 99) < 80: 141 | # 80% of tunnels are to the nearest room. 142 | other_room = min(rooms, key=new_room.distance_to) 143 | else: 144 | # 20% of tunnels are to the previous generated room. 145 | other_room = rooms[-1] 146 | t_start = new_room.center 147 | t_end = other_room.center 148 | if random.randint(0, 1): 149 | t_middle = t_start[0], t_end[1] 150 | else: 151 | t_middle = t_end[0], t_start[1] 152 | gm.tiles.T[tcod.line_where(*t_start, *t_middle)] = FLOOR 153 | gm.tiles.T[tcod.line_where(*t_middle, *t_end)] = FLOOR 154 | rooms.append(new_room) 155 | 156 | # Add player to the first room. 157 | gm.player = races.common.Player.spawn(gm[rooms[0].center], ai_cls=ai.PlayerControl) 158 | 159 | for room in rooms: 160 | room.place_entities(gm) 161 | 162 | gm.update_fov() 163 | return gm 164 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py38'] 4 | include = '\.pyi?$' 5 | 6 | [tool.isort] 7 | profile= "black" 8 | py_version = "38" 9 | skip_gitignore = true 10 | line_length = 88 11 | -------------------------------------------------------------------------------- /races/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional, Type 4 | 5 | import actor 6 | import graphic 7 | from actions.ai import BasicMonster 8 | from inventory import Inventory 9 | 10 | if TYPE_CHECKING: 11 | from actions import Action 12 | from location import Location 13 | 14 | 15 | class Fighter(graphic.Graphic): 16 | render_order = 0 17 | 18 | hp: int = 0 19 | power: int = 0 20 | defense: int = 0 21 | 22 | DEFAULT_AI: Type[Action] = BasicMonster 23 | 24 | def __init__(self, inventory: Optional[Inventory] = None) -> None: 25 | self.alive = True 26 | self.max_hp = self.hp 27 | self.inventory = inventory or Inventory() 28 | 29 | @classmethod 30 | def spawn( 31 | cls, location: Location, ai_cls: Optional[Type[Action]] = None 32 | ) -> actor.Actor: 33 | self = cls() 34 | return actor.Actor(location, self, ai_cls or cls.DEFAULT_AI) 35 | -------------------------------------------------------------------------------- /races/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from races import Fighter 4 | 5 | 6 | class Player(Fighter): 7 | name = "You" 8 | char = ord("@") 9 | color = (255, 255, 255) 10 | 11 | hp = 30 12 | power = 5 13 | defense = 2 14 | 15 | 16 | class Orc(Fighter): 17 | name = "Orc" 18 | char = ord("o") 19 | color = (63, 127, 63) 20 | 21 | hp = 10 22 | power = 3 23 | defense = 0 24 | 25 | 26 | class Troll(Fighter): 27 | name = "Troll" 28 | char = ord("T") 29 | color = (0, 127, 0) 30 | 31 | hp = 16 32 | power = 4 33 | defense = 1 34 | -------------------------------------------------------------------------------- /rendering.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Tuple 4 | 5 | import tcod.console 6 | from tcod.console import Console 7 | 8 | if TYPE_CHECKING: 9 | from model import Model 10 | 11 | 12 | def render_bar( 13 | console: tcod.console.Console, 14 | x: int, 15 | y: int, 16 | width: int, 17 | text: str, 18 | fullness: float, 19 | fg: Tuple[int, int, int], 20 | bg: Tuple[int, int, int], 21 | ) -> None: 22 | """Render a filled bar with centered text.""" 23 | console.print(x, y, text.center(width)[:width], fg=(255, 255, 255)) 24 | bar_bg = console.tiles_rgb.T["bg"][x : x + width, y] 25 | bar_bg[...] = bg 26 | fill_width = max(0, min(width, int(fullness * width))) 27 | bar_bg[:fill_width] = fg 28 | 29 | 30 | def draw_main_view(model: Model, console: Console) -> None: 31 | bar_width = 20 32 | player = model.player 33 | if player.location: 34 | model.active_map.camera_xy = player.location.xy 35 | 36 | console.clear() 37 | model.active_map.render(console) 38 | 39 | render_bar( 40 | console, 41 | 1, 42 | console.height - 2, 43 | bar_width, 44 | f"HP: {player.fighter.hp:02}/{player.fighter.max_hp:02}", 45 | player.fighter.hp / player.fighter.max_hp, 46 | (0x40, 0x80, 0), 47 | (0x80, 0, 0), 48 | ) 49 | 50 | x = bar_width + 2 51 | y = console.height 52 | log_width = console.width - x 53 | i = 0 54 | for text in model.log[::-1]: 55 | i += tcod.console.get_height_rect(log_width, str(text)) 56 | if i >= 7: 57 | break 58 | console.print_box( 59 | x, y - i, log_width, 0, str(text), fg=(255, 255, 255), bg=(0, 0, 0) 60 | ) 61 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tcod>=11.19 2 | numpy>=1.20 3 | -------------------------------------------------------------------------------- /states/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable, Generic, Optional, TypeVar 4 | 5 | import tcod 6 | 7 | import g 8 | 9 | CONSOLE_MIN_SIZE = (32, 10) # The smallest acceptable main console size. 10 | 11 | T = TypeVar("T") 12 | 13 | 14 | class StateBreak(Exception): 15 | """Breaks out of the active State.loop and makes it return None.""" 16 | 17 | 18 | class SaveAndQuit(Exception): 19 | pass 20 | 21 | 22 | class GameOverQuit(Exception): 23 | pass 24 | 25 | 26 | class State(Generic[T], tcod.event.EventDispatch[T]): 27 | MOVE_KEYS = { 28 | # Arrow keys. 29 | tcod.event.K_LEFT: (-1, 0), 30 | tcod.event.K_RIGHT: (1, 0), 31 | tcod.event.K_UP: (0, -1), 32 | tcod.event.K_DOWN: (0, 1), 33 | tcod.event.K_HOME: (-1, -1), 34 | tcod.event.K_END: (-1, 1), 35 | tcod.event.K_PAGEUP: (1, -1), 36 | tcod.event.K_PAGEDOWN: (1, 1), 37 | tcod.event.K_PERIOD: (0, 0), 38 | # Numpad keys. 39 | tcod.event.K_KP_1: (-1, 1), 40 | tcod.event.K_KP_2: (0, 1), 41 | tcod.event.K_KP_3: (1, 1), 42 | tcod.event.K_KP_4: (-1, 0), 43 | tcod.event.K_KP_5: (0, 0), 44 | tcod.event.K_CLEAR: (0, 0), 45 | tcod.event.K_KP_6: (1, 0), 46 | tcod.event.K_KP_7: (-1, -1), 47 | tcod.event.K_KP_8: (0, -1), 48 | tcod.event.K_KP_9: (1, -1), 49 | # Vi keys. 50 | tcod.event.K_h: (-1, 0), 51 | tcod.event.K_j: (0, 1), 52 | tcod.event.K_k: (0, -1), 53 | tcod.event.K_l: (1, 0), 54 | tcod.event.K_y: (-1, -1), 55 | tcod.event.K_u: (1, -1), 56 | tcod.event.K_b: (-1, 1), 57 | tcod.event.K_n: (1, 1), 58 | } 59 | 60 | COMMAND_KEYS = { 61 | tcod.event.K_d: "drop", 62 | tcod.event.K_i: "inventory", 63 | tcod.event.K_g: "pickup", 64 | tcod.event.K_ESCAPE: "escape", 65 | tcod.event.K_RETURN: "confirm", 66 | tcod.event.K_KP_ENTER: "confirm", 67 | } 68 | 69 | def loop(self) -> Optional[T]: 70 | """Run a state based game loop.""" 71 | while True: 72 | self.on_draw(g.console) 73 | g.context.present(g.console, keep_aspect=True, integer_scaling=True) 74 | for event in tcod.event.wait(): 75 | if event.type == "WINDOWRESIZED": 76 | g.console = configure_console() 77 | try: 78 | value = self.dispatch(event) 79 | except StateBreak: 80 | return None # Events may raise StateBreak to exit this state. 81 | if value is not None: 82 | return value 83 | 84 | def on_draw(self, console: tcod.console.Console) -> None: 85 | raise NotImplementedError() 86 | 87 | def ev_quit(self, event: tcod.event.Quit) -> Optional[T]: 88 | return self.cmd_quit() 89 | 90 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[T]: 91 | func: Callable[[], Optional[T]] 92 | if event.sym in self.COMMAND_KEYS: 93 | func = getattr(self, f"cmd_{self.COMMAND_KEYS[event.sym]}") 94 | return func() 95 | elif event.sym in self.MOVE_KEYS: 96 | return self.cmd_move(*self.MOVE_KEYS[event.sym]) 97 | return None 98 | 99 | def cmd_confirm(self) -> Optional[T]: 100 | pass 101 | 102 | def cmd_escape(self) -> Optional[T]: 103 | raise StateBreak() 104 | 105 | def cmd_quit(self) -> Optional[T]: 106 | """Save and quit.""" 107 | raise SystemExit() 108 | 109 | def cmd_move(self, x: int, y: int) -> Optional[T]: 110 | pass 111 | 112 | def cmd_pickup(self) -> Optional[T]: 113 | pass 114 | 115 | def cmd_inventory(self) -> Optional[T]: 116 | pass 117 | 118 | def cmd_drop(self) -> Optional[T]: 119 | pass 120 | 121 | 122 | def configure_console() -> tcod.console.Console: 123 | """Return a new main console with an automatically determined size.""" 124 | return tcod.Console(*g.context.recommended_console_size(*CONSOLE_MIN_SIZE)) 125 | -------------------------------------------------------------------------------- /states/ingame.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Generic, Optional, Tuple, TypeVar 4 | 5 | import tcod 6 | 7 | import rendering 8 | from actions import common 9 | from states import GameOverQuit, SaveAndQuit, State, StateBreak 10 | 11 | if TYPE_CHECKING: 12 | import actions 13 | from items import Item 14 | from model import Model 15 | 16 | T = TypeVar("T") 17 | 18 | 19 | class GameMapState(Generic[T], State[T]): 20 | def __init__(self, model: Model): 21 | super().__init__() 22 | self.model = model 23 | 24 | def on_draw(self, console: tcod.console.Console) -> None: 25 | rendering.draw_main_view(self.model, console) 26 | 27 | 28 | class PlayerReady(GameMapState["actions.Action"]): 29 | def cmd_escape(self) -> None: 30 | """Save and quit.""" 31 | raise SaveAndQuit() 32 | 33 | def cmd_move(self, x: int, y: int) -> actions.Action: 34 | """Move the player entity.""" 35 | return common.Move(self.model.player, (x, y)) 36 | 37 | def cmd_pickup(self) -> actions.Action: 38 | return common.Pickup(self.model.player) 39 | 40 | def cmd_inventory(self) -> Optional[actions.Action]: 41 | state = UseInventory(self.model) 42 | return state.loop() 43 | 44 | def cmd_drop(self) -> Optional[actions.Action]: 45 | state = DropInventory(self.model) 46 | return state.loop() 47 | 48 | 49 | class GameOver(GameMapState[None]): 50 | def cmd_quit(self) -> None: 51 | """Save and quit.""" 52 | raise SystemExit() 53 | 54 | def cmd_escape(self) -> None: 55 | """Finish game""" 56 | raise GameOverQuit() 57 | 58 | 59 | class BaseInventoryMenu(GameMapState["actions.Action"]): 60 | desc: str # Banner text. 61 | 62 | def __init__(self, model: Model): 63 | super().__init__(model) 64 | 65 | def on_draw(self, console: tcod.console.Console) -> None: 66 | """Draw inventory menu over the previous state.""" 67 | inventory_ = self.model.player.inventory 68 | rendering.draw_main_view(self.model, console) 69 | style: Any = {"fg": (255, 255, 255), "bg": (0, 0, 0)} 70 | console.print(0, 0, self.desc, **style) 71 | for i, item in enumerate(inventory_.contents): 72 | sym = inventory_.symbols[i] 73 | console.print(0, 2 + i, f"{sym}: {item.name}", **style) 74 | 75 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[actions.Action]: 76 | # Add check for item based symbols. 77 | inventory_ = self.model.player.inventory 78 | char: Optional[str] = None 79 | try: 80 | char = chr(event.sym) 81 | except ValueError: 82 | pass # Suppress exceptions for non-character keys. 83 | if char and char in inventory_.symbols: 84 | index = inventory_.symbols.index(char) 85 | if index < len(inventory_.contents): 86 | item = inventory_.contents[index] 87 | return self.pick_item(item) 88 | return super().ev_keydown(event) 89 | 90 | def pick_item(self, item: Item) -> Optional[actions.Action]: 91 | """Player selected this item.""" 92 | raise NotImplementedError() 93 | 94 | def cmd_quit(self) -> None: 95 | """Return to previous state.""" 96 | raise StateBreak() 97 | 98 | 99 | class UseInventory(BaseInventoryMenu): 100 | desc = "Select an item to USE, or press ESC to exit." 101 | 102 | def pick_item(self, item: Item) -> actions.Action: 103 | return common.ActivateItem(self.model.player, item) 104 | 105 | 106 | class DropInventory(BaseInventoryMenu): 107 | desc = "Select an item to DROP, or press ESC to exit." 108 | 109 | def pick_item(self, item: Item) -> actions.Action: 110 | return common.DropItem(self.model.player, item) 111 | 112 | 113 | class PickLocation(GameMapState[Tuple[int, int]]): 114 | """UI mode to ask the user for an x,y location.""" 115 | 116 | def __init__(self, model: Model, desc: str, start_xy: Tuple[int, int]) -> None: 117 | super().__init__(model) 118 | self.desc = desc 119 | self.cursor_xy = start_xy 120 | 121 | def on_draw(self, console: tcod.console.Console) -> None: 122 | super().on_draw(console) 123 | style: Any = {"fg": (255, 255, 255), "bg": (0, 0, 0)} 124 | console.print(0, 0, self.desc, **style) 125 | cam_x, cam_y = self.model.active_map.camera.get_left_top_pos( 126 | (console.width, console.height) 127 | ) 128 | x = self.cursor_xy[0] - cam_x 129 | y = self.cursor_xy[1] - cam_y 130 | if 0 <= x < console.width and 0 <= y < console.height: 131 | console.tiles_rgb.T[["fg", "bg"]][x, y] = (0, 0, 0), (255, 255, 255) 132 | 133 | def cmd_move(self, x: int, y: int) -> None: 134 | x += self.cursor_xy[0] 135 | y += self.cursor_xy[1] 136 | x = min(max(0, x), self.model.active_map.width - 1) 137 | y = min(max(0, y), self.model.active_map.height - 1) 138 | self.cursor_xy = x, y 139 | self.model.active_map.camera_xy = self.cursor_xy 140 | 141 | def cmd_confirm(self) -> Tuple[int, int]: 142 | return self.cursor_xy 143 | 144 | def cmd_quit(self) -> None: 145 | raise StateBreak() 146 | -------------------------------------------------------------------------------- /states/mainmenu.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import lzma 4 | import os.path 5 | import pickle 6 | import pickletools 7 | import sys 8 | import traceback 9 | from typing import Optional 10 | 11 | import tcod 12 | 13 | import procgen.dungeon 14 | import states.ingame 15 | from model import Model 16 | 17 | SAVE_FILE_NAME = "save.sav.xz" 18 | 19 | 20 | class MainMenu(states.State[None]): 21 | def __init__(self) -> None: 22 | super().__init__() 23 | self.model: Optional[Model] = None 24 | self.continue_msg = "No save file." 25 | try: 26 | with open(SAVE_FILE_NAME, "rb") as f: 27 | self.model = pickle.loads(lzma.decompress(f.read())) 28 | self.continue_msg = str(self.model) 29 | except Exception: 30 | traceback.print_exc(file=sys.stderr) 31 | self.continue_msg = "Error loading save." 32 | 33 | def on_draw(self, console: tcod.console.Console) -> None: 34 | console.clear() 35 | console.print(5, 5, f"c: Continue ({self.continue_msg})") 36 | console.print(5, 6, "n: New Game") 37 | console.print(5, 7, "q: Quit") 38 | 39 | def ev_keydown(self, event: tcod.event.KeyDown) -> None: 40 | if event.sym == tcod.event.K_c and self.model: 41 | self.start() 42 | elif event.sym == tcod.event.K_n: 43 | self.new_game() 44 | elif event.sym == tcod.event.K_q: 45 | self.cmd_quit() 46 | else: 47 | super().ev_keydown(event) 48 | 49 | def new_game(self) -> None: 50 | self.model = Model() 51 | self.model.active_map = procgen.dungeon.generate(self.model) 52 | self.start() 53 | 54 | def start(self) -> None: 55 | assert self.model 56 | try: 57 | self.model.loop() 58 | except states.GameOverQuit: 59 | # GameOver, remove save file. 60 | self.model = None 61 | self.remove_save() 62 | self.continue_msg = "No save file." 63 | except states.SaveAndQuit: 64 | # Save and return to the main menu. 65 | self.save() 66 | except SystemExit: 67 | # Save and exit immediately. 68 | if not self.model.is_player_dead: 69 | self.save() 70 | else: 71 | self.remove_save() 72 | raise 73 | except Exception: 74 | # Try to save on an error. 75 | self.save() 76 | raise 77 | self.continue_msg = str(self.model) 78 | 79 | def save(self) -> None: 80 | data = pickle.dumps(self.model, protocol=4) 81 | debug = f"Raw: {len(data)} bytes, " 82 | data = pickletools.optimize(data) 83 | debug += f"Optimized: {len(data)} bytes, " 84 | data = lzma.compress(data) 85 | debug += f"Compressed: {len(data)} bytes." 86 | print(debug) 87 | print("Game saved.") 88 | with open(SAVE_FILE_NAME, "wb") as f: 89 | f.write(data) 90 | 91 | def remove_save(self) -> None: 92 | if os.path.exists(SAVE_FILE_NAME): 93 | os.remove(SAVE_FILE_NAME) # Deletes the active save file. 94 | -------------------------------------------------------------------------------- /tqueue.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import heapq 4 | from typing import Callable, List, NamedTuple, Optional 5 | 6 | 7 | class Ticket(NamedTuple): 8 | """A Ticket represents a specific time and function to call at that time.""" 9 | 10 | tick: int 11 | unique_id: int 12 | func: Callable[[TurnQueue, Ticket], None] # type: ignore 13 | # https://github.com/python/mypy/issues/731 14 | 15 | 16 | class TurnQueue: 17 | def __init__(self) -> None: 18 | self.current_tick = 0 19 | self.last_unique_id = 0 # Used to sort same-tick ticks in FIFO order. 20 | self.heap: List[Ticket] = [] 21 | 22 | def schedule( 23 | self, interval: int, func: Callable[[TurnQueue, Ticket], None] 24 | ) -> Ticket: 25 | """Add a callable object to the turn queue. 26 | 27 | `interval` is the time to wait from the current time. 28 | 29 | `func` is the function to call during the scheduled time. 30 | 31 | Returns the newly scheduled Ticket instance. 32 | """ 33 | ticket = Ticket(self.current_tick + interval, self.last_unique_id, func) 34 | heapq.heappush(self.heap, ticket) 35 | self.last_unique_id += 1 36 | return ticket 37 | 38 | def reschedule( 39 | self, 40 | ticket: Ticket, 41 | interval: int, 42 | func: Optional[Callable[[TurnQueue, Ticket], None]] = None, 43 | ) -> Ticket: 44 | """Reschedule a new Ticket in place of the existing one. 45 | 46 | `ticket` must be the currently active Ticket. 47 | 48 | `interval` is the time to wait from the current time. 49 | 50 | `func` is the function to call during the scheduled time. It may be 51 | None to reuse the function from `ticket`. 52 | 53 | Returns the newly scheduled Ticket instance. 54 | """ 55 | assert ticket is not None 56 | assert self.heap[0] is ticket 57 | ticket = Ticket( 58 | self.current_tick + interval, 59 | self.last_unique_id, 60 | ticket.func if func is None else func, 61 | ) 62 | heapq.heappushpop(self.heap, ticket) 63 | self.last_unique_id += 1 64 | return ticket 65 | 66 | def unschedule(self, ticket: Ticket) -> None: 67 | """Explicitly remove the current ticket. 68 | 69 | `ticket` must be the currently active Ticket. 70 | """ 71 | assert ticket is not None 72 | assert self.heap[0] is ticket 73 | heapq.heappop(self.heap) 74 | 75 | def invoke_next(self) -> None: 76 | """Call the next scheduled function. 77 | 78 | This expects the scheduled function to take care of removing or 79 | rescheduling its own Ticket object. It will fail otherwise. 80 | """ 81 | ticket = self.heap[0] 82 | self.current_tick = ticket.tick 83 | ticket.func(self, ticket) 84 | assert ticket is not self.heap[0], f"{ticket!r} was not rescheduled." 85 | --------------------------------------------------------------------------------