├── components ├── __init__.py ├── base_component.py ├── inventory.py ├── equippable.py ├── level.py ├── fighter.py ├── equipment.py ├── ai.py └── consumable.py ├── requirements.txt ├── icon.ico ├── data ├── dejavu10x10_gs_tc.png └── menu_background.png ├── equipment_types.py ├── render_order.py ├── pyproject.toml ├── exceptions.py ├── .vscode ├── launch.json ├── settings.json └── extensions.json ├── color.py ├── .editorconfig ├── .github └── workflows │ ├── python-lint.yml │ └── python-package.yml ├── render_functions.py ├── tile_types.py ├── main.py ├── engine.py ├── entity_factories.py ├── message_log.py ├── setup_game.py ├── game_map.py ├── entity.py ├── actions.py ├── procgen.py ├── LICENSE.txt └── input_handlers.py /components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tcod>=11.15 2 | numpy>=1.18 3 | Pillow>=8.2.0 4 | -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TStand90/tcod_tutorial_v2/HEAD/icon.ico -------------------------------------------------------------------------------- /data/dejavu10x10_gs_tc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TStand90/tcod_tutorial_v2/HEAD/data/dejavu10x10_gs_tc.png -------------------------------------------------------------------------------- /data/menu_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TStand90/tcod_tutorial_v2/HEAD/data/menu_background.png -------------------------------------------------------------------------------- /equipment_types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class EquipmentType(Enum): 5 | WEAPON = auto() 6 | ARMOR = auto() 7 | -------------------------------------------------------------------------------- /render_order.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class RenderOrder(Enum): 5 | CORPSE = auto() 6 | ITEM = auto() 7 | ACTOR = auto() 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Project configuiration for Python tools. 2 | [tool.black] 3 | target-version = ["py38"] 4 | line-length = 120 5 | 6 | [tool.isort] 7 | py_version = "38" 8 | line_length = 120 9 | profile = "black" 10 | from_first = true 11 | skip_gitignore = true 12 | -------------------------------------------------------------------------------- /exceptions.py: -------------------------------------------------------------------------------- 1 | class Impossible(Exception): 2 | """Exception raised when an action is impossible to be performed. 3 | 4 | The reason is given as the exception message. 5 | """ 6 | 7 | 8 | class QuitWithoutSaving(SystemExit): 9 | """Can be raised to exit the game without automatically saving.""" 10 | -------------------------------------------------------------------------------- /components/base_component.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from engine import Engine 7 | from entity import Entity 8 | from game_map import GameMap 9 | 10 | 11 | class BaseComponent: 12 | parent: Entity # Owning entity instance. 13 | 14 | @property 15 | def gamemap(self) -> GameMap: 16 | return self.parent.gamemap 17 | 18 | @property 19 | def engine(self) -> Engine: 20 | return self.gamemap.engine 21 | -------------------------------------------------------------------------------- /.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 | // Launch main.py as an execuable module. 9 | "name": "Python: main.py", 10 | "type": "python", 11 | "request": "launch", 12 | "module": "main" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.enabled": true, 3 | "python.linting.pylintEnabled": true, 4 | "python.linting.flake8Enabled": true, 5 | "python.linting.mypyEnabled": true, 6 | "python.linting.mypyArgs": [ 7 | "--strict", 8 | "--follow-imports=silent", 9 | "--show-column-numbers" 10 | ], 11 | "editor.formatOnSave": true, 12 | "python.formatting.provider": "black", 13 | "editor.codeActionsOnSave": { 14 | "source.organizeImports": true // Runs isort. 15 | }, 16 | "editor.rulers": [ 17 | 120 18 | ], 19 | "files.associations": { 20 | "*.spec": "python" 21 | } 22 | } -------------------------------------------------------------------------------- /.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 | "ms-python.vscode-pylance", 8 | "editorconfig.editorconfig", 9 | "tamasfe.even-better-toml", 10 | "redhat.vscode-yaml" 11 | ], 12 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 13 | "unwantedRecommendations": [] 14 | } -------------------------------------------------------------------------------- /color.py: -------------------------------------------------------------------------------- 1 | white = (0xFF, 0xFF, 0xFF) 2 | black = (0x0, 0x0, 0x0) 3 | red = (0xFF, 0x0, 0x0) 4 | 5 | player_atk = (0xE0, 0xE0, 0xE0) 6 | enemy_atk = (0xFF, 0xC0, 0xC0) 7 | needs_target = (0x3F, 0xFF, 0xFF) 8 | status_effect_applied = (0x3F, 0xFF, 0x3F) 9 | descend = (0x9F, 0x3F, 0xFF) 10 | 11 | player_die = (0xFF, 0x30, 0x30) 12 | enemy_die = (0xFF, 0xA0, 0x30) 13 | 14 | invalid = (0xFF, 0xFF, 0x00) 15 | impossible = (0x80, 0x80, 0x80) 16 | error = (0xFF, 0x40, 0x40) 17 | 18 | welcome_text = (0x20, 0xA0, 0xFF) 19 | health_recovered = (0x0, 0xFF, 0x0) 20 | 21 | bar_text = white 22 | bar_filled = (0x0, 0x60, 0x0) 23 | bar_empty = (0x40, 0x10, 0x10) 24 | 25 | menu_title = (255, 255, 63) 26 | menu_text = white 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | # This config file ensures consistent whitespace handling across multiple editors. 4 | # Allows for things such as treating tabs as spaces for the current project without having to change your global 5 | # preferences. It also allows files to have different indentation based on their file type. 6 | 7 | # Your editor might need a plugin for this or it might already support .editorconfig files. 8 | 9 | root = true 10 | 11 | [*] 12 | charset = utf-8 13 | insert_final_newline = true 14 | trim_trailing_whitespace = true 15 | 16 | [*.py] 17 | indent_style = space 18 | indent_size = 4 19 | 20 | [*.json] 21 | indent_style = space 22 | indent_size = 4 23 | insert_final_newline = false 24 | 25 | [*.yml] 26 | indent_style = space 27 | indent_size = 2 28 | -------------------------------------------------------------------------------- /components/inventory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List 4 | 5 | from components.base_component import BaseComponent 6 | 7 | if TYPE_CHECKING: 8 | from entity import Actor, Item 9 | 10 | 11 | class Inventory(BaseComponent): 12 | parent: Actor 13 | 14 | def __init__(self, capacity: int): 15 | self.capacity = capacity 16 | self.items: List[Item] = [] 17 | 18 | def drop(self, item: Item) -> None: 19 | """ 20 | Removes an item from the inventory and restores it to the game map, at the player's current location. 21 | """ 22 | self.items.remove(item) 23 | item.place(self.parent.x, self.parent.y, self.gamemap) 24 | 25 | self.engine.message_log.add_message(f"You dropped the {item.name}.") 26 | -------------------------------------------------------------------------------- /components/equippable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from components.base_component import BaseComponent 6 | from equipment_types import EquipmentType 7 | 8 | if TYPE_CHECKING: 9 | from entity import Item 10 | 11 | 12 | class Equippable(BaseComponent): 13 | parent: Item 14 | 15 | def __init__( 16 | self, 17 | equipment_type: EquipmentType, 18 | power_bonus: int = 0, 19 | defense_bonus: int = 0, 20 | ): 21 | self.equipment_type = equipment_type 22 | 23 | self.power_bonus = power_bonus 24 | self.defense_bonus = defense_bonus 25 | 26 | 27 | class Dagger(Equippable): 28 | def __init__(self) -> None: 29 | super().__init__(equipment_type=EquipmentType.WEAPON, power_bonus=2) 30 | 31 | 32 | class Sword(Equippable): 33 | def __init__(self) -> None: 34 | super().__init__(equipment_type=EquipmentType.WEAPON, power_bonus=4) 35 | 36 | 37 | class LeatherArmor(Equippable): 38 | def __init__(self) -> None: 39 | super().__init__(equipment_type=EquipmentType.ARMOR, defense_bonus=1) 40 | 41 | 42 | class ChainMail(Equippable): 43 | def __init__(self) -> None: 44 | super().__init__(equipment_type=EquipmentType.ARMOR, defense_bonus=3) 45 | -------------------------------------------------------------------------------- /.github/workflows/python-lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and run some static tests. 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Lint 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.x"] # Latest Python 3. 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install APT dependencies 22 | if: runner.os == 'Linux' 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get install libsdl2-dev 26 | - name: Install Python dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install mypy black isort -r requirements.txt 30 | - name: MyPy 31 | uses: liskin/gh-problem-matcher-wrap@v1 32 | with: 33 | linters: mypy 34 | run: mypy --show-column-numbers . 35 | - name: isort 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 | -------------------------------------------------------------------------------- /render_functions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Tuple 4 | 5 | import color 6 | 7 | if TYPE_CHECKING: 8 | from tcod import Console 9 | 10 | from engine import Engine 11 | from game_map import GameMap 12 | 13 | 14 | def get_names_at_location(x: int, y: int, game_map: GameMap) -> str: 15 | if not game_map.in_bounds(x, y) or not game_map.visible[x, y]: 16 | return "" 17 | 18 | names = ", ".join(entity.name for entity in game_map.entities if entity.x == x and entity.y == y) 19 | 20 | return names.capitalize() 21 | 22 | 23 | def render_bar(console: Console, current_value: int, maximum_value: int, total_width: int) -> None: 24 | bar_width = int(float(current_value) / maximum_value * total_width) 25 | 26 | console.draw_rect(x=0, y=45, width=20, height=1, ch=1, bg=color.bar_empty) 27 | 28 | if bar_width > 0: 29 | console.draw_rect(x=0, y=45, width=bar_width, height=1, ch=1, bg=color.bar_filled) 30 | 31 | console.print(x=1, y=45, string=f"HP: {current_value}/{maximum_value}", fg=color.bar_text) 32 | 33 | 34 | def render_dungeon_level(console: Console, dungeon_level: int, location: Tuple[int, int]) -> None: 35 | """ 36 | Render the level the player is currently on, at the given location. 37 | """ 38 | x, y = location 39 | 40 | console.print(x=x, y=y, string=f"Dungeon level: {dungeon_level}") 41 | 42 | 43 | def render_names_at_mouse_location(console: Console, x: int, y: int, engine: Engine) -> None: 44 | mouse_x, mouse_y = engine.mouse_location 45 | 46 | names_at_mouse_location = get_names_at_location(x=mouse_x, y=mouse_y, game_map=engine.game_map) 47 | 48 | console.print(x=x, y=y, string=names_at_mouse_location) 49 | -------------------------------------------------------------------------------- /tile_types.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import numpy as np 4 | 5 | # Tile graphics structured type compatible with Console.tiles_rgb. 6 | graphic_dt = np.dtype( 7 | [ 8 | ("ch", np.int32), # Unicode codepoint. 9 | ("fg", "3B"), # 3 unsigned bytes, for RGB colors. 10 | ("bg", "3B"), 11 | ] 12 | ) 13 | 14 | # Tile struct used for statically defined tile data. 15 | tile_dt = np.dtype( 16 | [ 17 | ("walkable", bool), # True if this tile can be walked over. 18 | ("transparent", bool), # True if this tile doesn't block FOV. 19 | ("dark", graphic_dt), # Graphics for when this tile is not in FOV. 20 | ("light", graphic_dt), # Graphics for when the tile is in FOV. 21 | ] 22 | ) 23 | 24 | 25 | def new_tile( 26 | *, # Enforce the use of keywords, so that parameter order doesn't matter. 27 | walkable: int, 28 | transparent: int, 29 | dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]], 30 | light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]], 31 | ) -> np.ndarray: 32 | """Helper function for defining individual tile types""" 33 | return np.array((walkable, transparent, dark, light), dtype=tile_dt) 34 | 35 | 36 | # SHROUD represents unexplored, unseen tiles 37 | SHROUD = np.array((ord(" "), (255, 255, 255), (0, 0, 0)), dtype=graphic_dt) 38 | 39 | floor = new_tile( 40 | walkable=True, 41 | transparent=True, 42 | dark=(ord(" "), (255, 255, 255), (50, 50, 150)), 43 | light=(ord(" "), (255, 255, 255), (200, 180, 50)), 44 | ) 45 | wall = new_tile( 46 | walkable=False, 47 | transparent=False, 48 | dark=(ord(" "), (255, 255, 255), (0, 0, 100)), 49 | light=(ord(" "), (255, 255, 255), (130, 110, 50)), 50 | ) 51 | down_stairs = new_tile( 52 | walkable=True, 53 | transparent=True, 54 | dark=(ord(">"), (0, 0, 100), (50, 50, 150)), 55 | light=(ord(">"), (255, 255, 255), (200, 180, 50)), 56 | ) 57 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import traceback 3 | 4 | import tcod 5 | 6 | import color 7 | import exceptions 8 | import input_handlers 9 | import setup_game 10 | 11 | 12 | def save_game(handler: input_handlers.BaseEventHandler, filename: str) -> None: 13 | """If the current event handler has an active Engine then save it.""" 14 | if isinstance(handler, input_handlers.EventHandler): 15 | handler.engine.save_as(filename) 16 | print("Game saved.") 17 | 18 | 19 | def main() -> None: 20 | screen_width = 80 21 | screen_height = 50 22 | 23 | tileset = tcod.tileset.load_tilesheet("data/dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD) 24 | 25 | handler: input_handlers.BaseEventHandler = setup_game.MainMenu() 26 | 27 | with tcod.context.new( 28 | columns=screen_width, 29 | rows=screen_height, 30 | tileset=tileset, 31 | title="Yet Another Roguelike Tutorial", 32 | vsync=True, 33 | ) as context: 34 | root_console = tcod.Console(screen_width, screen_height, order="F") 35 | try: 36 | while True: 37 | root_console.clear() 38 | handler.on_render(console=root_console) 39 | context.present(root_console) 40 | 41 | try: 42 | for event in tcod.event.wait(): 43 | context.convert_event(event) 44 | handler = handler.handle_events(event) 45 | except Exception: # Handle exceptions in game. 46 | traceback.print_exc() # Print error to stderr. 47 | # Then print the error to the message log. 48 | if isinstance(handler, input_handlers.EventHandler): 49 | handler.engine.message_log.add_message(traceback.format_exc(), color.error) 50 | except exceptions.QuitWithoutSaving: 51 | raise 52 | except SystemExit: # Save and quit. 53 | save_game(handler, "savegame.sav") 54 | raise 55 | except BaseException: # Save on any other unexpected exception. 56 | save_game(handler, "savegame.sav") 57 | raise 58 | 59 | 60 | if __name__ == "__main__": 61 | main() 62 | -------------------------------------------------------------------------------- /components/level.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from components.base_component import BaseComponent 6 | 7 | if TYPE_CHECKING: 8 | from entity import Actor 9 | 10 | 11 | class Level(BaseComponent): 12 | parent: Actor 13 | 14 | def __init__( 15 | self, 16 | current_level: int = 1, 17 | current_xp: int = 0, 18 | level_up_base: int = 0, 19 | level_up_factor: int = 150, 20 | xp_given: int = 0, 21 | ): 22 | self.current_level = current_level 23 | self.current_xp = current_xp 24 | self.level_up_base = level_up_base 25 | self.level_up_factor = level_up_factor 26 | self.xp_given = xp_given 27 | 28 | @property 29 | def experience_to_next_level(self) -> int: 30 | return self.level_up_base + self.current_level * self.level_up_factor 31 | 32 | @property 33 | def requires_level_up(self) -> bool: 34 | return self.current_xp > self.experience_to_next_level 35 | 36 | def add_xp(self, xp: int) -> None: 37 | if xp == 0 or self.level_up_base == 0: 38 | return 39 | 40 | self.current_xp += xp 41 | 42 | self.engine.message_log.add_message(f"You gain {xp} experience points.") 43 | 44 | if self.requires_level_up: 45 | self.engine.message_log.add_message(f"You advance to level {self.current_level + 1}!") 46 | 47 | def increase_level(self) -> None: 48 | self.current_xp -= self.experience_to_next_level 49 | 50 | self.current_level += 1 51 | 52 | def increase_max_hp(self, amount: int = 20) -> None: 53 | self.parent.fighter.max_hp += amount 54 | self.parent.fighter.hp += amount 55 | 56 | self.engine.message_log.add_message("Your health improves!") 57 | 58 | self.increase_level() 59 | 60 | def increase_power(self, amount: int = 1) -> None: 61 | self.parent.fighter.base_power += amount 62 | 63 | self.engine.message_log.add_message("You feel stronger!") 64 | 65 | self.increase_level() 66 | 67 | def increase_defense(self, amount: int = 1) -> None: 68 | self.parent.fighter.base_defense += amount 69 | 70 | self.engine.message_log.add_message("Your movements are getting swifter!") 71 | 72 | self.increase_level() 73 | -------------------------------------------------------------------------------- /engine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | import lzma 5 | import pickle 6 | 7 | from tcod.console import Console 8 | from tcod.map import compute_fov 9 | 10 | from message_log import MessageLog 11 | import exceptions 12 | import render_functions 13 | 14 | if TYPE_CHECKING: 15 | from entity import Actor 16 | from game_map import GameMap, GameWorld 17 | 18 | 19 | class Engine: 20 | game_map: GameMap 21 | game_world: GameWorld 22 | 23 | def __init__(self, player: Actor): 24 | self.message_log = MessageLog() 25 | self.mouse_location = (0, 0) 26 | self.player = player 27 | 28 | def handle_enemy_turns(self) -> None: 29 | for entity in set(self.game_map.actors) - {self.player}: 30 | if entity.ai: 31 | try: 32 | entity.ai.perform() 33 | except exceptions.Impossible: 34 | pass # Ignore impossible action exceptions from AI. 35 | 36 | def update_fov(self) -> None: 37 | """Recompute the visible area based on the players point of view.""" 38 | self.game_map.visible[:] = compute_fov( 39 | self.game_map.tiles["transparent"], 40 | (self.player.x, self.player.y), 41 | radius=8, 42 | ) 43 | # If a tile is "visible" it should be added to "explored". 44 | self.game_map.explored |= self.game_map.visible 45 | 46 | def render(self, console: Console) -> None: 47 | self.game_map.render(console) 48 | 49 | self.message_log.render(console=console, x=21, y=45, width=40, height=5) 50 | 51 | render_functions.render_bar( 52 | console=console, 53 | current_value=self.player.fighter.hp, 54 | maximum_value=self.player.fighter.max_hp, 55 | total_width=20, 56 | ) 57 | 58 | render_functions.render_dungeon_level( 59 | console=console, 60 | dungeon_level=self.game_world.current_floor, 61 | location=(0, 47), 62 | ) 63 | 64 | render_functions.render_names_at_mouse_location(console=console, x=21, y=44, engine=self) 65 | 66 | def save_as(self, filename: str) -> None: 67 | """Save this Engine instance as a compressed file.""" 68 | save_data = lzma.compress(pickle.dumps(self)) 69 | with open(filename, "wb") as f: 70 | f.write(save_data) 71 | -------------------------------------------------------------------------------- /entity_factories.py: -------------------------------------------------------------------------------- 1 | from components import consumable, equippable 2 | from components.ai import HostileEnemy 3 | from components.equipment import Equipment 4 | from components.fighter import Fighter 5 | from components.inventory import Inventory 6 | from components.level import Level 7 | from entity import Actor, Item 8 | 9 | player = Actor( 10 | char="@", 11 | color=(255, 255, 255), 12 | name="Player", 13 | ai_cls=HostileEnemy, 14 | equipment=Equipment(), 15 | fighter=Fighter(hp=30, base_defense=1, base_power=2), 16 | inventory=Inventory(capacity=26), 17 | level=Level(level_up_base=200), 18 | ) 19 | 20 | orc = Actor( 21 | char="o", 22 | color=(63, 127, 63), 23 | name="Orc", 24 | ai_cls=HostileEnemy, 25 | equipment=Equipment(), 26 | fighter=Fighter(hp=10, base_defense=0, base_power=3), 27 | inventory=Inventory(capacity=0), 28 | level=Level(xp_given=35), 29 | ) 30 | troll = Actor( 31 | char="T", 32 | color=(0, 127, 0), 33 | name="Troll", 34 | ai_cls=HostileEnemy, 35 | equipment=Equipment(), 36 | fighter=Fighter(hp=16, base_defense=1, base_power=4), 37 | inventory=Inventory(capacity=0), 38 | level=Level(xp_given=100), 39 | ) 40 | 41 | confusion_scroll = Item( 42 | char="~", 43 | color=(207, 63, 255), 44 | name="Confusion Scroll", 45 | consumable=consumable.ConfusionConsumable(number_of_turns=10), 46 | ) 47 | fireball_scroll = Item( 48 | char="~", 49 | color=(255, 0, 0), 50 | name="Fireball Scroll", 51 | consumable=consumable.FireballDamageConsumable(damage=12, radius=3), 52 | ) 53 | health_potion = Item( 54 | char="!", 55 | color=(127, 0, 255), 56 | name="Health Potion", 57 | consumable=consumable.HealingConsumable(amount=4), 58 | ) 59 | lightning_scroll = Item( 60 | char="~", 61 | color=(255, 255, 0), 62 | name="Lightning Scroll", 63 | consumable=consumable.LightningDamageConsumable(damage=20, maximum_range=5), 64 | ) 65 | 66 | dagger = Item(char="/", color=(0, 191, 255), name="Dagger", equippable=equippable.Dagger()) 67 | 68 | sword = Item(char="/", color=(0, 191, 255), name="Sword", equippable=equippable.Sword()) 69 | 70 | leather_armor = Item( 71 | char="[", 72 | color=(139, 69, 19), 73 | name="Leather Armor", 74 | equippable=equippable.LeatherArmor(), 75 | ) 76 | 77 | chain_mail = Item(char="[", color=(139, 69, 19), name="Chain Mail", equippable=equippable.ChainMail()) 78 | -------------------------------------------------------------------------------- /message_log.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, List, Reversible, Tuple 2 | import textwrap 3 | 4 | import tcod 5 | 6 | import color 7 | 8 | 9 | class Message: 10 | def __init__(self, text: str, fg: Tuple[int, int, int]): 11 | self.plain_text = text 12 | self.fg = fg 13 | self.count = 1 14 | 15 | @property 16 | def full_text(self) -> str: 17 | """The full text of this message, including the count if necessary.""" 18 | if self.count > 1: 19 | return f"{self.plain_text} (x{self.count})" 20 | return self.plain_text 21 | 22 | 23 | class MessageLog: 24 | def __init__(self) -> None: 25 | self.messages: List[Message] = [] 26 | 27 | def add_message(self, text: str, fg: Tuple[int, int, int] = color.white, *, stack: bool = True) -> None: 28 | """Add a message to this log. 29 | 30 | `text` is the message text, `fg` is the text color. 31 | 32 | If `stack` is True then the message can stack with a previous message 33 | of the same text. 34 | """ 35 | if stack and self.messages and text == self.messages[-1].plain_text: 36 | self.messages[-1].count += 1 37 | else: 38 | self.messages.append(Message(text, fg)) 39 | 40 | def render(self, console: tcod.Console, x: int, y: int, width: int, height: int) -> None: 41 | """Render this log over the given area. 42 | 43 | `x`, `y`, `width`, `height` is the rectangular region to render onto 44 | the `console`. 45 | """ 46 | self.render_messages(console, x, y, width, height, self.messages) 47 | 48 | @staticmethod 49 | def wrap(string: str, width: int) -> Iterable[str]: 50 | """Return a wrapped text message.""" 51 | for line in string.splitlines(): # Handle newlines in messages. 52 | yield from textwrap.wrap( 53 | line, 54 | width, 55 | expand_tabs=True, 56 | ) 57 | 58 | @classmethod 59 | def render_messages( 60 | cls, console: tcod.Console, x: int, y: int, width: int, height: int, messages: Reversible[Message] 61 | ) -> None: 62 | """Render the messages provided. 63 | 64 | The `messages` are rendered starting at the last message and working 65 | backwards. 66 | """ 67 | y_offset = height - 1 68 | 69 | for message in reversed(messages): 70 | for line in reversed(list(cls.wrap(message.full_text, width))): 71 | console.print(x=x, y=y + y_offset, string=line, fg=message.fg) 72 | y_offset -= 1 73 | if y_offset < 0: 74 | return # No more space to print messages. 75 | -------------------------------------------------------------------------------- /components/fighter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from components.base_component import BaseComponent 6 | from render_order import RenderOrder 7 | import color 8 | 9 | if TYPE_CHECKING: 10 | from entity import Actor 11 | 12 | 13 | class Fighter(BaseComponent): 14 | parent: Actor 15 | 16 | def __init__(self, hp: int, base_defense: int, base_power: int): 17 | self.max_hp = hp 18 | self._hp = hp 19 | self.base_defense = base_defense 20 | self.base_power = base_power 21 | 22 | @property 23 | def hp(self) -> int: 24 | return self._hp 25 | 26 | @hp.setter 27 | def hp(self, value: int) -> None: 28 | self._hp = max(0, min(value, self.max_hp)) 29 | if self._hp == 0 and self.parent.ai: 30 | self.die() 31 | 32 | @property 33 | def defense(self) -> int: 34 | return self.base_defense + self.defense_bonus 35 | 36 | @property 37 | def power(self) -> int: 38 | return self.base_power + self.power_bonus 39 | 40 | @property 41 | def defense_bonus(self) -> int: 42 | if self.parent.equipment: 43 | return self.parent.equipment.defense_bonus 44 | else: 45 | return 0 46 | 47 | @property 48 | def power_bonus(self) -> int: 49 | if self.parent.equipment: 50 | return self.parent.equipment.power_bonus 51 | else: 52 | return 0 53 | 54 | def die(self) -> None: 55 | if self.engine.player is self.parent: 56 | death_message = "You died!" 57 | death_message_color = color.player_die 58 | else: 59 | death_message = f"{self.parent.name} is dead!" 60 | death_message_color = color.enemy_die 61 | 62 | self.parent.char = "%" 63 | self.parent.color = (191, 0, 0) 64 | self.parent.blocks_movement = False 65 | self.parent.ai = None 66 | self.parent.name = f"remains of {self.parent.name}" 67 | self.parent.render_order = RenderOrder.CORPSE 68 | 69 | self.engine.message_log.add_message(death_message, death_message_color) 70 | 71 | self.engine.player.level.add_xp(self.parent.level.xp_given) 72 | 73 | def heal(self, amount: int) -> int: 74 | if self.hp == self.max_hp: 75 | return 0 76 | 77 | new_hp_value = self.hp + amount 78 | 79 | if new_hp_value > self.max_hp: 80 | new_hp_value = self.max_hp 81 | 82 | amount_recovered = new_hp_value - self.hp 83 | 84 | self.hp = new_hp_value 85 | 86 | return amount_recovered 87 | 88 | def take_damage(self, amount: int) -> None: 89 | self.hp -= amount 90 | -------------------------------------------------------------------------------- /components/equipment.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | from components.base_component import BaseComponent 6 | from equipment_types import EquipmentType 7 | 8 | if TYPE_CHECKING: 9 | from entity import Actor, Item 10 | 11 | 12 | class Equipment(BaseComponent): 13 | parent: Actor 14 | 15 | def __init__(self, weapon: Optional[Item] = None, armor: Optional[Item] = None): 16 | self.weapon = weapon 17 | self.armor = armor 18 | 19 | @property 20 | def defense_bonus(self) -> int: 21 | bonus = 0 22 | 23 | if self.weapon is not None and self.weapon.equippable is not None: 24 | bonus += self.weapon.equippable.defense_bonus 25 | 26 | if self.armor is not None and self.armor.equippable is not None: 27 | bonus += self.armor.equippable.defense_bonus 28 | 29 | return bonus 30 | 31 | @property 32 | def power_bonus(self) -> int: 33 | bonus = 0 34 | 35 | if self.weapon is not None and self.weapon.equippable is not None: 36 | bonus += self.weapon.equippable.power_bonus 37 | 38 | if self.armor is not None and self.armor.equippable is not None: 39 | bonus += self.armor.equippable.power_bonus 40 | 41 | return bonus 42 | 43 | def item_is_equipped(self, item: Item) -> bool: 44 | return self.weapon == item or self.armor == item 45 | 46 | def unequip_message(self, item_name: str) -> None: 47 | self.parent.gamemap.engine.message_log.add_message(f"You remove the {item_name}.") 48 | 49 | def equip_message(self, item_name: str) -> None: 50 | self.parent.gamemap.engine.message_log.add_message(f"You equip the {item_name}.") 51 | 52 | def equip_to_slot(self, slot: str, item: Item, add_message: bool) -> None: 53 | current_item = getattr(self, slot) 54 | 55 | if current_item is not None: 56 | self.unequip_from_slot(slot, add_message) 57 | 58 | setattr(self, slot, item) 59 | 60 | if add_message: 61 | self.equip_message(item.name) 62 | 63 | def unequip_from_slot(self, slot: str, add_message: bool) -> None: 64 | current_item = getattr(self, slot) 65 | 66 | if add_message: 67 | self.unequip_message(current_item.name) 68 | 69 | setattr(self, slot, None) 70 | 71 | def toggle_equip(self, equippable_item: Item, add_message: bool = True) -> None: 72 | if equippable_item.equippable and equippable_item.equippable.equipment_type == EquipmentType.WEAPON: 73 | slot = "weapon" 74 | else: 75 | slot = "armor" 76 | 77 | if getattr(self, slot) == equippable_item: 78 | self.unequip_from_slot(slot, add_message) 79 | else: 80 | self.equip_to_slot(slot, equippable_item, add_message) 81 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and run some static tests. 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Deploy 5 | 6 | on: [push, pull_request] 7 | 8 | defaults: 9 | run: 10 | shell: bash 11 | 12 | env: 13 | python-version: "3.8" 14 | pyinstaller-version: "4.3" 15 | project-name: roguelike-tutorial 16 | 17 | jobs: 18 | package: 19 | runs-on: ${{ matrix.os }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - os: windows-2019 25 | platform-name: windows.x64 26 | - os: macos-10.15 27 | platform-name: macos.x64 28 | - os: ubuntu-20.04 29 | platform-name: linux.x64 30 | env: 31 | archive-name: roguelike-tutorial-${{ matrix.platform-name }} 32 | steps: 33 | - name: Checkout code 34 | # fetch-depth=0 and v1 are needed for 'git describe' to work correctly. 35 | uses: actions/checkout@v1 36 | with: 37 | fetch-depth: 0 38 | - name: Set archive name 39 | run: | 40 | ARCHIVE_NAME=${{ env.project-name }}-`git describe --always`-${{ matrix.platform-name }} 41 | echo "Archive name set to: $ARCHIVE_NAME" 42 | echo "archive-name=$ARCHIVE_NAME" >> $GITHUB_ENV 43 | - name: Set up Python ${{ env.python-version }} 44 | uses: actions/setup-python@v2 45 | with: 46 | python-version: ${{ env.python-version }} 47 | - name: Install APT dependencies 48 | if: runner.os == 'Linux' 49 | run: | 50 | sudo apt-get update 51 | sudo apt-get install libsdl2-dev 52 | - name: Install Python dependencies 53 | run: | 54 | python -m pip install --upgrade pip 55 | python -m pip install PyInstaller==${{ env.pyinstaller-version }} -r requirements.txt 56 | - name: Run PyInstaller 57 | env: 58 | PYTHONOPTIMIZE: 1 # Enable optimizations as if the -O flag is given. 59 | PYTHONHASHSEED: 42 # Try to ensure deterministic results. 60 | run: | 61 | pyinstaller build.spec 62 | # This step exists for debugging. Such as checking if data files were included correctly by PyInstaller. 63 | - name: List distribution files 64 | run: | 65 | find dist 66 | # Archive the PyInstaller build using the appropriate tool for the platform. 67 | - name: Tar files 68 | if: runner.os != 'Windows' 69 | run: | 70 | tar --format=ustar -czvf ${{ env.archive-name }}.tar.gz dist/*/ 71 | - name: Archive files 72 | if: runner.os == 'Windows' 73 | shell: pwsh 74 | run: | 75 | Compress-Archive dist/*/ ${{ env.archive-name }}.zip 76 | # Upload archives as artifacts, these can be downloaded from the GitHub actions page. 77 | - name: "Upload Artifact" 78 | uses: actions/upload-artifact@v2 79 | with: 80 | name: automated-builds 81 | path: ${{ env.archive-name }}.* 82 | retention-days: 7 83 | if-no-files-found: error 84 | # If a tag is pushed then a new archives are uploaded to GitHub Releases automatically. 85 | - name: Upload release 86 | if: startsWith(github.ref, 'refs/tags/') 87 | uses: svenstaro/upload-release-action@v2 88 | with: 89 | repo_token: ${{ secrets.GITHUB_TOKEN }} 90 | file: ${{ env.archive-name }}.* 91 | file_glob: true 92 | tag: ${{ github.ref }} 93 | overwrite: true 94 | -------------------------------------------------------------------------------- /setup_game.py: -------------------------------------------------------------------------------- 1 | """Handle the loading and initialization of game sessions.""" 2 | from __future__ import annotations 3 | 4 | from typing import Optional 5 | import copy 6 | import lzma 7 | import pickle 8 | import traceback 9 | 10 | from PIL import Image # type: ignore 11 | import tcod 12 | 13 | from engine import Engine 14 | from game_map import GameWorld 15 | import color 16 | import entity_factories 17 | import input_handlers 18 | 19 | # Load the background image. Pillow returns an object convertable into a NumPy array. 20 | background_image = Image.open("data/menu_background.png") 21 | 22 | 23 | def new_game() -> Engine: 24 | """Return a brand new game session as an Engine instance.""" 25 | map_width = 80 26 | map_height = 43 27 | 28 | room_max_size = 10 29 | room_min_size = 6 30 | max_rooms = 30 31 | 32 | player = copy.deepcopy(entity_factories.player) 33 | 34 | engine = Engine(player=player) 35 | 36 | engine.game_world = GameWorld( 37 | engine=engine, 38 | max_rooms=max_rooms, 39 | room_min_size=room_min_size, 40 | room_max_size=room_max_size, 41 | map_width=map_width, 42 | map_height=map_height, 43 | ) 44 | 45 | engine.game_world.generate_floor() 46 | engine.update_fov() 47 | 48 | engine.message_log.add_message("Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text) 49 | 50 | dagger = copy.deepcopy(entity_factories.dagger) 51 | leather_armor = copy.deepcopy(entity_factories.leather_armor) 52 | 53 | dagger.parent = player.inventory 54 | leather_armor.parent = player.inventory 55 | 56 | player.inventory.items.append(dagger) 57 | player.equipment.toggle_equip(dagger, add_message=False) 58 | 59 | player.inventory.items.append(leather_armor) 60 | player.equipment.toggle_equip(leather_armor, add_message=False) 61 | 62 | return engine 63 | 64 | 65 | def load_game(filename: str) -> Engine: 66 | """Load an Engine instance from a file.""" 67 | with open(filename, "rb") as f: 68 | engine = pickle.loads(lzma.decompress(f.read())) 69 | assert isinstance(engine, Engine) 70 | return engine 71 | 72 | 73 | class MainMenu(input_handlers.BaseEventHandler): 74 | """Handle the main menu rendering and input.""" 75 | 76 | def on_render(self, console: tcod.Console) -> None: 77 | """Render the main menu on a background image.""" 78 | console.draw_semigraphics(background_image, 0, 0) 79 | 80 | console.print( 81 | console.width // 2, 82 | console.height // 2 - 4, 83 | "TOMBS OF THE ANCIENT KINGS", 84 | fg=color.menu_title, 85 | alignment=tcod.CENTER, 86 | ) 87 | console.print( 88 | console.width // 2, 89 | console.height - 2, 90 | "By (Your name here)", 91 | fg=color.menu_title, 92 | alignment=tcod.CENTER, 93 | ) 94 | 95 | menu_width = 24 96 | for i, text in enumerate(["[N] Play a new game", "[C] Continue last game", "[Q] Quit"]): 97 | console.print( 98 | console.width // 2, 99 | console.height // 2 - 2 + i, 100 | text.ljust(menu_width), 101 | fg=color.menu_text, 102 | bg=color.black, 103 | alignment=tcod.CENTER, 104 | bg_blend=tcod.BKGND_ALPHA(64), 105 | ) 106 | 107 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[input_handlers.BaseEventHandler]: 108 | if event.sym in (tcod.event.K_q, tcod.event.K_ESCAPE): 109 | raise SystemExit() 110 | elif event.sym == tcod.event.K_c: 111 | try: 112 | return input_handlers.MainGameEventHandler(load_game("savegame.sav")) 113 | except FileNotFoundError: 114 | return input_handlers.PopupMessage(self, "No saved game to load.") 115 | except Exception as exc: 116 | traceback.print_exc() # Print to stderr. 117 | return input_handlers.PopupMessage(self, f"Failed to load save:\n{exc}") 118 | elif event.sym == tcod.event.K_n: 119 | return input_handlers.MainGameEventHandler(new_game()) 120 | 121 | return None 122 | -------------------------------------------------------------------------------- /game_map.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Iterable, Iterator, Optional 4 | 5 | from tcod.console import Console 6 | import numpy as np 7 | 8 | from entity import Actor, Item 9 | import tile_types 10 | 11 | if TYPE_CHECKING: 12 | from engine import Engine 13 | from entity import Entity 14 | 15 | 16 | class GameMap: 17 | def __init__(self, engine: Engine, width: int, height: int, entities: Iterable[Entity] = ()): 18 | self.engine = engine 19 | self.width, self.height = width, height 20 | self.entities = set(entities) 21 | self.tiles = np.full((width, height), fill_value=tile_types.wall, order="F") 22 | 23 | self.visible = np.full((width, height), fill_value=False, order="F") # Tiles the player can currently see 24 | self.explored = np.full((width, height), fill_value=False, order="F") # Tiles the player has seen before 25 | 26 | self.downstairs_location = (0, 0) 27 | 28 | @property 29 | def gamemap(self) -> GameMap: 30 | return self 31 | 32 | @property 33 | def actors(self) -> Iterator[Actor]: 34 | """Iterate over this maps living actors.""" 35 | yield from (entity for entity in self.entities if isinstance(entity, Actor) and entity.is_alive) 36 | 37 | @property 38 | def items(self) -> Iterator[Item]: 39 | yield from (entity for entity in self.entities if isinstance(entity, Item)) 40 | 41 | def get_blocking_entity_at_location( 42 | self, 43 | location_x: int, 44 | location_y: int, 45 | ) -> Optional[Entity]: 46 | for entity in self.entities: 47 | if entity.blocks_movement and entity.x == location_x and entity.y == location_y: 48 | return entity 49 | 50 | return None 51 | 52 | def get_actor_at_location(self, x: int, y: int) -> Optional[Actor]: 53 | for actor in self.actors: 54 | if actor.x == x and actor.y == y: 55 | return actor 56 | 57 | return None 58 | 59 | def in_bounds(self, x: int, y: int) -> bool: 60 | """Return True if x and y are inside of the bounds of this map.""" 61 | return 0 <= x < self.width and 0 <= y < self.height 62 | 63 | def render(self, console: Console) -> None: 64 | """ 65 | Renders the map. 66 | 67 | If a tile is in the "visible" array, then draw it with the "light" colors. 68 | If it isn't, but it's in the "explored" array, then draw it with the "dark" colors. 69 | Otherwise, the default is "SHROUD". 70 | """ 71 | console.rgb[0 : self.width, 0 : self.height] = np.select( 72 | condlist=[self.visible, self.explored], 73 | choicelist=[self.tiles["light"], self.tiles["dark"]], 74 | default=tile_types.SHROUD, 75 | ) 76 | 77 | entities_sorted_for_rendering = sorted(self.entities, key=lambda x: x.render_order.value) 78 | 79 | for entity in entities_sorted_for_rendering: 80 | if self.visible[entity.x, entity.y]: 81 | console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color) 82 | 83 | 84 | class GameWorld: 85 | """ 86 | Holds the settings for the GameMap, and generates new maps when moving down the stairs. 87 | """ 88 | 89 | def __init__( 90 | self, 91 | *, 92 | engine: Engine, 93 | map_width: int, 94 | map_height: int, 95 | max_rooms: int, 96 | room_min_size: int, 97 | room_max_size: int, 98 | current_floor: int = 0, 99 | ): 100 | self.engine = engine 101 | 102 | self.map_width = map_width 103 | self.map_height = map_height 104 | 105 | self.max_rooms = max_rooms 106 | 107 | self.room_min_size = room_min_size 108 | self.room_max_size = room_max_size 109 | 110 | self.current_floor = current_floor 111 | 112 | def generate_floor(self) -> None: 113 | from procgen import generate_dungeon 114 | 115 | self.current_floor += 1 116 | 117 | self.engine.game_map = generate_dungeon( 118 | max_rooms=self.max_rooms, 119 | room_min_size=self.room_min_size, 120 | room_max_size=self.room_max_size, 121 | map_width=self.map_width, 122 | map_height=self.map_height, 123 | engine=self.engine, 124 | ) 125 | -------------------------------------------------------------------------------- /components/ai.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List, Optional, Tuple 4 | import random 5 | 6 | import numpy as np 7 | import tcod 8 | 9 | from actions import Action, BumpAction, MeleeAction, MovementAction, WaitAction 10 | 11 | if TYPE_CHECKING: 12 | from entity import Actor 13 | 14 | 15 | class BaseAI(Action): 16 | def perform(self) -> None: 17 | raise NotImplementedError() 18 | 19 | def get_path_to(self, dest_x: int, dest_y: int) -> List[Tuple[int, int]]: 20 | """Compute and return a path to the target position. 21 | 22 | If there is no valid path then returns an empty list. 23 | """ 24 | # Copy the walkable array. 25 | cost = np.array(self.entity.gamemap.tiles["walkable"], dtype=np.int8) 26 | 27 | for entity in self.entity.gamemap.entities: 28 | # Check that an enitiy blocks movement and the cost isn't zero (blocking.) 29 | if entity.blocks_movement and cost[entity.x, entity.y]: 30 | # Add to the cost of a blocked position. 31 | # A lower number means more enemies will crowd behind each other in 32 | # hallways. A higher number means enemies will take longer paths in 33 | # order to surround the player. 34 | cost[entity.x, entity.y] += 10 35 | 36 | # Create a graph from the cost array and pass that graph to a new pathfinder. 37 | graph = tcod.path.SimpleGraph(cost=cost, cardinal=2, diagonal=3) 38 | pathfinder = tcod.path.Pathfinder(graph) 39 | 40 | pathfinder.add_root((self.entity.x, self.entity.y)) # Start position. 41 | 42 | # Compute the path to the destination and remove the starting point. 43 | path: List[List[int]] = pathfinder.path_to((dest_x, dest_y))[1:].tolist() 44 | 45 | # Convert from List[List[int]] to List[Tuple[int, int]]. 46 | return [(index[0], index[1]) for index in path] 47 | 48 | 49 | class HostileEnemy(BaseAI): 50 | def __init__(self, entity: Actor): 51 | super().__init__(entity) 52 | self.path: List[Tuple[int, int]] = [] 53 | 54 | def perform(self) -> None: 55 | target = self.engine.player 56 | dx = target.x - self.entity.x 57 | dy = target.y - self.entity.y 58 | distance = max(abs(dx), abs(dy)) # Chebyshev distance. 59 | 60 | if self.engine.game_map.visible[self.entity.x, self.entity.y]: 61 | if distance <= 1: 62 | return MeleeAction(self.entity, dx, dy).perform() 63 | 64 | self.path = self.get_path_to(target.x, target.y) 65 | 66 | if self.path: 67 | dest_x, dest_y = self.path.pop(0) 68 | return MovementAction( 69 | self.entity, 70 | dest_x - self.entity.x, 71 | dest_y - self.entity.y, 72 | ).perform() 73 | 74 | return WaitAction(self.entity).perform() 75 | 76 | 77 | class ConfusedEnemy(BaseAI): 78 | """ 79 | A confused enemy will stumble around aimlessly for a given number of turns, then revert back to its previous AI. 80 | If an actor occupies a tile it is randomly moving into, it will attack. 81 | """ 82 | 83 | def __init__(self, entity: Actor, previous_ai: Optional[BaseAI], turns_remaining: int): 84 | super().__init__(entity) 85 | 86 | self.previous_ai = previous_ai 87 | self.turns_remaining = turns_remaining 88 | 89 | def perform(self) -> None: 90 | # Revert the AI back to the original state if the effect has run its course. 91 | if self.turns_remaining <= 0: 92 | self.engine.message_log.add_message(f"The {self.entity.name} is no longer confused.") 93 | self.entity.ai = self.previous_ai 94 | else: 95 | # Pick a random direction 96 | direction_x, direction_y = random.choice( 97 | [ 98 | (-1, -1), # Northwest 99 | (0, -1), # North 100 | (1, -1), # Northeast 101 | (-1, 0), # West 102 | (1, 0), # East 103 | (-1, 1), # Southwest 104 | (0, 1), # South 105 | (1, 1), # Southeast 106 | ] 107 | ) 108 | 109 | self.turns_remaining -= 1 110 | 111 | # The actor will either try to move or attack in the chosen random direction. 112 | # Its possible the actor will just bump into the wall, wasting a turn. 113 | return BumpAction( 114 | self.entity, 115 | direction_x, 116 | direction_y, 117 | ).perform() 118 | -------------------------------------------------------------------------------- /entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional, Tuple, Type, TypeVar, Union 4 | import copy 5 | import math 6 | 7 | from render_order import RenderOrder 8 | 9 | if TYPE_CHECKING: 10 | from components.ai import BaseAI 11 | from components.consumable import Consumable 12 | from components.equipment import Equipment 13 | from components.equippable import Equippable 14 | from components.fighter import Fighter 15 | from components.inventory import Inventory 16 | from components.level import Level 17 | from game_map import GameMap 18 | 19 | T = TypeVar("T", bound="Entity") 20 | 21 | 22 | class Entity: 23 | """ 24 | A generic object to represent players, enemies, items, etc. 25 | """ 26 | 27 | parent: Union[GameMap, Inventory] 28 | 29 | def __init__( 30 | self, 31 | parent: Optional[GameMap] = None, 32 | x: int = 0, 33 | y: int = 0, 34 | char: str = "?", 35 | color: Tuple[int, int, int] = (255, 255, 255), 36 | name: str = "", 37 | blocks_movement: bool = False, 38 | render_order: RenderOrder = RenderOrder.CORPSE, 39 | ): 40 | self.x = x 41 | self.y = y 42 | self.char = char 43 | self.color = color 44 | self.name = name 45 | self.blocks_movement = blocks_movement 46 | self.render_order = render_order 47 | if parent: 48 | # If parent isn't provided now then it will be set later. 49 | self.parent = parent 50 | parent.entities.add(self) 51 | 52 | @property 53 | def gamemap(self) -> GameMap: 54 | return self.parent.gamemap 55 | 56 | def spawn(self: T, gamemap: GameMap, x: int, y: int) -> T: 57 | """Spawn a copy of this instance at the given location.""" 58 | clone = copy.deepcopy(self) 59 | clone.x = x 60 | clone.y = y 61 | clone.parent = gamemap 62 | gamemap.entities.add(clone) 63 | return clone 64 | 65 | def place(self, x: int, y: int, gamemap: Optional[GameMap] = None) -> None: 66 | """Place this entitiy at a new location. Handles moving across GameMaps.""" 67 | self.x = x 68 | self.y = y 69 | if gamemap: 70 | if hasattr(self, "parent"): # Possibly uninitialized. 71 | if self.parent is self.gamemap: 72 | self.gamemap.entities.remove(self) 73 | self.parent = gamemap 74 | gamemap.entities.add(self) 75 | 76 | def distance(self, x: int, y: int) -> float: 77 | """ 78 | Return the distance between the current entity and the given (x, y) coordinate. 79 | """ 80 | return math.sqrt((x - self.x) ** 2 + (y - self.y) ** 2) 81 | 82 | def move(self, dx: int, dy: int) -> None: 83 | # Move the entity by a given amount 84 | self.x += dx 85 | self.y += dy 86 | 87 | 88 | class Actor(Entity): 89 | def __init__( 90 | self, 91 | *, 92 | x: int = 0, 93 | y: int = 0, 94 | char: str = "?", 95 | color: Tuple[int, int, int] = (255, 255, 255), 96 | name: str = "", 97 | ai_cls: Type[BaseAI], 98 | equipment: Equipment, 99 | fighter: Fighter, 100 | inventory: Inventory, 101 | level: Level, 102 | ): 103 | super().__init__( 104 | x=x, 105 | y=y, 106 | char=char, 107 | color=color, 108 | name=name, 109 | blocks_movement=True, 110 | render_order=RenderOrder.ACTOR, 111 | ) 112 | 113 | self.ai: Optional[BaseAI] = ai_cls(self) 114 | 115 | self.equipment: Equipment = equipment 116 | self.equipment.parent = self 117 | 118 | self.fighter = fighter 119 | self.fighter.parent = self 120 | 121 | self.inventory = inventory 122 | self.inventory.parent = self 123 | 124 | self.level = level 125 | self.level.parent = self 126 | 127 | @property 128 | def is_alive(self) -> bool: 129 | """Returns True as long as this actor can perform actions.""" 130 | return bool(self.ai) 131 | 132 | 133 | class Item(Entity): 134 | def __init__( 135 | self, 136 | *, 137 | x: int = 0, 138 | y: int = 0, 139 | char: str = "?", 140 | color: Tuple[int, int, int] = (255, 255, 255), 141 | name: str = "", 142 | consumable: Optional[Consumable] = None, 143 | equippable: Optional[Equippable] = None, 144 | ): 145 | super().__init__( 146 | x=x, 147 | y=y, 148 | char=char, 149 | color=color, 150 | name=name, 151 | blocks_movement=False, 152 | render_order=RenderOrder.ITEM, 153 | ) 154 | 155 | self.consumable = consumable 156 | 157 | if self.consumable: 158 | self.consumable.parent = self 159 | 160 | self.equippable = equippable 161 | 162 | if self.equippable: 163 | self.equippable.parent = self 164 | -------------------------------------------------------------------------------- /components/consumable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | from components.base_component import BaseComponent 6 | from exceptions import Impossible 7 | from input_handlers import ActionOrHandler, AreaRangedAttackHandler, SingleRangedAttackHandler 8 | import actions 9 | import color 10 | import components.ai 11 | import components.inventory 12 | 13 | if TYPE_CHECKING: 14 | from entity import Actor, Item 15 | 16 | 17 | class Consumable(BaseComponent): 18 | parent: Item 19 | 20 | def get_action(self, consumer: Actor) -> Optional[ActionOrHandler]: 21 | """Try to return the action for this item.""" 22 | return actions.ItemAction(consumer, self.parent) 23 | 24 | def activate(self, action: actions.ItemAction) -> None: 25 | """Invoke this items ability. 26 | 27 | `action` is the context for this activation. 28 | """ 29 | raise NotImplementedError() 30 | 31 | def consume(self) -> None: 32 | """Remove the consumed item from its containing inventory.""" 33 | entity = self.parent 34 | inventory = entity.parent 35 | if isinstance(inventory, components.inventory.Inventory): 36 | inventory.items.remove(entity) 37 | 38 | 39 | class ConfusionConsumable(Consumable): 40 | def __init__(self, number_of_turns: int): 41 | self.number_of_turns = number_of_turns 42 | 43 | def get_action(self, consumer: Actor) -> SingleRangedAttackHandler: 44 | self.engine.message_log.add_message("Select a target location.", color.needs_target) 45 | return SingleRangedAttackHandler( 46 | self.engine, 47 | callback=lambda xy: actions.ItemAction(consumer, self.parent, xy), 48 | ) 49 | 50 | def activate(self, action: actions.ItemAction) -> None: 51 | consumer = action.entity 52 | target = action.target_actor 53 | 54 | if not self.engine.game_map.visible[action.target_xy]: 55 | raise Impossible("You cannot target an area that you cannot see.") 56 | if not target: 57 | raise Impossible("You must select an enemy to target.") 58 | if target is consumer: 59 | raise Impossible("You cannot confuse yourself!") 60 | 61 | self.engine.message_log.add_message( 62 | f"The eyes of the {target.name} look vacant, as it starts to stumble around!", 63 | color.status_effect_applied, 64 | ) 65 | target.ai = components.ai.ConfusedEnemy( 66 | entity=target, 67 | previous_ai=target.ai, 68 | turns_remaining=self.number_of_turns, 69 | ) 70 | self.consume() 71 | 72 | 73 | class FireballDamageConsumable(Consumable): 74 | def __init__(self, damage: int, radius: int): 75 | self.damage = damage 76 | self.radius = radius 77 | 78 | def get_action(self, consumer: Actor) -> AreaRangedAttackHandler: 79 | self.engine.message_log.add_message("Select a target location.", color.needs_target) 80 | return AreaRangedAttackHandler( 81 | self.engine, 82 | radius=self.radius, 83 | callback=lambda xy: actions.ItemAction(consumer, self.parent, xy), 84 | ) 85 | 86 | def activate(self, action: actions.ItemAction) -> None: 87 | target_xy = action.target_xy 88 | 89 | if not self.engine.game_map.visible[target_xy]: 90 | raise Impossible("You cannot target an area that you cannot see.") 91 | 92 | targets_hit = False 93 | for actor in self.engine.game_map.actors: 94 | if actor.distance(*target_xy) <= self.radius: 95 | self.engine.message_log.add_message( 96 | f"The {actor.name} is engulfed in a fiery explosion, taking {self.damage} damage!" 97 | ) 98 | actor.fighter.take_damage(self.damage) 99 | targets_hit = True 100 | 101 | if not targets_hit: 102 | raise Impossible("There are no targets in the radius.") 103 | self.consume() 104 | 105 | 106 | class HealingConsumable(Consumable): 107 | def __init__(self, amount: int): 108 | self.amount = amount 109 | 110 | def activate(self, action: actions.ItemAction) -> None: 111 | consumer = action.entity 112 | amount_recovered = consumer.fighter.heal(self.amount) 113 | 114 | if amount_recovered > 0: 115 | self.engine.message_log.add_message( 116 | f"You consume the {self.parent.name}, and recover {amount_recovered} HP!", 117 | color.health_recovered, 118 | ) 119 | self.consume() 120 | else: 121 | raise Impossible("Your health is already full.") 122 | 123 | 124 | class LightningDamageConsumable(Consumable): 125 | def __init__(self, damage: int, maximum_range: int): 126 | self.damage = damage 127 | self.maximum_range = maximum_range 128 | 129 | def activate(self, action: actions.ItemAction) -> None: 130 | consumer = action.entity 131 | target = None 132 | closest_distance = self.maximum_range + 1.0 133 | 134 | for actor in self.engine.game_map.actors: 135 | if actor is not consumer and self.parent.gamemap.visible[actor.x, actor.y]: 136 | distance = consumer.distance(actor.x, actor.y) 137 | 138 | if distance < closest_distance: 139 | target = actor 140 | closest_distance = distance 141 | 142 | if target: 143 | self.engine.message_log.add_message( 144 | f"A lighting bolt strikes the {target.name} with a loud thunder, for {self.damage} damage!" 145 | ) 146 | target.fighter.take_damage(self.damage) 147 | self.consume() 148 | else: 149 | raise Impossible("No enemy is close enough to strike.") 150 | -------------------------------------------------------------------------------- /actions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional, Tuple 4 | 5 | import color 6 | import exceptions 7 | 8 | if TYPE_CHECKING: 9 | from engine import Engine 10 | from entity import Actor, Entity, Item 11 | 12 | 13 | class Action: 14 | def __init__(self, entity: Actor) -> None: 15 | super().__init__() 16 | self.entity = entity 17 | 18 | @property 19 | def engine(self) -> Engine: 20 | """Return the engine this action belongs to.""" 21 | return self.entity.gamemap.engine 22 | 23 | def perform(self) -> None: 24 | """Perform this action with the objects needed to determine its scope. 25 | 26 | `self.engine` is the scope this action is being performed in. 27 | 28 | `self.entity` is the object performing the action. 29 | 30 | This method must be overridden by Action subclasses. 31 | """ 32 | raise NotImplementedError() 33 | 34 | 35 | class PickupAction(Action): 36 | """Pickup an item and add it to the inventory, if there is room for it.""" 37 | 38 | def __init__(self, entity: Actor): 39 | super().__init__(entity) 40 | 41 | def perform(self) -> None: 42 | actor_location_x = self.entity.x 43 | actor_location_y = self.entity.y 44 | inventory = self.entity.inventory 45 | 46 | for item in self.engine.game_map.items: 47 | if actor_location_x == item.x and actor_location_y == item.y: 48 | if len(inventory.items) >= inventory.capacity: 49 | raise exceptions.Impossible("Your inventory is full.") 50 | 51 | self.engine.game_map.entities.remove(item) 52 | item.parent = self.entity.inventory 53 | inventory.items.append(item) 54 | 55 | self.engine.message_log.add_message(f"You picked up the {item.name}!") 56 | return 57 | 58 | raise exceptions.Impossible("There is nothing here to pick up.") 59 | 60 | 61 | class ItemAction(Action): 62 | def __init__(self, entity: Actor, item: Item, target_xy: Optional[Tuple[int, int]] = None): 63 | super().__init__(entity) 64 | self.item = item 65 | if not target_xy: 66 | target_xy = entity.x, entity.y 67 | self.target_xy = target_xy 68 | 69 | @property 70 | def target_actor(self) -> Optional[Actor]: 71 | """Return the actor at this actions destination.""" 72 | return self.engine.game_map.get_actor_at_location(*self.target_xy) 73 | 74 | def perform(self) -> None: 75 | """Invoke the items ability, this action will be given to provide context.""" 76 | if self.item.consumable: 77 | self.item.consumable.activate(self) 78 | 79 | 80 | class DropItem(ItemAction): 81 | def perform(self) -> None: 82 | if self.entity.equipment.item_is_equipped(self.item): 83 | self.entity.equipment.toggle_equip(self.item) 84 | 85 | self.entity.inventory.drop(self.item) 86 | 87 | 88 | class EquipAction(Action): 89 | def __init__(self, entity: Actor, item: Item): 90 | super().__init__(entity) 91 | 92 | self.item = item 93 | 94 | def perform(self) -> None: 95 | self.entity.equipment.toggle_equip(self.item) 96 | 97 | 98 | class WaitAction(Action): 99 | def perform(self) -> None: 100 | pass 101 | 102 | 103 | class TakeStairsAction(Action): 104 | def perform(self) -> None: 105 | """ 106 | Take the stairs, if any exist at the entity's location. 107 | """ 108 | if (self.entity.x, self.entity.y) == self.engine.game_map.downstairs_location: 109 | self.engine.game_world.generate_floor() 110 | self.engine.message_log.add_message("You descend the staircase.", color.descend) 111 | else: 112 | raise exceptions.Impossible("There are no stairs here.") 113 | 114 | 115 | class ActionWithDirection(Action): 116 | def __init__(self, entity: Actor, dx: int, dy: int): 117 | super().__init__(entity) 118 | 119 | self.dx = dx 120 | self.dy = dy 121 | 122 | @property 123 | def dest_xy(self) -> Tuple[int, int]: 124 | """Returns this actions destination.""" 125 | return self.entity.x + self.dx, self.entity.y + self.dy 126 | 127 | @property 128 | def blocking_entity(self) -> Optional[Entity]: 129 | """Return the blocking entity at this actions destination..""" 130 | return self.engine.game_map.get_blocking_entity_at_location(*self.dest_xy) 131 | 132 | @property 133 | def target_actor(self) -> Optional[Actor]: 134 | """Return the actor at this actions destination.""" 135 | return self.engine.game_map.get_actor_at_location(*self.dest_xy) 136 | 137 | def perform(self) -> None: 138 | raise NotImplementedError() 139 | 140 | 141 | class MeleeAction(ActionWithDirection): 142 | def perform(self) -> None: 143 | target = self.target_actor 144 | if not target: 145 | raise exceptions.Impossible("Nothing to attack.") 146 | 147 | damage = self.entity.fighter.power - target.fighter.defense 148 | 149 | attack_desc = f"{self.entity.name.capitalize()} attacks {target.name}" 150 | if self.entity is self.engine.player: 151 | attack_color = color.player_atk 152 | else: 153 | attack_color = color.enemy_atk 154 | 155 | if damage > 0: 156 | self.engine.message_log.add_message(f"{attack_desc} for {damage} hit points.", attack_color) 157 | target.fighter.hp -= damage 158 | else: 159 | self.engine.message_log.add_message(f"{attack_desc} but does no damage.", attack_color) 160 | 161 | 162 | class MovementAction(ActionWithDirection): 163 | def perform(self) -> None: 164 | dest_x, dest_y = self.dest_xy 165 | 166 | if not self.engine.game_map.in_bounds(dest_x, dest_y): 167 | # Destination is out of bounds. 168 | raise exceptions.Impossible("That way is blocked.") 169 | if not self.engine.game_map.tiles["walkable"][dest_x, dest_y]: 170 | # Destination is blocked by a tile. 171 | raise exceptions.Impossible("That way is blocked.") 172 | if self.engine.game_map.get_blocking_entity_at_location(dest_x, dest_y): 173 | # Destination is blocked by an entity. 174 | raise exceptions.Impossible("That way is blocked.") 175 | 176 | self.entity.move(self.dx, self.dy) 177 | 178 | 179 | class BumpAction(ActionWithDirection): 180 | def perform(self) -> None: 181 | if self.target_actor: 182 | return MeleeAction(self.entity, self.dx, self.dy).perform() 183 | 184 | else: 185 | return MovementAction(self.entity, self.dx, self.dy).perform() 186 | -------------------------------------------------------------------------------- /procgen.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Dict, Iterator, List, Tuple 4 | import random 5 | 6 | import tcod 7 | 8 | from game_map import GameMap 9 | import entity_factories 10 | import tile_types 11 | 12 | if TYPE_CHECKING: 13 | from engine import Engine 14 | from entity import Entity 15 | 16 | 17 | max_items_by_floor = [ 18 | (1, 1), 19 | (4, 2), 20 | ] 21 | 22 | max_monsters_by_floor = [ 23 | (1, 2), 24 | (4, 3), 25 | (6, 5), 26 | ] 27 | 28 | item_chances: Dict[int, List[Tuple[Entity, int]]] = { 29 | 0: [(entity_factories.health_potion, 35)], 30 | 2: [(entity_factories.confusion_scroll, 10)], 31 | 4: [(entity_factories.lightning_scroll, 25), (entity_factories.sword, 5)], 32 | 6: [(entity_factories.fireball_scroll, 25), (entity_factories.chain_mail, 15)], 33 | } 34 | 35 | enemy_chances: Dict[int, List[Tuple[Entity, int]]] = { 36 | 0: [(entity_factories.orc, 80)], 37 | 3: [(entity_factories.troll, 15)], 38 | 5: [(entity_factories.troll, 30)], 39 | 7: [(entity_factories.troll, 60)], 40 | } 41 | 42 | 43 | def get_max_value_for_floor(max_value_by_floor: List[Tuple[int, int]], floor: int) -> int: 44 | current_value = 0 45 | 46 | for floor_minimum, value in max_value_by_floor: 47 | if floor_minimum > floor: 48 | break 49 | else: 50 | current_value = value 51 | 52 | return current_value 53 | 54 | 55 | def get_entities_at_random( 56 | weighted_chances_by_floor: Dict[int, List[Tuple[Entity, int]]], 57 | number_of_entities: int, 58 | floor: int, 59 | ) -> List[Entity]: 60 | entity_weighted_chances = {} 61 | 62 | for key, values in weighted_chances_by_floor.items(): 63 | if key > floor: 64 | break 65 | else: 66 | for value in values: 67 | entity = value[0] 68 | weighted_chance = value[1] 69 | 70 | entity_weighted_chances[entity] = weighted_chance 71 | 72 | entities = list(entity_weighted_chances.keys()) 73 | entity_weighted_chance_values = list(entity_weighted_chances.values()) 74 | 75 | chosen_entities = random.choices(entities, weights=entity_weighted_chance_values, k=number_of_entities) 76 | 77 | return chosen_entities 78 | 79 | 80 | class RectangularRoom: 81 | def __init__(self, x: int, y: int, width: int, height: int): 82 | self.x1 = x 83 | self.y1 = y 84 | self.x2 = x + width 85 | self.y2 = y + height 86 | 87 | @property 88 | def center(self) -> Tuple[int, int]: 89 | center_x = int((self.x1 + self.x2) / 2) 90 | center_y = int((self.y1 + self.y2) / 2) 91 | 92 | return center_x, center_y 93 | 94 | @property 95 | def inner(self) -> Tuple[slice, slice]: 96 | """Return the inner area of this room as a 2D array index.""" 97 | return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) 98 | 99 | def intersects(self, other: RectangularRoom) -> bool: 100 | """Return True if this room overlaps with another RectangularRoom.""" 101 | return self.x1 <= other.x2 and self.x2 >= other.x1 and self.y1 <= other.y2 and self.y2 >= other.y1 102 | 103 | 104 | def place_entities(room: RectangularRoom, dungeon: GameMap, floor_number: int) -> None: 105 | number_of_monsters = random.randint(0, get_max_value_for_floor(max_monsters_by_floor, floor_number)) 106 | number_of_items = random.randint(0, get_max_value_for_floor(max_items_by_floor, floor_number)) 107 | 108 | monsters: List[Entity] = get_entities_at_random(enemy_chances, number_of_monsters, floor_number) 109 | items: List[Entity] = get_entities_at_random(item_chances, number_of_items, floor_number) 110 | 111 | for entity in monsters + items: 112 | x = random.randint(room.x1 + 1, room.x2 - 1) 113 | y = random.randint(room.y1 + 1, room.y2 - 1) 114 | 115 | if not any(entity.x == x and entity.y == y for entity in dungeon.entities): 116 | entity.spawn(dungeon, x, y) 117 | 118 | 119 | def tunnel_between(start: Tuple[int, int], end: Tuple[int, int]) -> Iterator[Tuple[int, int]]: 120 | """Return an L-shaped tunnel between these two points.""" 121 | x1, y1 = start 122 | x2, y2 = end 123 | if random.random() < 0.5: # 50% chance. 124 | # Move horizontally, then vertically. 125 | corner_x, corner_y = x2, y1 126 | else: 127 | # Move vertically, then horizontally. 128 | corner_x, corner_y = x1, y2 129 | 130 | # Generate the coordinates for this tunnel. 131 | for x, y in tcod.los.bresenham((x1, y1), (corner_x, corner_y)).tolist(): 132 | yield x, y 133 | for x, y in tcod.los.bresenham((corner_x, corner_y), (x2, y2)).tolist(): 134 | yield x, y 135 | 136 | 137 | def generate_dungeon( 138 | max_rooms: int, 139 | room_min_size: int, 140 | room_max_size: int, 141 | map_width: int, 142 | map_height: int, 143 | engine: Engine, 144 | ) -> GameMap: 145 | """Generate a new dungeon map.""" 146 | player = engine.player 147 | dungeon = GameMap(engine, map_width, map_height, entities=[player]) 148 | 149 | rooms: List[RectangularRoom] = [] 150 | 151 | center_of_last_room = (0, 0) 152 | 153 | for _ in range(max_rooms): 154 | room_width = random.randint(room_min_size, room_max_size) 155 | room_height = random.randint(room_min_size, room_max_size) 156 | 157 | x = random.randint(0, dungeon.width - room_width - 1) 158 | y = random.randint(0, dungeon.height - room_height - 1) 159 | 160 | # "RectangularRoom" class makes rectangles easier to work with 161 | new_room = RectangularRoom(x, y, room_width, room_height) 162 | 163 | # Run through the other rooms and see if they intersect with this one. 164 | if any(new_room.intersects(other_room) for other_room in rooms): 165 | continue # This room intersects, so go to the next attempt. 166 | # If there are no intersections then the room is valid. 167 | 168 | # Dig out this rooms inner area. 169 | dungeon.tiles[new_room.inner] = tile_types.floor 170 | 171 | if len(rooms) == 0: 172 | # The first room, where the player starts. 173 | player.place(*new_room.center, dungeon) 174 | else: # All rooms after the first. 175 | # Dig out a tunnel between this room and the previous one. 176 | for x, y in tunnel_between(rooms[-1].center, new_room.center): 177 | dungeon.tiles[x, y] = tile_types.floor 178 | 179 | center_of_last_room = new_room.center 180 | 181 | place_entities(new_room, dungeon, engine.game_world.current_floor) 182 | 183 | dungeon.tiles[center_of_last_room] = tile_types.down_stairs 184 | dungeon.downstairs_location = center_of_last_room 185 | 186 | # Finally, append the new room to the list. 187 | rooms.append(new_room) 188 | 189 | return dungeon 190 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /input_handlers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Callable, Optional, Tuple, Union 4 | import os 5 | 6 | import tcod 7 | 8 | from actions import Action, BumpAction, PickupAction, WaitAction 9 | import actions 10 | import color 11 | import exceptions 12 | 13 | if TYPE_CHECKING: 14 | from engine import Engine 15 | from entity import Item 16 | 17 | 18 | MOVE_KEYS = { 19 | # Arrow keys. 20 | tcod.event.K_UP: (0, -1), 21 | tcod.event.K_DOWN: (0, 1), 22 | tcod.event.K_LEFT: (-1, 0), 23 | tcod.event.K_RIGHT: (1, 0), 24 | tcod.event.K_HOME: (-1, -1), 25 | tcod.event.K_END: (-1, 1), 26 | tcod.event.K_PAGEUP: (1, -1), 27 | tcod.event.K_PAGEDOWN: (1, 1), 28 | # Numpad keys. 29 | tcod.event.K_KP_1: (-1, 1), 30 | tcod.event.K_KP_2: (0, 1), 31 | tcod.event.K_KP_3: (1, 1), 32 | tcod.event.K_KP_4: (-1, 0), 33 | tcod.event.K_KP_6: (1, 0), 34 | tcod.event.K_KP_7: (-1, -1), 35 | tcod.event.K_KP_8: (0, -1), 36 | tcod.event.K_KP_9: (1, -1), 37 | # Vi keys. 38 | tcod.event.K_h: (-1, 0), 39 | tcod.event.K_j: (0, 1), 40 | tcod.event.K_k: (0, -1), 41 | tcod.event.K_l: (1, 0), 42 | tcod.event.K_y: (-1, -1), 43 | tcod.event.K_u: (1, -1), 44 | tcod.event.K_b: (-1, 1), 45 | tcod.event.K_n: (1, 1), 46 | } 47 | 48 | WAIT_KEYS = { 49 | tcod.event.K_PERIOD, 50 | tcod.event.K_KP_5, 51 | tcod.event.K_CLEAR, 52 | } 53 | 54 | CONFIRM_KEYS = { 55 | tcod.event.K_RETURN, 56 | tcod.event.K_KP_ENTER, 57 | } 58 | 59 | ActionOrHandler = Union[Action, "BaseEventHandler"] 60 | """An event handler return value which can trigger an action or switch active handlers. 61 | 62 | If a handler is returned then it will become the active handler for future events. 63 | If an action is returned it will be attempted and if it's valid then 64 | MainGameEventHandler will become the active handler. 65 | """ 66 | 67 | 68 | class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]): 69 | def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: 70 | """Handle an event and return the next active event handler.""" 71 | state = self.dispatch(event) 72 | if isinstance(state, BaseEventHandler): 73 | return state 74 | assert not isinstance(state, Action), f"{self!r} can not handle actions." 75 | return self 76 | 77 | def on_render(self, console: tcod.Console) -> None: 78 | raise NotImplementedError() 79 | 80 | def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]: 81 | raise SystemExit() 82 | 83 | 84 | class PopupMessage(BaseEventHandler): 85 | """Display a popup text window.""" 86 | 87 | def __init__(self, parent_handler: BaseEventHandler, text: str): 88 | self.parent = parent_handler 89 | self.text = text 90 | 91 | def on_render(self, console: tcod.Console) -> None: 92 | """Render the parent and dim the result, then print the message on top.""" 93 | self.parent.on_render(console) 94 | console.tiles_rgb["fg"] //= 8 95 | console.tiles_rgb["bg"] //= 8 96 | 97 | console.print( 98 | console.width // 2, 99 | console.height // 2, 100 | self.text, 101 | fg=color.white, 102 | bg=color.black, 103 | alignment=tcod.CENTER, 104 | ) 105 | 106 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]: 107 | """Any key returns to the parent handler.""" 108 | return self.parent 109 | 110 | 111 | class EventHandler(BaseEventHandler): 112 | def __init__(self, engine: Engine): 113 | self.engine = engine 114 | 115 | def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: 116 | """Handle events for input handlers with an engine.""" 117 | action_or_state = self.dispatch(event) 118 | if isinstance(action_or_state, BaseEventHandler): 119 | return action_or_state 120 | if self.handle_action(action_or_state): 121 | # A valid action was performed. 122 | if not self.engine.player.is_alive: 123 | # The player was killed sometime during or after the action. 124 | return GameOverEventHandler(self.engine) 125 | elif self.engine.player.level.requires_level_up: 126 | return LevelUpEventHandler(self.engine) 127 | return MainGameEventHandler(self.engine) # Return to the main handler. 128 | return self 129 | 130 | def handle_action(self, action: Optional[Action]) -> bool: 131 | """Handle actions returned from event methods. 132 | 133 | Returns True if the action will advance a turn. 134 | """ 135 | if action is None: 136 | return False 137 | 138 | try: 139 | action.perform() 140 | except exceptions.Impossible as exc: 141 | self.engine.message_log.add_message(exc.args[0], color.impossible) 142 | return False # Skip enemy turn on exceptions. 143 | 144 | self.engine.handle_enemy_turns() 145 | 146 | self.engine.update_fov() 147 | return True 148 | 149 | def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: 150 | if self.engine.game_map.in_bounds(event.tile.x, event.tile.y): 151 | self.engine.mouse_location = event.tile.x, event.tile.y 152 | 153 | def on_render(self, console: tcod.Console) -> None: 154 | self.engine.render(console) 155 | 156 | 157 | class AskUserEventHandler(EventHandler): 158 | """Handles user input for actions which require special input.""" 159 | 160 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: 161 | """By default any key exits this input handler.""" 162 | if event.sym in { # Ignore modifier keys. 163 | tcod.event.K_LSHIFT, 164 | tcod.event.K_RSHIFT, 165 | tcod.event.K_LCTRL, 166 | tcod.event.K_RCTRL, 167 | tcod.event.K_LALT, 168 | tcod.event.K_RALT, 169 | tcod.event.K_LGUI, 170 | tcod.event.K_RGUI, 171 | tcod.event.K_MODE, 172 | }: 173 | return None 174 | return self.on_exit() 175 | 176 | def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: 177 | """By default any mouse click exits this input handler.""" 178 | return self.on_exit() 179 | 180 | def on_exit(self) -> Optional[ActionOrHandler]: 181 | """Called when the user is trying to exit or cancel an action. 182 | 183 | By default this returns to the main event handler. 184 | """ 185 | return MainGameEventHandler(self.engine) 186 | 187 | 188 | class CharacterScreenEventHandler(AskUserEventHandler): 189 | TITLE = "Character Information" 190 | 191 | def on_render(self, console: tcod.Console) -> None: 192 | super().on_render(console) 193 | 194 | if self.engine.player.x <= 30: 195 | x = 40 196 | else: 197 | x = 0 198 | 199 | y = 0 200 | 201 | width = len(self.TITLE) + 4 202 | 203 | console.draw_frame( 204 | x=x, 205 | y=y, 206 | width=width, 207 | height=7, 208 | title=self.TITLE, 209 | clear=True, 210 | fg=(255, 255, 255), 211 | bg=(0, 0, 0), 212 | ) 213 | 214 | console.print(x=x + 1, y=y + 1, string=f"Level: {self.engine.player.level.current_level}") 215 | console.print(x=x + 1, y=y + 2, string=f"XP: {self.engine.player.level.current_xp}") 216 | console.print( 217 | x=x + 1, 218 | y=y + 3, 219 | string=f"XP for next Level: {self.engine.player.level.experience_to_next_level}", 220 | ) 221 | 222 | console.print(x=x + 1, y=y + 4, string=f"Attack: {self.engine.player.fighter.power}") 223 | console.print(x=x + 1, y=y + 5, string=f"Defense: {self.engine.player.fighter.defense}") 224 | 225 | 226 | class LevelUpEventHandler(AskUserEventHandler): 227 | TITLE = "Level Up" 228 | 229 | def on_render(self, console: tcod.Console) -> None: 230 | super().on_render(console) 231 | 232 | if self.engine.player.x <= 30: 233 | x = 40 234 | else: 235 | x = 0 236 | 237 | console.draw_frame( 238 | x=x, 239 | y=0, 240 | width=35, 241 | height=8, 242 | title=self.TITLE, 243 | clear=True, 244 | fg=(255, 255, 255), 245 | bg=(0, 0, 0), 246 | ) 247 | 248 | console.print(x=x + 1, y=1, string="Congratulations! You level up!") 249 | console.print(x=x + 1, y=2, string="Select an attribute to increase.") 250 | 251 | console.print( 252 | x=x + 1, 253 | y=4, 254 | string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})", 255 | ) 256 | console.print( 257 | x=x + 1, 258 | y=5, 259 | string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})", 260 | ) 261 | console.print( 262 | x=x + 1, 263 | y=6, 264 | string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})", 265 | ) 266 | 267 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: 268 | player = self.engine.player 269 | key = event.sym 270 | index = key - tcod.event.K_a 271 | 272 | if 0 <= index <= 2: 273 | if index == 0: 274 | player.level.increase_max_hp() 275 | elif index == 1: 276 | player.level.increase_power() 277 | else: 278 | player.level.increase_defense() 279 | else: 280 | self.engine.message_log.add_message("Invalid entry.", color.invalid) 281 | 282 | return None 283 | 284 | return super().ev_keydown(event) 285 | 286 | def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: 287 | """ 288 | Don't allow the player to click to exit the menu, like normal. 289 | """ 290 | return None 291 | 292 | 293 | class InventoryEventHandler(AskUserEventHandler): 294 | """This handler lets the user select an item. 295 | 296 | What happens then depends on the subclass. 297 | """ 298 | 299 | TITLE = "" 300 | 301 | def on_render(self, console: tcod.Console) -> None: 302 | """Render an inventory menu, which displays the items in the inventory, and the letter to select them. 303 | Will move to a different position based on where the player is located, so the player can always see where 304 | they are. 305 | """ 306 | super().on_render(console) 307 | number_of_items_in_inventory = len(self.engine.player.inventory.items) 308 | 309 | height = number_of_items_in_inventory + 2 310 | 311 | if height <= 3: 312 | height = 3 313 | 314 | if self.engine.player.x <= 30: 315 | x = 40 316 | else: 317 | x = 0 318 | 319 | y = 0 320 | 321 | width = len(self.TITLE) + 4 322 | 323 | console.draw_frame( 324 | x=x, 325 | y=y, 326 | width=width, 327 | height=height, 328 | clear=True, 329 | fg=(255, 255, 255), 330 | bg=(0, 0, 0), 331 | ) 332 | console.print(x + 1, y, f" {self.TITLE} ", fg=(0, 0, 0), bg=(255, 255, 255)) 333 | 334 | if number_of_items_in_inventory > 0: 335 | for i, item in enumerate(self.engine.player.inventory.items): 336 | item_key = chr(ord("a") + i) 337 | 338 | is_equipped = self.engine.player.equipment.item_is_equipped(item) 339 | 340 | item_string = f"({item_key}) {item.name}" 341 | 342 | if is_equipped: 343 | item_string = f"{item_string} (E)" 344 | 345 | console.print(x + 1, y + i + 1, item_string) 346 | else: 347 | console.print(x + 1, y + 1, "(Empty)") 348 | 349 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: 350 | player = self.engine.player 351 | key = event.sym 352 | index = key - tcod.event.K_a 353 | 354 | if 0 <= index <= 26: 355 | try: 356 | selected_item = player.inventory.items[index] 357 | except IndexError: 358 | self.engine.message_log.add_message("Invalid entry.", color.invalid) 359 | return None 360 | return self.on_item_selected(selected_item) 361 | return super().ev_keydown(event) 362 | 363 | def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: 364 | """Called when the user selects a valid item.""" 365 | raise NotImplementedError() 366 | 367 | 368 | class InventoryActivateHandler(InventoryEventHandler): 369 | """Handle using an inventory item.""" 370 | 371 | TITLE = "Select an item to use" 372 | 373 | def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: 374 | if item.consumable: 375 | # Return the action for the selected item. 376 | return item.consumable.get_action(self.engine.player) 377 | elif item.equippable: 378 | return actions.EquipAction(self.engine.player, item) 379 | else: 380 | return None 381 | 382 | 383 | class InventoryDropHandler(InventoryEventHandler): 384 | """Handle dropping an inventory item.""" 385 | 386 | TITLE = "Select an item to drop" 387 | 388 | def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: 389 | """Drop this item.""" 390 | return actions.DropItem(self.engine.player, item) 391 | 392 | 393 | class SelectIndexHandler(AskUserEventHandler): 394 | """Handles asking the user for an index on the map.""" 395 | 396 | def __init__(self, engine: Engine): 397 | """Sets the cursor to the player when this handler is constructed.""" 398 | super().__init__(engine) 399 | player = self.engine.player 400 | engine.mouse_location = player.x, player.y 401 | 402 | def on_render(self, console: tcod.Console) -> None: 403 | """Highlight the tile under the cursor.""" 404 | super().on_render(console) 405 | x, y = self.engine.mouse_location 406 | console.tiles_rgb["bg"][x, y] = color.white 407 | console.tiles_rgb["fg"][x, y] = color.black 408 | 409 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: 410 | """Check for key movement or confirmation keys.""" 411 | key = event.sym 412 | if key in MOVE_KEYS: 413 | modifier = 1 # Holding modifier keys will speed up key movement. 414 | if event.mod & (tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT): 415 | modifier *= 5 416 | if event.mod & (tcod.event.KMOD_LCTRL | tcod.event.KMOD_RCTRL): 417 | modifier *= 10 418 | if event.mod & (tcod.event.KMOD_LALT | tcod.event.KMOD_RALT): 419 | modifier *= 20 420 | 421 | x, y = self.engine.mouse_location 422 | dx, dy = MOVE_KEYS[key] 423 | x += dx * modifier 424 | y += dy * modifier 425 | # Clamp the cursor index to the map size. 426 | x = max(0, min(x, self.engine.game_map.width - 1)) 427 | y = max(0, min(y, self.engine.game_map.height - 1)) 428 | self.engine.mouse_location = x, y 429 | return None 430 | elif key in CONFIRM_KEYS: 431 | return self.on_index_selected(*self.engine.mouse_location) 432 | return super().ev_keydown(event) 433 | 434 | def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: 435 | """Left click confirms a selection.""" 436 | if self.engine.game_map.in_bounds(*event.tile): 437 | if event.button == 1: 438 | return self.on_index_selected(*event.tile) 439 | return super().ev_mousebuttondown(event) 440 | 441 | def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]: 442 | """Called when an index is selected.""" 443 | raise NotImplementedError() 444 | 445 | 446 | class LookHandler(SelectIndexHandler): 447 | """Lets the player look around using the keyboard.""" 448 | 449 | def on_index_selected(self, x: int, y: int) -> MainGameEventHandler: 450 | """Return to main handler.""" 451 | return MainGameEventHandler(self.engine) 452 | 453 | 454 | class SingleRangedAttackHandler(SelectIndexHandler): 455 | """Handles targeting a single enemy. Only the enemy selected will be affected.""" 456 | 457 | def __init__(self, engine: Engine, callback: Callable[[Tuple[int, int]], Optional[Action]]): 458 | super().__init__(engine) 459 | 460 | self.callback = callback 461 | 462 | def on_index_selected(self, x: int, y: int) -> Optional[Action]: 463 | return self.callback((x, y)) 464 | 465 | 466 | class AreaRangedAttackHandler(SelectIndexHandler): 467 | """Handles targeting an area within a given radius. Any entity within the area will be affected.""" 468 | 469 | def __init__( 470 | self, 471 | engine: Engine, 472 | radius: int, 473 | callback: Callable[[Tuple[int, int]], Optional[Action]], 474 | ): 475 | super().__init__(engine) 476 | 477 | self.radius = radius 478 | self.callback = callback 479 | 480 | def on_render(self, console: tcod.Console) -> None: 481 | """Highlight the tile under the cursor.""" 482 | super().on_render(console) 483 | 484 | x, y = self.engine.mouse_location 485 | 486 | # Draw a rectangle around the targeted area, so the player can see the affected tiles. 487 | console.draw_frame( 488 | x=x - self.radius - 1, 489 | y=y - self.radius - 1, 490 | width=self.radius ** 2, 491 | height=self.radius ** 2, 492 | fg=color.red, 493 | clear=False, 494 | ) 495 | 496 | def on_index_selected(self, x: int, y: int) -> Optional[Action]: 497 | return self.callback((x, y)) 498 | 499 | 500 | class MainGameEventHandler(EventHandler): 501 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: 502 | action: Optional[Action] = None 503 | 504 | key = event.sym 505 | modifier = event.mod 506 | 507 | player = self.engine.player 508 | 509 | if key == tcod.event.K_PERIOD and modifier & (tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT): 510 | return actions.TakeStairsAction(player) 511 | 512 | if key in MOVE_KEYS: 513 | dx, dy = MOVE_KEYS[key] 514 | action = BumpAction(player, dx, dy) 515 | elif key in WAIT_KEYS: 516 | action = WaitAction(player) 517 | 518 | elif key == tcod.event.K_ESCAPE: 519 | raise SystemExit() 520 | elif key == tcod.event.K_v: 521 | return HistoryViewer(self.engine) 522 | 523 | elif key == tcod.event.K_g: 524 | action = PickupAction(player) 525 | 526 | elif key == tcod.event.K_i: 527 | return InventoryActivateHandler(self.engine) 528 | elif key == tcod.event.K_d: 529 | return InventoryDropHandler(self.engine) 530 | elif key == tcod.event.K_c: 531 | return CharacterScreenEventHandler(self.engine) 532 | elif key == tcod.event.K_SLASH: 533 | return LookHandler(self.engine) 534 | 535 | # No valid key was pressed 536 | return action 537 | 538 | 539 | class GameOverEventHandler(EventHandler): 540 | def on_quit(self) -> None: 541 | """Handle exiting out of a finished game.""" 542 | if os.path.exists("savegame.sav"): 543 | os.remove("savegame.sav") # Deletes the active save file. 544 | raise exceptions.QuitWithoutSaving() # Avoid saving a finished game. 545 | 546 | def ev_quit(self, event: tcod.event.Quit) -> None: 547 | self.on_quit() 548 | 549 | def ev_keydown(self, event: tcod.event.KeyDown) -> None: 550 | if event.sym == tcod.event.K_ESCAPE: 551 | self.on_quit() 552 | 553 | 554 | CURSOR_Y_KEYS = { 555 | tcod.event.K_UP: -1, 556 | tcod.event.K_DOWN: 1, 557 | tcod.event.K_PAGEUP: -10, 558 | tcod.event.K_PAGEDOWN: 10, 559 | } 560 | 561 | 562 | class HistoryViewer(EventHandler): 563 | """Print the history on a larger window which can be navigated.""" 564 | 565 | def __init__(self, engine: Engine): 566 | super().__init__(engine) 567 | self.log_length = len(engine.message_log.messages) 568 | self.cursor = self.log_length - 1 569 | 570 | def on_render(self, console: tcod.Console) -> None: 571 | super().on_render(console) # Draw the main state as the background. 572 | 573 | log_console = tcod.Console(console.width - 6, console.height - 6) 574 | 575 | # Draw a frame with a custom banner title. 576 | log_console.draw_frame(0, 0, log_console.width, log_console.height) 577 | log_console.print_box(0, 0, log_console.width, 1, "┤Message history├", alignment=tcod.CENTER) 578 | 579 | # Render the message log using the cursor parameter. 580 | self.engine.message_log.render_messages( 581 | log_console, 582 | 1, 583 | 1, 584 | log_console.width - 2, 585 | log_console.height - 2, 586 | self.engine.message_log.messages[: self.cursor + 1], 587 | ) 588 | log_console.blit(console, 3, 3) 589 | 590 | def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[MainGameEventHandler]: 591 | # Fancy conditional movement to make it feel right. 592 | if event.sym in CURSOR_Y_KEYS: 593 | adjust = CURSOR_Y_KEYS[event.sym] 594 | if adjust < 0 and self.cursor == 0: 595 | # Only move from the top to the bottom when you're on the edge. 596 | self.cursor = self.log_length - 1 597 | elif adjust > 0 and self.cursor == self.log_length - 1: 598 | # Same with bottom to top movement. 599 | self.cursor = 0 600 | else: 601 | # Otherwise move while staying clamped to the bounds of the history log. 602 | self.cursor = max(0, min(self.cursor + adjust, self.log_length - 1)) 603 | elif event.sym == tcod.event.K_HOME: 604 | self.cursor = 0 # Move directly to the top message. 605 | elif event.sym == tcod.event.K_END: 606 | self.cursor = self.log_length - 1 # Move directly to the last message. 607 | else: # Any other key moves back to the main game state. 608 | return MainGameEventHandler(self.engine) 609 | return None 610 | --------------------------------------------------------------------------------