diff --git a/actions.py b/actions.py index 5e063f1..1522e56 100644 --- a/actions.py +++ b/actions.py @@ -3,10 +3,11 @@ from __future__ import annotations from typing import overload, Optional, Tuple, TYPE_CHECKING import color +import exceptions if TYPE_CHECKING: from engine import Engine - from entity import Actor, Entity + from entity import Actor, Entity, Item class Action: @@ -31,6 +32,29 @@ class Action: """ +class ItemAction(Action): + def __init__( + self, + entity: Actor, + item: Item, + target_xy: Optional[Tuple[int, int]] = None + ): + super().__init__(entity) + self.item = item + if not target_xy: + target_xy = entity.x, entity.y + self.target_xy = target_xy + + @property + def target_actor(self) -> Optional[Actor]: + """Return the actor at this action's destination.""" + return self.engine.game_map.get_actor_at_location(*self.target_xy) + + def perform(self) -> None: + """Invoke the item's ability, this action will be given to provide context.""" + self.item.consumable.activate(self) + + class EscapeAction(Action): def perform(self) -> None: raise SystemExit() @@ -68,7 +92,7 @@ class MeleeAction(ActionWithDirection): def perform(self) -> None: target = self.target_actor if not target: - return # No entity to attack. + raise exceptions.Impossible("Nothing to attack.") damage = self.entity.fighter.power - target.fighter.defense @@ -96,11 +120,14 @@ class MovementAction(ActionWithDirection): dest_x, dest_y = self.dest_xy if not self.engine.game_map.in_bounds(dest_x, dest_y): - return # Destination is out of bounds + # Destination is out of bounds + raise exceptions.Impossible("That way is blocked.") if not self.engine.game_map.tiles["walkable"][dest_x, dest_y]: - return # Destination is blocked by a tile. + # Destination is blocked by a tile. + raise exceptions.Impossible("That way is blocked.") if self.engine.game_map.get_blocking_entity_at_location(dest_x, dest_y): - return # Destination is blocked by an entity. + # Destination is blocked by an entity. + raise exceptions.Impossible("That way is blocked.") self.entity.move(self.dx, self.dy) diff --git a/color.py b/color.py index a31803d..44b4981 100644 --- a/color.py +++ b/color.py @@ -7,7 +7,12 @@ enemy_atk = (0xFF, 0xC0, 0xC0) player_die = (0xFF, 0x30, 0x30) enemy_die = (0xFF, 0xA0, 0x30) +invalid = (0xFF, 0xFF, 0x00) +impossible = (0x80, 0x80, 0x80) +error = (0xFF, 0x40, 0x40) + welcome_text = (0x20, 0xA0, 0xFF) +health_recovered = (0x0, 0xFF, 0x0) bar_text = white bar_filled = (0x0, 0x60, 0x0) diff --git a/components/consumable.py b/components/consumable.py new file mode 100644 index 0000000..c40cd13 --- /dev/null +++ b/components/consumable.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import actions +import color +from components.base_component import BaseComponent +from exceptions import Impossible + +if TYPE_CHECKING: + from entity import Actor, Item + + +class Consumable(BaseComponent): + parent: Item + + def get_action(self, consumer: Actor) -> Optional[actions.Action]: + """Try to return the action for this item.""" + return actions.ItemAction(consumer, self.parent) + + def activate(self, action: actions.ItemAction) -> None: + """Invoke this item's ability. + + `action` is the context for this activation. + """ + raise NotImplementedError() + + +class HealingConsumable(Consumable): + def __init__(self, amount: int): + self.amount = amount + + def activate(self, action: actions.ItemAction) -> None: + consumer = action.entity + amount_recovered = consumer.fighter.heal(self.amount) + + if amount_recovered > 0: + self.engine.message_log.add_message( + f"You consume the {self.parent.name}, and recover {amount_recovered} HP!", + color.health_recovered + ) + else: + raise Impossible(f"Your health is already full.") diff --git a/components/fighter.py b/components/fighter.py index 17785fe..3728de8 100644 --- a/components/fighter.py +++ b/components/fighter.py @@ -47,3 +47,21 @@ class Fighter(BaseComponent): self.parent.render_order = RenderOrder.CORPSE self.engine.message_log.add_message(death_message, death_message_color) + + def heal(self, amount: int) -> int: + if self.hp == self.max_hp: + return 0 + + new_hp_value = self.hp + amount + + if new_hp_value > self.max_hp: + new_hp_value = self.max_hp + + amount_recovered = new_hp_value - self.hp + + self.hp = new_hp_value + + return amount_recovered + + def take_damage(self, amount: int) -> None: + self.hp -= amount diff --git a/engine.py b/engine.py index fc390fc..adcc92f 100644 --- a/engine.py +++ b/engine.py @@ -6,6 +6,7 @@ from tcod.context import Context from tcod.console import Console from tcod.map import compute_fov +import exceptions from input_handlers import MainGameEventHandler from message_log import MessageLog from render_functions import render_bar, render_names_at_mouse_location @@ -27,7 +28,10 @@ class Engine: def handle_enemy_turns(self) -> None: for entity in set(self.game_map.actors) - {self.player}: if entity.ai: - entity.ai.perform() + try: + entity.ai.perform() + except exceptions.Impossible: + pass # Ignore impossible action exceptions from AI. def update_fov(self) -> None: """Recompute the visible area based on the player's point of view.""" diff --git a/entity.py b/entity.py index 6b8c823..9d1fdaa 100644 --- a/entity.py +++ b/entity.py @@ -7,6 +7,7 @@ from render_order import RenderOrder if TYPE_CHECKING: from components.ai import BaseAI + from components.consumable import Consumable from components.fighter import Fighter from game_map import GameMap @@ -105,3 +106,28 @@ class Actor(Entity): def is_alive(self) -> bool: """Returns True as long as this actor can perform actions.""" return bool(self.ai) + + +class Item(Entity): + def __init__( + self, + *, + x: int = 0, + y: int = 0, + char: str = "?", + color: Tuple[int, int, int] = (255, 255, 255), + name: str = "", + consumable: Consumable, + ): + super().__init__( + x=x, + y=y, + char=char, + color=color, + name=name, + blocks_movement=False, + render_order=RenderOrder.ITEM, + ) + + self.consumable = consumable + self.consumable.parent = self diff --git a/entity_factories.py b/entity_factories.py index bab7644..b172da4 100644 --- a/entity_factories.py +++ b/entity_factories.py @@ -1,6 +1,7 @@ from components.ai import HostileEnemy +from components.consumable import HealingConsumable from components.fighter import Fighter -from entity import Actor +from entity import Actor, Item player = Actor( char="@", @@ -24,3 +25,10 @@ troll = Actor( ai_cls=HostileEnemy, fighter=Fighter(hp=16, defense=1, power=4) ) + +health_potion = Item( + char="!", + color=(127, 0, 255), + name="Health Potion", + consumable=HealingConsumable(amount=4) +) diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000..acff3a4 --- /dev/null +++ b/exceptions.py @@ -0,0 +1,5 @@ +class Impossible(Exception): + """Exception raised when an action is impossible to be performed. + + The reason is given as the exception message. + """ \ No newline at end of file diff --git a/input_handlers.py b/input_handlers.py index bbb8b60..17859aa 100644 --- a/input_handlers.py +++ b/input_handlers.py @@ -4,7 +4,14 @@ from typing import overload, Optional, TYPE_CHECKING import tcod.event -from actions import Action, BumpAction, EscapeAction, WaitAction +from actions import ( + Action, + BumpAction, + EscapeAction, + WaitAction +) +import color +import exceptions if TYPE_CHECKING: from engine import Engine @@ -52,10 +59,28 @@ class EventHandler(tcod.event.EventDispatch[Action]): def __init__(self, engine: Engine): self.engine = engine - def handle_events(self, context: tcod.context.Context) -> None: - for event in tcod.event.wait(): - context.convert_event(event) - self.dispatch(event) + def handle_events(self, event: tcod.event.Event) -> None: + self.handle_action(self.dispatch(event)) + + def handle_action(self, action: Optional[Action]) -> bool: + """Handle actions returned from event methods. + + Returns True if the action will advance a turn. + """ + if action is None: + return False + + try: + action.perform() + except exceptions.Impossible as exc: + self.engine.message_log.add_message(exc.args[0], color.impossible) + return False # Skip enemy turn on exceptions. + + self.engine.handle_enemy_turns() + + self.engine.update_fov() + + return True def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: if self.engine.game_map.in_bounds(event.tile.x, event.tile.y): @@ -69,20 +94,6 @@ class EventHandler(tcod.event.EventDispatch[Action]): class MainGameEventHandler(EventHandler): - def handle_events(self, context: tcod.context.Context) -> None: - for event in tcod.event.wait(): - context.convert_event(event) - - action = self.dispatch(event) - - if action is None: - continue - - action.perform() - - self.engine.handle_enemy_turns() - self.engine.update_fov() - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: action: Optional[Action] = None @@ -106,25 +117,9 @@ class MainGameEventHandler(EventHandler): class GameOverEventHandler(EventHandler): - def handle_events(self, context: tcod.context.Context) -> None: - for event in tcod.event.wait(): - action = self.dispatch(event) - - if action is None: - continue - - action.perform() - - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: - action: Optional[Action] = None - - key = event.sym - - if key == tcod.event.K_ESCAPE: - action = EscapeAction(self.engine.player) - - # No valid key was pressed - return action + def ev_keydown(self, event: tcod.event.KeyDown) -> None: + if event.sym == tcod.event.K_ESCAPE: + raise SystemExit() CURSOR_Y_KEYS = { diff --git a/main.py b/main.py index 45e1108..0d74b2e 100755 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import copy +import traceback import tcod @@ -21,6 +22,7 @@ def main() -> None: max_rooms = 30 max_monsters_per_room = 2 + max_items_per_room = 2 tileset = tcod.tileset.load_tilesheet( "dejavu10x10_gs_tc.png", @@ -40,6 +42,7 @@ def main() -> None: map_width, map_height, max_monsters_per_room, + max_items_per_room, engine, ) engine.update_fov() @@ -62,7 +65,14 @@ def main() -> None: engine.event_handler.on_render(console=root_console) context.present(root_console) - engine.event_handler.handle_events(context) + try: + for event in tcod.event.wait(): + context.convert_event(event) + engine.event_handler.handle_events(event) + except Exception: # Handle exceptions in game. + traceback.print_exc() # Print error to stderr. + # Then print the error to the message log. + engine.message_log.add_message(traceback.format_exc(), color.error) if __name__ == "__main__": diff --git a/procgen.py b/procgen.py index 07dbc06..ffeddb2 100644 --- a/procgen.py +++ b/procgen.py @@ -46,8 +46,10 @@ def place_entities( room: RectangularRoom, dungeon: GameMap, maximum_monsters: int, + maximum_items: int, ) -> None: number_of_monsters = random.randint(0, maximum_monsters) + number_of_items = random.randint(0, maximum_items) for i in range(number_of_monsters): x = random.randint(room.x1 + 1, room.x2 - 1) @@ -59,6 +61,13 @@ def place_entities( else: entity_factories.troll.spawn(dungeon, x, y) + for i in range(number_of_items): + x = random.randint(room.x1 + 1, room.x2 - 1) + y = random.randint(room.y1 + 1, room.y2 - 1) + + if not any(entity.x == x and entity.y == y for entity in dungeon.entities): + entity_factories.health_potion.spawn(dungeon, x, y) + def tunnel_between(start: Tuple[int, int], end: Tuple[int, int]) -> Iterator[Tuple[int, int]]: """Return an L-shaped tunnel between these two points.""" @@ -86,6 +95,7 @@ def generate_dungeon( map_width: int, map_height: int, max_monsters_per_room: int, + max_items_per_room: int, engine: Engine, ) -> GameMap: """Generate a new dungeon map.""" @@ -120,7 +130,7 @@ def generate_dungeon( for x, y in tunnel_between(rooms[-1].center, new_room.center): dungeon.tiles[x, y] = tile_types.floor - place_entities(new_room, dungeon, max_monsters_per_room) + place_entities(new_room, dungeon, max_monsters_per_room, max_items_per_room) # Finally, append the new room to the list. rooms.append(new_room)