from __future__ import annotations
import dataclasses
import logging
import random
from typing import List, Tuple, TYPE_CHECKING, TypeVar
import pygame
from scripts.engine.internal.constant import (
ASSET_PATH,
DirectionType,
ICON_SIZE,
IMAGE_NOT_FOUND_PATH,
Shape,
ShapeType,
TILE_SIZE,
)
if TYPE_CHECKING:
from typing import Any, Dict, List, Optional, Tuple, Type, Union
from scripts.engine.internal.definition import TraitSpritePathsData, TraitSpritesData
__all__ = [
"get_image",
"get_images",
"flatten_images",
"get_class_members",
"lerp",
"clamp",
"get_coords_from_shape",
"is_close",
"value_to_member",
"convert_tile_string_to_xy",
"convert_direction_to_name",
"build_sprites_from_paths",
"roll",
]
_V = TypeVar("_V", int, float) # to represent components where we don't know which is being used
################################### IMAGES ########################################
[docs]def get_image(
img_path: str, desired_dimensions: Tuple[int, int] = (TILE_SIZE, TILE_SIZE), copy: bool = False
) -> pygame.Surface:
"""
Get the specified image and resize if dimensions provided. Dimensions are in (width, height) format. If img
path is "none" then a blank surface is created to the size of the desired dimensions, or TILE_SIZE if no
dimensions provided.
"""
from scripts.engine.internal.data import store # circular import in testing.
# ensure numbers arent negative
if desired_dimensions[0] <= 0 or desired_dimensions[1] <= 0:
logging.warning(
f"Get_image: Tried to use dimensions of {desired_dimensions}, which are negative. Used tile size instead."
)
desired_dimensions = (TILE_SIZE, TILE_SIZE)
# check if image path provided
if str(img_path).lower() != "none":
if f"{img_path}{desired_dimensions}" in store.images:
image = store.images[f"{img_path}{desired_dimensions}"]
else:
try:
# try and get the image provided
image = pygame.image.load(str(ASSET_PATH / img_path)).convert_alpha()
except:
image = pygame.image.load(str(IMAGE_NOT_FOUND_PATH)).convert_alpha()
logging.warning(
f"Get_image: Tried to use {img_path} but it wasn`t found. Used the not_found image instead."
)
else:
image = pygame.Surface((TILE_SIZE, TILE_SIZE))
image.set_alpha(0)
# resize if needed - should only need to resize if we havent got it from storage
if image.get_width() != desired_dimensions[0] or image.get_height() != desired_dimensions[1]:
width, height = desired_dimensions
image = pygame.transform.smoothscale(image, (width, height))
# add to storage
store.images[f"{img_path}{desired_dimensions}"] = image
# return a copy if requested
if copy:
return image.copy()
else:
return image
[docs]def get_images(
img_paths: List[str], desired_dimensions: Tuple[int, int] = (TILE_SIZE, TILE_SIZE), copy: bool = False
) -> List[pygame.Surface]:
"""
Get a collection of images.
"""
images = []
for path in img_paths:
images.append(get_image(path, desired_dimensions, copy))
return images
[docs]def flatten_images(images: List[pygame.Surface]) -> pygame.Surface:
"""
Flatten a list of images into a single image. All images must be the same size. Images are blitted in order.
"""
biggest_image: Optional[pygame.Surface] = None
biggest_image_index = -1
for i in range(len(images)):
img = images[i]
if (biggest_image is None) or (
img.get_width() > biggest_image.get_width() and img.get_height() > biggest_image.get_height()
):
biggest_image = img
biggest_image_index = i
base: pygame.Surface = biggest_image
for image in images[0:biggest_image_index] + images[biggest_image_index:]:
if image != biggest_image:
base.blit(image, (0, 0))
return base
[docs]def build_sprites_from_paths(
sprite_paths: List[TraitSpritePathsData], desired_size: Tuple[int, int] = (TILE_SIZE, TILE_SIZE)
) -> TraitSpritesData:
"""
Build a TraitSpritesData class from a list of TraitSpritePathsData. For each member in TraitSpritePathsData,
combines the sprites from each TraitSpritePathsData in the list and flattens to a single surface.
"""
paths: Dict[str, List[str]] = {}
sprites: Dict[str, List[pygame.Surface]] = {}
flattened_sprites: Dict[str, pygame.Surface] = {}
# bundle into cross-trait sprite path lists
for sprite_path in sprite_paths:
char_dict = dataclasses.asdict(sprite_path)
for name, path in char_dict.items():
if name != "render_order":
# check if key exists
if name in paths:
paths[name].append(path)
# if not init the dict
else:
paths[name] = [path]
# convert to sprites
for name, path_list in paths.items():
# override size for icon
if name == "path":
desired_size = (ICON_SIZE, ICON_SIZE)
sprites[name] = get_images(path_list, desired_size, True) # make sure to get copies
# flatten the images
for name, surface_list in sprites.items():
flattened_sprites[name] = flatten_images(surface_list)
# convert to dataclass
from scripts.engine.internal.definition import TraitSpritesData
converted = TraitSpritesData(**flattened_sprites)
return converted
################################### QUERY TOOLS ########################################
[docs]def get_class_members(cls: Type[Any]) -> List[str]:
"""
Get a class' members, excluding special methods e.g. anything prefixed with '__'. Useful for use with dataclasses.
"""
members = []
for member in cls.__dict__.keys():
if member[:2] != "__":
members.append(member)
return members
################################### MATHS ########################################
[docs]def lerp(initial_value: float, target_value: float, lerp_fraction: float) -> float:
"""
Linear interpolation between initial and target by amount. Fraction clamped between 0 and 1. >=0.99 is treated
as 1 to handle float imprecision.
"""
clamped_lerp_fraction = clamp(lerp_fraction, 0, 1)
if clamped_lerp_fraction >= 0.99:
return target_value
else:
return initial_value * (1 - clamped_lerp_fraction) + target_value * clamped_lerp_fraction
[docs]def clamp(value: _V, min_value: _V, max_value: _V) -> _V:
"""
Return the value, clamped between min and max.
"""
return max(min_value, min(value, max_value))
[docs]def is_close(current_pos: Tuple[float, float], target_pos: Tuple[float, float], delta: float = 0.05) -> bool:
"""
True if the absolute distance between both coordinates is less than delta.
"""
return abs(current_pos[0] - target_pos[0]) <= delta and abs(current_pos[1] - target_pos[1]) <= delta
################################### SHAPES ########################################
[docs]def get_coords_from_shape(shape: ShapeType, size: int, direction: Optional[Tuple[int, int]]) -> List[Tuple[int, int]]:
"""
Get a list of coordinates from a shape, size and direction.
"""
if shape == Shape.TARGET:
return [(0, 0)] # single target, centred on selection
elif shape == Shape.SQUARE:
return _calculate_square_shape(size)
elif shape == Shape.CIRCLE:
return _calculate_circle_shape(size)
elif shape == Shape.CROSS:
return _calculate_cross_shape(size)
elif shape == Shape.CONE:
if direction:
return _calculate_cone_shape(size, direction)
else:
logging.error(f"No direction passed to get_coords_from_shape for a Cone.")
raise KeyError("No direction for Cone.")
logging.error(f"Unknown shape '{shape}' passed to get_coords_from_shape")
raise KeyError(f"Unknown shape '{shape}'")
def _calculate_square_shape(size: int) -> List[Tuple[int, int]]:
"""
Calculate all the tiles in the range of a square
"""
coord_list = []
width = size
height = size
for x in range(-width, width + 1):
for y in range(-height, height + 1):
coord_list.append((x, y))
return coord_list
def _calculate_circle_shape(size: int) -> List[Tuple[int, int]]:
"""
Calculate all the tiles in the range of a circle
"""
coord_list = []
radius = (size + size + 1) / 2
for x in range(-size, size + 1):
for y in range(-size, size + 1):
if x * x + y * y < radius * radius:
coord_list.append((x, y))
return coord_list
def _calculate_cross_shape(size: int) -> List[Tuple[int, int]]:
"""
Calculate all the tiles in the range of a cross
"""
coord_list = [(0, 0)]
x_coords = [-1, 1]
for x in x_coords:
for y in range(-size, size + 1):
# ignore 0's to ensure no duplication when running through the range
# the multiplication of x by y means they are always both 0 if y is
if y != 0:
coord_list.append((x * y, y))
return coord_list
def _calculate_cone_shape(size: int, direction: Tuple[int, int]) -> List[Tuple[int, int]]:
# we need a direction since cones are not symmetric
coord_list = []
last_row = [(0, 0)]
# each size means 1 expansion of the cone
for _ in range(size):
# use a set so we don't add the same coord multiple times
new_row = set()
for coord in last_row:
new_coord = (coord[0] + direction[0], coord[1] + direction[1])
new_row.add(new_coord)
# calculate the perpendiculars in both directions
perpendiculars = [(direction[1], direction[0]), (-direction[1], -direction[0])]
for perpendicular_direction in perpendiculars:
perpendicular = (new_coord[0] + perpendicular_direction[0], new_coord[1] + perpendicular_direction[1])
new_row.add(perpendicular)
coord_list += list(last_row)
last_row = new_row # type: ignore
return coord_list + list(last_row)
################################### CONVERSIONS ########################################
[docs]def value_to_member(value: Any, cls: Type[Any]) -> str:
"""
Get a member of a class that matches the value given
"""
members = get_class_members(cls)
for member in members:
if getattr(cls, member) == value:
return member
return "No member with value found."
[docs]def convert_tile_string_to_xy(tile_pos_string: str) -> Tuple[int, int]:
"""
Convert a tile position string to (x, y)
"""
_x, _y = tile_pos_string.split(",")
x = int(_x) # str to int
y = int(_y)
return x, y
[docs]def convert_direction_to_name(direction: DirectionType) -> str:
"""
Get the direction name from the direction. e.g. (0,1) = 'up' etc.
"""
directions = {
(0, 1): "up",
(0, -1): "down",
(1, 0): "right",
(-1, 0): "left",
(1, 1): "up_right",
(-1, 1): "up_left",
(1, -1): "down_right",
(-1, -1): "down_left",
}
try:
direction_name = directions[direction]
except KeyError:
direction_name = "centre"
return direction_name
################################### CHANCE ########################################
[docs]def roll(min_value: int = 0, max_value: int = 99) -> int:
"""
Roll for a number between min and max
"""
return random.randint(min_value, max_value)