diff --git a/erynrl/geometry.py b/erynrl/geometry.py index adc9b90..ada08fc 100644 --- a/erynrl/geometry.py +++ b/erynrl/geometry.py @@ -205,6 +205,16 @@ class Rect: '''Maximum y-value that is still within the bounds of this rectangle.''' return self.origin.y + self.size.height - 1 + @property + def end_x(self) -> int: + '''X-value beyond the end of the rectangle.''' + return self.origin.x + self.size.width + + @property + def end_y(self) -> int: + '''Y-value beyond the end of the rectangle.''' + return self.origin.y + self.size.height + @property def width(self) -> int: '''The width of the rectangle. A convenience property for accessing `self.size.width`.''' @@ -299,8 +309,7 @@ class Rect: raise TypeError(f'{self.__class__.__name__} cannot contain value of type {other.__class__.__name__}') def __contains_point(self, pt: Point) -> bool: - return (pt.x >= self.min_x and pt.x <= self.max_x - and pt.y >= self.min_y and pt.y <= self.max_y) + return self.min_x <= pt.x <= self.max_x and self.min_y <= pt.y <= self.max_y def __contains_rect(self, other: 'Rect') -> bool: return (self.min_x <= other.min_x diff --git a/erynrl/interface/__init__.py b/erynrl/interface/__init__.py index 3375cd7..096f2f9 100644 --- a/erynrl/interface/__init__.py +++ b/erynrl/interface/__init__.py @@ -44,7 +44,6 @@ class Interface: hero = self.engine.hero self.info_window.update_hero(hero) - self.map_window.update_drawable_map_bounds() sorted_entities = sorted(self.engine.entities, key=lambda e: e.render_order.value) self.map_window.entities = sorted_entities diff --git a/erynrl/interface/window/__init__.py b/erynrl/interface/window/__init__.py index 3119246..aedd067 100644 --- a/erynrl/interface/window/__init__.py +++ b/erynrl/interface/window/__init__.py @@ -1,5 +1,9 @@ # Eryn Wells +''' +Declares the Window class. +''' + from typing import Generic, Optional, TypeVar from tcod import event as tev @@ -49,26 +53,34 @@ class Window: def __init__(self, bounds: Rect, *, framed: bool = True, event_handler: Optional['EventHandler'] = None): self.bounds = bounds + '''The window's bounds in console coordinates''' + self.is_framed = framed + '''A `bool` indicating whether the window has a frame''' + self.event_handler = event_handler or self.__class__.EventHandler(self) + '''The window's event handler''' @property def drawable_bounds(self) -> Rect: ''' - The bounds of the window that is drawable, inset by its frame if - `is_framed` is `True`. + A rectangle in console coordinates defining the area of the window that + is drawable, inset by the window's frame if it has one. ''' if self.is_framed: return self.bounds.inset_rect(1, 1, 1, 1) return self.bounds - def convert_console_point(self, point: Point) -> Optional[Point]: + def convert_console_point_to_window(self, point: Point, *, use_drawable_bounds: bool = False) -> Optional[Point]: ''' - Converts a point in console coordinates to window-relative coordinates. - If the point is out of bounds of the window, return None. + Converts a point in console coordinates to window 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 + bounds = self.drawable_bounds if use_drawable_bounds else self.bounds + if point in bounds: + return point - Vector.from_point(bounds.origin) + + return None def draw(self, console: Console): '''Draw the window to the conole''' diff --git a/erynrl/interface/window/map.py b/erynrl/interface/window/map.py index a7ab6ce..d8ac561 100644 --- a/erynrl/interface/window/map.py +++ b/erynrl/interface/window/map.py @@ -1,6 +1,10 @@ # Eryn Wells -from typing import List, Optional +''' +Declares the MapWindow class. +''' + +from typing import List import numpy as np import tcod.event as tev @@ -8,7 +12,7 @@ from tcod.console import Console from . import Window from ... import log -from ...geometry import Point, Rect, Size, Vector +from ...geometry import Point, Rect, Vector from ...map import Map from ...object import Entity, Hero @@ -20,18 +24,18 @@ class MapWindow(Window): '''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 + mouse_point = self.mouse_point_for_event(event) - log.UI.info('Mouse point in window %s', mouse_point) + converted_point = self.window.convert_console_point_to_window(mouse_point, use_drawable_bounds=True) + if not converted_point: + return False hero = self.window.hero if not hero: return False - map_point = self.window.convert_window_point_to_map(mouse_point) - log.UI.info('Mouse point in map %s', map_point) + map_point = self.window.convert_console_point_to_map(mouse_point) + log.UI.info('Mouse moved; finding path from hero to %s', map_point) map_ = self.window.map path = map_.find_walkable_path_from_point_to_point(hero.position, map_point) @@ -39,30 +43,51 @@ class MapWindow(Window): return False - # pylint: disable=redefined-builtin + def ev_mousebuttondown(self, event: tev.MouseButtonDown) -> bool: + mouse_point = self.mouse_point_for_event(event) + + converted_point = self.window.convert_console_point_to_window(mouse_point, use_drawable_bounds=True) + if not converted_point: + return False + + map_point = self.window.convert_console_point_to_map(mouse_point) + log.UI.info('Mouse button down at %s', map_point) + + return False + def __init__(self, bounds: Rect, map: Map, hero: Hero, **kwargs): super().__init__(bounds, event_handler=self.__class__.EventHandler(self), **kwargs) self.map = map + '''The game map''' + + self.visible_map_bounds = map.bounds + '''A rectangle in map coordinates defining the visible area of the map in the window''' - self.drawable_map_bounds = map.bounds self.hero = hero + '''The hero entity''' + self.entities: List[Entity] = [] + '''A list of all game entities to render on the map''' self._draw_bounds = self.drawable_bounds - - def convert_window_point_to_map(self, point: Point) -> Point: ''' - Convert a point in window coordinates to a point relative to the map's + A rectangle in console coordinates where the map will actually be drawn. + This area should always be entirely contained within the window's + drawable bounds. + ''' + + def convert_console_point_to_map(self, point: Point) -> Point: + ''' + Convert a point in console coordinates to a point relative to the map's origin point. ''' - return point - Vector.from_point(self._draw_bounds.origin) + return point - Vector.from_point(self._draw_bounds.origin) + Vector.from_point(self.visible_map_bounds.origin) - def update_drawable_map_bounds(self): + def _update_visible_map_bounds(self) -> Rect: ''' - Figure out what portion of the map is drawable and update - `self.drawable_map_bounds`. This method attempts to keep the hero - centered in the map viewport, while not overscrolling the map in either - direction. + Figure out what portion of the map is visible. This method attempts to + keep the hero centered in the map viewport, while not overscrolling the + map in either direction. ''' bounds = self.drawable_bounds map_bounds = self.map.bounds @@ -72,65 +97,71 @@ class MapWindow(Window): if viewport_is_wider_than_map and viewport_is_taller_than_map: # The whole map fits within the window's drawable bounds - self.drawable_map_bounds = map_bounds - return + return map_bounds # Attempt to keep the player centered in the viewport. hero_point = self.hero.position if viewport_is_wider_than_map: x = 0 + width = map_bounds.width else: - x = min(max(0, hero_point.x - bounds.mid_x), map_bounds.max_x - bounds.width) + half_width = bounds.width // 2 + x = min(max(0, hero_point.x - half_width), map_bounds.end_x - bounds.width) + width = bounds.width if viewport_is_taller_than_map: y = 0 + height = map_bounds.height else: - y = min(max(0, hero_point.y - bounds.mid_y), map_bounds.max_y - bounds.height) + half_height = bounds.height // 2 + y = min(max(0, hero_point.y - half_height), map_bounds.end_y - bounds.height) + height = bounds.height - origin = Point(x, y) - size = Size(min(bounds.width, map_bounds.width), min(bounds.height, map_bounds.height)) - - self.drawable_map_bounds = Rect(origin, size) + return Rect.from_raw_values(x, y, width, height) def _update_draw_bounds(self): ''' The area where the map should actually be drawn, accounting for the size - of the viewport (`drawable_bounds`)and the size of the map (`self.map.bounds`). + of the viewport (`drawable_bounds`) and the size of the map (`self.map.bounds`). ''' - drawable_map_bounds = self.drawable_map_bounds + visible_map_bounds = self.visible_map_bounds drawable_bounds = self.drawable_bounds - viewport_is_wider_than_map = drawable_bounds.width >= drawable_map_bounds.width - viewport_is_taller_than_map = drawable_bounds.height >= drawable_map_bounds.height + viewport_is_wider_than_map = drawable_bounds.width >= visible_map_bounds.width + viewport_is_taller_than_map = drawable_bounds.height >= visible_map_bounds.height if viewport_is_wider_than_map: # Center the map horizontally in the viewport - origin_x = drawable_bounds.min_x + (drawable_bounds.width - drawable_map_bounds.width) // 2 - width = drawable_map_bounds.width + x = drawable_bounds.min_x + (drawable_bounds.width - visible_map_bounds.width) // 2 + width = visible_map_bounds.width else: - origin_x = drawable_bounds.min_x + x = drawable_bounds.min_x width = drawable_bounds.width if viewport_is_taller_than_map: # Center the map vertically in the viewport - origin_y = drawable_bounds.min_y + (drawable_bounds.height - drawable_map_bounds.height) // 2 - height = drawable_map_bounds.height + y = drawable_bounds.min_y + (drawable_bounds.height - visible_map_bounds.height) // 2 + height = visible_map_bounds.height else: - origin_y = drawable_bounds.min_y + y = drawable_bounds.min_y height = drawable_bounds.height - self._draw_bounds = Rect(Point(origin_x, origin_y), Size(width, height)) + draw_bounds = Rect.from_raw_values(x, y, width, height) + assert draw_bounds in self.drawable_bounds + + return draw_bounds def draw(self, console: Console): super().draw(console) - self._update_draw_bounds() + + self.visible_map_bounds = self._update_visible_map_bounds() + self._draw_bounds = self._update_draw_bounds() self._draw_map(console) self._draw_entities(console) def _draw_map(self, console: Console): - drawable_map_bounds = self.drawable_map_bounds - drawable_bounds = self.drawable_bounds + drawable_map_bounds = self.visible_map_bounds map_slice = np.s_[ drawable_map_bounds.min_x: drawable_map_bounds.max_x + 1, @@ -143,26 +174,29 @@ class MapWindow(Window): console.tiles_rgb[console_slice] = self.map.composited_tiles[map_slice] - def _draw_entities(self, console): - map_bounds_vector = Vector.from_point(self.drawable_map_bounds.origin) + def _draw_entities(self, console: Console): + visible_map_bounds = self.visible_map_bounds + map_bounds_vector = Vector.from_point(self.visible_map_bounds.origin) draw_bounds_vector = Vector.from_point(self._draw_bounds.origin) for ent in self.entities: + entity_position = ent.position + + # Only draw entities that are within the visible map bounds + if entity_position not in visible_map_bounds: + continue + # Only draw entities that are in the field of view - if not self.map.point_is_visible(ent.position): + if not self.map.point_is_visible(entity_position): continue # Entity positions are relative to the (0, 0) point of the Map. In # order to render them in the correct position in the console, we # need to transform them into viewport-relative coordinates. - entity_position = ent.position map_tile_at_entity_position = self.map.composited_tiles[entity_position.numpy_index] position = ent.position - map_bounds_vector + draw_bounds_vector - if isinstance(ent, Hero): - log.UI.debug('Hero position: map=%s, window=%s', entity_position, position) - console.print( x=position.x, y=position.y,