We need a hero!

This commit is contained in:
staubsauger 2024-08-17 13:23:21 +02:00
commit b04ebc5bef
12 changed files with 332 additions and 260 deletions

View file

@ -1 +1,3 @@
# python-tcod-tutorial-2023 # pyrogue
A small roguelike written in Python using tcod.

5
g.py
View file

@ -6,7 +6,8 @@ import tcod.console
import tcod.context import tcod.context
import tcod.ecs import tcod.ecs
import game.state from game.screens import Screen
from game.components import Position
context: tcod.context.Context context: tcod.context.Context
"""The window managed by tcod.""" """The window managed by tcod."""
@ -14,7 +15,7 @@ context: tcod.context.Context
world: tcod.ecs.Registry world: tcod.ecs.Registry
"""The active ECS registry and current session.""" """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.""" """A stack of states with the last item being the active state."""
console: tcod.console.Console console: tcod.console.Console

99
game/screens/__init__.py Normal file
View file

@ -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

View file

@ -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))

View file

@ -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()

View file

@ -10,9 +10,8 @@ import tcod.console
import tcod.event import tcod.event
from tcod.event import KeySym from tcod.event import KeySym
import game.state_tools
from game.constants import DIRECTION_KEYS 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): class MenuItem(Protocol):
@ -20,7 +19,7 @@ class MenuItem(Protocol):
__slots__ = () __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.""" """Handle events passed to the menu item."""
def on_draw(self, console: tcod.console.Console, x: int, y: int, highlight: bool) -> None: 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.""" """Clickable menu item."""
label: str 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.""" """Handle events selecting this item."""
match event: match event:
case tcod.event.KeyDown(sym=sym) if sym in {KeySym.RETURN, KeySym.RETURN2, KeySym.KP_ENTER}: 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() @attrs.define()
class ListMenu(State): class ListMenu(Screen):
"""Simple list menu state.""" """Simple list menu state."""
items: tuple[MenuItem, ...] items: tuple[MenuItem, ...]
@ -58,7 +57,7 @@ class ListMenu(State):
x: int = 0 x: int = 0
y: 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.""" """Handle events for menus."""
match event: match event:
case tcod.event.Quit(): case tcod.event.Quit():
@ -84,18 +83,18 @@ class ListMenu(State):
case _: case _:
return self.activate_selected(event) 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.""" """Call the selected menu items callback."""
if self.selected is not None: if self.selected is not None:
return self.items[self.selected].on_event(event) return self.items[self.selected].on_event(event)
return None return None
def on_cancel(self) -> StateResult: def on_cancel(self) -> ScreenResult:
"""Handle escape or right click being pressed on menus.""" """Handle escape or right click being pressed on menus."""
return Pop() return Pop()
def on_draw(self, console: tcod.console.Console) -> None: def on_draw(self, console: tcod.console.Console) -> None:
"""Render the menu.""" """Render the menu."""
game.state_tools.draw_previous_state(self, console) draw_previous_screens(self, console)
for i, item in enumerate(self.items): for i, item in enumerate(self.items):
item.on_draw(console, x=self.x, y=self.y + i, highlight=i == self.selected) item.on_draw(console, x=self.x, y=self.y + i, highlight=i == self.selected)

View file

@ -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."""

View file

@ -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

View file

@ -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()

View file

@ -13,8 +13,5 @@ IsActor: Final = "IsActor"
IsItem: Final = "IsItem" IsItem: Final = "IsItem"
"""Entity is an item.""" """Entity is an item."""
IsWall: Final = "IsWall"
"""Entity is a wall."""
IsDoor: Final = "IsDoor" IsDoor: Final = "IsDoor"
"""Entiy is a door.""" """Entiy is a door."""

View file

@ -1,50 +1,88 @@
"""Functions for working with worlds.""" """Functions for working with worlds."""
from __future__ import annotations from __future__ import annotations
from random import Random from random import Random
from typing import Final
from tcod.ecs import Registry, Entity
from tcod.map import Map
import g import g
from tcod.ecs import Registry, Entity
from game.components import Gold, Graphic, Position 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: def new_world() -> Registry:
"""Return a freshly generated world.""" """Return a freshly generated world."""
world = Registry() world = Registry()
rng = world[None].components[Random] = Random()
player = world[object()] map = world[None].components[Map] = Map(100, 100)
player.components[Position] = Position(5, 5) map.walkable[:] = True
player.components[Graphic] = Graphic(ord("@")) map.transparent[:] = True
player.components[Gold] = 0
player.tags |= {IsPlayer, IsActor} 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): for _ in range(10):
gold = world[object()] world.new_entity(
gold.components[Position] = Position(rng.randint(0, 20), rng.randint(0, 20)) components={
gold.components[Graphic] = Graphic(ord("$"), fg=(255, 255, 0)) Position: Position(rng.randint(0, 20), rng.randint(0, 20)),
gold.components[Gold] = rng.randint(1, 10) Graphic: Graphic(ord("$"), fg=(255, 255, 0)),
gold.tags |= {IsItem} Gold: rng.randint(1, 10)
},
tags=[IsItem]
)
for i in range(20): for i in range(20):
if i == 5 or i == 9: if i == 5 or i == 9:
door = world[object()] add_door(world, Position(10,i))
door.components[Position] = Position(10, i) else:
door.components[Graphic] = Graphic(ord("\\")) add_wall(world, Position(10, i))
door.tags |= {IsDoor}
continue
wall = world[object()]
wall.components[Position] = Position(10, i)
wall.components[Graphic] = Graphic(ord("#"))
wall.tags |= {IsWall}
cam_pos = world_pos_to_map_pos(player_pos)
map.compute_fov(cam_pos.x, cam_pos.y)
return world return world
def unlock_door(door: Entity): def unlock_door(door: Entity):
door.components[Graphic] = Graphic(ord("_")) door.components[Graphic] = Graphic(ord("_"))
door.tags.discard(IsDoor) door.tags.clear()
add_wall(g.world, door.components[Position], remove=True)

12
main.py
View file

@ -8,8 +8,9 @@ import tcod.context
import tcod.tileset import tcod.tileset
import g import g
import game.state_tools
import game.states import game.screens
from game.screens.menu_screens import MainMenu
def main() -> None: def main() -> None:
"""Entry point function.""" """Entry point function."""
@ -23,9 +24,14 @@ def main() -> None:
tileset.set_tile(64, tileset2.get_tile(0)) tileset.set_tile(64, tileset2.get_tile(0))
tcod.tileset.procedural_block_elements(tileset=tileset) tcod.tileset.procedural_block_elements(tileset=tileset)
g.states = [game.states.MainMenu()] g.states = [game.states.MainMenu()]
#tcod.tileset.procedural_block_elements(tileset=tileset)
g.screens = [MainMenu()]
g.console = tcod.console.Console(80, 50) g.console = tcod.console.Console(80, 50)
with tcod.context.new(console=g.console, tileset=tileset) as g.context: with tcod.context.new(console=g.console, tileset=tileset) as g.context:
game.state_tools.main_loop() game.screens.main_loop()
if __name__ == "__main__": if __name__ == "__main__":