import ctypes
import io
import logging
import random
from typing import Tuple
import sdl2
import sdl2.ext
from sdl2 import (
rw_from_object, # https://pysdl2.readthedocs.io/en/latest/modules/sdl2.html#sdl2.sdl2.rw_from_object
SDL_Window, SDL_Renderer,
SDL_Rect, # https://wiki.libsdl.org/SDL_Rect
SDL_INIT_VIDEO, SDL_BLENDMODE_BLEND, SDL_FLIP_NONE,
SDL_CreateWindowAndRenderer, # https://wiki.libsdl.org/SDL_CreateWindowAndRenderer
SDL_DestroyRenderer, # https://wiki.libsdl.org/SDL_DestroyRenderer
SDL_DestroyWindow, # https://wiki.libsdl.org/SDL_DestroyWindow
SDL_SetWindowTitle, # https://wiki.libsdl.org/SDL_SetWindowTitle
SDL_RenderPresent, # https://wiki.libsdl.org/SDL_RenderPresent
SDL_RenderClear, # https://wiki.libsdl.org/SDL_RenderClear
SDL_SetRenderDrawColor, # https://wiki.libsdl.org/SDL_SetRenderDrawColor
SDL_FreeSurface, # https://wiki.libsdl.org/SDL_FreeSurface
SDL_SetSurfaceBlendMode, # https://wiki.libsdl.org/SDL_SetSurfaceBlendMode
SDL_CreateTextureFromSurface, # https://wiki.libsdl.org/SDL_CreateTextureFromSurface
SDL_DestroyTexture, # https://wiki.libsdl.org/SDL_DestroyTexture
SDL_QueryTexture, # https://wiki.libsdl.org/SDL_QueryTexture
SDL_RenderCopyEx, # https://wiki.libsdl.org/SDL_RenderCopyEx
SDL_CreateRGBSurface, # https://wiki.libsdl.org/SDL_CreateRGBSurface
SDL_ShowCursor, # https://wiki.libsdl.org/SDL_ShowCursor
SDL_BLENDMODE_ADD,
SDL_BLENDMODE_BLEND,
SDL_BLENDMODE_MOD,
SDL_BLENDMODE_NONE,
SDL_SetTextureAlphaMod,
SDL_SetTextureBlendMode,
SDL_SetTextureColorMod,
)
from sdl2.sdlimage import (
IMG_Load_RW, # https://www.libsdl.org/projects/SDL_image/docs/SDL_image_12.html#SEC12
IMG_Init, IMG_Quit, # https://www.libsdl.org/projects/SDL_image/docs/SDL_image_6.html#SEC6
IMG_INIT_JPG, IMG_INIT_PNG, IMG_INIT_TIF,
)
from sdl2.sdlttf import (
TTF_Init, TTF_Quit, # https://www.libsdl.org/projects/SDL_ttf/docs/SDL_ttf_6.html#SEC6
)
import ppb.assetlib as assets
import ppb.events as events
import ppb.flags as flags
from ppb.camera import Camera
from ppb.systems.sdl_utils import SdlSubSystem, sdl_call, img_call, ttf_call
from ppb.systems._utils import ObjectSideData
from ppb.utils import get_time
logger = logging.getLogger(__name__)
DEFAULT_RESOLUTION = 800, 600
OPACITY_MODES = {
flags.BlendModeAdd: SDL_BLENDMODE_ADD,
flags.BlendModeBlend: SDL_BLENDMODE_BLEND,
flags.BlendModeMod: SDL_BLENDMODE_MOD,
flags.BlendModeNone: SDL_BLENDMODE_NONE,
}
# TODO: Move Image out of the renderer so sprites can type hint appropriately.
[docs]
class Image(assets.Asset):
# Wraps POINTER(SDL_Surface)
not_found_message = "This may not be a problem, you can stop this warning by explicitly " \
"setting the `image` attribute on your Sprite subclass to an Image object."
def background_parse(self, data):
file = rw_from_object(io.BytesIO(data))
# ^^^^ is a pure-python emulation, does not need cleanup.
surface = img_call(
IMG_Load_RW, file, False,
_check_error=lambda rv: not rv
)
sdl_call(
SDL_SetSurfaceBlendMode, surface, SDL_BLENDMODE_BLEND,
_check_error=lambda rv: rv < 0
)
return surface
def file_missing(self):
width = height = 70 # Pixels, arbitrary
surface = sdl_call(
SDL_CreateRGBSurface, 0, width, height, 32, 0, 0, 0, 0,
_check_error=lambda rv: not rv
)
rand = random.Random(str(self.name))
r = rand.randint(65, 255)
g = rand.randint(65, 255)
b = rand.randint(65, 255)
color = sdl2.ext.Color(r, g, b)
sdl2.ext.fill(surface.contents, color)
return surface
def free(self, object, _SDL_FreeSurface=SDL_FreeSurface):
# ^^^ is a way to keep required functions during interpreter cleanup
_SDL_FreeSurface(object) # Can't fail
# object.contents = None
# Can't actually nullify the pointer. Good thing this is __del__.
class SmartPointer:
def __init__(self, obj, dest):
self.inner = obj
self.destructor = dest
def __del__(self):
self.destructor(self.inner)
class Renderer(SdlSubSystem):
_sdl_subsystems = SDL_INIT_VIDEO
def __init__(
self,
resolution=DEFAULT_RESOLUTION,
window_title: str = "PursuedPyBear",
target_frame_rate: int = 30,
target_camera_width=25,
**kwargs
):
self.resolution = resolution
self.window = None
self.window_title = window_title
self.scene_cameras = {}
self.target_camera_width = target_camera_width
self.target_frame_rate = target_frame_rate
self.target_frame_length = 1 / self.target_frame_rate
self.target_clock = get_time() + self.target_frame_length
self.last_frame = get_time()
self._texture_cache = ObjectSideData()
def __enter__(self):
super().__enter__()
img_call(IMG_Init, IMG_INIT_JPG | IMG_INIT_PNG | IMG_INIT_TIF)
ttf_call(TTF_Init, _check_error=lambda rv: rv == -1)
self.window = ctypes.POINTER(SDL_Window)()
self.renderer = ctypes.POINTER(SDL_Renderer)()
sdl_call(
SDL_CreateWindowAndRenderer,
self.resolution[0], # Width
self.resolution[1], # Height
0, # Flags
# SDL_WINDOW_ALLOW_HIGHDPI - Allow the renderer to work in HiDPI natively
ctypes.byref(self.window),
ctypes.byref(self.renderer),
_check_error=lambda rv: rv < 0
)
# NOTE: It looks like SDL_RENDERER_PRESENTVSYNC will cause SDL_RenderPresent() to block?
sdl_call(SDL_SetWindowTitle, self.window, self.window_title.encode('utf-8'))
def __exit__(self, *exc):
sdl_call(SDL_DestroyRenderer, self.renderer)
sdl_call(SDL_DestroyWindow, self.window)
ttf_call(TTF_Quit)
img_call(IMG_Quit)
super().__exit__(*exc)
def on_idle(self, idle_event: events.Idle, signal):
t = get_time()
if t >= self.target_clock:
signal(events.PreRender(t - self.last_frame))
signal(events.Render())
self.target_clock = t + self.target_frame_length
self.last_frame = t
def on_scene_started(self, scene_started, signal):
scene = scene_started.scene
# Initialize cameras
camera_class = getattr(scene, "camera_class", Camera)
# For future: This is basically the pattern we'd use to define
# multiple cameras. We'd just need to have the scene tell us the
# regions they should render to.
camera = camera_class(self, self.target_camera_width, self.resolution)
scene.main_camera = camera
self.scene_cameras[scene] = [camera]
self.set_cursor(scene)
def on_scene_continued(self, scene_continued: events.SceneContinued, signal):
self.set_cursor(scene_continued.scene)
def on_scene_stopped(self, scene_stopped, signal):
"""We don't need to hold onto references for scenes that stopped."""
del self.scene_cameras[scene_stopped.scene]
def on_render(self, render_event, signal):
camera = render_event.scene.main_camera
self.render_background(render_event.scene)
for game_object in render_event.scene.sprite_layers():
texture = self.prepare_resource(game_object)
if texture is None:
continue
src_rect, dest_rect, angle = self.compute_rectangles(
texture.inner, game_object, camera
)
sdl_call(
SDL_RenderCopyEx, self.renderer, texture.inner,
ctypes.byref(src_rect), ctypes.byref(dest_rect),
angle, None, SDL_FLIP_NONE,
_check_error=lambda rv: rv < 0
)
sdl_call(SDL_RenderPresent, self.renderer)
def render_background(self, scene):
bg = scene.background_color
sdl_call(
SDL_SetRenderDrawColor, self.renderer, bg[0], bg[1], bg[2], 255,
_check_error=lambda rv: rv < 0
)
sdl_call(SDL_RenderClear, self.renderer, _check_error=lambda rv: rv < 0)
def _object_has_dimension(self, game_object):
"""
Tests that an object has dimensionality and they're >0.
"""
if hasattr(game_object, 'width') and game_object.width <= 0:
return False
elif hasattr(game_object, 'height') and game_object.height <= 0:
return False
elif hasattr(game_object, 'size') and game_object.size <= 0:
return False
elif not (hasattr(game_object, 'width') or hasattr(game_object, 'height') or hasattr(game_object, 'size')):
return False
else:
return True
def prepare_resource(self, game_object):
"""
Get the SDL Texture for an object.
"""
if not self._object_has_dimension(game_object):
return None
if not hasattr(game_object, '__image__'):
return
image = game_object.__image__()
if image is None:
return None
surface = image.load()
try:
texture = self._texture_cache[surface]
except KeyError:
texture = SmartPointer(sdl_call(
SDL_CreateTextureFromSurface, self.renderer, surface,
_check_error=lambda rv: not rv
), SDL_DestroyTexture)
self._texture_cache[surface] = texture
opacity = getattr(game_object, 'opacity', 255)
opacity_mode = getattr(game_object, 'opacity_mode', flags.BlendModeBlend)
opacity_mode = OPACITY_MODES[opacity_mode]
tint = getattr(game_object, 'tint', (255, 255, 255))
sdl_call(
SDL_SetTextureAlphaMod, texture.inner, opacity,
_check_error=lambda rv: rv < 0
)
sdl_call(
SDL_SetTextureBlendMode, texture.inner, opacity_mode,
_check_error=lambda rv: rv < 0
)
sdl_call(
SDL_SetTextureColorMod, texture.inner, tint[0], tint[1], tint[2],
_check_error=lambda rv: rv < 0
)
return texture
def compute_rectangles(self, texture, game_object, camera):
flags = sdl2.stdinc.Uint32()
access = ctypes.c_int()
img_w = ctypes.c_int()
img_h = ctypes.c_int()
sdl_call(
SDL_QueryTexture, texture, ctypes.byref(flags), ctypes.byref(access),
ctypes.byref(img_w), ctypes.byref(img_h),
_check_error=lambda rv: rv < 0
)
src_rect = SDL_Rect(x=0, y=0, w=img_w, h=img_h)
if hasattr(game_object, 'width'):
obj_w = game_object.width
obj_h = game_object.height
else:
obj_w, obj_h = game_object.size
win_w, win_h = self.target_resolution(img_w.value, img_h.value, obj_w, obj_h, camera.pixel_ratio)
try:
center = camera.translate_point_to_screen(game_object.position)
except TypeError as error:
raise TypeError(f"""{type(game_object).__name__}.position was set to a tuple:
(number, number)
Expected a vector:
Vector(number, number)
""") from error
dest_rect = SDL_Rect(
x=int(center.x - win_w / 2),
y=int(center.y - win_h / 2),
w=win_w,
h=win_h,
)
return src_rect, dest_rect, ctypes.c_double(-game_object.rotation)
def set_cursor(self, scene):
show_cursor = int(bool(getattr(scene, "show_cursor", True)))
sdl_call(SDL_ShowCursor, show_cursor)
@staticmethod
def target_resolution(img_width, img_height, obj_width, obj_height, pixel_ratio):
if not obj_width:
ratio = img_height / (pixel_ratio * obj_height)
elif not obj_height:
ratio = img_width / (pixel_ratio * obj_width)
else:
ratio_w = img_width / (pixel_ratio * obj_width)
ratio_h = img_height / (pixel_ratio * obj_height)
ratio = min(ratio_w, ratio_h) # smaller value -> less reduction
return round(img_width / ratio), round(img_height / ratio)