diff --git a/erynrl/__main__.py b/erynrl/__main__.py index 84d8dfc..9b96c14 100644 --- a/erynrl/__main__.py +++ b/erynrl/__main__.py @@ -9,6 +9,7 @@ from . import log from .configuration import Configuration, FontConfiguration, FontConfigurationError from .engine import Engine from .geometry import Size +from .interface import Interface def parse_args(argv, *a, **kw): @@ -45,15 +46,15 @@ def main(argv): configuration = Configuration( console_font_configuration=font_config, - map_size=Size(80, 24), + map_size=Size(80, 40), sandbox=args.sandbox) engine = Engine(configuration) - + interface = Interface(configuration.console_size, engine) 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: - engine.run_event_loop(context, console) + + with tcod.context.new(columns=interface.console.width, rows=interface.console.height, tileset=tileset) as context: + interface.run_event_loop(context) return 0 diff --git a/erynrl/engine.py b/erynrl/engine.py index 4c50099..0477383 100644 --- a/erynrl/engine.py +++ b/erynrl/engine.py @@ -3,7 +3,7 @@ '''Defines the core game engine.''' import random -from typing import TYPE_CHECKING, List, MutableSet, NoReturn, Optional +from typing import TYPE_CHECKING, List, MutableSet, Optional import tcod @@ -13,20 +13,21 @@ from .actions.action import Action, ActionWithActor from .actions.result import ActionResult from .ai import HostileEnemy from .configuration import Configuration -from .events import GameOverEventHandler, MainGameEventHandler -from .geometry import Point, Size -from .interface import Interface +from .events import EngineEventHandler, GameOverEventHandler +from .geometry import Point from .map import Map from .map.generator import RoomsAndCorridorsGenerator 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 .messages import MessageLog from .object import Actor, Entity, Hero, Monster -if TYPE_CHECKING: - from .events import EventHandler - class Engine: '''The main game engine. @@ -76,7 +77,7 @@ class Engine: ElbowCorridorGenerator()) 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.__mouse_path_points: Optional[List[Point]] = None @@ -112,37 +113,16 @@ class Engine: 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) - 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): '''Process an Action from player input''' - if not isinstance(action, ActionWithActor): action.perform(self) return + self.begin_turn() + log.ACTIONS_TREE.info('Processing Hero Actions') log.ACTIONS_TREE.info('|-> %s', action.actor) @@ -160,6 +140,8 @@ class Engine: self.process_entity_actions() self.update_field_of_view() + self.finish_turn() + def process_entity_actions(self): '''Run AI for entities that have them, and process actions from those AIs''' hero_position = self.hero.position diff --git a/erynrl/events.py b/erynrl/events.py index f06634b..9507a11 100644 --- a/erynrl/events.py +++ b/erynrl/events.py @@ -1,65 +1,26 @@ # Eryn Wells -'''Defines event handling mechanisms.''' - from typing import Optional, TYPE_CHECKING import tcod +import tcod.event as tev -from . import log from .actions.action import Action -from .actions.game import ExitAction, RegenerateRoomsAction, BumpAction, WaitAction -from .geometry import Direction, Point +from .actions.game import BumpAction, ExitAction, RegenerateRoomsAction, WaitAction +from .geometry import Direction if TYPE_CHECKING: from .engine import Engine -class EventHandler(tcod.event.EventDispatch[Action]): - '''Abstract event handler class''' +class EngineEventHandler(tev.EventDispatch[Action]): + '''Handles event on behalf of the game engine, dispatching Actions back to the engine.''' def __init__(self, engine: 'Engine'): super().__init__() self.engine = engine - def handle_events(self, context: tcod.context.Context): - '''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]: + def ev_keydown(self, event: tev.KeyDown) -> Optional[Action]: action: Optional[Action] = None hero = self.engine.hero @@ -84,8 +45,6 @@ class MainGameEventHandler(EventHandler): action = BumpAction(hero, Direction.NorthEast) case tcod.event.KeySym.y: action = BumpAction(hero, Direction.NorthWest) - case tcod.event.KeySym.ESCAPE: - action = ExitAction() case tcod.event.KeySym.SPACE: action = RegenerateRoomsAction() case tcod.event.KeySym.PERIOD: @@ -94,22 +53,16 @@ class MainGameEventHandler(EventHandler): return action - def ev_mousemotion(self, event: tcod.event.MouseMotion) -> Optional[Action]: - mouse_point = Point(event.tile.x, event.tile.y) - if not self.engine.map.tile_is_in_bounds(mouse_point): - mouse_point = None - self.engine.update_mouse_point(mouse_point) + def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]: + return ExitAction() -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.''' - def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]: - action: Optional[Action] = None + def __init__(self, engine: 'Engine'): + super().__init__() + self.engine = engine - sym = event.sym - match sym: - case tcod.event.KeySym.ESCAPE: - action = ExitAction() - - return action + def ev_quit(self, event: tev.Quit) -> Optional[Action]: + return ExitAction() diff --git a/erynrl/interface/__init__.py b/erynrl/interface/__init__.py index bf33a73..26b151e 100644 --- a/erynrl/interface/__init__.py +++ b/erynrl/interface/__init__.py @@ -4,15 +4,18 @@ 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.context import Context from .color import HealthBar +from .events import InterfaceEventHandler from .percentage_bar import PercentageBar from .window import Window, MapWindow +from ..engine import Engine from ..geometry import Point, Rect, Size -from ..map import Map from ..messages import MessageLog from ..object import Entity, Hero @@ -20,25 +23,59 @@ from ..object import Entity, Hero class Interface: '''The game's user interface''' - # pylint: disable=redefined-builtin - def __init__(self, size: Size, map: Map, message_log: MessageLog): - 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 __init__(self, size: Size, engine: Engine): + self.engine = engine - 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''' - 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.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''' - self.map_window.draw(console) - self.info_window.draw(console) - self.message_window.draw(console) + self.map_window.draw(self.console) + self.info_window.draw(self.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): diff --git a/erynrl/interface/events.py b/erynrl/interface/events.py new file mode 100644 index 0000000..bd8e5fc --- /dev/null +++ b/erynrl/interface/events.py @@ -0,0 +1,50 @@ +# Eryn Wells + +'''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 diff --git a/erynrl/interface/window.py b/erynrl/interface/window.py index 9f67113..61d0e5e 100644 --- a/erynrl/interface/window.py +++ b/erynrl/interface/window.py @@ -1,8 +1,9 @@ # Eryn Wells -from typing import List +from typing import List, Optional import numpy as np +from tcod import event as tev from tcod.console import Console from .. import log @@ -12,11 +13,46 @@ from ..object import Entity, Hero 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.is_framed = framed + self.event_handler = event_handler or self.__class__.EventHandler(self) @property def drawable_bounds(self) -> Rect: @@ -28,6 +64,14 @@ class Window: return self.bounds.inset_rect(1, 1, 1, 1) 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): '''Draw the window to the conole''' if self.is_framed: @@ -46,6 +90,20 @@ class Window: class MapWindow(Window): '''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 def __init__(self, bounds: Rect, map: Map, **kwargs): super().__init__(bounds, **kwargs)