"""An abstraction for a grid of pixels that can be drawn onto."""
from __future__ import annotations
from typing import Optional
import sdl2, sdl2.ext, sdl2.sdlimage, ctypes
import os
from ...c_src import c_draw
from .. import Vector, Color, Display, get_path
[docs]class Surface:
"""
A grid of pixels that can be modified without being attached to a game object.
Args:
width: The width of the surface in pixels. Once set this cannot be changed. Defaults to 32.
height: The height of the surface in pixels. Once set this cannot be changed. Defaults to 32.
scale: The scale of the surface. Defaults to (1, 1).
rotation: The clockwise rotation of the sprite.
af: Whether to use anisotropic filtering. Defaults to False.
"""
def __init__(
self,
width: int = 32,
height: int = 32,
scale: Vector | tuple[float, float] = (1, 1),
rotation: float = 0,
af: bool = False,
):
if width <= 0 or height <= 0:
raise ValueError("Width and height must be greater than 0")
self.rotation: float = rotation
"""The clockwise rotation of the sprite."""
self.scale: Vector = Vector.create(scale)
"""The scale of the sprite."""
self._af: bool = af
self._width: int = width
self._height: int = height
self._color_key: Optional[int] = None
sdl2.SDL_SetHint(b"SDL_RENDER_SCALE_QUALITY", b"linear" if self._af else b"nearest")
self._tx: sdl2.SDL_Texture = sdl2.SDL_CreateTexture(
Display.renderer.sdlrenderer, Display.pixel_format, sdl2.SDL_TEXTUREACCESS_STREAMING, width, height
).contents
sdl2.SDL_SetTextureBlendMode(self._tx, sdl2.SDL_BLENDMODE_BLEND)
self._pixels: int = c_draw.create_pixel_buffer(width, height)
self._pixels_colorkey: int = 0
self.uptodate: bool = False
"""
Whether the texture is up to date with the surface.
Can be set to False to trigger a texture regeneration at the next draw cycle.
"""
@property
def width(self) -> int:
"""The width of the surface in pixels (read-only)."""
return self._width
@property
def height(self) -> int:
"""The height of the surface in pixels (read-only)."""
return self._height
@property
def af(self):
"""Whether to use anisotropic filtering."""
return self._af
@af.setter
def af(self, new: bool):
self._af = new
sdl2.SDL_SetHint(b"SDL_RENDER_SCALE_QUALITY", b"linear" if self._af else b"nearest")
self._tx: sdl2.SDL_Texture = sdl2.SDL_CreateTexture(
Display.renderer.sdlrenderer, Display.pixel_format, sdl2.SDL_TEXTUREACCESS_STREAMING, self.width,
self.height
).contents
sdl2.SDL_SetTextureBlendMode(self._tx, sdl2.SDL_BLENDMODE_BLEND)
self.uptodate = False
[docs] def size_scaled(self) -> Vector:
"""
Gets the current size of the Surface. (Scaled)
Returns:
The size of the Surface
"""
return Vector(self._width * self.scale.x, self._height * self.scale.y)
[docs] def size(self) -> Vector:
"""
Gets the current size of the Surface. (Unscaled)
Returns:
The size of the Surface
"""
return Vector(self._width, self._height)
def _blit(
self,
other: Surface,
src_rect: tuple[int, int, int, int] | None = None,
dst_rect: tuple[int, int, int, int] | None = None,
):
"""
This function uses the SDL coordinate system and should only be used internally. This is mainly kept for
Spritesheet. Blits (merges / copies) another Surface onto this one.
Args:
other: The Surface to blit onto this one.
src_rect: The area (x, y, width, height) to blit from in the source surface (other).
Defaults to the whole surface.
dst_rect: The area (x, y, width, height) to blit to in the destination surface (self).
Defaults to the whole surface.
Note:
Will not stretch the other surface to fit the destination rectangle.
"""
c_draw.blit(
other._pixels,
self._pixels,
other.width,
other.height,
self.width,
self.height,
*(src_rect or (0, 0, other.width, other.height)),
*(dst_rect or (0, 0, self.width, self.height)),
)
self.uptodate = False
[docs] def blit(
self,
other: Surface,
src_rect: tuple[int, int, int, int] | None = None,
dst: Vector | tuple[int, int] = (0, 0),
):
"""
Blits (merges / copies) another Surface onto this one.
Args:
other: The Surface to blit onto this one.
src_rect: The area (center_x, center_y, width, height) to crop from the source surface (other).
Defaults to the whole surface.
dst: The position to place the other surface. Defaults to (0, 0).
Note:
Will not stretch the other surface to fit the destination rectangle.
"""
src_rect = src_rect or (0, 0, int(other.width), int(other.height))
src_top_left = Display._center_to_top_left(other._convert_to_surface_space((0, 0)), src_rect[2:4])
dst_final = Display._center_to_top_left(self._convert_to_surface_space((*dst,)), src_rect[2:4])
c_draw.blit(
other._pixels,
self._pixels,
other.width,
other.height,
self.width,
self.height,
int(src_top_left[0]),
int(src_top_left[1]),
*src_rect[2:4],
int(dst_final[0]),
int(dst_final[1]),
*src_rect[2:4],
)
self.uptodate = False
[docs] def flip_x(self):
"""Flips the surface horizontally."""
c_draw.flip_x(self._pixels, self.width, self.height)
self.uptodate = False
[docs] def flip_y(self):
"""Flips the surface vertically."""
c_draw.flip_y(self._pixels, self.width, self.height)
self.uptodate = False
[docs] def flip_anti_diagonal(self):
"""Flips the surface along the anti diagonal."""
c_draw.flip_anti_diagonal(self._pixels, self.width, self.height)
self.uptodate = False
def _regen(self):
"""Updates the texture."""
if self._color_key is not None:
c_draw.colorkey_copy(self._pixels, self._pixels_colorkey, self._width, self._height, self._color_key)
sdl2.SDL_UpdateTexture(
self._tx, None, self._pixels if self._color_key is None else self._pixels_colorkey, self.width * 4
)
self.uptodate = True
[docs] def clear(self):
"""
Clears the surface.
"""
c_draw.clear_pixels(self._pixels, self._width, self._height)
self.uptodate = False
[docs] def fill(self, color: Color):
"""
Fill the surface with a color.
Args:
color: The color to fill with.
"""
self.draw_rect((0, 0), (self._width, self._height), fill=color)
def _convert_to_surface_space(self, pos: Vector | tuple[float, float]) -> tuple[float, float]:
"""Simple function that converts cartesian coordinates to surface space."""
return (pos[0] + self._width / 2, -pos[1] + self._height / 2)
def _convert_to_cartesian_space(self, pos: Vector | tuple[float, float]) -> tuple[float, float]:
"""Simple function that converts surface space to cartesian coordinates."""
return (pos[0] - self._width / 2, -pos[1] + self._height / 2)
[docs] def get_pixel(self, pos: Vector | tuple[float, float]) -> Color:
"""
Gets the color of a pixel on the surface.
Args:
pos: The position of the pixel.
Returns:
The color of the pixel.
"""
cart_pos = self._convert_to_surface_space(pos)
x, y = round(cart_pos[0]), round(cart_pos[1])
if 0 <= x < self._width and 0 <= y < self._height:
return Color.from_argb32(c_draw.get_pixel(self._pixels, self._width, self._height, x, y))
else:
raise ValueError(f"Position is outside of the ${self.__class__.__name__}.")
[docs] def set_pixel(self, pos: Vector | tuple[float, float], color: Color = Color.black, blending: bool = True):
"""
Draws a point on the surface.
Args:
pos: The position to draw the point.
color: The color of the point. Defaults to black.
blending: Whether to use blending. Defaults to False.
"""
cart_pos = self._convert_to_surface_space(pos)
x, y = round(cart_pos[0]), round(cart_pos[1])
c_draw.set_pixel(self._pixels, self._width, self._height, x, y, color.argb32(), blending)
self.uptodate = False
[docs] def draw_line(
self,
start: Vector | tuple[float, float],
end: Vector | tuple[float, float],
color: Color = Color.black,
aa: bool = False,
thickness: int = 1,
blending: bool = True
):
"""
Draws a line on the surface.
Args:
start: The start of the line.
end: The end of the line.
color: The color of the line. Defaults to black.
aa: Whether to use anti-aliasing. Defaults to False.
thickness: The thickness of the line. Defaults to 1.
blending: Whether to use blending. Defaults to False.
"""
start_pos = self._convert_to_surface_space(start)
end_pos = self._convert_to_surface_space(end)
sx, sy = round(start_pos[0]), round(start_pos[1])
ex, ey = round(end_pos[0]), round(end_pos[1])
c_draw.draw_line(
self._pixels, self._width, self._height, sx, sy, ex, ey, color.argb32(), aa, blending, thickness
)
self.uptodate = False
[docs] def draw_rect(
self,
center: Vector | tuple[float, float],
dims: Vector | tuple[float, float],
border: Color | None = None,
border_thickness: int = 1,
fill: Color | None = None,
blending: bool = True
):
"""
Draws a rectangle on the surface.
Args:
center: The top left corner of the rectangle.
dims: The dimensions of the rectangle.
border: The border color of the rectangle. Defaults to None.
border_thickness: The thickness of the border. Defaults to 1.
fill: The fill color of the rectangle. Set to None for no fill. Defaults to None.
blending: Whether to use blending. Defaults to False.
"""
top_left = Display._center_to_top_left(self._convert_to_surface_space(center), dims)
x, y = round(top_left[0]), round(top_left[1])
w, h = round(dims[0]), round(dims[1])
c_draw.draw_rect(
self._pixels,
self._width,
self._height,
x,
y,
w,
h,
border.argb32() if border else 0,
fill.argb32() if fill else 0,
blending,
border_thickness,
)
self.uptodate = False
[docs] def draw_circle(
self,
center: Vector | tuple[float, float],
radius: int,
border: Color | None = None,
border_thickness: int = 1,
fill: Color | None = None,
aa: bool = False,
blending: bool = True,
):
"""
Draws a circle on the surface.
Args:
center: The center of the circle.
radius: The radius of the circle.
border: The border color of the circle. Defaults to None.
border_thickness: The thickness of the border. Defaults to 1.
fill: The fill color of the circle. Set to None for no fill. Defaults to None.
aa: Whether to use anti-aliasing. Defaults to False.
blending: Whether to use blending. Defaults to False.
"""
center_pos = self._convert_to_surface_space(center)
x, y = round(center_pos[0]), round(center_pos[1])
c_draw.draw_circle(
self._pixels,
self._width,
self._height,
x,
y,
radius,
border.argb32() if border else 0,
fill.argb32() if fill else 0,
aa,
blending,
border_thickness,
)
self.uptodate = False
[docs] def draw_poly(
self,
points: list[Vector] | list[tuple[float, float]],
center: Vector | tuple[float, float] = (0, 0),
border: Color | None = None,
border_thickness: int = 1,
fill: Color | None = None,
aa: bool = False,
blending: bool = True,
):
"""
Draws a polygon on the surface.
Args:
points: The points of the polygon.
center: The center of the polygon.
border: The border color of the polygon. Defaults to None.
border_thickness: The thickness of the border. Defaults to 1.
fill: The fill color of the polygon. Set to None for no fill. Defaults to None.
aa: Whether to use anti-aliasing. Defaults to False.
blending: Whether to use blending. Defaults to False.
"""
center_pos = self._convert_to_surface_space(center)
c_draw.draw_poly(
self._pixels,
center_pos,
self._width,
self._height,
points,
border.argb32() if border else 0,
fill.argb32() if fill else 0,
aa,
blending,
border_thickness,
)
self.uptodate = False
[docs] def switch_color(self, color: Color, new_color: Color):
"""
Switches a color in the surface.
Args:
color: The color to switch.
new_color: The new color to switch to.
"""
c_draw.switch_colors(self._pixels, self._width, self._height, color.argb32(), new_color.argb32())
self.uptodate = False
[docs] def set_colorkey(self, color: Color):
"""
Sets the colorkey of the surface.
Args:
color: Color to set as the colorkey.
"""
if self._pixels_colorkey == 0:
self._pixels_colorkey = c_draw.create_pixel_buffer(self.width, self.height)
self._color_key = color.argb32()
self.uptodate = False
[docs] def remove_colorkey(self):
"""
Remove the colorkey of the surface.
"""
if self._pixels_colorkey != 0:
c_draw.free_pixel_buffer(self._pixels_colorkey)
self._pixels_colorkey = 0
self._color_key = None
self.uptodate = False
[docs] def clone(self) -> Surface:
"""
Clones the current surface.
Returns:
The cloned surface.
"""
new = Surface(
self.width,
self.height,
scale=self.scale.clone(),
rotation=self.rotation,
af=self.af,
)
new.blit(self)
new._pixels_colorkey = self._pixels_colorkey
new._color_key = self._color_key
new.set_alpha(self.get_alpha())
return new
[docs] def set_alpha(self, new: int):
"""
Sets surface wide alpha.
Args:
new: The new alpha. (value between 0-255)
"""
new = max(min(new, 255), 0)
sdl2.SDL_SetTextureAlphaMod(self._tx, new)
[docs] def get_alpha(self) -> int:
"""
Gets the surface wide alpha.
"""
y = ctypes.c_uint8()
sdl2.SDL_GetTextureAlphaMod(self._tx, ctypes.byref(y))
return y.value
[docs] def save_as(
self,
filename: str,
path: str = "./",
extension: str = "png",
save_to_temp_path: bool = False,
quality: int = 100,
) -> bool:
"""
Save the surface to a file.
Args:
filename: The name of the file to save to.
path: Path to output folder.
extension: The extension to save the file as. (png, jpg, bmp supported)
save_to_temp_path: Whether to save the file to a temporary path (i.e. MEIPASS used in exe).
quality: The quality of the jpg 0-100 (only used for jpgs).
Returns:
If save was successful.
"""
if extension not in ["png", "jpg", "bmp"]:
raise ValueError("Invalid extension. Only png, jpg, bmp are supported.")
render_surface = self._as_surf()
path_bytes: bytes = path.encode("utf-8")
if save_to_temp_path:
path_bytes = bytes(get_path(os.path.join(path, filename, filename + "." + extension)), "utf-8")
else:
path_bytes = bytes(os.path.join(path, filename + "." + extension), "utf-8")
succeeded = False
if extension == "png":
succeeded = sdl2.sdlimage.IMG_SavePNG(render_surface, path_bytes) == 0
elif extension == "jpg":
succeeded = sdl2.sdlimage.IMG_SaveJPG(render_surface, path_bytes, quality) == 0
else:
succeeded = sdl2.SDL_SaveBMP(render_surface, path_bytes) == 0
sdl2.SDL_FreeSurface(render_surface)
return succeeded
def _as_surf(self) -> sdl2.SDL_Surface:
"""
Converts the underlying texture to a SDL_Surface.
Returns:
The SDL_Surface.
"""
if not self.uptodate:
self._regen()
surf = sdl2.SDL_CreateRGBSurfaceWithFormatFrom(
self._pixels if self._pixels_colorkey == 0 else self._pixels_colorkey,
self._width,
self._height,
32,
self._width * 4,
Display.pixel_format,
)
sdl2.SDL_SetSurfaceAlphaMod(surf, self.get_alpha())
return surf
[docs] @classmethod
def from_file(
cls,
path: str,
scale: Vector | tuple[float, float] = (1, 1),
rotation: float = 0,
af: bool = False,
) -> Surface:
"""
Loads a surface from an image file.
Args:
path: The path to the file.
scale: The scale of the surface. Defaults to (1, 1).
rotation: The clockwise rotation of the sprite. Defaults to 0.
af: Whether to use anisotropic filtering. Defaults to False.
Returns:
The resultant surface.
"""
try:
surf_bad = sdl2.ext.load_img(path, False)
except OSError:
surf_bad = sdl2.ext.load_img(get_path(path), False)
except sdl2.ext.SDLError as e:
fname = path.replace("\\", "/").split("/")[-1]
raise TypeError(f"{fname} is not a valid image file") from e
surf = sdl2.SDL_ConvertSurfaceFormat(surf_bad, Display.pixel_format, 0).contents
s = cls(surf.w, surf.h, scale=scale, rotation=rotation, af=af)
c_draw.free_pixel_buffer(s._pixels)
s._pixels = c_draw.clone_pixel_buffer(surf.pixels, surf.w, surf.h)
sdl2.SDL_FreeSurface(surf)
sdl2.SDL_FreeSurface(surf_bad)
return s
@classmethod
def _from_surf(
cls,
surf: sdl2.SDL_Surface,
scale: Vector | tuple[float, float] = (1, 1),
rotation: float = 0,
af: bool = False
) -> Surface:
"""
Creates a Surface from an SDL_Surface.
Note that this does not free the original SDL_Surface.
Args:
surf: The SDL_Surface to create the surface from.
scale: The scale of the surface. Defaults to (1, 1).
rotation: The clockwise rotation of the sprite. Defaults to 0.
af: Whether to use anisotropic filtering. Defaults to False.
Returns:
The resultant surface.
"""
new_surf = sdl2.SDL_ConvertSurfaceFormat(surf, Display.pixel_format, 0).contents
s = cls(surf.w, surf.h, scale=scale, rotation=rotation, af=af)
c_draw.free_pixel_buffer(s._pixels)
s._pixels = c_draw.clone_pixel_buffer(new_surf.pixels, surf.w, surf.h)
sdl2.SDL_FreeSurface(new_surf)
return s
def __del__(self):
sdl2.SDL_DestroyTexture(self._tx)
c_draw.free_pixel_buffer(self._pixels)