import json import logging import sys from pathlib import Path from typing import Any, Optional import raylib as ray from pyray import Color, Rectangle, Vector2 from libs.animation import BaseAnimation, parse_animations from libs.config import get_config logger = logging.getLogger(__name__) class SkinInfo: def __init__(self, x: float, y: float, font_size: int, width: float, height: float, text: dict[str, str]): self.x = x self.y = y self.width = width self.height = height self.font_size = font_size self.text = text def __repr__(self): return f"{self.__dict__}" class Texture: """Texture class for managing textures and animations.""" def __init__(self, name: str, texture: Any, init_vals: dict[str, int]): self.name = name self.texture = texture self.init_vals = init_vals self.width = self.texture.width self.height = self.texture.height ray.GenTextureMipmaps(ray.ffi.addressof(self.texture)) ray.SetTextureFilter(self.texture, ray.TEXTURE_FILTER_TRILINEAR) ray.SetTextureWrap(self.texture, ray.TEXTURE_WRAP_CLAMP) self.x: list[int] = [0] self.y: list[int] = [0] self.x2: list[int] = [self.width] self.y2: list[int] = [self.height] self.controllable: list[bool] = [False] self.crop_data: Optional[list[tuple[float, float, float, float]]] = None def __repr__(self): return f"{self.__dict__}" class FramedTexture: def __init__(self, name: str, texture: list[Any], init_vals: dict[str, int]): self.name = name self.texture = texture self.init_vals = init_vals self.width = self.texture[0].width self.height = self.texture[0].height for texture_data in self.texture: ray.GenTextureMipmaps(ray.ffi.addressof(texture_data)) ray.SetTextureFilter(texture_data, ray.TEXTURE_FILTER_TRILINEAR) ray.SetTextureWrap(texture_data, ray.TEXTURE_WRAP_CLAMP) self.x: list[int] = [0] self.y: list[int] = [0] self.x2: list[int] = [self.width] self.y2: list[int] = [self.height] self.controllable: list[bool] = [False] self.crop_data: Optional[list[tuple[float, float, float, float]]] = None class TextureWrapper: """Texture wrapper class for managing textures and animations.""" def __init__(self): self.textures: dict[str, dict[str, Texture | FramedTexture]] = dict() self.animations: dict[int, BaseAnimation] = dict() self.skin_config: dict[str, SkinInfo] = dict() self.graphics_path = Path(f'Skins/{get_config()['paths']['skin']}/Graphics') if not self.graphics_path.exists(): logger.error("No skin has been configured") self.screen_width = 1280 self.screen_height = 720 self.screen_scale = 1.0 self.skin_config = dict() return self.parent_graphics_path = Path(f'Skins/{get_config()['paths']['skin']}/Graphics') if not (self.graphics_path / "skin_config.json").exists(): raise Exception("skin is missing a skin_config.json") data = json.loads((self.graphics_path / "skin_config.json").read_text(encoding='utf-8')) self.skin_config: dict[str, SkinInfo] = { k: SkinInfo(v.get('x', 0), v.get('y', 0), v.get('font_size', 0), v.get('width', 0), v.get('height', 0), v.get('text', dict())) for k, v in data.items() } self.screen_width = int(self.skin_config["screen"].width) self.screen_height = int(self.skin_config["screen"].height) self.screen_scale = self.screen_width / 1280 if "parent" in data["screen"]: parent = data["screen"]["parent"] self.parent_graphics_path = Path("Skins") / parent parent_data = json.loads((self.parent_graphics_path / "skin_config.json").read_text(encoding='utf-8')) for k, v in parent_data.items(): self.skin_config[k] = SkinInfo(v.get('x', 0) * self.screen_scale, v.get('y', 0) * self.screen_scale, v.get('font_size', 0) * self.screen_scale, v.get('width', 0) * self.screen_scale, v.get('height', 0) * self.screen_scale, v.get('text', dict())) def unload_textures(self): """Unload all textures and animations.""" ids = {} # Map ID to texture name for zip in self.textures: for file in self.textures[zip]: tex_object = self.textures[zip][file] if isinstance(tex_object.texture, list): for i, texture in enumerate(tex_object.texture): if texture.id in ids: logger.warning(f"Duplicate texture ID {texture.id}: {ids[texture.id]} and {zip}/{file}[{i}]") else: ids[texture.id] = f"{zip}/{file}[{i}]" ray.UnloadTexture(texture) else: if tex_object.texture.id in ids: logger.warning(f"Duplicate texture ID {tex_object.texture.id}: {ids[tex_object.texture.id]} and {zip}/{file}") else: ids[tex_object.texture.id] = f"{zip}/{file}" ray.UnloadTexture(tex_object.texture) self.textures.clear() self.animations.clear() logger.info("All textures unloaded") def get_animation(self, index: int, is_copy: bool = False): """Get an animation by ID and returns a reference. Returns a copy of the animation if is_copy is True.""" if index not in self.animations: raise Exception(f"Unable to find id {index} in loaded animations") if is_copy: new_anim = self.animations[index].copy() if self.animations[index].loop: new_anim.start() return new_anim if self.animations[index].loop: self.animations[index].start() return self.animations[index] def _read_tex_obj_data(self, tex_mapping: dict | list, tex_object: Texture | FramedTexture): if isinstance(tex_mapping, list): for i in range(len(tex_mapping)): if i == 0: tex_object.x[i] = tex_mapping[i].get("x", 0) tex_object.y[i] = tex_mapping[i].get("y", 0) tex_object.x2[i] = tex_mapping[i].get("x2", tex_object.width) tex_object.y2[i] = tex_mapping[i].get("y2", tex_object.height) tex_object.controllable[i] = tex_mapping[i].get("controllable", False) else: tex_object.x.append(tex_mapping[i].get("x", 0)) tex_object.y.append(tex_mapping[i].get("y", 0)) tex_object.x2.append(tex_mapping[i].get("x2", tex_object.width)) tex_object.y2.append(tex_mapping[i].get("y2", tex_object.height)) tex_object.controllable.append(tex_mapping[i].get("controllable", False)) if "frame_order" in tex_mapping[i]: tex_object.texture = list(map(lambda j: tex_object.texture[j], tex_mapping[i]["frame_order"])) if "crop" in tex_mapping[0]: tex_object.crop_data = tex_mapping[0]["crop"] tex_object.x2[i] = tex_object.crop_data[0][2] tex_object.y2[i] = tex_object.crop_data[0][3] else: tex_object.x = [tex_mapping.get("x", 0)] tex_object.y = [tex_mapping.get("y", 0)] tex_object.x2 = [tex_mapping.get("x2", tex_object.width)] tex_object.y2 = [tex_mapping.get("y2", tex_object.height)] tex_object.controllable = [tex_mapping.get("controllable", False)] if "frame_order" in tex_mapping and isinstance(tex_object, FramedTexture): tex_object.texture = list(map(lambda i: tex_object.texture[i], tex_mapping["frame_order"])) if "crop" in tex_mapping: tex_object.crop_data = tex_mapping["crop"] tex_object.x2 = [tex_object.crop_data[0][2]] tex_object.y2 = [tex_object.crop_data[0][3]] def load_animations(self, screen_name: str): """Load animations for a screen, falling back to parent if not found.""" screen_path = self.graphics_path / screen_name parent_screen_path = self.parent_graphics_path / screen_name if (screen_path / 'animation.json').exists(): with open(screen_path / 'animation.json') as json_file: self.animations = parse_animations(json.loads(json_file.read())) logger.info(f"Animations loaded for screen: {screen_name}") elif self.parent_graphics_path != self.graphics_path and (parent_screen_path / 'animation.json').exists(): with open(parent_screen_path / 'animation.json') as json_file: anim_json = json.loads(json_file.read()) for anim in anim_json: if "total_distance" in anim and not isinstance(anim["total_distance"], dict): anim["total_distance"] = anim["total_distance"] * self.screen_scale self.animations = parse_animations(anim_json) logger.info(f"Animations loaded for screen: {screen_name} (from parent)") # TODO: rename to load_folder, add parent_folder logic def load_zip(self, screen_name: str, subset: str): folder = (self.graphics_path / screen_name / subset) if screen_name in self.textures and subset in self.textures[screen_name]: return try: if not (folder / 'texture.json').exists(): raise Exception(f"texture.json file missing from {folder}") with open(folder / 'texture.json') as json_file: tex_mapping_data: dict[str, dict] = json.load(json_file) self.textures[folder.stem] = dict() encoding = sys.getfilesystemencoding() for tex_name in tex_mapping_data: tex_dir = folder / tex_name tex_file = folder / f"{tex_name}.png" tex_mapping = tex_mapping_data[tex_name] if tex_dir.is_dir(): frames = [ray.LoadTexture(str(frame).encode(encoding)) for frame in sorted(tex_dir.iterdir(), key=lambda x: int(x.stem)) if frame.is_file()] self.textures[folder.stem][tex_name] = FramedTexture(tex_name, frames, tex_mapping) self._read_tex_obj_data(tex_mapping, self.textures[folder.stem][tex_name]) elif tex_file.is_file(): tex = ray.LoadTexture(str(tex_file).encode(encoding)) self.textures[folder.stem][tex_name] = Texture(tex_name, tex, tex_mapping) self._read_tex_obj_data(tex_mapping, self.textures[folder.stem][tex_name]) else: logger.error(f"Texture {tex_name} was not found in {folder}") logger.info(f"Textures loaded from zip: {folder}") except Exception as e: logger.error(f"Failed to load textures from zip {folder}: {e}") def load_screen_textures(self, screen_name: str) -> None: """Load textures for a screen.""" screen_path = self.graphics_path / screen_name if not screen_path.exists(): logger.warning(f"Textures for Screen {screen_name} do not exist") return # Load animations self.load_animations(screen_name) # Load zip files from child screen path only for zip_file in screen_path.iterdir(): if zip_file.is_dir(): self.load_zip(screen_name, zip_file.stem) logger.info(f"Screen textures loaded for: {screen_name}") def control(self, tex_object: Texture | FramedTexture, index: int = 0): '''debug function''' distance = 1 if ray.IsKeyDown(ray.KEY_LEFT_SHIFT): distance = 10 if ray.IsKeyPressed(ray.KEY_LEFT): tex_object.x[index] -= distance logger.info(f"{tex_object.name}: {tex_object.x[index]}, {tex_object.y[index]}") if ray.IsKeyPressed(ray.KEY_RIGHT): tex_object.x[index] += distance logger.info(f"{tex_object.name}: {tex_object.x[index]}, {tex_object.y[index]}") if ray.IsKeyPressed(ray.KEY_UP): tex_object.y[index] -= distance logger.info(f"{tex_object.name}: {tex_object.x[index]}, {tex_object.y[index]}") if ray.IsKeyPressed(ray.KEY_DOWN): tex_object.y[index] += distance logger.info(f"{tex_object.name}: {tex_object.x[index]}, {tex_object.y[index]}") def clear_screen(self, color: Color): if isinstance(color, tuple): clear_color = [color[0], color[1], color[2], color[3]] else: clear_color = [color.r, color.g, color.b, color.a] ray.ClearBackground(clear_color) def _draw_texture_untyped(self, subset: str, texture: str, color: tuple[int, int, int, int], frame: int, scale: float, center: bool, mirror: str, x: float, y: float, x2: float, y2: float, origin: tuple[float, float], rotation: float, fade: float, index: int, src: Optional[tuple[float, float, float, float]], controllable: bool) -> None: if subset not in self.textures: return if texture not in self.textures[subset]: return mirror_x = -1 if mirror == 'horizontal' else 1 mirror_y = -1 if mirror == 'vertical' else 1 if fade != 1.1: final_color = ray.Fade(color, fade) else: final_color = color tex_object = self.textures[subset][texture] if src is not None: source_rect = src elif tex_object.crop_data is not None: source_rect = tex_object.crop_data[frame] else: source_rect = (0, 0, tex_object.width * mirror_x, tex_object.height * mirror_y) if center: dest_rect = (tex_object.x[index] + (tex_object.width//2) - ((tex_object.width * scale)//2) + x, tex_object.y[index] + (tex_object.height//2) - ((tex_object.height * scale)//2) + y, tex_object.x2[index]*scale + x2, tex_object.y2[index]*scale + y2) else: dest_rect = (tex_object.x[index] + x, tex_object.y[index] + y, tex_object.x2[index]*scale + x2, tex_object.y2[index]*scale + y2) if isinstance(tex_object, FramedTexture): if frame >= len(tex_object.texture): raise Exception(f"Frame {frame} not available in iterable texture {tex_object.name}") ray.DrawTexturePro(tex_object.texture[frame], source_rect, dest_rect, origin, rotation, final_color) else: ray.DrawTexturePro(tex_object.texture, source_rect, dest_rect, origin, rotation, final_color) if tex_object.controllable[index] or controllable: self.control(tex_object, index) def draw_texture(self, subset: str, texture: str, color: Color = Color(255, 255, 255, 255), frame: int = 0, scale: float = 1.0, center: bool = False, mirror: str = '', x: float = 0, y: float = 0, x2: float = 0, y2: float = 0, origin: Vector2 = Vector2(0,0), rotation: float = 0, fade: float = 1.1, index: int = 0, src: Optional[Rectangle] = None, controllable: bool = False) -> None: """ Wrapper function for raylib's draw_texture_pro(). Parameters: subset (str): The subset of textures to use. texture (str): The name of the texture to draw. color (ray.Color): The color to tint the texture. frame (int): The frame of the texture to draw. Only used if the texture is animated. scale (float): The scale factor to apply to the texture. center (bool): Whether to center the texture. mirror (str): The direction to mirror the texture, either 'horizontal' or 'vertical'. x (float): An x-value added to the top-left corner of the texture. y (float): The y-value added to the top-left corner of the texture. x2 (float): The x-value added to the bottom-right corner of the texture. y2 (float): The y-value added to the bottom-right corner of the texture. origin (ray.Vector2): The origin point of the texture. rotation (float): The rotation angle of the texture. fade (float): The fade factor to apply to the texture. index (int): The index of the position data for the texture. Only used if the texture has multiple positions. src (Optional[ray.Rectangle]): The source rectangle of the texture. controllable (bool): Whether the texture is controllable. """ if src is not None: src_data = (src.x, src.y, src.width, src.height) else: src_data = None if isinstance(color, tuple): color_data = (color[0], color[1], color[2], color[3]) else: color_data = (color.r, color.g, color.b, color.a) self._draw_texture_untyped(subset, texture, color_data, frame, scale, center, mirror, x, y, x2, y2, (origin.x, origin.y), rotation, fade, index, src_data, controllable) tex = TextureWrapper()