going-rogue/erynrl/interface/window.py

240 lines
8.7 KiB
Python
Raw Normal View History

# Eryn Wells <eryn@erynwells.me>
from typing import List, Optional
import numpy as np
from tcod import event as tev
from tcod.console import Console
from .. import log
from ..geometry import Point, Rect, Size, Vector
from ..map import Map
from ..object import Entity, Hero
class Window:
'''A user interface window. It can be framed and it can handle events.'''
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:
'''
The bounds of the window that is drawable, inset by its frame if
`is_framed` is `True`.
'''
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]:
'''
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:
console.draw_frame(
self.bounds.origin.x,
self.bounds.origin.y,
self.bounds.size.width,
self.bounds.size.height)
drawable_bounds = self.drawable_bounds
console.draw_rect(drawable_bounds.min_x, drawable_bounds.min_y,
drawable_bounds.width, drawable_bounds.height,
ord(' '), (255, 255, 255), (0, 0, 0))
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)
self.map = map
self.drawable_map_bounds = map.bounds
self.entities: List[Entity] = []
self._draw_bounds = self.drawable_bounds
def update_drawable_map_bounds(self, hero: Hero):
'''
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.
'''
bounds = self.drawable_bounds
map_bounds = self.map.bounds
viewport_is_wider_than_map = bounds.width > map_bounds.width
viewport_is_taller_than_map = bounds.height > map_bounds.height
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
# Attempt to keep the player centered in the viewport.
hero_point = hero.position
if viewport_is_wider_than_map:
x = 0
else:
x = min(max(0, hero_point.x - bounds.mid_x), map_bounds.max_x - bounds.width)
if viewport_is_taller_than_map:
y = 0
else:
y = min(max(0, hero_point.y - bounds.mid_y), map_bounds.max_y - 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)
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`).
'''
drawable_map_bounds = self.drawable_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
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
else:
origin_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
else:
origin_y = drawable_bounds.min_y
height = drawable_bounds.height
self._draw_bounds = Rect(Point(origin_x, origin_y), Size(width, height))
def draw(self, console: Console):
super().draw(console)
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
log.UI.info('Drawing map')
map_slice = np.s_[
drawable_map_bounds.min_x: drawable_map_bounds.max_x + 1,
drawable_map_bounds.min_y: drawable_map_bounds.max_y + 1]
console_draw_bounds = self._draw_bounds
console_slice = np.s_[
console_draw_bounds.min_x: console_draw_bounds.max_x + 1,
console_draw_bounds.min_y: console_draw_bounds.max_y + 1]
log.UI.debug('Map bounds=%s, slice=%s', drawable_map_bounds, map_slice)
log.UI.debug('Console bounds=%s, slice=%s', drawable_bounds, console_slice)
console.tiles_rgb[console_slice] = self.map.composited_tiles[map_slice]
log.UI.info('Done drawing map')
def _draw_entities(self, console):
map_bounds_vector = Vector.from_point(self.drawable_map_bounds.origin)
draw_bounds_vector = Vector.from_point(self._draw_bounds.origin)
log.UI.info('Drawing entities')
for ent in self.entities:
# Only draw entities that are in the field of view
if not self.map.point_is_visible(ent.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,
string=ent.symbol,
fg=ent.foreground,
bg=tuple(map_tile_at_entity_position['bg'][:3]))
log.UI.info('Done drawing entities')