3
\$\begingroup\$

I'm using pygame.rect.colliderect() to check hitbox collision between sprites in my game, but now that I'm rotating the sprites, I'm realizing I can't do this because there's no way to rotate a PyGame rect, so the hitboxes are way off now.

How should I handle collision in a game with rotating sprites if I don't want to use masks or pixel perfect collision? Is there a way to make a hitbox that rotates with the sprite?

\$\endgroup\$
3
\$\begingroup\$

As mentioned, pygame doesn't directly offer support for rotating hitboxes. However, you could either look into coding your own non-axis aligned box classes, or use a little cheat. The cheat basically calculates the axis-aligned hitbox that completely contains your sprite. It's pretty easy to calculate, and might be good enough for your game, depending on your needs of course. I myself use the trick in my current game project.

So here's the basic outline for the calculation. You'll need to rotate each corner of your sprite image by the rotation amount, and then find the largest and smallest coordinates, and build your box around those. The code might look something like this in pseudo-code (semi Python):

rotatedCorners = [rotate(topleft), rotate(topright), rotate(bottomleft), rotate(bottomright)]
# Assumes that "rotate" returns a tuple point.
xCoordinates = list(c[0] for c in rotatedCorners)
yCoordinates = list(c[1] for c in rotatedCorners)
# Then we just calculate the box data.
position = (min(xCoordinates), min(yCoordinates))
size = (max(xCoordinates) - position[0], max(yCoordinates) - position[1])

The rotate-function should look something like this:

def rotate(point):
    # First translates the point to have the origin at your sprite's center.
    origin = yourSpriteCenterPosition;
    originPoint = (point[0] - origin[0], point[1] - origin[1])
    # Then we rotate the point using basic trigonometry.
    rotatedX = originPoint[0] * cos(angle) - originPoint[1] * sin(angle)
    rotatedY = originPoint[0] * sin(angle) + originPoint[1] * cos(angle)

    # Finally we need to translate the point back to world space.
    return (rotatedX + origin[0], rotatedY + origin[1])

This is what my quick and dirty implementation yields:

spinning rocket

\$\endgroup\$
  • 1
    \$\begingroup\$ You can measure the max and min size of the hitbox, then give it a fixed size with average values - this gives a hitbox that is slightly smaller than the sprite on certain angles which creates the "Wow, it was close!" factor when an enemy moves past the pixels of your sprite but doesn't collide with the hitbox itself. \$\endgroup\$ – cprn May 26 '16 at 8:11
  • 1
    \$\begingroup\$ That's partly a good idea. On the other hand, it might be less frustrating on some situations, but still hit/miss something else. This is very dependant on the game. \$\endgroup\$ – Tyyppi_77 May 26 '16 at 8:15
3
\$\begingroup\$

Building on Tyyppi_77's answer above, there are a few approaches you can take:

1) Resizing the hit box (as he suggests) - although you can do it much much easier just by using image.get_rect(), which will have the same result.

2) Use circle collisions. In Pygame this is pretty easy if you're using the Sprite class. Just assign each sprite a self.radius, and then use pygame.sprite.collide_circle() - http://www.pygame.org/docs/ref/sprite.html#pygame.sprite.collide_circle NOTE: depending on the shape of your sprite, this may or may not result in better looking collisions.

3) Define a smaller hitbox. Give each sprite a custom hitbox that is a little bit smaller than the image. This can result in a much better game feel, and you don't get those near-miss collisions that can frustrate players. I recently did a tutorial on this, if you're interested: https://www.youtube.com/watch?v=5M_-cJP5rk8

4) Use a collision mask. This is how you do "pixel perfect" collisions in Pygame. You define a mask for each sprite - using pygame.mask.from_image() is the simplest way - and then use the pygame.sprite.collide_mask() callback for your collisions. http://www.pygame.org/docs/ref/sprite.html#pygame.sprite.collide_mask

Which solution you choose will depend on what you are trying to accomplish. Collisions can be done in lots of different ways, and programmers often over-engineer them when a simpler solution will give the same game feel. For example, circle collisions are much faster than masks, but in a fast-moving game the player usually won't be able to see the difference.

\$\endgroup\$
1
\$\begingroup\$

Usually hit boxes are implemented to always be axis aligned. Pygame is really old and it hasn't been updated in a couple of years so it is missing a lot of the features we'd expect from a game development API such as proper collision detection. I would suggest you either use circles for collision which are very easy to implement and are the fastest method of collision detection or you could write a proper collision box class that can be rotated with the object.

\$\endgroup\$
1
\$\begingroup\$

There's another alternative: Pymunk the 2d physics library. You can give your game objects pretty arbitrary shapes by adding several pymunk.Shapes to a pymunk.Body (as you see in the GameObject class) and then use them for collision detection in your py-game. In the example below a point query is used to delete sprites with the right mouse button (just for demonstration purposes). To implement bullets or other colliding objects you can use collision handlers. Of course you can turn the gravity and interactions between the bodies off as well if you don't need them.

The green lines show the outlines of the Pymunk shapes. The rect of the pygame sprite is set to the body.position in the update method, so that the image gets blitted at the correct point. That means in order to move the sprite, you would have to manipulate its body.

import math

import pygame as pg
import pymunk as pm
from pymunk import Vec2d


def flipy(p):
    """Convert chipmunk coordinates to pygame coordinates."""
    return Vec2d(p[0], -p[1]+600)


class GameObject(pg.sprite.Sprite):
    """A sprite with an attached physics body."""

    def __init__(self, pos, space):
        super().__init__()
        self.image = pg.Surface((80, 70), pg.SRCALPHA)
        offset = Vec2d(80/2, 70/2)  # Offset for poly drawing below.
        self.orig_image = self.image
        self.rect = self.image.get_rect(topleft=pos)

        self.space = space

        self.body = pm.Body(0, 0)
        # Attach the sprite to the body, so that we can access it later.
        self.body.sprite = self
        self.body.position = pos
        self.space.add(self.body)
        # Add 2 shapes to the body.
        # Vertices of the shapes.
        vertices = (
            [(-25, 14), (0, 24), (0, 0), (30, 0), (30, -20), (0, -20)],
            [(20, 34), (40, 24), (10, 0), (-10, 0)],
            )
        for verts in vertices:
            shape = pm.Poly(self.body, verts, radius=.1)
            shape.density = 1
            shape.friction = .9
            self.space.add(shape)
            # Need to transform the verts before they can
            # be used to draw a poly on self.image.
            verts2 = [vert.rotated(self.body.angle) + offset
                      for vert in shape.get_vertices()]
            verts2 = [(x, -y+70) for x, y in verts2]
            pg.draw.polygon(self.image, (60, 80, 140), verts2)

    def update(self, dt):
        # Synchronize the position of the sprite rect and Pymunk body.
        self.rect.center = flipy(self.body.position)
        # Rotate the image.
        self.image = pg.transform.rotate(
            self.orig_image, math.degrees(self.body.angle))
        self.rect = self.image.get_rect(center=self.rect.center)

        if self.rect.bottom > 560:  # Off-screen.
            self.space.remove(self.body, self.body.shapes)
            self.kill()


class Game:

    def __init__(self):
        self.fps = 30
        self.done = False
        self.green = pg.Color('springgreen')
        self.clock = pg.time.Clock()
        self.screen = pg.display.set_mode((800, 600))
        self.all_sprites = pg.sprite.Group()

        self.space = pm.Space()
        self.space.gravity = Vec2d(0.0, -900.0)
        # A static body platform.
        body = pm.Body(0, 0, pm.Body.STATIC)
        verts = [[150, 100], [650, 100], [650, 200], [150, 200]]
        poly = pm.Poly(body, verts, radius=.1)
        poly.friction = 0.5
        self.space.add(body, poly)

        self.shape_filter = pm.ShapeFilter()

    def run(self):
        while not self.done:
            self.dt = self.clock.tick(self.fps) / 1000
            self.handle_events()
            self.run_logic()
            self.draw()
            self.current_fps = self.clock.get_fps()

    def handle_events(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self.done = True
            elif event.type == pg.MOUSEBUTTONDOWN:
                if event.button == 1:  # Left button adds GameObjects.
                    self.all_sprites.add(
                        GameObject(flipy(event.pos), self.space))
                elif event.button == 3:  # Right button.
                    # Remove the body and shapes under
                    # the mouse cursor.
                    query = self.space.point_query(
                        flipy(pg.mouse.get_pos()), 0.0, self.shape_filter)
                    for info in query:
                        body = info.shape.body
                        # Don't remove the platform.
                        if hasattr(body, 'sprite'):
                            try:
                                self.space.remove(body, body.shapes)
                                body.sprite.kill()
                            except KeyError:
                                pass

    def run_logic(self):
        self.space.step(1/60)
        self.all_sprites.update(self.dt)

    def draw(self):
        self.screen.fill((166, 166, 150))
        # Platform rect.
        pg.draw.rect(self.screen, (120, 130, 110), (150, 400, 500, 100))
        self.all_sprites.draw(self.screen)

        # Draw the outlines of the shapes.
        for shape in self.space.shapes:
            pts = [flipy(pos.rotated(shape.body.angle) + shape.body.position)
                   for pos in shape.get_vertices()]
            pg.draw.lines(self.screen, self.green, True, pts, 1)

        pg.display.flip()


if __name__ == '__main__':
    pg.init()
    Game().run()
    pg.quit()
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.