From e82d2e5b496e98f6794012809b24a2aef24e92f0 Mon Sep 17 00:00:00 2001 From: Timothy Warren Date: Tue, 18 Jan 2022 14:04:05 -0500 Subject: [PATCH] Refactor event handling --- color.py | 3 ++ components/consumable.py | 22 ++++---- components/fighter.py | 2 - engine.py | 3 -- exceptions.py | 6 ++- input_handlers.py | 109 ++++++++++++++++++++++++--------------- main.py | 38 +++++++++----- 7 files changed, 113 insertions(+), 70 deletions(-) diff --git a/color.py b/color.py index 4dc7474..d7d0b5c 100644 --- a/color.py +++ b/color.py @@ -20,3 +20,6 @@ health_recovered = (0x0, 0xFF, 0x0) bar_text = white bar_filled = (0x0, 0x60, 0x0) bar_empty = (0x40, 0x10, 0x10) + +menu_title = (255, 255, 63) +menu_text = white diff --git a/components/consumable.py b/components/consumable.py index b8712ab..a7ab149 100644 --- a/components/consumable.py +++ b/components/consumable.py @@ -8,7 +8,11 @@ import components.ai import components.inventory from components.base_component import BaseComponent from exceptions import Impossible -from input_handlers import AreaRangedAttackHandler, SingleRangedAttackHandler +from input_handlers import ( + ActionOrHandler, + AreaRangedAttackHandler, + SingleRangedAttackHandler +) if TYPE_CHECKING: from entity import Actor, Item @@ -17,7 +21,7 @@ if TYPE_CHECKING: class Consumable(BaseComponent): parent: Item - def get_action(self, consumer: Actor) -> Optional[actions.Action]: + def get_action(self, consumer: Actor) -> Optional[ActionOrHandler]: """Try to return the action for this item.""" return actions.ItemAction(consumer, self.parent) @@ -40,18 +44,17 @@ class ConfusionConsumable(Consumable): def __init__(self, number_of_turns: int): self.number_of_turns = number_of_turns - def get_action(self, consumer: Actor) -> Optional[actions.Action]: + def get_action(self, consumer: Actor) -> SingleRangedAttackHandler: self.engine.message_log.add_message( "Select a target location.", color.needs_target ) - self.engine.event_handler = SingleRangedAttackHandler( + + return SingleRangedAttackHandler( self.engine, callback=lambda xy: actions.ItemAction(consumer, self.parent, xy), ) - return None - def activate(self, action: actions.ItemAction) -> None: consumer = action.entity target = action.target_actor @@ -99,19 +102,18 @@ class FireballDamageConsumable(Consumable): self.damage = damage self.radius = radius - def get_action(self, consumer: Actor) -> Optional[actions.Action]: + def get_action(self, consumer: Actor) -> AreaRangedAttackHandler: self.engine.message_log.add_message( "Select a target location.", color.needs_target ) - self.engine.event_handler = AreaRangedAttackHandler( + + return AreaRangedAttackHandler( self.engine, self.radius, lambda xy: actions.ItemAction(consumer, self.parent, xy), ) - return None - def activate(self, action: actions.ItemAction) -> None: target_xy = action.target_xy diff --git a/components/fighter.py b/components/fighter.py index 3728de8..79713ae 100644 --- a/components/fighter.py +++ b/components/fighter.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING import color from components.base_component import BaseComponent -from input_handlers import GameOverEventHandler from render_order import RenderOrder if TYPE_CHECKING: @@ -34,7 +33,6 @@ class Fighter(BaseComponent): if self.engine.player is self.parent: death_message = "You died!" death_message_color = color.player_die - self.engine.event_handler = GameOverEventHandler(self.engine) else: death_message = f"{self.parent.name} is dead!" death_message_color = color.enemy_die diff --git a/engine.py b/engine.py index adcc92f..deec79c 100644 --- a/engine.py +++ b/engine.py @@ -2,12 +2,10 @@ from __future__ import annotations from typing import TYPE_CHECKING -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 @@ -20,7 +18,6 @@ class Engine: game_map: GameMap def __init__(self, player: Actor): - self.event_handler: EventHandler = MainGameEventHandler(self) self.message_log = MessageLog() self.mouse_location = (0, 0) self.player = player diff --git a/exceptions.py b/exceptions.py index acff3a4..c73eeb6 100644 --- a/exceptions.py +++ b/exceptions.py @@ -2,4 +2,8 @@ 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 + """ + + +class QuitWithoutSaving(SystemExit): + """Can be raised to exit the game without automatically saving.""" \ No newline at end of file diff --git a/input_handlers.py b/input_handlers.py index d01f69c..1d17a2b 100644 --- a/input_handlers.py +++ b/input_handlers.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import overload, Callable, Optional, Tuple, TYPE_CHECKING +from typing import overload, Callable, Optional, Tuple, TYPE_CHECKING, Union import tcod.event @@ -61,13 +61,50 @@ CONFIRM_KEYS = { tcod.event.K_KP_ENTER, } +ActionOrHandler = Union[Action, "BaseEventHandler"] +""" +An event handler return value which can trigger an action or switch active handlers. -class EventHandler(tcod.event.EventDispatch[Action]): +If a handler is returned than it will become the active handler for future events. +if an action is returned it will be attempted and if it's valid then +MainGameEventHandler will become the active handler. +""" + + +class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]): + def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: + """Handle an event and return the next active event handler.""" + state = self.dispatch(event) + if isinstance(state, BaseEventHandler): + return state + assert not isinstance(state, Action), f"{self!r} can not handle actions." + + return self + + @overload + def on_render(self, console: tcod.Console) -> None: + """Must be implemented""" + + def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]: + raise SystemExit() + + +class EventHandler(BaseEventHandler): def __init__(self, engine: Engine): self.engine = engine - def handle_events(self, event: tcod.event.Event) -> None: - self.handle_action(self.dispatch(event)) + def handle_events(self, event: tcod.event.Event) -> BaseEventHandler: + """Handle events for input handlers with an engine.""" + action_or_state = self.dispatch(event) + if isinstance(action_or_state, BaseEventHandler): + return action_or_state + if self.handle_action(action_or_state): + # A valid action was performed. + if not self.engine.player.is_alive: + # The player was killed sometime during or after the action. + return GameOverEventHandler(self.engine) + return MainGameEventHandler(self.engine) # Return to the main handler. + return self def handle_action(self, action: Optional[Action]) -> bool: """Handle actions returned from event methods. @@ -93,9 +130,6 @@ class EventHandler(tcod.event.EventDispatch[Action]): if self.engine.game_map.in_bounds(event.tile.x, event.tile.y): self.engine.mouse_location = event.tile.x, event.tile.y - def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]: - raise SystemExit() - def on_render(self, console: tcod.Console) -> None: self.engine.render(console) @@ -103,16 +137,7 @@ class EventHandler(tcod.event.EventDispatch[Action]): class AskUserEventHandler(EventHandler): """Handles user input for actions which require special input.""" - def handle_action(self, action: Optional[Action]) -> bool: - """Return to the main event handler when a valid action was performed.""" - if super().handle_action(action): - self.engine.event_handler = MainGameEventHandler(self.engine) - - return True - - return False - - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: """By default any key exits this input handler.""" # Ignore modifier keys. if event.sym in { @@ -127,18 +152,16 @@ class AskUserEventHandler(EventHandler): return self.on_exit() - def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[Action]: + def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: """By default any mouse click exits this input handler.""" return self.on_exit() - def on_exit(self) -> Optional[Action]: + def on_exit(self) -> Optional[ActionOrHandler]: """Called when the user is trying to exit or cancel an action. By default this returns to the main event handler. """ - self.engine.event_handler = MainGameEventHandler(self.engine) - - return None + return MainGameEventHandler(self.engine) class InventoryEventHandler(AskUserEventHandler): @@ -189,7 +212,7 @@ class InventoryEventHandler(AskUserEventHandler): else: console.print(x + 1, y + 1, "(Empty)") - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: player = self.engine.player key = event.sym index = key - tcod.event.K_a @@ -206,7 +229,7 @@ class InventoryEventHandler(AskUserEventHandler): return super().ev_keydown(event) @overload - def on_item_selected(self, item: Item) -> Optional[Action]: + def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: """Called when the user selects a valid item.""" @@ -215,7 +238,7 @@ class InventoryActivateHandler(InventoryEventHandler): TITLE = "Select an item to use" - def on_item_selected(self, item: Item) -> Optional[Action]: + def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: """Return the action for the selected item.""" return item.consumable.get_action(self.engine.player) @@ -225,7 +248,7 @@ class InventoryDropHandler(InventoryEventHandler): TITLE = "Select an item to drop" - def on_item_selected(self, item: Item) -> Optional[Action]: + def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: """Drop this item.""" return actions.DropItem(self.engine.player, item) @@ -246,7 +269,7 @@ class SelectIndexHandler(AskUserEventHandler): console.tiles_rgb["bg"][x, y] = color.white console.tiles_rgb["fg"][x, y] = color.black - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: """Check for key movement or confirmation keys.""" key = event.sym if key in MOVE_KEYS: @@ -273,7 +296,7 @@ class SelectIndexHandler(AskUserEventHandler): return super().ev_keydown(event) - def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[Action]: + def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: """Left click confirms a selection.""" if self.engine.game_map.in_bounds(*event.tile): if event.button == 1: @@ -282,16 +305,16 @@ class SelectIndexHandler(AskUserEventHandler): return super().ev_mousebuttondown(event) @overload - def on_index_selected(self, x: int, y: int) -> Optional[Action]: + def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]: """Called when an index is selected.""" class LookHandler(SelectIndexHandler): """Lets the player look around using the keyboard.""" - def on_index_selected(self, x: int, y: int) -> None: + def on_index_selected(self, x: int, y: int) -> MainGameEventHandler: """Return to main handler.""" - self.engine.event_handler = MainGameEventHandler(self.engine) + return MainGameEventHandler(self.engine) class SingleRangedAttackHandler(SelectIndexHandler): @@ -300,13 +323,13 @@ class SingleRangedAttackHandler(SelectIndexHandler): def __init__( self, engine: Engine, - callback: Callable[[Tuple[int, int]], Optional[Action]] + callback: Callable[[Tuple[int, int]], Optional[ActionOrHandler]] ): super().__init__(engine) self.callback = callback - def on_index_selected(self, x: int, y: int) -> Optional[Action]: + def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]: return self.callback((x, y)) @@ -320,7 +343,7 @@ class AreaRangedAttackHandler(SelectIndexHandler): self, engine: Engine, radius: int, - callback: Callable[[Tuple[int, int]], Optional[Action]] + callback: Callable[[Tuple[int, int]], Optional[ActionOrHandler]] ): super().__init__(engine) @@ -343,12 +366,12 @@ class AreaRangedAttackHandler(SelectIndexHandler): clear=False, ) - def on_index_selected(self, x: int, y: int) -> Optional[Action]: + def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]: return self.callback((x, y)) class MainGameEventHandler(EventHandler): - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: action: Optional[Action] = None key = event.sym @@ -365,17 +388,17 @@ class MainGameEventHandler(EventHandler): raise SystemExit() elif key == tcod.event.K_v: - self.engine.event_handler = HistoryViewer(self.engine) + return HistoryViewer(self.engine) elif key == tcod.event.K_g: action = PickupAction(player) elif key == tcod.event.K_i: - self.engine.event_handler = InventoryActivateHandler(self.engine) + return InventoryActivateHandler(self.engine) elif key == tcod.event.K_d: - self.engine.event_handler = InventoryDropHandler(self.engine) + return InventoryDropHandler(self.engine) elif key == tcod.event.K_SLASH: - self.engine.event_handler = LookHandler(self.engine) + return LookHandler(self.engine) # No valid key was pressed return action @@ -430,7 +453,7 @@ class HistoryViewer(EventHandler): ) log_console.blit(console, 3, 3) - def ev_keydown(self, event: tcod.event.KeyDown) -> None: + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[MainGameEventHandler]: # Fancy conditional movement to make it feel right. if event.sym in CURSOR_Y_KEYS: adjust = CURSOR_Y_KEYS[event.sym] @@ -448,4 +471,6 @@ class HistoryViewer(EventHandler): elif event.sym == tcod.event.K_END: self.cursor = self.log_length - 1 # Move directly to the last message. else: # Any other key moves back to the main game state. - self.engine.event_handler = MainGameEventHandler(self.engine) + return MainGameEventHandler(self.engine) + + return None diff --git a/main.py b/main.py index 0d74b2e..238fe9f 100755 --- a/main.py +++ b/main.py @@ -7,6 +7,8 @@ import tcod import color from engine import Engine import entity_factories +import exceptions +import input_handlers from procgen import generate_dungeon @@ -52,6 +54,8 @@ def main() -> None: color.welcome_text ) + handler: input_handlers.BaseEventHandler = input_handlers.MainGameEventHandler(engine) + with tcod.context.new_terminal( screen_width, screen_height, @@ -60,19 +64,29 @@ def main() -> None: vsync=True, ) as context: root_console = tcod.Console(screen_width, screen_height, order="F") - while True: - root_console.clear() - engine.event_handler.on_render(console=root_console) - context.present(root_console) + try: + while True: + root_console.clear() + engine.event_handler.on_render(console=root_console) + context.present(root_console) - 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) + 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) + + except exceptions.QuitWithoutSaving: + raise + except SystemExit: # Save and quit. + # TODO: Add the save function here + raise + except BaseException: # Save on any other unexpected exception. + # TODO: Add the save function here + raise if __name__ == "__main__":