The4thIceman
The4thIceman

Reputation: 3889

How to find perfect collision of a moving object into an obstacle

I have two sprites: a robot sprite and an obstacle sprite. I am using mask.overlap to determine if there is an overlap to prevent the robot from moving into the area of the obstacle (it functions as a blocking obstacle). Below is a portion of the movement evaluation code. It tests to see if the movement will cause a collision:

if pressed_keys[pygame.K_s]:
    temp_position = [robot.rect.x, robot.rect.y]
    temp_position[1] += speed
    offset_x = temp_position[0] - obstacle.rect.x
    offset_y = temp_position[1] - obstacle.rect.y

    overlap = obstacle.mask.overlap(robot.mask, (offset_x, offset_y))
    if overlap is None:
        robot.rect.y += speed
    else:
        # adjust the speed to make the objects perfectly collide

This code works. If the movement would cause a collision, then it prevents the robot from moving.

ISSUE

For high speeds, the code prevents movement like it should, but it leaves a visual gap between the robot and the obstacle.

For example: if the speed is 30 and the two obstacles are 20 pixels away, the code will prevent the movement because a collision would be caused. But leaves a 20 pixel gap.

GOAL

If there were to be a collision, adjust the speed to the remaining pixel distance (20px like in the example) so that the robot and the obstacle perfectly collide. The robot can't move 30, but he can move 20. How can I calculate that remaining distance?

Upvotes: 3

Views: 648

Answers (3)

The4thIceman
The4thIceman

Reputation: 3889

I decided to go with the approach suggested by skrx in his comment: to basically back up by 1px until there is no longer a collision.

if pressed_keys[pygame.K_s]:
    temp_position = [robot.rect.x, robot.rect.y]
    temp_position[1] += speed
    offset_x = temp_position[0] - obstacle.rect.x
    offset_y = temp_position[1] - obstacle.rect.y

    overlap = obstacle.mask.overlap(robot.mask, (offset_x, offset_y))
    if overlap is None:
        robot.rect.y += speed
    else:
        for step_speed in range(1, speed - 1):
            collision[1] -= 1
            offset_x = collision[0] - obstacle.rect.x
            offset_y = collision[1] - obstacle.rect.y
            overlap_adj = obstacle.mask.overlap(robot.mask, (offset_x, offset_y))
            if overlap_adj is None:
                robot.rect.y += (speed - step_speed)
                break

This is a bit of a clumsy approach, but it will satisfy what i need for now and keep vector math at bay. For those who are looking for the proper way to approach this using normalized vectors and such, i would recommend using the answer skrx provided. I will likely come back to this and update it in the future. But for now, this will give users a couple options on how to proceed with perfect collision.

Upvotes: 1

skrx
skrx

Reputation: 20478

Here's what I described in the comment. Check if the sprites are colliding (I use spritecollide and the pygame.sprite.collide_mask functions here), and then use the normalized negative velocity vector to move the player backwards until it doesn't collide with the obstacle anymore.

import pygame as pg
from pygame.math import Vector2


pg.init()
screen = pg.display.set_mode((800, 600))
GRAY = pg.Color('gray12')

CIRCLE_BLUE = pg.Surface((70, 70), pg.SRCALPHA)
pg.draw.circle(CIRCLE_BLUE, (0, 0, 230), (35, 35), 35)
CIRCLE_RED = pg.Surface((170, 170), pg.SRCALPHA)
pg.draw.circle(CIRCLE_RED, (230, 0, 0), (85, 85), 85)


class Player(pg.sprite.Sprite):

    def __init__(self, pos, key_left, key_right, key_up, key_down):
        super().__init__()
        self.image = CIRCLE_BLUE
        self.mask = pg.mask.from_surface(self.image)
        self.rect = self.image.get_rect(topleft=pos)
        self.vel = Vector2(0, 0)
        self.pos = Vector2(self.rect.topleft)
        self.dt = 0.03
        self.key_left = key_left
        self.key_right = key_right
        self.key_up = key_up
        self.key_down = key_down

    def handle_event(self, event):
        if event.type == pg.KEYDOWN:
            if event.key == self.key_left:
                self.vel.x = -230
            elif event.key == self.key_right:
                self.vel.x = 230
            elif event.key == self.key_up:
                self.vel.y = -230
            elif event.key == self.key_down:
                self.vel.y = 230
        elif event.type == pg.KEYUP:
            if event.key == self.key_left and self.vel.x < 0:
                self.vel.x = 0
            elif event.key == self.key_right and self.vel.x > 0:
                self.vel.x = 0
            elif event.key == self.key_down and self.vel.y > 0:
                self.vel.y = 0
            elif event.key == self.key_up and self.vel.y < 0:
                self.vel.y = 0

    def update(self, dt):
        self.pos += self.vel * dt
        self.rect.center = self.pos


class Obstacle(pg.sprite.Sprite):

    def __init__(self, pos):
        super().__init__()
        self.image = CIRCLE_RED
        self.mask = pg.mask.from_surface(self.image)
        self.rect = self.image.get_rect(topleft=pos)


class Game:

    def __init__(self):
        self.done = False
        self.clock = pg.time.Clock()
        self.screen = screen
        self.player = Player((100, 50), pg.K_a, pg.K_d, pg.K_w, pg.K_s)
        obstacle = Obstacle((300, 240))
        self.all_sprites = pg.sprite.Group(self.player, obstacle)
        self.obstacles = pg.sprite.Group(obstacle)

    def run(self):
        while not self.done:
            self.dt = self.clock.tick(60) / 1000
            self.handle_events()
            self.run_logic()
            self.draw()
        pg.quit()

    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 == 2:
                    print(BACKGROUND.get_at(event.pos))
            self.player.handle_event(event)

    def run_logic(self):
        self.all_sprites.update(self.dt)
        collided_sprites = pg.sprite.spritecollide(
            self.player, self.obstacles, False, pg.sprite.collide_mask)
        for obstacle in collided_sprites:
            # The length of the velocity vector tells us how many steps we need.
            for _ in range(int(self.player.vel.length())+1):
                # Move back. Use the normalized velocity vector.
                self.player.pos -= self.player.vel.normalize()
                self.player.rect.center = self.player.pos
                # Break out of the loop when the masks aren't touching anymore.
                if not pg.sprite.collide_mask(self.player, obstacle):
                    break

    def draw(self):
        self.screen.fill(GRAY)
        self.all_sprites.draw(self.screen)
        pg.display.flip()


if __name__ == '__main__':
    Game().run()

Upvotes: 2

Davis Herring
Davis Herring

Reputation: 40013

You can pretty easily get a precise (if not exact) solution via a bisection search: once the collision is detected at the end of the full step, try half a step, and then either one or three quarters, and so on. This is treating the collision test as a boolean-valued function of the movement distance and looking for a “zero” (really the transition from miss to hit).

Note that this does nothing to resolve the issue of clipping through a thin wall or corner (where the initial collision test fails to detect the obstacle) and with complicated obstacles will find an arbitrary edge (not necessarily the first) to stop at.

Upvotes: 1

Related Questions