commit ceb000f229bc81634ebac5cd31bbfb1f41f21390 Author: Tyrel Souza Date: Sun Jan 2 10:23:25 2022 -0500 Initial commit http://rogueliketutorials.com/tutorials/tcod/v2/part-5/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..4940481 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..76a72c6 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/roguelike.iml b/.idea/roguelike.iml new file mode 100644 index 0000000..74d515a --- /dev/null +++ b/.idea/roguelike.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/actions.py b/actions.py new file mode 100644 index 0000000..4e6ef12 --- /dev/null +++ b/actions.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from engine import Engine + from entity import Entity + + +class Action: + def perform(self, engine: Engine, entity: Entity) -> None: + """Perform this action with the objects needed to determine its scope. + + `engine` is the scope this action is being performed in. + + `entity` is the object performing the action. + + This method must be overridden by Action subclasses. + """ + raise NotImplementedError() + + +class EscapeAction(Action): + def perform(self, engine: Engine, entity: Entity) -> None: + raise SystemExit() + + +class MovementAction(Action): + def __init__(self, dx: int, dy: int): + super().__init__() + + self.dx = dx + self.dy = dy + + def perform(self, engine: Engine, entity: Entity) -> None: + dest_x = entity.x + self.dx + dest_y = entity.y + self.dy + + if not engine.game_map.in_bounds(dest_x, dest_y): + return # OOB + if not engine.game_map.tiles["walkable"][dest_x, dest_y]: + return # can't walk + + entity.move(self.dx, self.dy) diff --git a/dejavu10x10_gs_tc.png b/dejavu10x10_gs_tc.png new file mode 100644 index 0000000..ea3adbe Binary files /dev/null and b/dejavu10x10_gs_tc.png differ diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..a8519fa --- /dev/null +++ b/engine.py @@ -0,0 +1,43 @@ +from typing import Set, Iterable, Any + +from tcod.context import Context +from tcod.console import Console +from tcod.map import compute_fov + +from entity import Entity +from game_map import GameMap +from input_handlers import EventHandler + + +class Engine: + def __init__(self, entities: Set[Entity], event_handler: EventHandler, game_map: GameMap, player: Entity): + self.entities = entities + self.event_handler = event_handler + self.player = player + self.game_map = game_map + self.update_fov() + + def handle_events(self, events: Iterable[Any]) -> None: + for event in events: + action = self.event_handler.dispatch(event) + if action is None: + continue + action.perform(self, self.player) + self.update_fov() + + def update_fov(self) -> None: + self.game_map.visible[:] = compute_fov( + self.game_map.tiles["transparent"], + (self.player.x, self.player.y), + radius=8, + ) + self.game_map.explored |= self.game_map.visible + + def render(self, console: Console, context: Context) -> None: + self.game_map.render(console) + for entity in self.entities: + if self.game_map.visible[entity.x, entity.y]: + console.print(entity.x, entity.y, entity.char, fg=entity.color) + + context.present(console) + console.clear() \ No newline at end of file diff --git a/entity.py b/entity.py new file mode 100644 index 0000000..61338db --- /dev/null +++ b/entity.py @@ -0,0 +1,15 @@ +from typing import Tuple + +class Entity: + """ + A generic object to represent players, enemies, items, etc. + """ + def __init__(self, x: int, y: int, char: str, color: Tuple[int, int, int]): + self.x = x + self.y = y + self.char = char + self.color = color + + def move(self, dx: int, dy: int) -> None: + self.x += dx + self.y += dy diff --git a/game_map.py b/game_map.py new file mode 100644 index 0000000..4660a86 --- /dev/null +++ b/game_map.py @@ -0,0 +1,23 @@ +import numpy as np # type: ignore +from tcod.console import Console + +import tile_types + + +class GameMap: + def __init__(self, width: int, height: int): + self.width, self.height = width, height + self.tiles = np.full((width,height), fill_value=tile_types.wall, order="F") + + self.visible = np.full((width, height), fill_value=False, order="F") + self.explored = np.full((width, height), fill_value=False, order="F") + + def in_bounds(self, x: int, y: int) -> bool: + return 0 <= x < self.width and 0 <= y < self.height + + def render(self, console: Console) -> None: + console.tiles_rgb[0:self.width, 0:self.height] = np.select( + condlist=[self.visible, self.explored], + choicelist=[self.tiles["light"], self.tiles["dark"]], + default=tile_types.SHROUD + ) diff --git a/input_handlers.py b/input_handlers.py new file mode 100644 index 0000000..51ccfee --- /dev/null +++ b/input_handlers.py @@ -0,0 +1,29 @@ +from typing import Optional + +import tcod.event + +from actions import Action, EscapeAction, MovementAction + + +class EventHandler(tcod.event.EventDispatch[Action]): + def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]: + raise SystemExit() + + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: + action: Optional[Action] = None + + key = event.sym + + if key == tcod.event.K_UP: + action = MovementAction(dx=0, dy=-1) + elif key == tcod.event.K_DOWN: + action = MovementAction(dx=0, dy=1) + elif key == tcod.event.K_LEFT: + action = MovementAction(dx=-1, dy=0) + elif key == tcod.event.K_RIGHT: + action = MovementAction(dx=1, dy=0) + + elif key == tcod.event.K_ESCAPE: + action = EscapeAction() + + return action diff --git a/main.py b/main.py new file mode 100644 index 0000000..29a3be9 --- /dev/null +++ b/main.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import tcod + +from entity import Entity +from engine import Engine +from input_handlers import EventHandler +from procgen import generate_dungeon + + +def main(): + screen_width = 80 + screen_height = 50 + + map_width = 80 + map_height = 45 + + room_max_size = 10 + room_min_size = 6 + max_rooms = 30 + + tileset = tcod.tileset.load_tilesheet( + "dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD + ) + + event_handler = EventHandler() + + player = Entity(screen_width // 2, screen_height // 2, "@", (255, 255, 255)) + npc = Entity(screen_width // 2 - 5, screen_height // 2, "@", (255, 255, 0)) + entities = {npc, player} + + game_map = generate_dungeon( + max_rooms=max_rooms, + room_min_size=room_min_size, + room_max_size=room_max_size, + map_width=map_width, + map_height=map_height, + player=player + ) + + engine = Engine(entities=entities, event_handler=event_handler, game_map=game_map, player=player) + + with tcod.context.new_terminal( + screen_width, + screen_height, + tileset=tileset, + title="Yet Another Roguelike Tutorial", + vsync=True + ) as context: + root_console = tcod.Console(screen_width, screen_height, order="F") + while True: + engine.render(console=root_console, context=context) + events = tcod.event.wait() + engine.handle_events(events) + + +if __name__ == "__main__": + main() diff --git a/procgen.py b/procgen.py new file mode 100644 index 0000000..cf2d2ba --- /dev/null +++ b/procgen.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import random +from typing import Tuple, Iterator, TYPE_CHECKING, List + +import tcod.los + +import tile_types +from game_map import GameMap + +if TYPE_CHECKING: + from entity import Entity + + +class RectangularRoom: + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> Tuple[int, int]: + return (self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2 + + @property + def inner(self) -> Tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: RectangularRoom) -> bool: + """Return True if this room overlaps with another RectangularRoom.""" + return ( + self.x1 <= other.x2 + and self.x2 >= other.x1 + and self.y1 <= other.y2 + and self.y2 >= other.y1 + ) + + +def tunnel_between( + start: Tuple[int, int], end: Tuple[int, int] +) -> Iterator[Tuple[int, int]]: + """ return an L shape tunel between points""" + x1, y1 = start + x2, y2 = end + if random.random() < 0.5: + corner_x, corner_y = x2, y1 + else: + corner_x, corner_y = x1, y2 + + for x, y in tcod.los.bresenham((x1, y1), (corner_x, corner_y)).tolist(): + yield x, y + + for x, y in tcod.los.bresenham((corner_x, corner_y), (x2, y2)).tolist(): + yield x, y + + +def generate_dungeon( + max_rooms: int, + room_min_size: int, + room_max_size: int, + map_width: int, + map_height: int, + player: Entity +) -> GameMap: + dungeon = GameMap(map_width, map_height) + rooms: List[RectangularRoom] = [] + + for room in range(max_rooms): + room_width = random.randint(room_min_size, room_max_size) + room_height = random.randint(room_min_size, room_max_size) + x = random.randint(0, dungeon.width - room_width - 1) + y = random.randint(0, dungeon.height - room_height - 1) + + new_room = RectangularRoom(x,y, room_width,room_height) + + if any(new_room.intersects(other_room) for other_room in rooms): + continue + + dungeon.tiles[new_room.inner] = tile_types.floor + + if len(rooms) == 0: + # first room + player.x, player.y = new_room.center + else: + for x,y in tunnel_between(rooms[-1].center, new_room.center): + dungeon.tiles[x,y] = tile_types.floor + + rooms.append(new_room) + + return dungeon diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3fd497 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +tcod>=11.14 +numpy>=1.18 +black \ No newline at end of file diff --git a/tile_types.py b/tile_types.py new file mode 100644 index 0000000..4b5c617 --- /dev/null +++ b/tile_types.py @@ -0,0 +1,49 @@ +from typing import Tuple + +import numpy as np # type: ignore + +graphic_dt = np.dtype( + [ + ("ch", np.int32), # unicode codepoint + ("fg", "3B"), # RGB + ("bg", "3B"), + ] +) + +tile_dt = np.dtype( + [ + ("walkable", np.bool), # True if can be walked over + ("transparent", np.bool), # True if doesn't block FOV + ("dark", graphic_dt), # graphics for when this tile is not in FOV + ("light", graphic_dt), # graphics for when this tile is not in FOV + ] +) + + +def new_tile( + *, # keywords only + walkable: int, + transparent: int, + dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]], + light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]], +) -> np.ndarray: + """helper function for making tiles""" + return np.array((walkable, transparent, dark, light), dtype=tile_dt) + + +# SHROUD represents unexplored, unseen tiles +SHROUD = np.array((ord(" "), (255, 255, 255), (0, 0, 0)), dtype=graphic_dt) + +floor = new_tile( + walkable=True, + transparent=True, + dark=(ord(" "), (255, 255, 255), (50, 50, 150)), + light=(ord(" "), (255, 255, 255), (200, 180, 50)), +) + +wall = new_tile( + walkable=False, + transparent=False, + dark=(ord(" "), (255, 255, 255), (0, 0, 150)), + light=(ord(" "), (255, 255, 255), (130, 110, 50)), +)