Restructure event handling

Events start in the Interface. The interface gets first crack at any incoming
events. If the interface doesn't handle the event, it is given to the
engine. The engine has an EngineEventHandler that yields actions just
like the event handler prior to this change.

The interface's event handler passes events to each window in the
interface. Windows can choose to handle events however they like, and
they return a bool indicating whether the event was fully handled.
This commit is contained in:
Eryn Wells 2023-03-07 21:29:05 -08:00
parent ee1c6f2222
commit 003aedf30e
6 changed files with 197 additions and 116 deletions

View file

@ -9,6 +9,7 @@ from . import log
from .configuration import Configuration, FontConfiguration, FontConfigurationError from .configuration import Configuration, FontConfiguration, FontConfigurationError
from .engine import Engine from .engine import Engine
from .geometry import Size from .geometry import Size
from .interface import Interface
def parse_args(argv, *a, **kw): def parse_args(argv, *a, **kw):
@ -45,15 +46,15 @@ def main(argv):
configuration = Configuration( configuration = Configuration(
console_font_configuration=font_config, console_font_configuration=font_config,
map_size=Size(80, 24), map_size=Size(80, 40),
sandbox=args.sandbox) sandbox=args.sandbox)
engine = Engine(configuration) engine = Engine(configuration)
interface = Interface(configuration.console_size, engine)
tileset = configuration.console_font_configuration.tileset tileset = configuration.console_font_configuration.tileset
console = tcod.Console(*configuration.console_size.numpy_shape, order='F')
with tcod.context.new(columns=console.width, rows=console.height, tileset=tileset) as context: with tcod.context.new(columns=interface.console.width, rows=interface.console.height, tileset=tileset) as context:
engine.run_event_loop(context, console) interface.run_event_loop(context)
return 0 return 0

View file

@ -3,7 +3,7 @@
'''Defines the core game engine.''' '''Defines the core game engine.'''
import random import random
from typing import TYPE_CHECKING, List, MutableSet, NoReturn, Optional from typing import TYPE_CHECKING, List, MutableSet, Optional
import tcod import tcod
@ -13,20 +13,21 @@ from .actions.action import Action, ActionWithActor
from .actions.result import ActionResult from .actions.result import ActionResult
from .ai import HostileEnemy from .ai import HostileEnemy
from .configuration import Configuration from .configuration import Configuration
from .events import GameOverEventHandler, MainGameEventHandler from .events import EngineEventHandler, GameOverEventHandler
from .geometry import Point, Size from .geometry import Point
from .interface import Interface
from .map import Map from .map import Map
from .map.generator import RoomsAndCorridorsGenerator from .map.generator import RoomsAndCorridorsGenerator
from .map.generator.cellular_atomata import CellularAtomataMapGenerator from .map.generator.cellular_atomata import CellularAtomataMapGenerator
from .map.generator.room import BSPRectMethod, CellularAtomatonRoomMethod, OrRoomMethod, RoomGenerator, RandomRectMethod, RectangularRoomMethod from .map.generator.room import (
BSPRectMethod,
CellularAtomatonRoomMethod,
OrRoomMethod,
RoomGenerator,
RectangularRoomMethod)
from .map.generator.corridor import ElbowCorridorGenerator from .map.generator.corridor import ElbowCorridorGenerator
from .messages import MessageLog from .messages import MessageLog
from .object import Actor, Entity, Hero, Monster from .object import Actor, Entity, Hero, Monster
if TYPE_CHECKING:
from .events import EventHandler
class Engine: class Engine:
'''The main game engine. '''The main game engine.
@ -76,7 +77,7 @@ class Engine:
ElbowCorridorGenerator()) ElbowCorridorGenerator())
self.map = Map(config, map_generator) self.map = Map(config, map_generator)
self.event_handler: 'EventHandler' = MainGameEventHandler(self) self.event_handler = EngineEventHandler(self)
self.__current_mouse_point: Optional[Point] = None self.__current_mouse_point: Optional[Point] = None
self.__mouse_path_points: Optional[List[Point]] = None self.__mouse_path_points: Optional[List[Point]] = None
@ -112,37 +113,16 @@ class Engine:
self.update_field_of_view() self.update_field_of_view()
# Interface elements
self.interface = Interface(Size(80, 50), self.map, self.message_log)
self.message_log.add_message('Greetings adventurer!', fg=(127, 127, 255), stack=False) self.message_log.add_message('Greetings adventurer!', fg=(127, 127, 255), stack=False)
def print_to_console(self, console):
'''Print the whole game to the given console.'''
self.map.highlight_points(self.__mouse_path_points or [])
sorted_entities = sorted(self.entities, key=lambda e: e.render_order.value)
self.interface.update(self.current_turn, self.hero, sorted_entities)
self.interface.draw(console)
def run_event_loop(self, context: tcod.context.Context, console: tcod.Console) -> NoReturn:
'''Run the event loop forever. This method never returns.'''
while True:
console.clear()
self.print_to_console(console)
context.present(console)
self.begin_turn()
self.event_handler.handle_events(context)
self.finish_turn()
def process_input_action(self, action: Action): def process_input_action(self, action: Action):
'''Process an Action from player input''' '''Process an Action from player input'''
if not isinstance(action, ActionWithActor): if not isinstance(action, ActionWithActor):
action.perform(self) action.perform(self)
return return
self.begin_turn()
log.ACTIONS_TREE.info('Processing Hero Actions') log.ACTIONS_TREE.info('Processing Hero Actions')
log.ACTIONS_TREE.info('|-> %s', action.actor) log.ACTIONS_TREE.info('|-> %s', action.actor)
@ -160,6 +140,8 @@ class Engine:
self.process_entity_actions() self.process_entity_actions()
self.update_field_of_view() self.update_field_of_view()
self.finish_turn()
def process_entity_actions(self): def process_entity_actions(self):
'''Run AI for entities that have them, and process actions from those AIs''' '''Run AI for entities that have them, and process actions from those AIs'''
hero_position = self.hero.position hero_position = self.hero.position

View file

@ -1,65 +1,26 @@
# Eryn Wells <eryn@erynwells.me> # Eryn Wells <eryn@erynwells.me>
'''Defines event handling mechanisms.'''
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
import tcod import tcod
import tcod.event as tev
from . import log
from .actions.action import Action from .actions.action import Action
from .actions.game import ExitAction, RegenerateRoomsAction, BumpAction, WaitAction from .actions.game import BumpAction, ExitAction, RegenerateRoomsAction, WaitAction
from .geometry import Direction, Point from .geometry import Direction
if TYPE_CHECKING: if TYPE_CHECKING:
from .engine import Engine from .engine import Engine
class EventHandler(tcod.event.EventDispatch[Action]): class EngineEventHandler(tev.EventDispatch[Action]):
'''Abstract event handler class''' '''Handles event on behalf of the game engine, dispatching Actions back to the engine.'''
def __init__(self, engine: 'Engine'): def __init__(self, engine: 'Engine'):
super().__init__() super().__init__()
self.engine = engine self.engine = engine
def handle_events(self, context: tcod.context.Context): def ev_keydown(self, event: tev.KeyDown) -> Optional[Action]:
'''Wait for events and handle them.'''
for event in tcod.event.wait():
context.convert_event(event)
self.handle_event(event)
def handle_event(self, event: tcod.event.Event):
'''
Handle an event by transforming it into an Action and processing it until it is completed. If the Action
succeeds, also process actions from other Entities.
Parameters
----------
event : tcod.event.Event
The event to handle
'''
action = self.dispatch(event)
# Unhandled event. Ignore it.
if not action:
log.EVENTS.debug('Unhandled event: %s', event)
return
self.engine.process_input_action(action)
def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
return ExitAction()
class MainGameEventHandler(EventHandler):
'''
Handler of `tcod` events for the main game.
Receives input from the player and dispatches actions to the game engine to interat with the hero and other objects
in the game.
'''
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
action: Optional[Action] = None action: Optional[Action] = None
hero = self.engine.hero hero = self.engine.hero
@ -84,8 +45,6 @@ class MainGameEventHandler(EventHandler):
action = BumpAction(hero, Direction.NorthEast) action = BumpAction(hero, Direction.NorthEast)
case tcod.event.KeySym.y: case tcod.event.KeySym.y:
action = BumpAction(hero, Direction.NorthWest) action = BumpAction(hero, Direction.NorthWest)
case tcod.event.KeySym.ESCAPE:
action = ExitAction()
case tcod.event.KeySym.SPACE: case tcod.event.KeySym.SPACE:
action = RegenerateRoomsAction() action = RegenerateRoomsAction()
case tcod.event.KeySym.PERIOD: case tcod.event.KeySym.PERIOD:
@ -94,22 +53,16 @@ class MainGameEventHandler(EventHandler):
return action return action
def ev_mousemotion(self, event: tcod.event.MouseMotion) -> Optional[Action]: def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
mouse_point = Point(event.tile.x, event.tile.y) return ExitAction()
if not self.engine.map.tile_is_in_bounds(mouse_point):
mouse_point = None
self.engine.update_mouse_point(mouse_point)
class GameOverEventHandler(EventHandler): class GameOverEventHandler(tev.EventDispatch[Action]):
'''When the game is over (the hero dies, the player quits, etc), this event handler takes over.''' '''When the game is over (the hero dies, the player quits, etc), this event handler takes over.'''
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: def __init__(self, engine: 'Engine'):
action: Optional[Action] = None super().__init__()
self.engine = engine
sym = event.sym def ev_quit(self, event: tev.Quit) -> Optional[Action]:
match sym: return ExitAction()
case tcod.event.KeySym.ESCAPE:
action = ExitAction()
return action

View file

@ -4,15 +4,18 @@
The game's graphical user interface The game's graphical user interface
''' '''
from typing import List from typing import NoReturn
from tcod import event as tev
from tcod.console import Console from tcod.console import Console
from tcod.context import Context
from .color import HealthBar from .color import HealthBar
from .events import InterfaceEventHandler
from .percentage_bar import PercentageBar from .percentage_bar import PercentageBar
from .window import Window, MapWindow from .window import Window, MapWindow
from ..engine import Engine
from ..geometry import Point, Rect, Size from ..geometry import Point, Rect, Size
from ..map import Map
from ..messages import MessageLog from ..messages import MessageLog
from ..object import Entity, Hero from ..object import Entity, Hero
@ -20,25 +23,59 @@ from ..object import Entity, Hero
class Interface: class Interface:
'''The game's user interface''' '''The game's user interface'''
# pylint: disable=redefined-builtin def __init__(self, size: Size, engine: Engine):
def __init__(self, size: Size, map: Map, message_log: MessageLog): self.engine = engine
self.map_window = MapWindow(Rect(Point(0, 0), Size(size.width, size.height - 5)), map)
self.info_window = InfoWindow(Rect(Point(0, size.height - 5), Size(28, 5)))
self.message_window = MessageLogWindow(Rect(Point(28, size.height - 5), Size(size.width - 28, 5)), message_log)
def update(self, turn_count: int, hero: Hero, entities: List[Entity]): self.console = Console(*size.numpy_shape, order='F')
self.map_window = MapWindow(
Rect.from_raw_values(0, 0, size.width, size.height - 5),
engine.map)
self.info_window = InfoWindow(
Rect.from_raw_values(0, size.height - 5, 28, 5))
self.message_window = MessageLogWindow(
Rect.from_raw_values(28, size.height - 5, size.width - 28, 5),
engine.message_log)
self.event_handler = InterfaceEventHandler(self)
def update(self):
'''Update game state that the interface needs to render''' '''Update game state that the interface needs to render'''
self.info_window.turn_count = turn_count self.info_window.turn_count = self.engine.current_turn
hero = self.engine.hero
self.info_window.update_hero(hero) self.info_window.update_hero(hero)
self.map_window.update_drawable_map_bounds(hero) self.map_window.update_drawable_map_bounds(hero)
self.map_window.entities = entities
def draw(self, console: Console): sorted_entities = sorted(self.engine.entities, key=lambda e: e.render_order.value)
self.map_window.entities = sorted_entities
def draw(self):
'''Draw the UI to the console''' '''Draw the UI to the console'''
self.map_window.draw(console) self.map_window.draw(self.console)
self.info_window.draw(console) self.info_window.draw(self.console)
self.message_window.draw(console) self.message_window.draw(self.console)
def run_event_loop(self, context: Context) -> NoReturn:
'''Run the event loop forever. This method never returns.'''
while True:
self.update()
self.console.clear()
self.draw()
context.present(self.console)
for event in tev.wait():
did_handle = self.event_handler.dispatch(event)
if did_handle:
continue
action = self.engine.event_handler.dispatch(event)
if not action:
# The engine didn't handle the event, so just drop it.
continue
self.engine.process_input_action(action)
class InfoWindow(Window): class InfoWindow(Window):

View file

@ -0,0 +1,50 @@
# Eryn Wells <eryn@erynwells.me>
'''Defines event handling mechanisms.'''
from typing import TYPE_CHECKING
from tcod import event as tev
if TYPE_CHECKING:
from . import Interface
class InterfaceEventHandler(tev.EventDispatch[bool]):
'''The event handler for the user interface.'''
def __init__(self, interface: 'Interface'):
super().__init__()
self.interface = interface
self._handlers = []
self._refresh_handlers()
def _refresh_handlers(self):
self._handlers = [
self.interface.map_window.event_handler,
self.interface.message_window.event_handler,
self.interface.info_window.event_handler,
]
def ev_keydown(self, event: tev.KeyDown) -> bool:
return self._handle_event(event)
def ev_keyup(self, event: tev.KeyUp) -> bool:
return self._handle_event(event)
def ev_mousemotion(self, event: tev.MouseMotion) -> bool:
return self._handle_event(event)
def ev_mousebuttondown(self, event: tev.MouseButtonDown) -> bool:
return self._handle_event(event)
def ev_mousebuttonup(self, event: tev.MouseButtonUp) -> bool:
return self._handle_event(event)
def _handle_event(self, event: tev.Event) -> bool:
for handler in self._handlers:
if handler and handler.dispatch(event):
return True
return False

View file

@ -1,8 +1,9 @@
# Eryn Wells <eryn@erynwells.me> # Eryn Wells <eryn@erynwells.me>
from typing import List from typing import List, Optional
import numpy as np import numpy as np
from tcod import event as tev
from tcod.console import Console from tcod.console import Console
from .. import log from .. import log
@ -12,11 +13,46 @@ from ..object import Entity, Hero
class Window: class Window:
'''A user interface window. It can be framed.''' '''A user interface window. It can be framed and it can handle events.'''
def __init__(self, bounds: Rect, *, framed: bool = True): class EventHandler(tev.EventDispatch[bool]):
'''
Handles events for a Window. Event dispatch methods return True if the event
was handled and no further action is needed.
'''
def __init__(self, window: 'Window'):
super().__init__()
self.window = window
def mouse_point_for_event(self, event: tev.MouseState) -> Point:
'''
Return the mouse point in tiles for a window event. Raises a ValueError
if the event is not a mouse event.
'''
if not isinstance(event, tev.MouseState):
raise ValueError("Can't get mouse point for non-mouse event")
return Point(event.tile.x, event.tile.y)
def ev_keydown(self, event: tev.KeyDown) -> bool:
return False
def ev_keyup(self, event: tev.KeyUp) -> bool:
return False
def ev_mousemotion(self, event: tev.MouseMotion) -> bool:
mouse_point = self.mouse_point_for_event(event)
if mouse_point not in self.window.bounds:
return False
return False
def __init__(self, bounds: Rect, *, framed: bool = True, event_handler: Optional['EventHandler'] = None):
self.bounds = bounds self.bounds = bounds
self.is_framed = framed self.is_framed = framed
self.event_handler = event_handler or self.__class__.EventHandler(self)
@property @property
def drawable_bounds(self) -> Rect: def drawable_bounds(self) -> Rect:
@ -28,6 +64,14 @@ class Window:
return self.bounds.inset_rect(1, 1, 1, 1) return self.bounds.inset_rect(1, 1, 1, 1)
return self.bounds return self.bounds
def convert_console_point(self, point: Point) -> Optional[Point]:
'''
Converts a point in console coordinates to window-relative coordinates.
If the point is out of bounds of the window, return None.
'''
converted_point = point - Vector.from_point(self.bounds.origin)
return converted_point if converted_point in self.bounds else None
def draw(self, console: Console): def draw(self, console: Console):
'''Draw the window to the conole''' '''Draw the window to the conole'''
if self.is_framed: if self.is_framed:
@ -46,6 +90,20 @@ class Window:
class MapWindow(Window): class MapWindow(Window):
'''A Window that displays a game map''' '''A Window that displays a game map'''
class EventHandler(Window.EventHandler):
'''An event handler for the MapWindow.'''
def ev_mousemotion(self, event: tev.MouseMotion) -> bool:
mouse_point = self.window.convert_console_point(self.mouse_point_for_event(event))
if not mouse_point:
return False
# TODO: Convert window point to map point
# TODO: Perform a path finding operation from the hero to the mouse point
# TODO: Highlight those points on the map
return False
# pylint: disable=redefined-builtin # pylint: disable=redefined-builtin
def __init__(self, bounds: Rect, map: Map, **kwargs): def __init__(self, bounds: Rect, map: Map, **kwargs):
super().__init__(bounds, **kwargs) super().__init__(bounds, **kwargs)