diff --git a/README.md b/README.md index 2339b4e..2e6c695 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# python-tcod-tutorial-2023 +# pyrogue + +A small roguelike written in Python using tcod. diff --git a/g.py b/g.py index 66440ab..64d1041 100644 --- a/g.py +++ b/g.py @@ -6,7 +6,8 @@ import tcod.console import tcod.context import tcod.ecs -import game.state +from game.screens import Screen +from game.components import Position context: tcod.context.Context """The window managed by tcod.""" @@ -14,7 +15,7 @@ context: tcod.context.Context world: tcod.ecs.Registry """The active ECS registry and current session.""" -states: list[game.state.State] = [] +screens: list[Screen] = [] """A stack of states with the last item being the active state.""" console: tcod.console.Console diff --git a/game/screens/__init__.py b/game/screens/__init__.py new file mode 100644 index 0000000..f8da103 --- /dev/null +++ b/game/screens/__init__.py @@ -0,0 +1,99 @@ +"""Package for game screens.""" + +from __future__ import annotations + +from typing import Protocol, TypeAlias + +import attrs +import tcod.console +import tcod.event + +import g + + +class Screen(Protocol): + """An abstract game screen.""" + + __slots__ = () + + def on_event(self, event: tcod.event.Event) -> ScreenResult: + """Called on events.""" + + def on_draw(self, console: tcod.console.Console) -> None: + """Called when the screen is being drawn.""" + + +@attrs.define() +class Push: + """Push a new screen on top of the stack.""" + + screen: Screen + + +@attrs.define() +class Pop: + """Remove the current screen from the stack.""" + + +@attrs.define() +class Reset: + """Replace the entire stack with a new screen.""" + + screen: Screen + + +ScreenResult: TypeAlias = "Push | Pop | Reset | None" +"""Union of screen results.""" + + +def main_draw() -> None: + """Render and present the active screen.""" + if not g.screens: + return + g.console.clear() + g.screens[-1].on_draw(g.console) + g.context.present(g.console) + + +def apply_screen_result(result: ScreenResult) -> None: + """Apply a ScreenResult to `g.screens`.""" + match result: + case Push(screen=screen): + g.screens.append(screen) + case Pop(): + g.screens.pop() + case Reset(screen=screen): + while g.screens: + apply_screen_result(Pop()) + apply_screen_result(Push(screen)) + case None: + pass + case _: + raise TypeError(result) + + +def main_loop() -> None: + """Run the active screen forever.""" + while g.screens: + main_draw() + for event in tcod.event.wait(): + tile_event = g.context.convert_event(event) + if g.screens: + apply_screen_result(g.screens[-1].on_event(tile_event)) + + +def get_previous_screen(screen: Screen) -> Screen | None: + """Return the screen before `screen` in the stack if it exists.""" + current_index = next(index for index, value in enumerate(g.screens) if value is screen) + return g.screens[current_index - 1] if current_index > 0 else None + + +def draw_previous_screens(screen: Screen, console: tcod.console.Console, dim: bool = True) -> None: + """Draw previous screens, optionally dimming all but the active screen.""" + prev_screen = get_previous_screen(screen) + if prev_screen is None: + return + prev_screen.on_draw(console) + if dim and screen is g.screens[-1]: + console.rgb["fg"] //= 4 + console.rgb["bg"] //= 4 diff --git a/game/screens/game_screens.py b/game/screens/game_screens.py new file mode 100644 index 0000000..238dc6f --- /dev/null +++ b/game/screens/game_screens.py @@ -0,0 +1,101 @@ +"""All states the are used in-game""" + +from __future__ import annotations + +import attrs +import tcod.console +import tcod.constants +import tcod.event +from tcod.event import KeySym +from tcod.map import Map + +import g +from game.components import Gold, Graphic, Position +from game.constants import DIRECTION_KEYS, ACTION_KEYS +from game.screens import Push, Screen, ScreenResult +from game.tags import IsItem, IsPlayer, IsDoor, IsActor +from game.constants import WALL_CHAR +from game.screens import menu_screens +from game.world_tools import world_pos_to_map_pos, map_pos_to_world_pos + +@attrs.define() +class MainScreen(Screen): + """Primary in-game state.""" + + def on_event(self, event: tcod.event.Event) -> ScreenResult: + """Handle events for the in-game state.""" + (player,) = g.world.Q.all_of(tags=[IsPlayer]) + map: Map = g.world[None].components[Map] + match event: + case tcod.event.KeyDown(sym=sym) if sym in ACTION_KEYS: + for door in g.world.Q.all_of(tags=[IsDoor]): + player_pos = player.components[Position] + if (player_pos - door.components[Position]).length() < 2: + ACTION_KEYS[sym](door) + cam_map = world_pos_to_map_pos(player_pos) + map.compute_fov(cam_map.x, cam_map.y, 100) + + case tcod.event.Quit(): + raise SystemExit() + case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS: + new_pos = player.components[Position] + DIRECTION_KEYS[sym] + map_pos = world_pos_to_map_pos(new_pos) + if not map.walkable[map_pos.y][map_pos.x]: + return None + player.components[Position] = new_pos + cam_map = world_pos_to_map_pos(new_pos) + map.compute_fov(cam_map.x, cam_map.y, 100) + + # Auto pickup gold + for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]): + player.components[Gold] += gold.components[Gold] + text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g" + g.world[None].components[("Text", str)] = text + gold.clear() + return None + case tcod.event.KeyDown(sym=KeySym.ESCAPE): + return Push(menu_screens.MainMenu()) + case _: + return None + + def on_draw(self, console: tcod.console.Console) -> None: + """Draw the standard screen.""" + centers = [a.components[Position] for a in g.world.Q.all_of(tags=[IsPlayer])] + camera_pos = sum(centers, start=Position(0,0)) + camera_pos = camera_pos.mod(len(centers)) + h = console.height//2 + w = console.width//2 + + map: Map = g.world[None].components[Map] + # TODO: eigentlich wäre andersrum rendern schöner + # also nicht alle objekte zu rendern und dabei rauszufinden ob sie auf dem screen sind, + # sondern über alle screen zellen laufen, über prüfen ob es im fov ist und dann gucken ob es dort ein objekt + # gibt und es entsprechend rendern + def draw(e_pos, e_graph): + screen_pos = e_pos - camera_pos + map_pos = world_pos_to_map_pos(e_pos) + if (-w <= screen_pos.x < w\ + and -h <= screen_pos.y < h): + if map.fov[map_pos.y][map_pos.x]: + graphic = e_graph + else: + graphic = Graphic(0x2591, (50, 50, 50)) + if graphic.ch != 0: + console.rgb[["ch", "fg"]][screen_pos.y + h, screen_pos.x + w] = graphic.ch, graphic.fg + + # Draw walls + for (y, row) in enumerate(map.walkable): + for (x, val) in enumerate(row): + pos = map_pos_to_world_pos(Position(x,y)) + draw(pos, Graphic(0) if val else Graphic(WALL_CHAR)) + # draw all entities that are not actors + for entity in g.world.Q.all_of(components=[Position, Graphic]).none_of(tags=[IsActor]): + draw(entity.components[Position], entity.components[Graphic]) + # draw all actors + for actor in g.world.Q.all_of(components=[Position, Graphic], tags=[IsActor]).none_of(tags=[IsPlayer]): + draw(actor.components[Position], entity.components[Graphic]) + # draw the player + for player in g.world.Q.all_of(tags=[IsPlayer]): + draw(player.components[Position], player.components[Graphic]) + if text := g.world[None].components.get(("Text", str)): + console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0)) diff --git a/game/screens/menu_screens.py b/game/screens/menu_screens.py new file mode 100644 index 0000000..bfa4346 --- /dev/null +++ b/game/screens/menu_screens.py @@ -0,0 +1,45 @@ +"""The main menu state""" + +from __future__ import annotations + +import g +import game.screens.menus +import game.world_tools +from game.screens import Reset, ScreenResult +from game.screens.game_screens import MainScreen + +class MainMenu(game.screens.menus.ListMenu): + """Main/escape menu.""" + __slots__ = () + + def __init__(self) -> None: + """Initialize the main menu.""" + items = [ + game.screens.menus.SelectItem("New game", self.new_game), + game.screens.menus.SelectItem("Quit", self.quit), + ] + if hasattr(g, "world"): + items.insert(0, game.screens.menus.SelectItem("Continue", self.continue_)) + + super().__init__( + items=tuple(items), + selected=0, + x=5, + y=5, + ) + + @staticmethod + def continue_() -> ScreenResult: + """Return to the game.""" + return Reset(MainScreen()) + + @staticmethod + def new_game() -> ScreenResult: + """Begin a new game.""" + g.world = game.world_tools.new_world() + return Reset(MainScreen()) + + @staticmethod + def quit() -> ScreenResult: + """Close the program.""" + raise SystemExit() diff --git a/game/menus.py b/game/screens/menus.py similarity index 85% rename from game/menus.py rename to game/screens/menus.py index 7c1bb37..7927cc5 100644 --- a/game/menus.py +++ b/game/screens/menus.py @@ -10,9 +10,8 @@ import tcod.console import tcod.event from tcod.event import KeySym -import game.state_tools from game.constants import DIRECTION_KEYS -from game.state import Pop, State, StateResult +from game.screens import Pop, Screen, ScreenResult, draw_previous_screens class MenuItem(Protocol): @@ -20,7 +19,7 @@ class MenuItem(Protocol): __slots__ = () - def on_event(self, event: tcod.event.Event) -> StateResult: + def on_event(self, event: tcod.event.Event) -> ScreenResult: """Handle events passed to the menu item.""" def on_draw(self, console: tcod.console.Console, x: int, y: int, highlight: bool) -> None: @@ -32,9 +31,9 @@ class SelectItem(MenuItem): """Clickable menu item.""" label: str - callback: Callable[[], StateResult] + callback: Callable[[], ScreenResult] - def on_event(self, event: tcod.event.Event) -> StateResult: + def on_event(self, event: tcod.event.Event) -> ScreenResult: """Handle events selecting this item.""" match event: case tcod.event.KeyDown(sym=sym) if sym in {KeySym.RETURN, KeySym.RETURN2, KeySym.KP_ENTER}: @@ -50,7 +49,7 @@ class SelectItem(MenuItem): @attrs.define() -class ListMenu(State): +class ListMenu(Screen): """Simple list menu state.""" items: tuple[MenuItem, ...] @@ -58,7 +57,7 @@ class ListMenu(State): x: int = 0 y: int = 0 - def on_event(self, event: tcod.event.Event) -> StateResult: + def on_event(self, event: tcod.event.Event) -> ScreenResult: """Handle events for menus.""" match event: case tcod.event.Quit(): @@ -84,18 +83,18 @@ class ListMenu(State): case _: return self.activate_selected(event) - def activate_selected(self, event: tcod.event.Event) -> StateResult: + def activate_selected(self, event: tcod.event.Event) -> ScreenResult: """Call the selected menu items callback.""" if self.selected is not None: return self.items[self.selected].on_event(event) return None - def on_cancel(self) -> StateResult: + def on_cancel(self) -> ScreenResult: """Handle escape or right click being pressed on menus.""" return Pop() def on_draw(self, console: tcod.console.Console) -> None: """Render the menu.""" - game.state_tools.draw_previous_state(self, console) + draw_previous_screens(self, console) for i, item in enumerate(self.items): item.on_draw(console, x=self.x, y=self.y + i, highlight=i == self.selected) diff --git a/game/state.py b/game/state.py deleted file mode 100644 index 4b935c4..0000000 --- a/game/state.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Base classes for states.""" - -from __future__ import annotations - -from typing import Protocol, TypeAlias - -import attrs -import tcod.console -import tcod.event - - -class State(Protocol): - """An abstract game state.""" - - __slots__ = () - - def on_event(self, event: tcod.event.Event) -> StateResult: - """Called on events.""" - - def on_draw(self, console: tcod.console.Console) -> None: - """Called when the state is being drawn.""" - - -@attrs.define() -class Push: - """Push a new state on top of the stack.""" - - state: State - - -@attrs.define() -class Pop: - """Remove the current state from the stack.""" - - -@attrs.define() -class Reset: - """Replace the entire stack with a new state.""" - - state: State - - -StateResult: TypeAlias = "Push | Pop | Reset | None" -"""Union of state results.""" diff --git a/game/state_tools.py b/game/state_tools.py deleted file mode 100644 index 77d2e22..0000000 --- a/game/state_tools.py +++ /dev/null @@ -1,61 +0,0 @@ -"""State handling functions.""" - -from __future__ import annotations - -import tcod.console - -import g -from game.state import Pop, Push, Reset, State, StateResult - - -def main_draw() -> None: - """Render and present the active state.""" - if not g.states: - return - g.console.clear() - g.states[-1].on_draw(g.console) - g.context.present(g.console) - - -def apply_state_result(result: StateResult) -> None: - """Apply a StateResult to `g.states`.""" - match result: - case Push(state=state): - g.states.append(state) - case Pop(): - g.states.pop() - case Reset(state=state): - while g.states: - apply_state_result(Pop()) - apply_state_result(Push(state)) - case None: - pass - case _: - raise TypeError(result) - - -def main_loop() -> None: - """Run the active state forever.""" - while g.states: - main_draw() - for event in tcod.event.wait(): - tile_event = g.context.convert_event(event) - if g.states: - apply_state_result(g.states[-1].on_event(tile_event)) - - -def get_previous_state(state: State) -> State | None: - """Return the state before `state` in the stack if it exists.""" - current_index = next(index for index, value in enumerate(g.states) if value is state) - return g.states[current_index - 1] if current_index > 0 else None - - -def draw_previous_state(state: State, console: tcod.console.Console, dim: bool = True) -> None: - """Draw previous states, optionally dimming all but the active state.""" - prev_state = get_previous_state(state) - if prev_state is None: - return - prev_state.on_draw(console) - if dim and state is g.states[-1]: - console.rgb["fg"] //= 4 - console.rgb["bg"] //= 4 diff --git a/game/states.py b/game/states.py deleted file mode 100644 index 107d464..0000000 --- a/game/states.py +++ /dev/null @@ -1,111 +0,0 @@ -"""A collection of game states.""" - -from __future__ import annotations -from functools import reduce - -import attrs -import tcod.console -import tcod.constants -import tcod.event -from tcod.event import KeySym - -import g -import game.menus -import game.world_tools -from game.components import Gold, Graphic, Position -from game.constants import DIRECTION_KEYS, ACTION_KEYS -from game.state import Push, Reset, State, StateResult -from game.tags import IsItem, IsPlayer, IsWall, IsDoor, IsActor - - -@attrs.define() -class InGame(State): - """Primary in-game state.""" - - def on_event(self, event: tcod.event.Event) -> StateResult: - """Handle events for the in-game state.""" - (player,) = g.world.Q.all_of(tags=[IsPlayer]) - match event: - case tcod.event.KeyDown(sym=sym) if sym in ACTION_KEYS: - for door in g.world.Q.all_of(tags=[IsDoor]): - if (player.components[Position] - door.components[Position]).length() < 2: - ACTION_KEYS[sym](door) - case tcod.event.Quit(): - raise SystemExit() - case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS: - new_pos = player.components[Position] + DIRECTION_KEYS[sym] - if g.world.Q.all_of(tags=[new_pos, IsWall]) or g.world.Q.all_of(tags=[new_pos, IsDoor]): - return None - player.components[Position] = new_pos - # Auto pickup gold - for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]): - player.components[Gold] += gold.components[Gold] - text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g" - g.world[None].components[("Text", str)] = text - gold.clear() - return None - case tcod.event.KeyDown(sym=KeySym.ESCAPE): - return Push(MainMenu()) - case _: - return None - - def on_draw(self, console: tcod.console.Console) -> None: - """Draw the standard screen.""" - centers = g.world.Q.all_of(tags=[IsPlayer]) - centers = [a.components[Position] for a in centers] - center = sum(centers, start=Position(0,0)) - center = center.mod(len(centers)) - - def draw(e): - pos = e.components[Position] - center - if (-console.width//2 <= pos.x < console.width//2\ - and -console.height//2 <= pos.y < console.height//2): - graphic = e.components[Graphic] - console.rgb[["ch", "fg"]][pos.y + console.height//2, pos.x + console.width//2] = graphic.ch, graphic.fg - - for entity in g.world.Q.all_of(components=[Position, Graphic]).none_of(tags=[IsActor]): - draw(entity) - for actor in g.world.Q.all_of(components=[Position, Graphic], tags=[IsActor]).none_of(tags=[IsPlayer]): - draw(actor) - for player in g.world.Q.all_of(tags=[IsPlayer]): - draw(player) - if text := g.world[None].components.get(("Text", str)): - console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0)) - - -class MainMenu(game.menus.ListMenu): - """Main/escape menu.""" - - __slots__ = () - - def __init__(self) -> None: - """Initialize the main menu.""" - items = [ - game.menus.SelectItem("New game", self.new_game), - game.menus.SelectItem("Quit", self.quit), - ] - if hasattr(g, "world"): - items.insert(0, game.menus.SelectItem("Continue", self.continue_)) - - super().__init__( - items=tuple(items), - selected=0, - x=5, - y=5, - ) - - @staticmethod - def continue_() -> StateResult: - """Return to the game.""" - return Reset(InGame()) - - @staticmethod - def new_game() -> StateResult: - """Begin a new game.""" - g.world = game.world_tools.new_world() - return Reset(InGame()) - - @staticmethod - def quit() -> StateResult: - """Close the program.""" - raise SystemExit() diff --git a/game/tags.py b/game/tags.py index 954fb52..bfbbe7c 100644 --- a/game/tags.py +++ b/game/tags.py @@ -13,8 +13,5 @@ IsActor: Final = "IsActor" IsItem: Final = "IsItem" """Entity is an item.""" -IsWall: Final = "IsWall" -"""Entity is a wall.""" - IsDoor: Final = "IsDoor" """Entiy is a door.""" diff --git a/game/world_tools.py b/game/world_tools.py index 7ef2605..7e0e844 100644 --- a/game/world_tools.py +++ b/game/world_tools.py @@ -1,50 +1,88 @@ """Functions for working with worlds.""" from __future__ import annotations - from random import Random +from typing import Final + +from tcod.ecs import Registry, Entity +from tcod.map import Map import g -from tcod.ecs import Registry, Entity - from game.components import Gold, Graphic, Position -from game.tags import IsActor, IsItem, IsPlayer, IsWall, IsDoor +from game.tags import IsActor, IsItem, IsPlayer, IsDoor + +world_center: Final = Position(50, 50) + +def world_pos_to_map_pos(pos): + return pos+world_center + +def map_pos_to_world_pos(pos): + return pos-world_center + +def add_wall(world, pos, remove=False): + r_pos = world_pos_to_map_pos(pos) + map = world[None].components[Map] + map.walkable[r_pos.y][r_pos.x] = remove + map.transparent[r_pos.y][r_pos.x] = remove + +def add_door(world, pos): + world.new_entity( + components={ + Position: pos, + Graphic: Graphic(ord('\\')) + }, + tags=[IsDoor] + ) + add_wall(world, pos) def new_world() -> Registry: """Return a freshly generated world.""" world = Registry() - rng = world[None].components[Random] = Random() - player = world[object()] - player.components[Position] = Position(5, 5) - player.components[Graphic] = Graphic(ord("@")) - player.components[Gold] = 0 - player.tags |= {IsPlayer, IsActor} + map = world[None].components[Map] = Map(100, 100) + map.walkable[:] = True + map.transparent[:] = True + + rng = world[None].components[Random] = Random() + player_pos = Position(5,5) + world.new_entity( + components={ + Position: player_pos, + Graphic: Graphic(ord('@')), + Gold: 0 + }, + tags=[IsActor, IsPlayer] + ) + # player = world[object()] # <- das hier ist das gleiche wie das world.new_entity darüber + # player.components[Position] = Position(5, 5) + # player.components[Graphic] = Graphic(ord("@")) + # player.components[Gold] = 0 + # player.tags |= {IsPlayer, IsActor} for _ in range(10): - gold = world[object()] - gold.components[Position] = Position(rng.randint(0, 20), rng.randint(0, 20)) - gold.components[Graphic] = Graphic(ord("$"), fg=(255, 255, 0)) - gold.components[Gold] = rng.randint(1, 10) - gold.tags |= {IsItem} + world.new_entity( + components={ + Position: Position(rng.randint(0, 20), rng.randint(0, 20)), + Graphic: Graphic(ord("$"), fg=(255, 255, 0)), + Gold: rng.randint(1, 10) + }, + tags=[IsItem] + ) for i in range(20): if i == 5 or i == 9: - door = world[object()] - door.components[Position] = Position(10, i) - door.components[Graphic] = Graphic(ord("\\")) - door.tags |= {IsDoor} - continue - wall = world[object()] - wall.components[Position] = Position(10, i) - wall.components[Graphic] = Graphic(ord("#")) - wall.tags |= {IsWall} + add_door(world, Position(10,i)) + else: + add_wall(world, Position(10, i)) + cam_pos = world_pos_to_map_pos(player_pos) + map.compute_fov(cam_pos.x, cam_pos.y) return world def unlock_door(door: Entity): door.components[Graphic] = Graphic(ord("_")) - door.tags.discard(IsDoor) + door.tags.clear() + add_wall(g.world, door.components[Position], remove=True) diff --git a/main.py b/main.py index 04af335..9bf707a 100755 --- a/main.py +++ b/main.py @@ -8,8 +8,9 @@ import tcod.context import tcod.tileset import g -import game.state_tools -import game.states + +import game.screens +from game.screens.menu_screens import MainMenu def main() -> None: """Entry point function.""" @@ -23,9 +24,14 @@ def main() -> None: tileset.set_tile(64, tileset2.get_tile(0)) tcod.tileset.procedural_block_elements(tileset=tileset) g.states = [game.states.MainMenu()] + + #tcod.tileset.procedural_block_elements(tileset=tileset) + + g.screens = [MainMenu()] + g.console = tcod.console.Console(80, 50) with tcod.context.new(console=g.console, tileset=tileset) as g.context: - game.state_tools.main_loop() + game.screens.main_loop() if __name__ == "__main__":