Refactor map rendering

- Move all map rendering to a new MapWindow class
- Clip map rendering to the bounds of the Window
- Pass in Entities list from the Engine and render them relative to the window

The map doesn't scroll yet, so it'll always be clipped on the bottom and
right.
This commit is contained in:
Eryn Wells 2023-02-12 15:55:01 -08:00
parent ec28f984da
commit 8efd3ce207
4 changed files with 109 additions and 40 deletions

View file

@ -106,23 +106,11 @@ class Engine:
'''Print the whole game to the given console.'''
self.map.highlight_points(self.__mouse_path_points or [])
self.interface.update(self.hero, self.current_turn)
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)
entities_at_mouse_position = []
for ent in sorted(self.entities, key=lambda e: e.render_order.value):
# Only process entities that are in the field of view
if not self.map.visible[tuple(ent.position)]:
continue
ent.print_to_console(console)
if ent.position == self.__current_mouse_point:
entities_at_mouse_position.append(ent)
if len(entities_at_mouse_position) > 0:
console.print(x=1, y=43, string=', '.join(e.name for e in entities_at_mouse_position))
def run_event_loop(self, context: tcod.context.Context, console: tcod.Console) -> NoReturn:
'''Run the event loop forever. This method never returns.'''
while True:

View file

@ -1,27 +1,31 @@
# Eryn Wells <eryn@erynwells.me>
from typing import Optional
from typing import List
from tcod.console import Console
from .color import HealthBar
from .percentage_bar import PercentageBar
from .window import Window
from .window import Window, MapWindow
from ..geometry import Point, Rect, Size
from ..map import Map
from ..messages import MessageLog
from ..object import Hero
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 update(self, hero: Hero, turn_count: int):
def update(self, turn_count: int, hero: Hero, entities: List[Entity]):
self.info_window.turn_count = turn_count
self.info_window.update_hero(hero)
self.map_window.entities = entities
def draw(self, console: Console):
self.map_window.draw(console)
@ -29,51 +33,46 @@ class Interface:
self.message_window.draw(console)
class MapWindow(Window):
def __init__(self, bounds: Rect, map: Map):
super().__init__(bounds)
self.map = map
def draw(self, console):
super().draw(console)
# TODO: Get a 2D slice of tiles from the map given a rect based on the window's drawable area
drawable_area = self.drawable_area
self.map.print_to_console(console, drawable_area)
class InfoWindow(Window):
'''A window that displays information about the player'''
def __init__(self, bounds: Rect):
super().__init__(bounds, framed=True)
self.turn_count: int = 0
drawable_area = self.drawable_area
drawable_area = self.drawable_bounds
self.hit_points_bar = PercentageBar(
position=Point(drawable_area.min_x + 6, drawable_area.min_y),
width=20,
colors=list(HealthBar.bar_colors()))
def update_hero(self, hero: Hero):
hp, max_hp = hero.fighter.hit_points, hero.fighter.maximum_hit_points
assert hero.fighter
fighter = hero.fighter
hp, max_hp = fighter.hit_points, fighter.maximum_hit_points
self.hit_points_bar.percent_filled = hp / max_hp
def draw(self, console):
super().draw(console)
drawable_area = self.drawable_area
console.print(x=drawable_area.min_x + 2, y=drawable_area.min_y, string='HP:')
drawable_bounds = self.drawable_bounds
console.print(x=drawable_bounds.min_x + 2, y=drawable_bounds.min_y, string='HP:')
self.hit_points_bar.render_to_console(console)
if self.turn_count:
console.print(x=drawable_area.min_x, y=drawable_area.min_y + 1, string=f'Turn: {self.turn_count}')
console.print(x=drawable_bounds.min_x, y=drawable_bounds.min_y + 1, string=f'Turn: {self.turn_count}')
class MessageLogWindow(Window):
'''A window that displays a list of messages'''
def __init__(self, bounds: Rect, message_log: MessageLog):
super().__init__(bounds, framed=True)
self.message_log = message_log
def draw(self, console):
super().draw(console)
self.message_log.render_to_console(console, self.drawable_area)
self.message_log.render_to_console(console, self.drawable_bounds)

View file

@ -1,17 +1,24 @@
# Eryn Wells <eryn@erynwells.me>
from typing import List
import numpy as np
from tcod.console import Console
from ..geometry import Rect
from ..object import Entity
from ..geometry import Rect, Vector
from ..map import Map
class Window:
'''A user interface window. It can be framed.'''
def __init__(self, bounds: Rect, *, framed: bool = True):
self.bounds = bounds
self.is_framed = framed
@property
def drawable_area(self) -> Rect:
def drawable_bounds(self) -> Rect:
if self.is_framed:
return self.bounds.inset_rect(1, 1, 1, 1)
return self.bounds
@ -23,3 +30,64 @@ class Window:
self.bounds.origin.y,
self.bounds.size.width,
self.bounds.size.height)
class MapWindow(Window):
'''A Window that displays a game map'''
def __init__(self, bounds: Rect, map_: Map, **kwargs):
super().__init__(bounds, **kwargs)
self.map = map_
self.entities: List[Entity] = []
def draw(self, console: Console):
super().draw(console)
self._draw_map(console)
self._draw_entities(console)
def _draw_map(self, console: Console):
map_ = self.map
map_size = map_.size
drawable_bounds = self.drawable_bounds
width = min(map_size.width, drawable_bounds.width)
height = min(map_size.height, drawable_bounds.height)
# TODO: Adjust the slice according to where the hero is.
map_slice = np.s_[0:width, 0:height]
min_x = drawable_bounds.min_x
max_x = min_x + width
min_y = drawable_bounds.min_y
max_y = min_y + height
console.tiles_rgb[min_x:max_x, min_y:max_y] = self.map.composited_tiles[map_slice]
def _draw_entities(self, console):
drawable_bounds_vector = Vector.from_point(self.drawable_bounds.origin)
for ent in self.entities:
# Only process entities that are in the field of view
if not self.map.visible[tuple(ent.position)]:
continue
# Entity positions are 0-based relative to the (0, 0) point of the Map. In order to render them in the
# correct position in the console, we need to offset the position.
entity_position = ent.position
map_tile_at_entity_position = self.map.composited_tiles[entity_position.x, entity_position.y]
position = ent.position + drawable_bounds_vector
console.print(
x=position.x,
y=position.y,
string=ent.symbol,
fg=ent.foreground,
bg=tuple(map_tile_at_entity_position['bg'][:3]))
# if ent.position == self.__current_mouse_point:
# entities_at_mouse_position.append(ent)
# if len(entities_at_mouse_position) > 0:
# console.print(x=1, y=43, string=', '.join(e.name for e in entities_at_mouse_position))

View file

@ -47,6 +47,20 @@ class Map:
'''The size of the map'''
return self.configuration.map_size
@property
def composited_tiles(self) -> np.ndarray:
# TODO: Hold onto the result here so that this doen't have to be done every time this property is called.
return np.select(
condlist=[
self.highlighted,
self.visible,
self.explored],
choicelist=[
self.tiles['highlighted'],
self.tiles['light'],
self.tiles['dark']],
default=Shroud)
def random_walkable_position(self) -> Point:
'''Return a random walkable point on the map.'''
if not self.__walkable_points: