Search code examples
pythonimagepython-3.xpygamestretch

Stretch an Image in Pygame while preserving the corners


The title says it all really. The effect I'm desiring is going to be used for UI, since UI bubbles will appear, and I want to animate them stretching.

Chat bubbles in iOS messaging apps are a good example of this behavior, see here for example. Here's the main image reproduced:

enter image description here

Notice the last chat bubbles wonky behavior. This is not normal in messaging apps, and the proper stretching is what I want to achieve with Pygame.

Is there any easy way to reproduce this specific kind of stretching in Pygame? Even if there are some constraints, like, all corners have to be the same size or something. I'd just like to know what is possible.

Thanks!


Solution

  • Based on what I had suggested in the comments, here is an implementation of the SliceSprite class that creates and renders a 9-sliced sprite in pygame. I have also included a sample to show how it might be used. It is definitely rough around the edges (does not check for invalid input like when you resize the sprite with a width less than your defined left and right slice sizes) but should still be a useful start. This code has been updated and polished to handle these edge cases and does not recreate nine subsurfaces on every draw call as suggested by @skrx in the comments.

    slicesprite.py

    import pygame
    
    class SliceSprite(pygame.sprite.Sprite):
        """
        SliceSprite extends pygame.sprite.Sprite to allow for 9-slicing of its contents.
        Slicing of its image property is set using a slicing tuple (left, right, top, bottom).
        Values for (left, right, top, bottom) are distances from the image edges.
        """
        width_error = ValueError("SliceSprite width cannot be less than (left + right) slicing")
        height_error = ValueError("SliceSprite height cannot be less than (top + bottom) slicing")
    
        def __init__(self, image, slicing=(0, 0, 0, 0)):
            """
            Creates a SliceSprite object.
            _sliced_image is generated in _generate_slices() only when _regenerate_slices is True.
            This avoids recomputing the sliced image whenever each SliceSprite parameter is changed
            unless absolutely necessary! Additionally, _rect does not have direct @property access
            since updating properties of the rect would not be trigger _regenerate_slices.
    
            Args:
                image (pygame.Surface): the original surface to be sliced
                slicing (tuple(left, right, top, bottom): the 9-slicing margins relative to image edges
            """
            pygame.sprite.Sprite.__init__(self)
            self._image = image
            self._sliced_image = None
            self._rect = self.image.get_rect()
            self._slicing = slicing
            self._regenerate_slices = True
    
        @property
        def image(self):
            return self._image
    
        @image.setter
        def image(self, new_image):
            self._image = new_image
            self._regenerate_slices = True
    
        @property
        def width(self):
            return self._rect.width
    
        @width.setter
        def width(self, new_width):
            self._rect.width = new_width
            self._regenerate_slices = True
    
        @property
        def height(self):
            return self._rect.height
    
        @height.setter
        def height(self, new_height):
            self._rect.height = new_height
            self._regenerate_slices = True
    
        @property
        def x(self):
            return self._rect.x
    
        @x.setter
        def x(self, new_x):
            self._rect.x = new_x
            self._regenerate_slices = True
    
        @property
        def y(self):
            return self._rect.y
    
        @y.setter
        def y(self, new_y):
            self._rect.y = new_y
            self._regenerate_slices = True
    
        @property
        def slicing(self):
            return self._slicing
    
        @slicing.setter
        def slicing(self, new_slicing=(0, 0, 0, 0)):
            self._slicing = new_slicing
            self._regenerate_slices = True
    
        def get_rect(self):
            return self._rect
    
        def set_rect(self, new_rect):
            self._rect = new_rect
            self._regenerate_slices = True
    
        def _generate_slices(self):
            """
            Internal method required to generate _sliced_image property.
            This first creates nine subsurfaces of the original image (corners, edges, and center).
            Next, each subsurface is appropriately scaled using pygame.transform.smoothscale.
            Finally, each subsurface is translated in "relative coordinates."
            Raises appropriate errors if rect cannot fit the center of the original image.
            """
            num_slices = 9
            x, y, w, h = self._image.get_rect()
            l, r, t, b = self._slicing
            mw = w - l - r
            mh = h - t - b
            wr = w - r
            hb = h - b
    
            rect_data = [
                (0, 0, l, t), (l, 0, mw, t), (wr, 0, r, t),
                (0, t, l, mh), (l, t, mw, mh), (wr, t, r, mh),
                (0, hb, l, b), (l, hb, mw, b), (wr, hb, r, b),
            ]
    
            x, y, w, h = self._rect
            mw = w - l - r
            mh = h - t - b
            if mw < 0: raise SliceSprite.width_error
            if mh < 0: raise SliceSprite.height_error
    
            scales = [
                (l, t), (mw, t), (r, t),
                (l, mh), (mw, mh), (r, mh),
                (l, b), (mw, b), (r, b),
            ]
    
            translations = [
                (0, 0), (l, 0), (l + mw, 0),
                (0, t), (l, t), (l + mw, t),
                (0, t + mh), (l, t + mh), (l + mw, t + mh),
            ]
    
            self._sliced_image = pygame.Surface((w, h))
            for i in range(num_slices):
                rect = pygame.rect.Rect(rect_data[i])
                surf_slice = self.image.subsurface(rect)
                stretched_slice = pygame.transform.smoothscale(surf_slice, scales[i])
                self._sliced_image.blit(stretched_slice, translations[i])
    
        def draw(self, surface):
            """
            Draws the SliceSprite onto the desired surface.
            Calls _generate_slices only at draw time only if necessary.
            Note that the final translation occurs here in "absolute coordinates."
    
            Args:
                surface (pygame.Surface): the parent surface for blitting SliceSprite
            """
            x, y, w, h, = self._rect
            if self._regenerate_slices:
                self._generate_slices()
                self._regenerate_slices = False
            surface.blit(self._sliced_image, (x, y))
    

    Example usage (main.py):

    import pygame
    from slicesprite import SliceSprite
    
    if __name__ == "__main__":
        pygame.init()
        screen = pygame.display.set_mode((800, 600))
        clock = pygame.time.Clock()
        done = False
    
        outer_points = [(0, 20), (20, 0), (80, 0), (100, 20), (100, 80), (80, 100), (20, 100), (0, 80)]
        inner_points = [(10, 25), (25, 10), (75, 10), (90, 25), (90, 75), (75, 90), (25, 90), (10, 75)]
        image = pygame.Surface((100, 100), pygame.SRCALPHA)
        pygame.draw.polygon(image, (20, 100, 150), outer_points)
        pygame.draw.polygon(image, (0, 60, 120), inner_points)
    
        button = SliceSprite(image, slicing=(25, 25, 25, 25))
        button.set_rect((50, 100, 500, 200))
        #Alternate version if you hate using rects for some reason
        #button.x = 50
        #button.y = 100
        #button.width = 500
        #button.height = 200
    
        while not done:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    done = True
            screen.fill((0, 0, 0))
            button.draw(screen)
            pygame.display.flip()
            clock.tick()