1
0

Refactor event handling

This commit is contained in:
Timothy Warren 2022-01-18 14:04:05 -05:00
parent a1e6125c34
commit e82d2e5b49
7 changed files with 113 additions and 70 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -3,3 +3,7 @@ class Impossible(Exception):
The reason is given as the exception message.
"""
class QuitWithoutSaving(SystemExit):
"""Can be raised to exit the game without automatically saving."""

View File

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

38
main.py
View File

@ -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__":