from __future__ import annotations import os from typing import overload, Callable, Optional, Tuple, TYPE_CHECKING, Union import tcod.event import actions from actions import ( Action, BumpAction, PickupAction, WaitAction ) import color import exceptions if TYPE_CHECKING: from engine import Engine from entity import Item MOVE_KEYS = { # Arrow keys tcod.event.K_UP: (0, -1), tcod.event.K_DOWN: (0, 1), tcod.event.K_LEFT: (-1, 0), tcod.event.K_RIGHT: (1, 0), tcod.event.K_HOME: (-1, -1), tcod.event.K_END: (-1, 1), tcod.event.K_PAGEUP: (1, -1), tcod.event.K_PAGEDOWN: (1, 1), # Numpad keys tcod.event.K_KP_1: (-1, 1), tcod.event.K_KP_2: (0, 1), tcod.event.K_KP_3: (1, 1), tcod.event.K_KP_4: (-1, 0), tcod.event.K_KP_6: (1, 0), tcod.event.K_KP_7: (-1, -1), tcod.event.K_KP_8: (0, -1), tcod.event.K_KP_9: (1, -1), # Vi keys tcod.event.K_h: (-1, 0), tcod.event.K_j: (0, 1), tcod.event.K_k: (0, -1), tcod.event.K_l: (1, 0), tcod.event.K_y: (-1, -1), tcod.event.K_u: (1, -1), tcod.event.K_b: (-1, 1), tcod.event.K_n: (1, 1), } WAIT_KEYS = { tcod.event.K_PERIOD, tcod.event.K_KP_5, tcod.event.K_CLEAR, } CONFIRM_KEYS = { tcod.event.K_RETURN, tcod.event.K_KP_ENTER, } ActionOrHandler = Union[Action, "BaseEventHandler"] """ An event handler return value which can trigger an action or switch active handlers. 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 PopupMessage(BaseEventHandler): """Display a popup text window.""" def __init__(self, parent_handler: BaseEventHandler, text: str): self.parent = parent_handler self.text = text def on_render(self, console: tcod.Console) -> None: """Render the parent and dim the result, then print the message on top.""" self.parent.on_render(console) console.tiles_rgb["fg"] //= 8 console.tiles_rgb["bg"] //= 8 console.print( console.width // 2, console.height // 2, self.text, fg=color.white, bg=color.black, alignment=tcod.CENTER, ) def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]: """Any key returns to the parent handler.""" return self.parent class EventHandler(BaseEventHandler): def __init__(self, engine: Engine): self.engine = engine 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) elif self.engine.player.level.requires_level_up: return LevelUpEventHandler(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. 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): self.engine.mouse_location = event.tile.x, event.tile.y def on_render(self, console: tcod.Console) -> None: self.engine.render(console) class AskUserEventHandler(EventHandler): """Handles user input for actions which require special input.""" 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 { tcod.event.K_LSHIFT, tcod.event.K_RSHIFT, tcod.event.K_LCTRL, tcod.event.K_RCTRL, tcod.event.K_LALT, tcod.event.K_RALT, }: return None return self.on_exit() 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[ActionOrHandler]: """Called when the user is trying to exit or cancel an action. By default this returns to the main event handler. """ return MainGameEventHandler(self.engine) class LevelUpEventHandler(AskUserEventHandler): TITLE = "Level Up" def on_render(self, console: tcod.Console) -> None: super().on_render(console) if self.engine.player.x <= 30: x = 40 else: x = 0 console.draw_frame( x=x, y=0, width=35, height=8, title=self.TITLE, clear=True, fg=(255, 255, 255), bg=(0, 0, 0) ) console.print(x + 1, 1, "Congratulations! You level up!") console.print(x + 1, 2, "Select an attribut to increase.") console.print( x=x + 1, y=4, string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})" ) console.print( x=x + 1, y=5, string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})" ) console.print( x=x + 1, y=6, string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})" ) def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: player = self.engine.player key = event.sym index = key - tcod.event.K_a if 0 <= index <= 2: if index == 0: player.level.increase_max_hp() elif index == 1: player.level.increase_power() else: player.level.increase_defense() else: self.engine.message_log.add_message("Invalid entry.", color.invalid) return None return super().ev_keydown(event) def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]: """ Don't allow the player to click to exit the menu, like normal """ return None class InventoryEventHandler(AskUserEventHandler): """This handler lets the user select an item. What happens then depends on the subclass. """ TITLE = "" def on_render(self, console: tcod.Console) -> None: """Render an inventory menu, which displays the items in the inventory, and the letter to select them. Will move to a different position based on where the player is located, so the player can always see where they are. """ super().on_render(console) number_of_items_in_inventory = len(self.engine.player.inventory.items) height = number_of_items_in_inventory + 2 if height <= 3: height = 3 if self.engine.player.x <= 30: x = 40 else: x = 0 y = 0 width = len(self.TITLE) + 4 console.draw_frame( x, y, width, height, title=self.TITLE, clear=True, fg=(255, 255, 255), bg=(0, 0, 0), ) if number_of_items_in_inventory > 0: for i, item in enumerate(self.engine.player.inventory.items): item_key = chr(ord("a") + i) console.print(x + 1, y + i + 1, f"({item_key}) {item.name}") else: console.print(x + 1, y + 1, "(Empty)") def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]: player = self.engine.player key = event.sym index = key - tcod.event.K_a if 0 <= index <= 26: try: selected_item = player.inventory.items[index] except IndexError: self.engine.message_log.add_message("Invalid entry.", color.invalid) return None return self.on_item_selected(selected_item) return super().ev_keydown(event) @overload def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: """Called when the user selects a valid item.""" class InventoryActivateHandler(InventoryEventHandler): """Handle using an inventory item.""" TITLE = "Select an item to use" def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: """Return the action for the selected item.""" return item.consumable.get_action(self.engine.player) class InventoryDropHandler(InventoryEventHandler): """Handle dropping an inventory item.""" TITLE = "Select an item to drop" def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]: """Drop this item.""" return actions.DropItem(self.engine.player, item) class SelectIndexHandler(AskUserEventHandler): """Handles asking the user for an index on the map.""" def __init__(self, engine: Engine): """Sets the cursor to the player when this handler is constructed.""" super().__init__(engine) player = self.engine.player engine.mouse_location = player.x, player.y def on_render(self, console: tcod.Console) -> None: """Highlight the tile under the cursor.""" super().on_render(console) x, y = self.engine.mouse_location 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[ActionOrHandler]: """Check for key movement or confirmation keys.""" key = event.sym if key in MOVE_KEYS: modifier = 1 # Holding modifier keys will speed up key movement if event.mod & (tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT): modifier *= 5 if event.mod & (tcod.event.KMOD_LCTRL | tcod.event.KMOD_RCTRL): modifier *= 10 if event.mod & (tcod.event.KMOD_LALT | tcod.event.KMOD_RALT): modifier *= 20 x, y = self.engine.mouse_location dx, dy = MOVE_KEYS[key] x += dx * modifier y += dy * modifier # Clamp the cursor index to the map size. x = max(0, min(x, self.engine.game_map.width - 1)) y = max(0, min(y, self.engine.game_map.height - 1)) self.engine.mouse_location = x, y return None elif key in CONFIRM_KEYS: return self.on_index_selected(*self.engine.mouse_location) return super().ev_keydown(event) 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: return self.on_index_selected(*event.tile) return super().ev_mousebuttondown(event) @overload 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) -> MainGameEventHandler: """Return to main handler.""" return MainGameEventHandler(self.engine) class SingleRangedAttackHandler(SelectIndexHandler): """Handles targeting a single enemy. Only the enemy selected will be affected.""" def __init__( self, engine: Engine, callback: Callable[[Tuple[int, int]], Optional[ActionOrHandler]] ): super().__init__(engine) self.callback = callback def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]: return self.callback((x, y)) class AreaRangedAttackHandler(SelectIndexHandler): """ Handles targeting an area within a given radius. Any entity within the area will be affected. """ def __init__( self, engine: Engine, radius: int, callback: Callable[[Tuple[int, int]], Optional[ActionOrHandler]] ): super().__init__(engine) self.radius = radius self.callback = callback def on_render(self, console: tcod.Console) -> None: """Highlight the tile under the cursor.""" super().on_render(console) x, y = self.engine.mouse_location # Draw a rectangle around the targeted area, so the player can see the affected tiles. console.draw_frame( x=x - self.radius - 1, y=y - self.radius - 1, width=self.radius ** 2, height=self.radius ** 2, fg=color.red, clear=False, ) 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[ActionOrHandler]: action: Optional[Action] = None key = event.sym modifier = event.mod player = self.engine.player if key == tcod.event.K_PERIOD and modifier & ( tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT ): return actions.TakeStairsAction(player) if key in MOVE_KEYS: dx, dy = MOVE_KEYS[key] action = BumpAction(player, dx, dy) elif key in WAIT_KEYS: action = WaitAction(player) elif key == tcod.event.K_ESCAPE: raise SystemExit() elif key == tcod.event.K_v: return HistoryViewer(self.engine) elif key == tcod.event.K_g: action = PickupAction(player) elif key == tcod.event.K_i: return InventoryActivateHandler(self.engine) elif key == tcod.event.K_d: return InventoryDropHandler(self.engine) elif key == tcod.event.K_SLASH: return LookHandler(self.engine) # No valid key was pressed return action class GameOverEventHandler(EventHandler): def on_quit(self) -> None: """Handle exiting out of a finished game.""" if os.path.exists("savegame.sav"): os.remove("savegame.sav") # Deletes the active save file. raise exceptions.QuitWithoutSaving() # Avoid saving a finished game. def ev_quit(self, event: tcod.event.Quit) -> None: self.on_quit() def ev_keydown(self, event: tcod.event.KeyDown) -> None: if event.sym == tcod.event.K_ESCAPE: self.on_quit() CURSOR_Y_KEYS = { tcod.event.K_UP: -1, tcod.event.K_DOWN: 1, tcod.event.K_PAGEUP: -10, tcod.event.K_PAGEDOWN: 10, } class HistoryViewer(EventHandler): """Print the history on a larger window which can be navigated.""" def __init__(self, engine: Engine): super().__init__(engine) self.log_length = len(engine.message_log.messages) self.cursor = self.log_length - 1 def on_render(self, console: tcod.Console) -> None: super().on_render(console) # Draw the main state as the background. log_console = tcod.Console(console.width - 6, console.height - 6) # Draw a frame with a custom banner title. log_console.draw_frame(0, 0, log_console.width, log_console.height) log_console.print_box( 0, 0, log_console.width, 1, "┤Message history├", alignment=tcod.CENTER ) # Render the message log using the cursor parameter. self.engine.message_log.render_messages( log_console, 1, 1, log_console.width - 2, log_console.height - 2, self.engine.message_log.messages[: self.cursor + 1], ) log_console.blit(console, 3, 3) 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] if adjust < 0 and self.cursor == 0: # Only move from the top to the bottom when you're on the edge. self.cursor = self.log_length - 1 elif adjust > 0 and self.cursor == self.log_length - 1: # Same with bottom to top movement. self.cursor = 0 else: # Otherwise move while staying clamped to the bounds of the history log. self.cursor = max(0, min(self.cursor + adjust, self.log_length - 1)) elif event.sym == tcod.event.K_HOME: self.cursor = 0 # Move directly to the top message. 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. return MainGameEventHandler(self.engine) return None