Beispiel #1
0
class MapEditorView:

    def __init__(self,
                 pygame_screen,
                 camera_world_area: Rect,
                 screen_size: Tuple[int, int],
                 images_by_sprite: Dict[Sprite, Dict[Direction, List[ImageWithRelativePosition]]],
                 images_by_ui_sprite: Dict[UiIconSprite, Any],
                 images_by_portrait_sprite: Dict[PortraitIconSprite, Any],
                 world_area: Rect,
                 player_position: Tuple[int, int],
                 entities_by_type: Dict[EntityTab, List[MapEditorWorldEntity]],
                 grid_cell_size: int,
                 map_file_path: str):
        camera_size = camera_world_area.size
        self._camera_size = camera_size
        self._screen_size = screen_size
        self._screen_render = DrawableArea(pygame_screen)
        self._map_file_path = map_file_path

        self._images_by_sprite = images_by_sprite
        self._images_by_ui_sprite = images_by_ui_sprite
        self._images_by_portrait_sprite = images_by_portrait_sprite
        self._ui_screen_area = Rect(0, camera_size[1], screen_size[0], screen_size[1] - camera_size[1])
        self._screen_render = DrawableArea(pygame_screen)
        self._ui_render = DrawableArea(pygame_screen, self._translate_ui_position_to_screen)

        self._font_debug_info = pygame.font.Font(DIR_FONTS + 'Courier New Bold.ttf', 12)
        self._font_ui_icon_keys = pygame.font.Font(DIR_FONTS + 'Courier New Bold.ttf', 12)
        w_tab_button = 110
        self._tab_buttons_by_entity_type = {
            EntityTab.ADVANCED: RadioButton(self._ui_render, Rect(220, 10, w_tab_button, 20), "ADVANCED (C)"),
            EntityTab.ITEMS: RadioButton(self._ui_render, Rect(340, 10, w_tab_button, 20), "ITEMS (V)"),
            EntityTab.NPCS: RadioButton(self._ui_render, Rect(460, 10, w_tab_button, 20), "NPCS (B)"),
            EntityTab.WALLS: RadioButton(self._ui_render, Rect(580, 10, w_tab_button, 20), "WALLS (N)"),
            EntityTab.MISC: RadioButton(self._ui_render, Rect(700, 10, w_tab_button, 20), "MISC. (M)"),
        }
        self._tabs_by_pygame_key = {
            pygame.K_c: EntityTab.ADVANCED,
            pygame.K_v: EntityTab.ITEMS,
            pygame.K_b: EntityTab.NPCS,
            pygame.K_n: EntityTab.WALLS,
            pygame.K_m: EntityTab.MISC,
        }

        self._entities_by_type = entities_by_type
        self._tab_buttons = list(self._tab_buttons_by_entity_type.values())
        self._minimap = Minimap(self._ui_render, Rect(self._screen_size[0] - 180, 20, 160, 160), world_area,
                                player_position)
        self._shown_tab: EntityTab = None
        self._set_shown_tab(EntityTab.ADVANCED)
        self._checkboxes = [
            Checkbox(self._ui_render, Rect(15, 100, 140, 20), "outlines", False,
                     lambda checked: ToggleOutlines(checked))
        ]
        self._buttons = [
            Button(self._ui_render, Rect(15, 125, 140, 20), "Generate random", lambda: GenerateRandomMap()),
            Button(self._ui_render, Rect(15, 150, 140, 20), "Save", lambda: SaveMap())
        ]

        # USER INPUT STATE
        self._is_left_mouse_button_down = False
        self._is_right_mouse_button_down = False
        self._mouse_screen_pos = (0, 0)
        self._hovered_component = None
        self._user_state: UserState = UserState.deleting_entities()
        self._snapped_mouse_screen_pos = (0, 0)
        self._snapped_mouse_world_pos = (0, 0)
        self._is_snapped_mouse_within_world = True
        self._is_snapped_mouse_over_ui = False
        self._entity_icon_hovered_by_mouse: MapEditorWorldEntity = None
        self._smart_floor_tiles_to_add: List[Tuple[int, int, int, int]] = []
        self._smart_floor_tiles_to_delete: List[Tuple[int, int, int, int]] = []

        # MUTABLE WORLD-RELATED STATE
        self.camera_world_area: Rect = camera_world_area
        self.world_area: Rect = world_area
        self.grid_cell_size = grid_cell_size

        self._setup_ui_components()

    def _setup_ui_components(self):
        icon_space = 5
        x_1 = 165
        y_2 = 40
        self.button_delete_entities = self._create_map_editor_icon(
            Rect(20, y_2, MAP_EDITOR_UI_ICON_SIZE[0], MAP_EDITOR_UI_ICON_SIZE[1]),
            'Q', None, UiIconSprite.MAP_EDITOR_TRASHCAN, 0, None)
        self.button_delete_decorations = self._create_map_editor_icon(
            Rect(20 + MAP_EDITOR_UI_ICON_SIZE[0] + icon_space, y_2, MAP_EDITOR_UI_ICON_SIZE[0],
                 MAP_EDITOR_UI_ICON_SIZE[1]), 'Z', None, UiIconSprite.MAP_EDITOR_RECYCLING, 0, None)
        self.entity_icons_by_type = {}
        num_icons_per_row = 23
        for entity_type in EntityTab:
            self.entity_icons_by_type[entity_type] = []
            for i, entity in enumerate(self._entities_by_type[entity_type]):
                x = x_1 + (i % num_icons_per_row) * (MAP_EDITOR_UI_ICON_SIZE[0] + icon_space)
                row_index = (i // num_icons_per_row)
                y = y_2 + row_index * (MAP_EDITOR_UI_ICON_SIZE[1] + icon_space)
                if entity.item_id is not None:
                    data = get_item_data(entity.item_id)
                    category_name = None
                    if data.item_equipment_category:
                        category_name = ITEM_EQUIPMENT_CATEGORY_NAMES[data.item_equipment_category]
                    description_lines = create_item_description(entity.item_id)
                    item_name = build_item_name(entity.item_id)
                    is_rare = entity.item_id.suffix_id is not None
                    is_unique = data.is_unique
                    tooltip = TooltipGraphics.create_for_item(self._ui_render, item_name, category_name, (x, y),
                                                              description_lines, is_rare=is_rare, is_unique=is_unique)
                elif entity.consumable_type is not None:
                    data = CONSUMABLES[entity.consumable_type]
                    tooltip = TooltipGraphics.create_for_consumable(self._ui_render, data, (x, y))
                elif entity.npc_type is not None:
                    data = NON_PLAYER_CHARACTERS[entity.npc_type]
                    tooltip = TooltipGraphics.create_for_npc(self._ui_render, entity.npc_type, data, (x, y))
                elif entity.portal_id is not None:
                    tooltip = TooltipGraphics.create_for_portal(self._ui_render, entity.portal_id, (x, y))
                elif entity.is_smart_floor_tile:
                    tooltip = TooltipGraphics.create_for_smart_floor_tile(self._ui_render, entity.entity_size, (x, y))
                else:
                    tooltip = None
                icon = self._create_map_editor_icon(
                    Rect(x, y, MAP_EDITOR_UI_ICON_SIZE[0], MAP_EDITOR_UI_ICON_SIZE[1]), '', entity.sprite, None,
                    entity.map_editor_entity_id, tooltip)
                self.entity_icons_by_type[entity_type].append(icon)

    # HANDLE USER INPUT

    def handle_mouse_movement(self, mouse_screen_pos: Tuple[int, int]) -> Optional[MapEditorAction]:

        self._set_currently_hovered_component_not_hovered()

        self._mouse_screen_pos = mouse_screen_pos
        self._entity_icon_hovered_by_mouse = None

        self._snapped_mouse_screen_pos = ((mouse_screen_pos[0] // self.grid_cell_size) * self.grid_cell_size,
                                          (mouse_screen_pos[1] // self.grid_cell_size) * self.grid_cell_size)
        self._snapped_mouse_world_pos = sum_of_vectors(self._snapped_mouse_screen_pos,
                                                       self.camera_world_area.topleft)
        self._is_snapped_mouse_within_world = self.world_area.collidepoint(self._snapped_mouse_world_pos[0],
                                                                           self._snapped_mouse_world_pos[1])
        self._is_snapped_mouse_over_ui = self._is_screen_position_within_ui(self._snapped_mouse_screen_pos)

        mouse_ui_pos = self._translate_screen_position_to_ui(mouse_screen_pos)

        for icon in self.entity_icons_by_type[self._shown_tab]:
            if icon.contains(mouse_ui_pos):
                self._on_hover_component(icon)
                entity = self._get_map_editor_entity_by_id(icon.map_editor_entity_id)
                self._entity_icon_hovered_by_mouse = entity
                return

        # noinspection PyTypeChecker
        for component in self._checkboxes + self._buttons + self._tab_buttons:
            if component.contains(mouse_ui_pos):
                self._on_hover_component(component)
                return

        if self._minimap.contains(mouse_ui_pos):
            self._on_hover_component(self._minimap)
            if self._is_left_mouse_button_down:
                position_ratio = self._minimap.get_position_ratio(mouse_ui_pos)
                return SetCameraPosition(position_ratio)
            return

        if self._is_left_mouse_button_down and self._is_snapped_mouse_within_world and not self._is_snapped_mouse_over_ui:
            if self._user_state.placing_entity:
                if self._user_state.placing_entity.wall_type or self._user_state.placing_entity.decoration_sprite:
                    return AddEntity(self._snapped_mouse_world_pos, self._user_state.placing_entity)
                elif self._user_state.placing_entity.is_smart_floor_tile:
                    return self._add_smart_floor_tiles()
            elif self._user_state.deleting_entities:
                return DeleteEntities(self._snapped_mouse_world_pos)
            elif self._user_state.deleting_decorations:
                return DeleteDecorations(self._snapped_mouse_world_pos)
            else:
                raise Exception("Unhandled user state: " + str(self._user_state))

        if self._is_right_mouse_button_down and self._is_snapped_mouse_within_world and not self._is_snapped_mouse_over_ui:
            if self._user_state.placing_entity:
                if self._user_state.placing_entity.is_smart_floor_tile:
                    return self._delete_smart_floor_tiles()

    def _get_map_editor_entity_by_id(self, map_editor_entity_id: int):
        entity = [e for e in self._entities_by_type[self._shown_tab]
                  if e.map_editor_entity_id == map_editor_entity_id][0]
        return entity

    def _add_smart_floor_tiles(self):
        pos = self._snapped_mouse_world_pos
        tile_size = self._user_state.placing_entity.entity_size[0]

        for x in range(pos[0], min(pos[0] + tile_size, self.world_area.right), GRID_CELL_SIZE):
            for y in range(pos[1], min(pos[1] + tile_size, self.world_area.bottom), GRID_CELL_SIZE):
                self._smart_floor_tiles_to_add.append((x, y, GRID_CELL_SIZE, GRID_CELL_SIZE))

    def _delete_smart_floor_tiles(self):
        pos = self._snapped_mouse_world_pos
        tile_size = self._user_state.placing_entity.entity_size[0]
        for x in range(pos[0], min(pos[0] + tile_size, self.world_area.right), GRID_CELL_SIZE):
            for y in range(pos[1], min(pos[1] + tile_size, self.world_area.bottom), GRID_CELL_SIZE):
                self._smart_floor_tiles_to_delete.append((x, y, GRID_CELL_SIZE, GRID_CELL_SIZE))

    def _set_currently_hovered_component_not_hovered(self):
        if self._hovered_component is not None:
            self._hovered_component.hovered = False
            self._hovered_component = None

    def _on_hover_component(self, component):
        self._hovered_component = component
        self._hovered_component.hovered = True

    def handle_mouse_left_click(self) -> Optional[MapEditorAction]:

        if self._entity_icon_hovered_by_mouse:
            self._user_state = UserState.placing_entity(self._entity_icon_hovered_by_mouse)

        self._is_left_mouse_button_down = True
        # noinspection PyTypeChecker
        if self._hovered_component in self._checkboxes + self._buttons:
            return self._hovered_component.on_click()
        if self._hovered_component == self._minimap:
            mouse_ui_position = self._translate_screen_position_to_ui(self._mouse_screen_pos)
            position_ratio = self._minimap.get_position_ratio(mouse_ui_position)
            return SetCameraPosition(position_ratio)
        for entity_type in self._tab_buttons_by_entity_type:
            if self._hovered_component == self._tab_buttons_by_entity_type[entity_type]:
                self._set_shown_tab(entity_type)

        if self._user_state.placing_entity:
            entity_being_placed = self._user_state.placing_entity
            if self._is_snapped_mouse_within_world and not self._is_snapped_mouse_over_ui:
                if entity_being_placed.is_smart_floor_tile:
                    return self._add_smart_floor_tiles()
                else:
                    return AddEntity(self._snapped_mouse_world_pos, entity_being_placed)

        elif self._user_state.deleting_entities:
            return DeleteEntities(self._snapped_mouse_world_pos)
        elif self._user_state.deleting_decorations:
            return DeleteDecorations(self._snapped_mouse_world_pos)
        else:
            raise Exception("Unhandled user state: %s" % self._user_state)

    def handle_mouse_right_click(self) -> Optional[MapEditorAction]:

        self._is_right_mouse_button_down = True

        if self._user_state.placing_entity:
            entity_being_placed = self._user_state.placing_entity
            if self._is_snapped_mouse_within_world and not self._is_snapped_mouse_over_ui:
                if entity_being_placed.is_smart_floor_tile:
                    return self._delete_smart_floor_tiles()

    def handle_mouse_left_release(self):
        self._is_left_mouse_button_down = False
        if self._is_snapped_mouse_within_world and not self._is_snapped_mouse_over_ui:
            entity = self._user_state.placing_entity
            if entity and entity.is_smart_floor_tile:
                tiles = list(self._smart_floor_tiles_to_add)
                self._smart_floor_tiles_to_add.clear()
                return AddSmartFloorTiles(tiles)

    def handle_mouse_right_release(self):
        self._is_right_mouse_button_down = False
        if self._is_snapped_mouse_within_world and not self._is_snapped_mouse_over_ui:
            entity = self._user_state.placing_entity
            if entity and entity.is_smart_floor_tile:
                tiles = list(self._smart_floor_tiles_to_delete)
                self._smart_floor_tiles_to_delete.clear()
                return DeleteSmartFloorTiles(tiles)

    def handle_key_down(self, key):
        number_keys = [pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_4, pygame.K_5, pygame.K_6, pygame.K_7, pygame.K_8,
                       pygame.K_9, pygame.K_0]
        if key == pygame.K_q:
            self._user_state = UserState.deleting_entities()
        elif key == pygame.K_z:
            self._user_state = UserState.deleting_decorations()
        elif key in self._tabs_by_pygame_key:
            self._set_shown_tab(self._tabs_by_pygame_key[key])
        elif key in number_keys:
            index = number_keys.index(key)
            shown_entity_icons = self.entity_icons_by_type[self._shown_tab]
            if index < len(shown_entity_icons):
                targeted_icon = shown_entity_icons[index]
                entity = self._get_map_editor_entity_by_id(targeted_icon.map_editor_entity_id)
                self._user_state = UserState.placing_entity(entity)

    def _translate_ui_position_to_screen(self, position):
        return position[0] + self._ui_screen_area.x, position[1] + self._ui_screen_area.y

    def _translate_screen_position_to_ui(self, position: Tuple[int, int]):
        return position[0] - self._ui_screen_area.x, position[1] - self._ui_screen_area.y

    def _get_image_for_sprite(self, sprite: Sprite, direction: Direction,
                              animation_progress: float) -> ImageWithRelativePosition:

        images: Dict[Direction, List[ImageWithRelativePosition]] = self._images_by_sprite[sprite]
        if direction in images:
            images_for_this_direction = images[direction]
        else:
            images_for_this_direction = next(iter(images.values()))

        animation_frame_index = int(len(images_for_this_direction) * animation_progress)
        return images_for_this_direction[animation_frame_index]

    def _set_shown_tab(self, shown_tab: EntityTab):
        if self._shown_tab:
            self._tab_buttons_by_entity_type[self._shown_tab].enabled = False
        self._shown_tab = shown_tab
        self._tab_buttons_by_entity_type[shown_tab].enabled = True

    def render(
            self,
            num_enemies: int,
            num_walls: int,
            num_decorations: int,
            npc_positions: List[Tuple[int, int]],
            player_position: Tuple[int, int],
            grid: Grid,
            named_world_positions: Dict[Tuple[int, int], str],
            fps_string: str):

        x_camera = self.camera_world_area.x
        y_camera = self.camera_world_area.y

        placing_entity = self._user_state.placing_entity

        if placing_entity and placing_entity.is_smart_floor_tile:

            xmin = (x_camera - self.world_area.x) // GRID_CELL_SIZE
            xmax = xmin + self.camera_world_area.w // GRID_CELL_SIZE
            ymin = (y_camera - self.world_area.y) // GRID_CELL_SIZE
            ymax = ymin + self.camera_world_area.h // GRID_CELL_SIZE
            for x in range(xmin, xmax):
                for y in range(ymin, ymax):
                    if grid.is_floor((x, y)):
                        rect = Rect(x * GRID_CELL_SIZE + self.world_area.x - x_camera,
                                    y * GRID_CELL_SIZE + self.world_area.y - y_camera,
                                    GRID_CELL_SIZE,
                                    GRID_CELL_SIZE)
                        self._screen_render.rect((100, 180, 250), rect, 2)

        moused_over_names = [s for (p, s) in named_world_positions.items() if p == self._snapped_mouse_world_pos]

        self._screen_render.rect(COLOR_BLACK, Rect(0, 0, self._camera_size[0], self._camera_size[1]), 3)
        self._screen_render.rect_filled(COLOR_BLACK, Rect(0, self._camera_size[1], self._screen_size[0],
                                                          self._screen_size[1] - self._camera_size[1]))

        self.button_delete_entities.render(self._user_state.deleting_entities)
        self.button_delete_decorations.render(self._user_state.deleting_decorations)

        for icon in self.entity_icons_by_type[self._shown_tab]:
            highlighted = placing_entity and placing_entity.map_editor_entity_id == icon.map_editor_entity_id
            icon.render(highlighted)

        self._screen_render.rect(COLOR_WHITE, self._ui_screen_area, 1)

        debug_entries = [
            self._map_file_path,
            "--------------------------------",
            fps_string + " fps",
            "# enemies: " + str(num_enemies),
            "# walls: " + str(num_walls),
            "# decorations: " + str(num_decorations),
            "Cell size: " + str(self.grid_cell_size),
            "Mouse world pos: " + str(self._snapped_mouse_world_pos),
            "Mouse over: " + (moused_over_names[0] if moused_over_names else "...")
        ]
        self._screen_render.rect_transparent(Rect(0, 0, 280, 5 + len(debug_entries) * 22), 150, COLOR_BLACK)
        for i, entry in enumerate(debug_entries):
            self._screen_render.text(self._font_debug_info, entry, (10, 12 + i * 20))

        self._minimap.update_camera_area(self.camera_world_area)
        self._minimap.update_npc_positions(npc_positions)
        self._minimap.update_player_position(player_position)
        self._minimap.update_world_area(self.world_area)

        # noinspection PyTypeChecker
        for component in self._checkboxes + self._tab_buttons + self._buttons + [self._minimap]:
            component.render()

        if self._hovered_component and self._hovered_component.tooltip:
            tooltip: TooltipGraphics = self._hovered_component.tooltip
            tooltip.render()

        if not self._is_snapped_mouse_over_ui:
            snapped_mouse_rect = Rect(self._snapped_mouse_screen_pos[0], self._snapped_mouse_screen_pos[1],
                                      self.grid_cell_size, self.grid_cell_size)
            if not self._is_snapped_mouse_within_world:
                self._screen_render.rect((250, 50, 0), snapped_mouse_rect, 3)
            elif placing_entity:
                if not placing_entity.is_smart_floor_tile:
                    self._render_map_editor_world_entity_at_position(
                        placing_entity.sprite, self._snapped_mouse_screen_pos)
                self._render_placed_entity_outline(self._snapped_mouse_screen_pos, placing_entity.entity_size)
            elif self._user_state.deleting_entities:
                self._screen_render.rect((250, 250, 0), snapped_mouse_rect, 3)
            elif self._user_state.deleting_decorations:
                self._screen_render.rect((0, 250, 250), snapped_mouse_rect, 3)
            else:
                raise Exception("Unhandled user_state: " + str(self._user_state))

        for tile in self._smart_floor_tiles_to_add:
            rect = Rect(tile[0] - x_camera, tile[1] - y_camera, tile[2], tile[3])
            self._screen_render.rect((180, 180, 250), rect, 2)
        for tile in self._smart_floor_tiles_to_delete:
            rect = Rect(tile[0] - x_camera, tile[1] - y_camera, tile[2], tile[3])
            self._screen_render.rect((250, 180, 180), rect, 2)

    def update_wall_positions(self, wall_positions):
        self._minimap.set_walls(wall_positions)

    def _render_map_editor_world_entity_at_position(self, sprite: Sprite, position: Tuple[int, int]):
        image_with_relative_position = self._get_image_for_sprite(sprite, Direction.DOWN, 0)
        sprite_position = sum_of_vectors(position, image_with_relative_position.position_relative_to_entity)
        self._screen_render.image(image_with_relative_position.image, sprite_position)

    def _render_placed_entity_outline(self, position: Tuple[int, int], entity_size: Tuple[int, int]):
        self._screen_render.rect((50, 250, 0), Rect(position[0], position[1], entity_size[0], entity_size[1]), 3)

    def _create_map_editor_icon(self, rect: Rect, user_input_key: str,
                                sprite: Optional[Sprite], ui_icon_sprite: Optional[UiIconSprite],
                                map_editor_entity_id: int, tooltip: Optional[TooltipGraphics]) -> MapEditorIcon:
        if sprite:
            image = self._images_by_sprite[sprite][Direction.DOWN][0].image
        elif ui_icon_sprite:
            image = self._images_by_ui_sprite[ui_icon_sprite]
        else:
            raise Exception("Nothing to render!")

        return MapEditorIcon(self._ui_render, rect, image, self._font_ui_icon_keys, user_input_key,
                             map_editor_entity_id, tooltip)

    def _is_screen_position_within_ui(self, screen_position: Tuple[int, int]):
        ui_position = self._translate_screen_position_to_ui(screen_position)
        return ui_position[1] >= 0
Beispiel #2
0
class GameUiView:
    def __init__(self, pygame_screen, camera_size: Tuple[int, int],
                 screen_size: Tuple[int, int],
                 images_by_ui_sprite: Dict[UiIconSprite, Any],
                 big_images_by_ui_sprite: Dict[UiIconSprite, Any],
                 images_by_portrait_sprite: Dict[PortraitIconSprite, Any],
                 ability_key_labels: List[str]):

        # INIT PYGAME FONTS
        pygame.font.init()

        # SETUP FUNDAMENTALS
        self.screen_render = DrawableArea(pygame_screen)
        self.ui_render = DrawableArea(pygame_screen,
                                      self._translate_ui_position_to_screen)
        self.ui_screen_area = Rect(0, camera_size[1], screen_size[0],
                                   screen_size[1] - camera_size[1])
        self.camera_size = camera_size
        self.screen_size = screen_size
        self.ability_key_labels = ability_key_labels

        # FONTS
        self.font_splash_screen = pygame.font.Font(
            DIR_FONTS + 'Arial Rounded Bold.ttf', 64)
        self.font_ui_stat_bar_numbers = pygame.font.Font(
            DIR_FONTS + 'Monaco.dfont', 12)
        self.font_ui_money = pygame.font.Font(DIR_FONTS + 'Monaco.dfont', 12)
        self.font_tooltip_details = pygame.font.Font(
            DIR_FONTS + 'Monaco.dfont', 12)
        self.font_buttons = pygame.font.Font(DIR_FONTS + 'Monaco.dfont', 12)
        self.font_stats = pygame.font.Font(DIR_FONTS + 'Monaco.dfont', 9)
        self.font_buff_texts = pygame.font.Font(DIR_FONTS + 'Monaco.dfont', 12)
        self.font_message = pygame.font.Font(DIR_FONTS + 'Monaco.dfont', 14)
        self.font_debug_info = pygame.font.Font(DIR_FONTS + 'Monaco.dfont', 12)
        self.font_ui_icon_keys = pygame.font.Font(
            DIR_FONTS + 'Courier New Bold.ttf', 12)
        self.font_level = pygame.font.Font(DIR_FONTS + 'Courier New Bold.ttf',
                                           11)

        # IMAGES
        self.images_by_ui_sprite = images_by_ui_sprite
        self.big_images_by_ui_sprite = big_images_by_ui_sprite
        self.images_by_portrait_sprite = images_by_portrait_sprite
        self.images_by_item_category = {
            ItemEquipmentCategory.HEAD:
            self.images_by_ui_sprite[UiIconSprite.INVENTORY_TEMPLATE_HELMET],
            ItemEquipmentCategory.CHEST:
            self.images_by_ui_sprite[UiIconSprite.INVENTORY_TEMPLATE_CHEST],
            ItemEquipmentCategory.MAIN_HAND:
            self.images_by_ui_sprite[UiIconSprite.INVENTORY_TEMPLATE_MAINHAND],
            ItemEquipmentCategory.OFF_HAND:
            self.images_by_ui_sprite[UiIconSprite.INVENTORY_TEMPLATE_OFFHAND],
            ItemEquipmentCategory.NECK:
            self.images_by_ui_sprite[UiIconSprite.INVENTORY_TEMPLATE_NECK],
            ItemEquipmentCategory.RING:
            self.images_by_ui_sprite[UiIconSprite.INVENTORY_TEMPLATE_RING],
        }

        # UI COMPONENTS
        self.ability_icons_row: Rect = Rect(0, 0, 0, 0)
        self.ability_icons: List[AbilityIcon] = []
        self.consumable_icons_row: Rect = Rect(0, 0, 0, 0)
        self.consumable_icons: List[ConsumableIcon] = []
        self.inventory_icons_rect: Rect = Rect(0, 0, 0, 0)
        self.inventory_icons: List[ItemIcon] = []
        self.exp_bar = ExpBar(self.ui_render, Rect(135, 8, 300, 2),
                              self.font_level)
        self.minimap = Minimap(self.ui_render, Rect(475, 52, 80, 80),
                               Rect(0, 0, 1, 1), (0, 0))
        self.buffs = Buffs(self.ui_render, self.font_buff_texts, (10, -35))
        self.money_text = Text(self.ui_render, self.font_ui_money, (24, 150),
                               "NO MONEY")
        self.talents_window: TalentsWindow = None
        self.quests_window: QuestsWindow = None
        self.message = Message(self.screen_render, self.font_message,
                               self.ui_screen_area.w // 2,
                               self.ui_screen_area.y - 30)
        self.paused_splash_screen = PausedSplashScreen(
            self.screen_render, self.font_splash_screen,
            Rect(0, 0, self.screen_size[0], self.screen_size[1]))
        self.controls_window = ControlsWindow(self.ui_render,
                                              self.font_tooltip_details,
                                              self.font_stats)

        # SETUP UI COMPONENTS
        self._setup_ability_icons()
        self._setup_consumable_icons()
        self._setup_inventory_icons()
        self._setup_health_and_mana_bars()
        self._setup_stats_window()
        self._setup_talents_window(TalentsState(TalentsConfig({})))
        self._setup_quests_window()
        self._setup_toggle_buttons()
        self._setup_portrait()
        self._setup_dialog()

        # QUICKLY CHANGING STATE
        self.hovered_component = None
        self.fps_string = ""
        self.game_mode_string = ""
        self.enabled_toggle: ToggleButton = None
        self.item_slot_being_dragged: ItemIcon = None
        self.consumable_slot_being_dragged: ConsumableIcon = None
        self.is_mouse_hovering_ui = False
        self.mouse_screen_position = (0, 0)
        self.dialog_state = DialogState()

        self._ticks_since_last_consumable_action = 0
        self._ticks_since_last_ability_action = 0
        self.highlighted_consumable_action: Optional[int] = None
        self.highlighted_ability_action: Optional[AbilityType] = None
        self.manually_highlighted_inventory_item: Optional[
            ItemId] = None  # used for dialog

        self.info_message = InfoMessage()

    def _setup_ability_icons(self):
        x_0 = 140
        y = 112
        icon_space = 2
        icon_rect_padding = 2
        abilities_rect_pos = (x_0 - icon_rect_padding, y - icon_rect_padding)
        max_num_abilities = 5
        self.ability_icons_row = Rect(
            abilities_rect_pos[0], abilities_rect_pos[1],
            (UI_ICON_SIZE[0] + icon_space) * max_num_abilities - icon_space +
            icon_rect_padding * 2, UI_ICON_SIZE[1] + icon_rect_padding * 2)

        self.ability_icons = []
        for i in range(max_num_abilities):
            x = x_0 + i * (UI_ICON_SIZE[0] + icon_space)
            rect = Rect(x, y, UI_ICON_SIZE[0], UI_ICON_SIZE[1])
            icon = AbilityIcon(self.ui_render, rect, None, None,
                               self.font_ui_icon_keys, None, None, 0)
            self.ability_icons.append(icon)

    def _setup_consumable_icons(self):
        x_0 = 140
        y = 52
        icon_space = 2
        icon_rect_padding = 2
        consumables_rect_pos = (x_0 - icon_rect_padding, y - icon_rect_padding)
        max_num_consumables = 5
        self.consumable_icons_row = Rect(
            consumables_rect_pos[0], consumables_rect_pos[1],
            (UI_ICON_SIZE[0] + icon_space) * max_num_consumables - icon_space +
            icon_rect_padding * 2, UI_ICON_SIZE[1] + icon_rect_padding * 2)

        self.consumable_icons = []
        for i in range(max_num_consumables):
            x = x_0 + i * (UI_ICON_SIZE[0] + icon_space)
            rect = Rect(x, y, UI_ICON_SIZE[0], UI_ICON_SIZE[1])
            slot_number = i + 1
            icon = ConsumableIcon(self.ui_render, rect, None, str(slot_number),
                                  self.font_ui_icon_keys, None, [],
                                  slot_number)
            self.consumable_icons.append(icon)

    def _setup_inventory_icons(self):
        x_0 = 325
        y_0 = 24
        icon_space = 2
        icon_rect_padding = 2
        items_rect_pos = (x_0 - icon_rect_padding, y_0 - icon_rect_padding)
        num_item_slot_rows = 4
        num_slots_per_row = 3
        self.inventory_icons_rect = Rect(
            items_rect_pos[0], items_rect_pos[1],
            (UI_ICON_SIZE[0] + icon_space) * num_slots_per_row - icon_space +
            icon_rect_padding * 2, num_item_slot_rows * UI_ICON_SIZE[1] +
            (num_item_slot_rows - 1) * icon_space + icon_rect_padding * 2)
        for i in range(num_item_slot_rows * num_slots_per_row):
            x = x_0 + (i % num_slots_per_row) * (UI_ICON_SIZE[0] + icon_space)
            y = y_0 + (i // num_slots_per_row) * (UI_ICON_SIZE[1] + icon_space)
            rect = Rect(x, y, UI_ICON_SIZE[0], UI_ICON_SIZE[1])
            icon = ItemIcon(self.ui_render, rect, None, None, None, None, i)
            self.inventory_icons.append(icon)

    def _setup_health_and_mana_bars(self):
        rect_healthbar = Rect(20, 111, 100, 14)
        self.healthbar = StatBar(self.ui_render,
                                 rect_healthbar, (200, 0, 50),
                                 None,
                                 0,
                                 1,
                                 show_numbers=True,
                                 font=self.font_ui_stat_bar_numbers)
        rect_manabar = Rect(20, 132, 100, 14)
        self.manabar = StatBar(self.ui_render,
                               rect_manabar, (50, 0, 200),
                               None,
                               0,
                               1,
                               show_numbers=True,
                               font=self.font_ui_stat_bar_numbers)

    def _setup_toggle_buttons(self):
        x = 600
        y_0 = 12
        w = 150
        h = 20
        font = self.font_buttons
        self.stats_toggle = ToggleButton(self.ui_render, Rect(x, y_0, w,
                                                              h), font,
                                         "STATS    [A]", ToggleButtonId.STATS,
                                         False, self.stats_window)
        self.talents_toggle = ToggleButton(self.ui_render,
                                           Rect(x, y_0 + 25, w,
                                                h), font, "TALENTS  [N]",
                                           ToggleButtonId.TALENTS, False,
                                           self.talents_window)
        # TODO Add hotkey, and handle that user input in this module
        self.quests_toggle = ToggleButton(self.ui_render,
                                          Rect(x, y_0 + 50, w,
                                               h), font, "QUESTS   [B]",
                                          ToggleButtonId.QUESTS, False,
                                          self.quests_window)

        self.controls_toggle = ToggleButton(self.ui_render,
                                            Rect(x, y_0 + 75, w,
                                                 h), font, "HELP     [H]",
                                            ToggleButtonId.HELP, False,
                                            self.controls_window)
        self.toggle_buttons = [
            self.stats_toggle, self.talents_toggle, self.quests_toggle,
            self.controls_toggle
        ]
        self.sound_checkbox = Checkbox(self.ui_render,
                                       Rect(x, y_0 + 100, 70, h), "SOUND",
                                       True, lambda _: ToggleSound())
        self.save_button = Button(self.ui_render, Rect(x + 80, y_0 + 100, 70,
                                                       h), "SAVE [S]",
                                  lambda: SaveGame())
        self.fullscreen_checkbox = Checkbox(self.ui_render,
                                            Rect(x, y_0 + 125, w,
                                                 h), "FULLSCREEN", True,
                                            lambda _: ToggleFullscreen())

    def _setup_stats_window(self):
        self.stats_window = StatsWindow(self.ui_render,
                                        self.font_tooltip_details,
                                        self.font_stats, None, 0, None, 1)

    def _setup_quests_window(self):
        self.quests_window = QuestsWindow(self.ui_render,
                                          self.font_tooltip_details,
                                          self.font_stats)

    def _setup_talents_window(self, talents: TalentsState):
        talent_tiers: List[TalentTierData] = []
        option_data_from_config = lambda config: TalentOptionData(
            config.name, config.description, self.images_by_ui_sprite[
                config.ui_icon_sprite])
        for tier_state in talents.tiers:
            options = [
                option_data_from_config(config)
                for config in tier_state.options
            ]
            tier_data = TalentTierData(tier_state.status,
                                       tier_state.required_level,
                                       tier_state.picked_index, options)
            talent_tiers.append(tier_data)
        if self.talents_window is None:
            self.talents_window = TalentsWindow(self.ui_render,
                                                self.font_tooltip_details,
                                                self.font_stats, talent_tiers)
        else:
            self.talents_window.update(talent_tiers)

    def _setup_portrait(self):
        rect = Rect(20, 18, PORTRAIT_ICON_SIZE[0], PORTRAIT_ICON_SIZE[1])
        self.portrait = Portrait(self.ui_render, rect, None)

    def _setup_dialog(self):
        self.dialog = Dialog(self.screen_render, None, None, [], 0,
                             PORTRAIT_ICON_SIZE, UI_ICON_SIZE)

    # --------------------------------------------------------------------------------------------------------
    #                                     HANDLE USER INPUT
    # --------------------------------------------------------------------------------------------------------

    def handle_mouse_movement(self, mouse_screen_pos: Tuple[int, int]):
        self.mouse_screen_position = mouse_screen_pos
        self.is_mouse_hovering_ui = is_point_in_rect(mouse_screen_pos,
                                                     self.ui_screen_area)
        self._check_for_hovered_components()

    def handle_mouse_movement_in_dialog(self, mouse_screen_pos: Tuple[int,
                                                                      int]):
        self.mouse_screen_position = mouse_screen_pos
        self.is_mouse_hovering_ui = is_point_in_rect(mouse_screen_pos,
                                                     self.ui_screen_area)
        self.dialog.handle_mouse_movement(mouse_screen_pos)

    def handle_mouse_click_in_dialog(
            self) -> Optional[Tuple[NpcType, int, int]]:
        clicked_option_index = self.dialog.get_hovered_option_index()
        if clicked_option_index is not None:
            previous_index = self.dialog_state.option_index
            self.dialog_state.option_index = clicked_option_index
            self._update_dialog_graphics()
            return self.dialog_state.npc.npc_type, previous_index, clicked_option_index

    def _check_for_hovered_components(self):
        mouse_ui_position = self._translate_screen_position_to_ui(
            self.mouse_screen_position)

        # noinspection PyTypeChecker
        simple_components = [
            self.healthbar, self.manabar, self.sound_checkbox,
            self.save_button, self.fullscreen_checkbox
        ] + self.ability_icons + self.toggle_buttons

        for component in simple_components:
            if component.contains(mouse_ui_position):
                self._on_hover_component(component)
                return
        # TODO Unify hover handling for consumables/items
        # noinspection PyTypeChecker
        for icon in self.consumable_icons + self.inventory_icons:
            collision_offset = icon.get_collision_offset(mouse_ui_position)
            if collision_offset:
                self._on_hover_component(icon)
                return

        # TODO Unify hover handling of window icons
        if self.talents_window.shown:
            hovered_icon = self.talents_window.get_icon_containing(
                mouse_ui_position)
            if hovered_icon:
                self._on_hover_component(hovered_icon)
                return

        # If something was hovered, we would have returned from the method
        self._set_currently_hovered_component_not_hovered()

    def _on_hover_component(self, component):
        self._set_currently_hovered_component_not_hovered()
        self.hovered_component = component
        self.hovered_component.hovered = True

    def _set_currently_hovered_component_not_hovered(self):
        if self.hovered_component is not None:
            self.hovered_component.hovered = False
            self.hovered_component = None

    def handle_mouse_click(self) -> List[EventTriggeredFromUi]:
        if self.hovered_component in self.toggle_buttons:
            self._on_click_toggle(self.hovered_component)
        elif self.hovered_component in [
                self.save_button, self.fullscreen_checkbox, self.sound_checkbox
        ]:
            return [self.hovered_component.on_click()]
        elif self.hovered_component in self.inventory_icons and self.hovered_component.item_id:
            self.item_slot_being_dragged = self.hovered_component
            return [StartDraggingItemOrConsumable()]
        elif self.hovered_component in self.consumable_icons and self.hovered_component.consumable_types:
            self.consumable_slot_being_dragged = self.hovered_component
            return [StartDraggingItemOrConsumable()]
        elif self.hovered_component in self.talents_window.get_pickable_talent_icons(
        ):
            talent_icon: TalentIcon = self.hovered_component
            return [
                PickTalent(talent_icon.tier_index, talent_icon.option_index)
            ]
        return []

    def handle_mouse_right_click(self) -> List[EventTriggeredFromUi]:
        if self.hovered_component in self.inventory_icons:
            self.item_slot_being_dragged = None
            item_icon: ItemIcon = self.hovered_component
            return [TrySwitchItemInInventory(item_icon.inventory_slot_index)]
        return []

    def handle_mouse_release(self) -> List[EventTriggeredFromUi]:
        triggered_events = []
        if self.item_slot_being_dragged:
            if self.hovered_component in self.inventory_icons and self.hovered_component != self.item_slot_being_dragged:
                item_icon: ItemIcon = self.hovered_component
                event = DragItemBetweenInventorySlots(
                    self.item_slot_being_dragged.inventory_slot_index,
                    item_icon.inventory_slot_index)
                triggered_events.append(event)
            if not self.is_mouse_hovering_ui:
                event = DropItemOnGround(
                    self.item_slot_being_dragged.inventory_slot_index,
                    self.mouse_screen_position)
                triggered_events.append(event)
            self.item_slot_being_dragged = None

        if self.consumable_slot_being_dragged:
            if self.hovered_component in self.consumable_icons and self.hovered_component != self.consumable_slot_being_dragged:
                consumable_icon: ConsumableIcon = self.hovered_component
                event = DragConsumableBetweenInventorySlots(
                    self.consumable_slot_being_dragged.slot_number,
                    consumable_icon.slot_number)
                triggered_events.append(event)
            if not self.is_mouse_hovering_ui:
                event = DropConsumableOnGround(
                    self.consumable_slot_being_dragged.slot_number,
                    self.mouse_screen_position)
                triggered_events.append(event)
            self.consumable_slot_being_dragged = None

        return triggered_events

    def handle_space_click(self) -> Optional[Tuple[NonPlayerCharacter, int]]:
        if self.dialog_state.active:
            self.dialog_state.active = False
            self._update_dialog_graphics()
            return self.dialog_state.npc, self.dialog_state.option_index
        return None

    def handle_key_press(self, key) -> List[EventTriggeredFromUi]:
        if key == pygame.K_s:
            return [SaveGame()]
        elif key == pygame.K_n:
            self._click_toggle_button(ToggleButtonId.TALENTS)
            return [ToggleWindow()]
        elif key == pygame.K_a:
            self._click_toggle_button(ToggleButtonId.STATS)
            return [ToggleWindow()]
        elif key == pygame.K_h:
            self._click_toggle_button(ToggleButtonId.HELP)
            return [ToggleWindow()]
        elif key == pygame.K_b:
            self._click_toggle_button(ToggleButtonId.QUESTS)
            return [ToggleWindow()]
        return []

    # --------------------------------------------------------------------------------------------------------
    #                              HANDLE TOGGLE BUTTON USER INTERACTIONS
    # --------------------------------------------------------------------------------------------------------

    def _click_toggle_button(self, ui_toggle: ToggleButtonId):
        toggle = [
            tb for tb in self.toggle_buttons if tb.toggle_id == ui_toggle
        ][0]
        self._on_click_toggle(toggle)

    def _on_click_toggle(self, clicked_toggle: ToggleButton):
        play_sound(SoundId.UI_TOGGLE)
        if clicked_toggle.is_open:
            self.enabled_toggle.close()
            self.enabled_toggle = None
        else:
            if self.enabled_toggle is not None:
                self.enabled_toggle.close()
            self.enabled_toggle = clicked_toggle
            self.enabled_toggle.open()
        self._check_for_hovered_components()

    def close_talent_window(self):
        if self.enabled_toggle == self.talents_toggle:
            self.enabled_toggle.close()
            self.enabled_toggle = None
            self._check_for_hovered_components()

    # --------------------------------------------------------------------------------------------------------
    #                              REACT TO OBSERVABLE EVENTS
    # --------------------------------------------------------------------------------------------------------

    def on_talents_updated(self, talents_state: TalentsState):
        self._setup_talents_window(talents_state)
        if talents_state.has_unpicked_talents(
        ) and not self.talents_toggle.is_open:
            self.talents_toggle.highlighted = True

    def on_talent_was_unlocked(self, _event):
        if self.enabled_toggle != self.talents_toggle:
            self.talents_toggle.highlighted = True

    def on_ability_was_clicked(self, ability_type: AbilityType):
        self.highlighted_ability_action = ability_type
        self._ticks_since_last_ability_action = 0

    def on_consumable_was_clicked(self, slot_number: int):
        self.highlighted_consumable_action = slot_number
        self._ticks_since_last_consumable_action = 0

    def on_player_movement_speed_updated(self, speed_multiplier: float):
        self.stats_window.player_speed_multiplier = speed_multiplier

    def on_player_exp_updated(self, event: Tuple[int, float]):
        level, ratio_exp_until_next_level = event
        self.exp_bar.update(level, ratio_exp_until_next_level)
        self.stats_window.level = level

    def on_player_quests_updated(self, event: Tuple[List[Quest], List[Quest]]):
        active_quests, completed_quests = event
        self.quests_window.active_quests = list(active_quests)
        self.quests_window.completed_quests = list(completed_quests)

    def on_money_updated(self, money: int):
        self.money_text.text = "Money: " + str(money)

    def on_cooldowns_updated(self,
                             ability_cooldowns_remaining: Dict[AbilityType,
                                                               int]):
        for icon in self.ability_icons:
            ability_type = icon.ability_type
            if ability_type:
                ability = ABILITIES[ability_type]
                icon.cooldown_remaining_ratio = ability_cooldowns_remaining[
                    ability_type] / ability.cooldown

    def on_health_updated(self, health: Tuple[int, int]):
        value, max_value = health
        self.healthbar.update(value, max_value)

    def on_mana_updated(self, mana: Tuple[int, int]):
        value, max_value = mana
        self.manabar.update(value, max_value)

    def on_buffs_updated(self, active_buffs: List[BuffWithDuration]):
        buffs = []
        for active_buff in active_buffs:
            buff_type = active_buff.buff_effect.get_buff_type()
            # Buffs that don't have description texts shouldn't be displayed. (They are typically irrelevant to the
            # player)
            if buff_type in BUFF_TEXTS:
                text = BUFF_TEXTS[buff_type]
                ratio_remaining = active_buff.get_ratio_duration_remaining()
                buffs.append((text, ratio_remaining))
        self.buffs.update(buffs)

    def on_player_stats_updated(self, player_state: PlayerState):
        self.stats_window.player_state = player_state
        health_regen = player_state.health_resource.get_effective_regen()
        mana_regen = player_state.mana_resource.get_effective_regen()

        tooltip_details = [
            DetailLine("regeneration: " + "{:.1f}".format(health_regen) + "/s")
        ]
        health_tooltip = TooltipGraphics(
            self.ui_render,
            COLOR_WHITE,
            "Health",
            tooltip_details,
            bottom_left=(self.healthbar.rect.left - 2,
                         self.healthbar.rect.top - 1))
        self.healthbar.tooltip = health_tooltip
        tooltip_details = [
            DetailLine("regeneration: " + "{:.1f}".format(mana_regen) + "/s")
        ]
        mana_tooltip = TooltipGraphics(self.ui_render,
                                       COLOR_WHITE,
                                       "Mana",
                                       tooltip_details,
                                       bottom_left=(self.manabar.rect.left - 2,
                                                    self.manabar.rect.top - 1))
        self.manabar.tooltip = mana_tooltip

    def on_abilities_updated(self, abilities_by_key_string: Dict[str,
                                                                 AbilityType]):
        for i, icon in enumerate(self.ability_icons):
            key_string = self.ability_key_labels[i]
            ability_type = abilities_by_key_string.get(key_string, None)
            ability = ABILITIES[ability_type] if ability_type else None
            image = self.images_by_ui_sprite[
                ability.icon_sprite] if ability else None
            icon.update(image=image,
                        label=key_string,
                        ability=ability,
                        ability_type=ability_type)

    def on_consumables_updated(self,
                               consumable_slots: Dict[int,
                                                      List[ConsumableType]]):
        for i, slot_number in enumerate(consumable_slots):
            icon = self.consumable_icons[i]
            consumable_types = consumable_slots[slot_number]
            image = None
            consumable = None
            if consumable_types:
                consumable = CONSUMABLES[consumable_types[0]]
                image = self.images_by_ui_sprite[consumable.icon_sprite]

            icon.update(image, consumable, consumable_types)

    def on_inventory_updated(self, item_slots: List[ItemInventorySlot]):
        for i in range(len(item_slots)):
            icon = self.inventory_icons[i]
            slot = item_slots[i]
            item_id = slot.get_item_id() if not slot.is_empty() else None
            slot_equipment_category = slot.enforced_equipment_category
            image = None
            tooltip = None
            if item_id:
                item_type = item_id.item_type
                data = get_item_data_by_type(item_type)
                image = self.images_by_ui_sprite[data.icon_sprite]
                category_name = None
                if data.item_equipment_category:
                    category_name = ITEM_EQUIPMENT_CATEGORY_NAMES[
                        data.item_equipment_category]
                description_lines = create_item_description(item_id)
                item_name = item_id.name
                is_rare = bool(item_id.affix_stats)
                is_unique = data.is_unique
                tooltip = TooltipGraphics.create_for_item(self.ui_render,
                                                          item_name,
                                                          category_name,
                                                          icon.rect.topleft,
                                                          description_lines,
                                                          is_rare=is_rare,
                                                          is_unique=is_unique)
            elif slot_equipment_category:
                image = self.images_by_item_category[slot_equipment_category]
                category_name = ITEM_EQUIPMENT_CATEGORY_NAMES[
                    slot_equipment_category]
                tooltip_details = [
                    DetailLine("[" + category_name + "]"),
                    DetailLine(
                        "You have nothing equipped. Drag an item here to equip it!"
                    )
                ]
                tooltip = TooltipGraphics(self.ui_render,
                                          COLOR_WHITE,
                                          "...",
                                          tooltip_details,
                                          bottom_left=icon.rect.topleft)

            icon.image = image
            icon.tooltip = tooltip
            icon.slot_equipment_category = slot_equipment_category
            icon.item_id = item_id

    def on_fullscreen_changed(self, fullscreen: bool):
        self.fullscreen_checkbox.checked = fullscreen

    def on_world_area_updated(self, world_area: Rect):
        self.minimap.update_world_area(world_area)

    def on_walls_seen(self, seen_wall_positions: List[Tuple[int, int]]):
        self.minimap.add_walls(seen_wall_positions)

    def on_player_position_updated(self, center_position: Tuple[int, int]):
        self.minimap.update_player_position(center_position)

    # --------------------------------------------------------------------------------------------------------
    #                              HANDLE DIALOG USER INTERACTIONS
    # --------------------------------------------------------------------------------------------------------

    def start_dialog_with_npc(self, npc: NonPlayerCharacter,
                              dialog_data: DialogData) -> int:
        self.dialog_state.active = True
        self.dialog_state.npc = npc
        self.dialog_state.data = dialog_data
        if self.dialog_state.option_index >= len(dialog_data.options):
            self.dialog_state.option_index = 0
        self._update_dialog_graphics()
        return self.dialog_state.option_index

    def change_dialog_option(self, delta: int) -> Tuple[NpcType, int, int]:
        previous_option_index = self.dialog_state.option_index
        self.dialog_state.option_index = (self.dialog_state.option_index +
                                          delta) % len(
                                              self.dialog_state.data.options)
        self._update_dialog_graphics()
        new_option_index = self.dialog_state.option_index
        return self.dialog_state.npc.npc_type, previous_option_index, new_option_index

    def _update_dialog_graphics(self):
        if self.dialog_state.active:

            build_dialog_option = lambda option: DialogOption(
                option.summary, option.action_text, self.images_by_ui_sprite[
                    option.ui_icon_sprite] if option.ui_icon_sprite else None,
                option.detail_header, option.detail_body)

            data = self.dialog_state.data
            image = self.images_by_portrait_sprite[data.portrait_icon_sprite]
            text_body = data.text_body
            options = [build_dialog_option(option) for option in data.options]
            active_option_index = self.dialog_state.option_index
            self.dialog.show_with_data(data.name, image, text_body, options,
                                       active_option_index)
        else:
            self.dialog.hide()

    def has_open_dialog(self) -> bool:
        return self.dialog_state.active

    # --------------------------------------------------------------------------------------------------------
    #                                     UPDATE MISC. STATE
    # --------------------------------------------------------------------------------------------------------

    def update(self, time_passed: Millis):
        self.minimap.update(time_passed)

        self._ticks_since_last_consumable_action += time_passed
        if self._ticks_since_last_consumable_action > HIGHLIGHT_CONSUMABLE_ACTION_DURATION:
            self.highlighted_consumable_action = None

        self._ticks_since_last_ability_action += time_passed
        if self._ticks_since_last_ability_action > HIGHLIGHT_ABILITY_ACTION_DURATION:
            self.highlighted_ability_action = None

        self.info_message.notify_time_passed(time_passed)

    def update_hero(self, hero_id: HeroId):
        sprite = HEROES[hero_id].portrait_icon_sprite
        image = self.images_by_portrait_sprite[sprite]
        self.portrait.image = image
        self.stats_window.hero_id = hero_id

    def set_paused(self, paused: bool):
        self.paused_splash_screen.shown = paused

    def update_fps_string(self, fps_string: str):
        self.fps_string = fps_string

    def update_game_mode_string(self, game_mode_string: str):
        self.game_mode_string = game_mode_string

    def remove_highlight_from_talent_toggle(self):
        self.talents_toggle.highlighted = False

    def set_minimap_highlight(self, position_ratio: Tuple[float, float]):
        self.minimap.set_highlight(position_ratio)

    def remove_minimap_highlight(self):
        self.minimap.remove_highlight()

    def set_inventory_highlight(self, item_id: ItemId):
        for icon in self.inventory_icons:
            if icon.item_id == item_id:
                self.manually_highlighted_inventory_item = item_id

    def remove_inventory_highlight(self):
        self.manually_highlighted_inventory_item = None

    # --------------------------------------------------------------------------------------------------------
    #                                          RENDERING
    # --------------------------------------------------------------------------------------------------------

    def _translate_ui_position_to_screen(self, position):
        return position[0] + self.ui_screen_area.x, position[
            1] + self.ui_screen_area.y

    def _translate_screen_position_to_ui(self, position: Tuple[int, int]):
        return position[0] - self.ui_screen_area.x, position[
            1] - self.ui_screen_area.y

    def _render_item_being_dragged(self, item_id: ItemId,
                                   mouse_screen_position: Tuple[int, int],
                                   relative_mouse_pos: Tuple[int, int]):
        ui_icon_sprite = get_item_data(item_id).icon_sprite
        big_image = self.big_images_by_ui_sprite[ui_icon_sprite]
        self._render_dragged(big_image, mouse_screen_position,
                             relative_mouse_pos)

    def _render_consumable_being_dragged(self, consumable_type: ConsumableType,
                                         mouse_screen_position: Tuple[int,
                                                                      int],
                                         relative_mouse_pos: Tuple[int, int]):
        ui_icon_sprite = CONSUMABLES[consumable_type].icon_sprite
        big_image = self.big_images_by_ui_sprite[ui_icon_sprite]
        self._render_dragged(big_image, mouse_screen_position,
                             relative_mouse_pos)

    def _render_dragged(self, big_image, mouse_screen_position,
                        relative_mouse_pos):
        position = (mouse_screen_position[0] - relative_mouse_pos[0] -
                    (UI_ICON_BIG_SIZE[0] - UI_ICON_SIZE[0]) // 2,
                    mouse_screen_position[1] - relative_mouse_pos[1] -
                    (UI_ICON_BIG_SIZE[1] - UI_ICON_SIZE[1]) // 2)
        self.screen_render.image(big_image, position)

    def render(self):

        self.screen_render.rect(
            COLOR_BORDER, Rect(0, 0, self.camera_size[0], self.camera_size[1]),
            1)
        self.screen_render.rect_filled(
            (20, 10, 0),
            Rect(0, self.camera_size[1], self.screen_size[0],
                 self.screen_size[1] - self.camera_size[1]))

        # CONSUMABLES
        self.ui_render.rect_filled((60, 60, 80), self.consumable_icons_row)
        for icon in self.consumable_icons:
            # TODO treat this as state and update it elsewhere
            recently_clicked = icon.slot_number == self.highlighted_consumable_action
            icon.render(recently_clicked)

        # ABILITIES
        self.ui_render.rect_filled((60, 60, 80), self.ability_icons_row)
        for icon in self.ability_icons:
            ability_type = icon.ability_type
            # TODO treat this as state and update it elsewhere
            if ability_type:
                recently_clicked = ability_type == self.highlighted_ability_action
                icon.render(recently_clicked)

        # ITEMS
        self.ui_render.rect_filled((60, 60, 80), self.inventory_icons_rect)
        for icon in self.inventory_icons:
            # TODO treat this as state and update it elsewhere
            highlighted = False
            if self.item_slot_being_dragged and self.item_slot_being_dragged.item_id:
                item_type = self.item_slot_being_dragged.item_id.item_type
                item_data = get_item_data_by_type(item_type)
                highlighted = item_data.item_equipment_category \
                              and icon.slot_equipment_category == item_data.item_equipment_category
            if self.manually_highlighted_inventory_item and self.manually_highlighted_inventory_item == icon.item_id:
                highlighted = True
            icon.render(highlighted)

        # MINIMAP
        self.minimap.render()

        simple_components = [
            self.exp_bar, self.portrait, self.healthbar, self.manabar,
            self.money_text, self.buffs, self.sound_checkbox, self.save_button,
            self.fullscreen_checkbox, self.stats_window, self.talents_window,
            self.quests_window, self.controls_window
        ] + self.toggle_buttons

        for component in simple_components:
            component.render()

        self.screen_render.rect(COLOR_BORDER, self.ui_screen_area, 1)

        self.screen_render.rect_transparent(Rect(1, 1, 70, 20), 100,
                                            COLOR_BLACK)
        self.screen_render.text(self.font_debug_info,
                                "fps: " + self.fps_string, (6, 4))
        if self.game_mode_string:
            self.screen_render.rect_transparent(Rect(1, 23, 70, 20), 100,
                                                COLOR_BLACK)
            self.screen_render.text(self.font_debug_info,
                                    self.game_mode_string, (6, 26))

        self.message.render(self.info_message.message)

        # TODO Bring back relative render position for dragged entities
        if self.item_slot_being_dragged:
            self._render_item_being_dragged(
                self.item_slot_being_dragged.item_id,
                self.mouse_screen_position,
                (UI_ICON_SIZE[0] // 2, (UI_ICON_SIZE[1] // 2)))
        elif self.consumable_slot_being_dragged:
            self._render_consumable_being_dragged(
                self.consumable_slot_being_dragged.consumable_types[0],
                self.mouse_screen_position,
                (UI_ICON_SIZE[0] // 2, (UI_ICON_SIZE[1] // 2)))

        self.dialog.render()

        if self.hovered_component and self.hovered_component.tooltip and not self.item_slot_being_dragged \
                and not self.consumable_slot_being_dragged:
            tooltip: TooltipGraphics = self.hovered_component.tooltip
            tooltip.render()

        self.paused_splash_screen.render()
Beispiel #3
0
class GameWorldView:

    def __init__(self, pygame_screen, camera_size: Tuple[int, int], screen_size: Tuple[int, int],
                 images_by_sprite: Dict[Sprite, Dict[Direction, List[ImageWithRelativePosition]]]):
        pygame.font.init()
        self.screen_render = DrawableArea(pygame_screen)
        self.ui_render = DrawableArea(pygame_screen, self._translate_ui_position_to_screen)
        self.world_render = DrawableArea(pygame_screen, self._translate_world_position_to_screen)

        self.ui_screen_area = Rect(0, camera_size[1], screen_size[0], screen_size[1] - camera_size[1])
        self.camera_size = camera_size
        self.screen_size = screen_size

        self.font_npc_action = pygame.font.Font(DIR_FONTS + 'Monaco.dfont', 12)
        self.font_debug_info = pygame.font.Font(DIR_FONTS + 'Arial Rounded Bold.ttf', 19)
        self.font_visual_text_small = pygame.font.Font(DIR_FONTS + 'Courier New Bold.ttf', 12)
        self.font_visual_text = pygame.font.Font(DIR_FONTS + 'Courier New Bold.ttf', 14)
        self.font_visual_text_large = pygame.font.Font(DIR_FONTS + 'Courier New Bold.ttf', 16)
        self.font_quest_giver_mark = pygame.font.Font(DIR_FONTS + 'Courier New Bold.ttf', 28)

        self.images_by_sprite: Dict[Sprite, Dict[Direction, List[ImageWithRelativePosition]]] = images_by_sprite

        # This is updated every time the view is called
        self.camera_world_area = None

    # ------------------------------------
    #         TRANSLATING COORDINATES
    # ------------------------------------

    def _translate_world_position_to_screen(self, world_position):
        return (self._translate_world_x_to_screen(world_position[0]),
                self._translate_world_y_to_screen(world_position[1]))

    def _translate_screen_position_to_world(self, screen_position):
        return int(screen_position[0] + self.camera_world_area.x), int(screen_position[1] + self.camera_world_area.y)

    def _translate_world_x_to_screen(self, world_x):
        return int(world_x - self.camera_world_area.x)

    def _translate_world_y_to_screen(self, world_y):
        return int(world_y - self.camera_world_area.y)

    def _translate_ui_position_to_screen(self, position):
        return position[0] + self.ui_screen_area.x, position[1] + self.ui_screen_area.y

    def _translate_screen_position_to_ui(self, position: Tuple[int, int]):
        return position[0] - self.ui_screen_area.x, position[1] - self.ui_screen_area.y

    # ------------------------------------
    #       DRAWING THE GAME WORLD
    # ------------------------------------

    def _world_ground(self, entire_world_area: Rect):
        grid_width = 35
        # TODO num squares should depend on map size. Ideally this dumb looping logic should change.
        num_squares = 200
        column_screen_y_0 = self._translate_world_y_to_screen(self.camera_world_area.y)
        column_screen_y_1 = self._translate_world_y_to_screen(
            min(entire_world_area.y + entire_world_area.h, self.camera_world_area.y + self.camera_world_area.h))
        for i_col in range(num_squares):
            world_x = entire_world_area.x + i_col * grid_width
            if entire_world_area.x < world_x < entire_world_area.x + entire_world_area.w:
                screen_x = self._translate_world_x_to_screen(world_x)
                self.screen_render.line(COLOR_BACKGROUND_LINES, (screen_x, column_screen_y_0),
                                        (screen_x, column_screen_y_1),
                                        1)
        row_screen_x_0 = self._translate_world_x_to_screen(self.camera_world_area.x)
        row_screen_x_1 = self._translate_world_x_to_screen(
            min(entire_world_area.x + entire_world_area.w, self.camera_world_area.x + self.camera_world_area.w))
        for i_row in range(num_squares):
            world_y = entire_world_area.y + i_row * grid_width
            if entire_world_area.y < world_y < entire_world_area.y + entire_world_area.h:
                screen_y = self._translate_world_y_to_screen(world_y)
                self.screen_render.line(COLOR_BACKGROUND_LINES, (row_screen_x_0, screen_y), (row_screen_x_1, screen_y),
                                        1)

        if RENDER_WORLD_COORDINATES:
            for i_col in range(num_squares):
                for i_row in range(num_squares):
                    if i_col % 4 == 0 and i_row % 4 == 0:
                        world_x = entire_world_area.x + i_col * grid_width
                        screen_x = self._translate_world_x_to_screen(world_x)
                        world_y = entire_world_area.y + i_row * grid_width
                        screen_y = self._translate_world_y_to_screen(world_y)
                        self.screen_render.text(self.font_debug_info, str(world_x) + "," + str(world_y),
                                                (screen_x, screen_y),
                                                (250, 250, 250))

    def _world_entity(self, entity: Union[WorldEntity, DecorationEntity]):
        if not entity.visible:
            return
        if entity.sprite is None:
            raise Exception("Entity has no sprite value: " + str(entity))
        elif entity.sprite in self.images_by_sprite:
            image_with_relative_position = self._get_image_for_sprite(
                entity.sprite, entity.direction, entity.movement_animation_progress)
            self.world_render.image_with_relative_pos(image_with_relative_position, entity.get_position())
        elif entity.sprite == Sprite.NONE:
            # This value is used by entities that don't use sprites. They might have other graphics (like VisualEffects)
            pass
        else:
            raise Exception("Unhandled sprite: " + str(entity.sprite))

    def _get_image_for_sprite(self, sprite: Sprite, direction: Direction,
                              animation_progress: float) -> ImageWithRelativePosition:

        images: Dict[Direction, List[ImageWithRelativePosition]] = self.images_by_sprite[sprite]
        if direction in images:
            images_for_this_direction = images[direction]
        else:
            images_for_this_direction = next(iter(images.values()))

        animation_frame_index = int(len(images_for_this_direction) * animation_progress)
        return images_for_this_direction[animation_frame_index]

    def _visual_effect(self, visual_effect):
        if isinstance(visual_effect, VisualLine):
            self._visual_line(visual_effect)
        elif isinstance(visual_effect, VisualCircle):
            self._visual_circle(visual_effect)
        elif isinstance(visual_effect, VisualRect):
            self._visual_rect(visual_effect)
        elif isinstance(visual_effect, VisualCross):
            self._visual_cross(visual_effect)
        elif isinstance(visual_effect, VisualText):
            self._visual_text(visual_effect)
        elif isinstance(visual_effect, VisualSprite):
            self._visual_sprite(visual_effect)
        elif isinstance(visual_effect, VisualParticleSystem):
            self._visual_particle_system(visual_effect)
        else:
            raise Exception("Unhandled visual effect: " + str(visual_effect))

    def _visual_line(self, line: VisualLine):
        self.world_render.line(line.color, line.start_position, line.end_position, line.line_width)

    def _visual_circle(self, visual_circle: VisualCircle):
        position = visual_circle.circle()[0]
        radius = visual_circle.circle()[1]
        self.world_render.circle(visual_circle.color, position, radius, visual_circle.line_width)

    def _visual_rect(self, visual_rect: VisualRect):
        self.world_render.rect(visual_rect.color, visual_rect.rect(), visual_rect.line_width)

    def _visual_cross(self, visual_cross: VisualCross):
        for start_pos, end_pos in visual_cross.lines():
            self.world_render.line(visual_cross.color, start_pos, end_pos, visual_cross.line_width)

    def _visual_text(self, visual_text: VisualText):
        text = visual_text.text
        position = visual_text.position()
        # Adjust position so that long texts don't appear too far to the right
        translated_position = (position[0] - 3 * len(text), position[1])
        # limit the space long texts claim on the screen (example "BLOCK" and "DODGE")
        if len(text) >= 4:
            font = self.font_visual_text_small
        elif visual_text.emphasis:
            font = self.font_visual_text_large
        else:
            font = self.font_visual_text
        self.world_render.text(font, text, translated_position, visual_text.color)

    def _visual_sprite(self, visual_sprite: VisualSprite):
        position = visual_sprite.position
        animation_progress = visual_sprite.animation_progress()
        sprite = visual_sprite.sprite
        if sprite in self.images_by_sprite:
            image_with_relative_position = self._get_image_for_sprite(
                sprite, Direction.DOWN, animation_progress)
            self.world_render.image_with_relative_pos(image_with_relative_position, position)
        else:
            raise Exception("Unhandled sprite: " + str(sprite))

    def _visual_particle_system(self, visual_particle_system: VisualParticleSystem):
        for particle in visual_particle_system.particles():
            self.world_render.rect_transparent(particle.rect, particle.alpha, particle.color)

    def _stat_bar_for_world_entity(self, world_entity, h, relative_y, ratio, color, border_color=None):
        self.world_render.stat_bar(world_entity.x + 1, world_entity.y + relative_y,
                                   world_entity.pygame_collision_rect.w - 2, h, ratio, color, border_color=border_color)

    def _entity_action_text(self, entity_action_text: EntityActionText):
        entity_center_pos = entity_action_text.entity.get_center_position()
        header_prefix = "[Space] "
        header_line = header_prefix + entity_action_text.text
        detail_lines = []
        for detail_entry in entity_action_text.details:
            detail_lines += split_text_into_lines(detail_entry, 30)
        if detail_lines:
            line_length = max(max([len(line) for line in detail_lines]), len(header_line))
        else:
            line_length = len(header_line)
        rect_width = line_length * 8
        rect_height = 16 + len(detail_lines) * 16
        rect_pos = (entity_center_pos[0] - rect_width // 2, entity_center_pos[1] - 60)
        self.world_render.rect_transparent(Rect(rect_pos[0], rect_pos[1], rect_width, rect_height), 150, (0, 0, 0))
        if entity_action_text.style == EntityActionTextStyle.LOOT_RARE:
            color = (190, 150, 250)
        elif entity_action_text.style == EntityActionTextStyle.LOOT_UNIQUE:
            color = (250, 250, 150)
        else:
            color = (255, 255, 255)
        self.world_render.text(self.font_npc_action, header_prefix, (rect_pos[0] + 4, rect_pos[1]))
        prefix_w = self.font_npc_action.size(header_prefix)[0]
        self.world_render.text(self.font_npc_action, entity_action_text.text, (rect_pos[0] + 4 + prefix_w, rect_pos[1]),
                               color)
        for i, detail_line in enumerate(detail_lines):
            self.world_render.text(self.font_npc_action, detail_line, (rect_pos[0] + 4, rect_pos[1] + (i + 1) * 16))

    def _quest_giver_mark(self, npc: NonPlayerCharacter):
        entity_pos = npc.world_entity.get_center_position()
        if npc.quest_giver_state == QuestGiverState.CAN_GIVE_NEW_QUEST:
            color = (255, 215, 0)
            mark = "!"
        elif npc.quest_giver_state == QuestGiverState.WAITING_FOR_PLAYER:
            color = (192, 192, 192)
            mark = "?"
        else:
            color = (255, 215, 0)
            mark = "?"
        self.world_render.text(self.font_quest_giver_mark, mark, (entity_pos[0] - 8, entity_pos[1] - 64), (0, 0, 0))
        self.world_render.text(self.font_quest_giver_mark, mark, (entity_pos[0] - 9, entity_pos[1] - 65), color)

    def render_world(self, all_entities_to_render: List[WorldEntity], decorations_to_render: List[DecorationEntity],
                     camera_world_area, non_player_characters: List[NonPlayerCharacter], is_player_invisible: bool,
                     player_active_buffs: List[BuffWithDuration],
                     player_entity: WorldEntity, visual_effects, render_hit_and_collision_boxes, player_health,
                     player_max_health, entire_world_area: Rect, entity_action_text: Optional[EntityActionText]):
        self.camera_world_area = camera_world_area

        self.screen_render.fill(COLOR_BACKGROUND)
        self._world_ground(entire_world_area)

        all_entities_to_render.sort(key=lambda entry: (-entry.view_z, entry.y))

        for decoration_entity in decorations_to_render:
            self._world_entity(decoration_entity)

        for entity in all_entities_to_render:
            self._world_entity(entity)
            if entity == player_entity and is_player_invisible:
                self.world_render.rect((200, 100, 250), player_entity.rect(), 2)

        player_sprite_y_relative_to_entity = \
            ENTITY_SPRITE_INITIALIZERS[player_entity.sprite][Direction.DOWN].position_relative_to_entity[1]
        if player_entity.visible:
            self._stat_bar_for_world_entity(player_entity, 5, player_sprite_y_relative_to_entity - 5,
                                            player_health / player_max_health, (100, 200, 0))

        # Buffs related to channeling something are rendered above player's head with progress from left to right
        for buff in player_active_buffs:
            if buff.buff_effect.get_buff_type() in CHANNELING_BUFFS:
                ratio = 1 - buff.get_ratio_duration_remaining()
                self._stat_bar_for_world_entity(player_entity, 3, player_sprite_y_relative_to_entity - 11, ratio,
                                                (150, 150, 250))

        if render_hit_and_collision_boxes:
            for entity in all_entities_to_render:
                self.world_render.rect((250, 250, 250), entity.rect(), 1)

        for npc in non_player_characters:
            if npc.is_enemy:
                if npc.is_boss:
                    healthbar_color = (255, 215, 0)
                    border_color = COLOR_RED
                else:
                    healthbar_color = COLOR_RED
                    border_color = None
            else:
                healthbar_color = (250, 250, 0)
                border_color = None
                if npc.quest_giver_state is not None:
                    self._quest_giver_mark(npc)

            npc_sprite_y_relative_to_entity = \
                ENTITY_SPRITE_INITIALIZERS[npc.world_entity.sprite][Direction.DOWN].position_relative_to_entity[1]
            if not npc.is_neutral:
                self._stat_bar_for_world_entity(npc.world_entity, 3, npc_sprite_y_relative_to_entity - 5,
                                                npc.health_resource.get_partial(), healthbar_color, border_color)
            if npc.active_buffs:
                buff = npc.active_buffs[0]
                if buff.should_duration_be_visualized_on_enemies():
                    self._stat_bar_for_world_entity(npc.world_entity, 2, npc_sprite_y_relative_to_entity - 9,
                                                    buff.get_ratio_duration_remaining(), (250, 250, 250))
        for visual_effect in visual_effects:
            self._visual_effect(visual_effect)

        if entity_action_text:
            self._entity_action_text(entity_action_text)
class MapEditorView:
    def __init__(self, pygame_screen, camera_size: Tuple[int, int],
                 screen_size: Tuple[int, int],
                 images_by_sprite: Dict[Sprite,
                                        Dict[Direction,
                                             List[ImageWithRelativePosition]]],
                 images_by_ui_sprite: Dict[UiIconSprite, Any],
                 images_by_portrait_sprite: Dict[PortraitIconSprite, Any]):
        self.camera_size = camera_size
        self.screen_size = screen_size
        self.screen_render = DrawableArea(pygame_screen)

        self.images_by_sprite = images_by_sprite
        self.images_by_ui_sprite = images_by_ui_sprite
        self.images_by_portrait_sprite = images_by_portrait_sprite
        self.ui_screen_area = Rect(0, camera_size[1], screen_size[0],
                                   screen_size[1] - camera_size[1])
        self.screen_render = DrawableArea(pygame_screen)
        self.ui_render = DrawableArea(pygame_screen,
                                      self._translate_ui_position_to_screen)

        self.font_debug_info = pygame.font.Font(
            DIR_FONTS + 'Courier New Bold.ttf', 12)
        self.font_ui_icon_keys = pygame.font.Font(
            DIR_FONTS + 'Courier New Bold.ttf', 12)
        w_tab_button = 75
        self.tab_buttons = {
            EntityTab.ITEMS:
            RadioButton(self.ui_render, Rect(300, 10, w_tab_button, 20),
                        "ITEMS (V)"),
            EntityTab.NPCS:
            RadioButton(self.ui_render, Rect(380, 10, w_tab_button, 20),
                        "NPCS (B)"),
            EntityTab.WALLS:
            RadioButton(self.ui_render, Rect(460, 10, w_tab_button, 20),
                        "WALLS (N)"),
            EntityTab.MISC:
            RadioButton(self.ui_render, Rect(540, 10, w_tab_button, 20),
                        "MISC. (M)"),
        }
        self.shown_tab: EntityTab = EntityTab.ITEMS
        self.checkbox_show_entity_outlines = Checkbox(self.ui_render,
                                                      Rect(15, 100, 120, 20),
                                                      "outlines", True)
        self.checkboxes = [self.checkbox_show_entity_outlines]
        self.mouse_screen_position = (0, 0)
        self.hovered_component = None

    def handle_mouse_movement(self, mouse_screen_pos: Tuple[int, int]):
        self.mouse_screen_position = mouse_screen_pos

        mouse_ui_position = self._translate_screen_position_to_ui(
            mouse_screen_pos)

        for checkbox in self.checkboxes:
            if checkbox.contains(mouse_ui_position):
                self._on_hover_component(checkbox)

    def _on_hover_component(self, component):
        self._set_currently_hovered_component_not_hovered()
        self.hovered_component = component
        self.hovered_component.hovered = True

    def _set_currently_hovered_component_not_hovered(self):
        if self.hovered_component is not None:
            self.hovered_component.hovered = False
            self.hovered_component = None

    def handle_mouse_click(self):
        if self.hovered_component in self.checkboxes:
            self.hovered_component.on_click()

    def _translate_ui_position_to_screen(self, position):
        return position[0] + self.ui_screen_area.x, position[
            1] + self.ui_screen_area.y

    def _translate_screen_position_to_ui(self, position: Tuple[int, int]):
        return position[0] - self.ui_screen_area.x, position[
            1] - self.ui_screen_area.y

    def _get_image_for_sprite(
            self, sprite: Sprite, direction: Direction,
            animation_progress: float) -> ImageWithRelativePosition:

        images: Dict[
            Direction,
            List[ImageWithRelativePosition]] = self.images_by_sprite[sprite]
        if direction in images:
            images_for_this_direction = images[direction]
        else:
            images_for_this_direction = next(iter(images.values()))

        animation_frame_index = int(
            len(images_for_this_direction) * animation_progress)
        return images_for_this_direction[animation_frame_index]

    def set_shown_tab(self, shown_tab: EntityTab):
        self.tab_buttons[self.shown_tab].enabled = False
        self.shown_tab = shown_tab
        self.tab_buttons[shown_tab].enabled = True

    def render(
        self, entities: List[MapEditorWorldEntity],
        placing_entity: Optional[MapEditorWorldEntity],
        deleting_entities: bool, deleting_decorations: bool, num_enemies: int,
        num_walls: int, num_decorations: int, grid_cell_size: int,
        mouse_screen_position: Tuple[int,
                                     int], camera_rect_ratio: Tuple[float,
                                                                    float,
                                                                    float,
                                                                    float],
        npc_positions_ratio: List[Tuple[float, float]],
        wall_positions_ratio: List[Tuple[float, float]]
    ) -> Optional[MapEditorWorldEntity]:

        mouse_ui_position = self._translate_screen_position_to_ui(
            mouse_screen_position)

        hovered_by_mouse: MapEditorWorldEntity = None

        self.screen_render.rect(
            COLOR_BLACK, Rect(0, 0, self.camera_size[0], self.camera_size[1]),
            3)
        self.screen_render.rect_filled(
            COLOR_BLACK,
            Rect(0, self.camera_size[1], self.screen_size[0],
                 self.screen_size[1] - self.camera_size[1]))

        icon_space = 5

        y_1 = 10
        y_2 = y_1 + 30

        x_0 = 20

        # TODO Handle all these icons as state, similarly to how the game UI is done, and the tab radio buttons

        self._map_editor_icon_in_ui(x_0, y_2, MAP_EDITOR_UI_ICON_SIZE,
                                    deleting_entities, 'Q', None,
                                    UiIconSprite.MAP_EDITOR_TRASHCAN)
        self._map_editor_icon_in_ui(
            x_0 + MAP_EDITOR_UI_ICON_SIZE[0] + icon_space, y_2,
            MAP_EDITOR_UI_ICON_SIZE, deleting_decorations, 'Z', None,
            UiIconSprite.MAP_EDITOR_RECYCLING)

        x_1 = 155
        num_icons_per_row = 23

        for i, entity in enumerate(entities):
            is_this_entity_being_placed = entity is placing_entity
            x = x_1 + (i % num_icons_per_row) * (MAP_EDITOR_UI_ICON_SIZE[0] +
                                                 icon_space)
            row_index = (i // num_icons_per_row)
            y = y_2 + row_index * (MAP_EDITOR_UI_ICON_SIZE[1] + icon_space)
            if is_point_in_rect(
                    mouse_ui_position,
                    Rect(x, y, MAP_EDITOR_UI_ICON_SIZE[0],
                         MAP_EDITOR_UI_ICON_SIZE[1])):
                hovered_by_mouse = entity
            self._map_editor_icon_in_ui(x, y, MAP_EDITOR_UI_ICON_SIZE,
                                        is_this_entity_being_placed, '',
                                        entity.sprite, None)

        self.screen_render.rect(COLOR_WHITE, self.ui_screen_area, 1)

        self.screen_render.rect_transparent(Rect(0, 0, 150, 80), 100,
                                            COLOR_BLACK)
        self.screen_render.text(self.font_debug_info,
                                "# enemies: " + str(num_enemies), (5, 3))
        self.screen_render.text(self.font_debug_info,
                                "# walls: " + str(num_walls), (5, 20))
        self.screen_render.text(self.font_debug_info,
                                "# decorations: " + str(num_decorations),
                                (5, 37))
        self.screen_render.text(self.font_debug_info,
                                "Cell size: " + str(grid_cell_size), (5, 54))

        self._render_minimap(camera_rect_ratio, npc_positions_ratio,
                             wall_positions_ratio)

        for button in self.tab_buttons.values():
            button.render()

        for checkbox in self.checkboxes:
            checkbox.render()

        return hovered_by_mouse

    def _render_minimap(self, camera_rect_ratio, npc_positions_ratio,
                        wall_positions_ratio):
        rect = Rect(self.screen_size[0] - 180, self.screen_size[1] - 180, 160,
                    160)
        border_width = 1
        self.screen_render.rect(
            COLOR_WHITE,
            Rect(rect.x - border_width, rect.y - border_width,
                 rect.w + border_width * 2, rect.h + border_width * 2),
            border_width)

        for npc_pos in npc_positions_ratio:
            self.screen_render.rect((200, 200, 200),
                                    Rect(rect.x + npc_pos[0] * rect.w,
                                         rect.y + npc_pos[1] * rect.h, 1, 1),
                                    1)
        for wall_pos in wall_positions_ratio:
            self.screen_render.rect((100, 100, 150),
                                    Rect(rect.x + wall_pos[0] * rect.w,
                                         rect.y + wall_pos[1] * rect.h, 1, 1),
                                    1)

        self.screen_render.rect((150, 250, 150),
                                Rect(rect.x + camera_rect_ratio[0] * rect.w,
                                     rect.y + camera_rect_ratio[1] * rect.h,
                                     camera_rect_ratio[2] * rect.w,
                                     camera_rect_ratio[3] * rect.h), 1)

    def render_map_editor_mouse_rect(self, color: Tuple[int, int, int],
                                     map_editor_mouse_rect: Rect):
        self.screen_render.rect(color, map_editor_mouse_rect, 3)

    def render_map_editor_world_entity_at_position(self, sprite: Sprite,
                                                   entity_size: Tuple[int,
                                                                      int],
                                                   position: Tuple[int, int]):
        image_with_relative_position = self._get_image_for_sprite(
            sprite, Direction.DOWN, 0)
        sprite_position = sum_of_vectors(
            position, image_with_relative_position.position_relative_to_entity)
        self.screen_render.image(image_with_relative_position.image,
                                 sprite_position)
        self.screen_render.rect((50, 250, 0),
                                Rect(position[0], position[1], entity_size[0],
                                     entity_size[1]), 3)

    def _map_editor_icon_in_ui(self, x, y, size: Tuple[int,
                                                       int], highlighted: bool,
                               user_input_key: str, sprite: Optional[Sprite],
                               ui_icon_sprite: Optional[UiIconSprite]):
        w = size[0]
        h = size[1]
        self.ui_render.rect_filled((40, 40, 40), Rect(x, y, w, h))
        if sprite:
            image = self.images_by_sprite[sprite][Direction.DOWN][0].image
        elif ui_icon_sprite:
            image = self.images_by_ui_sprite[ui_icon_sprite]
        else:
            raise Exception("Nothing to render!")

        icon_scaled_image = pygame.transform.scale(image, size)
        self.ui_render.image(icon_scaled_image, (x, y))

        self.ui_render.rect(COLOR_WHITE, Rect(x, y, w, h), 1)
        if highlighted:
            self.ui_render.rect(COLOR_HIGHLIGHTED_ICON,
                                Rect(x - 1, y - 1, w + 2, h + 2), 3)
        self.ui_render.text(self.font_ui_icon_keys, user_input_key,
                            (x + 12, y + h + 4))

    def is_screen_position_within_ui(self, screen_position: Tuple[int, int]):
        ui_position = self._translate_screen_position_to_ui(screen_position)
        return ui_position[1] >= 0