Source code for ppb.systems.renderer

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)