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