NotQuiteParadise2

Source code for nqp.core.visual

from __future__ import annotations

import logging
import os
from typing import TYPE_CHECKING

import pygame

from nqp.base_classes.animation import Animation
from nqp.base_classes.image import Image
from nqp.core.constants import ASSET_PATH, DEFAULT_IMAGE_SIZE, FontEffects, FontType, IMG_FORMATS
from nqp.core.debug import Timer
from nqp.core.utility import clamp, clip
from nqp.resource_controllers.image_resource_controller import ImageResourceController
from nqp.ui_elements.generic.fancy_font import FancyFont
from nqp.ui_elements.generic.font import Font

if TYPE_CHECKING:
    from typing import Dict, List, Optional, Tuple

    from nqp.core.game import Game

__all__ = ["Visual"]


[docs]class Visual: """ Store and manage images. """
[docs] def __init__(self, game: Game): with Timer("Visual: initialised"): self._game: Game = game self._image_folders: List[str] = [ # must be the folder that contains the images "actions", "event_images", "factions", "items", "projectiles", "rooms", "tiles", "ui/backgrounds", "ui/borders", "ui/cursors", "ui/icons", "ui/keys", "ui/records", "ui/resources", "ui/stats", "ui/windows/basic", "ui/windows/fancy", "upgrades", "world", ] # don't add debug folder self._animation_folders: List[str] = [ # must be the folder that contains the folders of images "bosses", "commanders", "effects", "units", "ui_animations", "world_animations", ] self.tilesets = { # TODO - refactor to align to style tileset.split(".")[0]: self._load_tileset(ASSET_PATH / "tiles" / tileset) for tileset in os.listdir(ASSET_PATH / "tiles") } self._images: ImageResourceController = ImageResourceController(self._image_folders) self._animation_frames: Dict[str, Dict[str, List[Image]]] = self._load_animation_frames() # folder_name: {frame_name, [animation_frames]} self._fonts: Dict[FontType, Tuple[str, Tuple[int, int, int]]] = self._load_fonts() # FontType: path, colour self._active_animations: List[Animation] = []
[docs] def update(self, delta_time: float): active_animations = self._active_animations # update animations mod_delta = delta_time for animation in active_animations: animation.update(mod_delta, self._game.memory.game_speed) # remove the finished animations if animation.is_finished and animation.delete_on_finish: self._active_animations.remove(animation)
@staticmethod def _load_fonts(): return { FontType.NEGATIVE: (str(ASSET_PATH / "fonts/small_font.png"), (255, 0, 0)), FontType.DISABLED: (str(ASSET_PATH / "fonts/small_font.png"), (128, 128, 128)), FontType.DEFAULT: (str(ASSET_PATH / "fonts/small_font.png"), (255, 255, 255)), FontType.POSITIVE: (str(ASSET_PATH / "fonts/small_font.png"), (0, 255, 0)), FontType.INSTRUCTION: (str(ASSET_PATH / "fonts/small_font.png"), (240, 205, 48)), FontType.NOTIFICATION: (str(ASSET_PATH / "fonts/large_font.png"), (117, 50, 168)), } def _load_animation_frames(self) -> Dict[str, Dict[str, List[Image]]]: """ Load all animation frames specified in self._animation_folders """ animations = {} folders = self._animation_folders anim_counter = 0 frame_set_counter = 0 frame_counter = 0 # loop all specified folders for folder in folders: path = ASSET_PATH / folder directory_contents = os.listdir(path) for anim_folder_name in directory_contents: # check we have data for the given animation try: if anim_folder_name not in getattr(self._game.data, folder): continue except AttributeError: # we didnt find the folder in Data, so carry on regardless pass # we expect a sub folder for the item e.g. bosses/test_boss anim_path = path / anim_folder_name if os.path.isdir(anim_path): animations[anim_folder_name] = {} anim_counter += 1 # ...and then sub folders for each set of frames, e.g. bosses/test_boss/move frame_sets = os.listdir(anim_path) for frame_set_name in frame_sets: # warn about duplicates if frame_set_name in animations[anim_folder_name].keys(): logging.warning( f"Animation's frame set [{frame_set_name}] already loaded; non-unique file name." ) frame_set_path = anim_path / frame_set_name if os.path.isdir(frame_set_path): animations[anim_folder_name][frame_set_name] = [] frame_set_counter += 1 # load the frames animation_frames_folder = os.listdir(frame_set_path) for frame_name in animation_frames_folder: if frame_name.split(".")[-1] in IMG_FORMATS: frame_path = path / anim_folder_name / frame_set_name / frame_name image = pygame.image.load(str(frame_path)).convert_alpha() image_ = Image(image=image) # record the frame animations[anim_folder_name][frame_set_name].append(image_) frame_counter += 1 logging.debug( f"Visual: {anim_counter} Animations loaded, containing {frame_set_counter} frame sets, " f"made up of {frame_counter} frames." ) return animations def _load_tileset(self, path): """ Loads a tileset from a spritesheet. """ # TODO - rewrite to align to other load styles tileset_data = [] spritesheet = pygame.image.load(str(path)).convert_alpha() for y in range(spritesheet.get_height() // DEFAULT_IMAGE_SIZE): tileset_data.append([]) for x in range(spritesheet.get_width() // DEFAULT_IMAGE_SIZE): tileset_data[-1].append( clip( spritesheet, x * DEFAULT_IMAGE_SIZE, y * DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_SIZE, ) ) return tileset_data
[docs] def create_animation( self, animation_name: str, frame_set_name: str, loop: bool = True, uses_simulation_time: bool = True ) -> Animation: """ Create a new animation and add it to the internal update list. """ frames = self._animation_frames[animation_name] anim = Animation( frames, loop=loop, starting_frame_set_name=frame_set_name, uses_simulation_time=uses_simulation_time ) self._active_animations.append(anim) return anim
[docs] def get_image( self, image_name: str, size: pygame.Vector2 = (DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_SIZE), copy: bool = False, ) -> Image: """ Get an image from the library. If a size is specified and it differs to the size held then the image is resized before returning. If copy = True then a copy of the image held in the library is provided. Remember to use a copy if you want to amend the image, otherwise the copy in the library is amended. A transparent surface is available with the name "blank". """ # normalise size desired_width, desired_height = size # ensure numbers arent negative if desired_width <= 0 or desired_height <= 0: logging.warning( f"Get_image: Tried to use dimensions of {size}, which are negative. Default size " f"used instead." ) size = (DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_SIZE) internal_name = f"{image_name}@{desired_width}x{desired_height}" # check if exists already try: image = self._images[internal_name] except KeyError: image = None for held_image_name in self._images.keys(): if image_name in held_image_name: image = self._images[held_image_name] break # if no image found replace with not found if image is None: logging.warning(f"Image requested [{image_name}] not found.") image_ = self.get_image("not_found") image = image_.surface else: # resize and store resized image assert isinstance(image, pygame.Surface) image = pygame.transform.smoothscale(image.copy(), size) self._images[f"not_found@{desired_width}x{desired_height}"] = image # return a copy if requested if copy: final_image = Image(image=image.copy()) else: final_image = Image(image=image) return final_image
[docs] def create_font( self, font_type: FontType, text: str, pos: pygame.Vector2 = pygame.Vector2(0, 0), line_width: int = 0 ) -> Font: """ Create a font instance. """ line_width = clamp(line_width, 0, self._game.window.width) path, colour = self._fonts[font_type] font = Font(path, colour, text, line_width, pos) return font
[docs] def create_fancy_font( self, text: str, pos: pygame.Vector2 = (0, 0), line_width: int = 0, font_effects: Optional[List[FontEffects]] = None, ) -> FancyFont: """ Create a FancyFont instance. If line_width isnt given then will default to full screen. """ line_width = clamp(line_width, 0, self._game.window.width) # handle mutable default if font_effects is None: font_effects = [] font = FancyFont(text, pos, line_width, font_effects) return font