1
votes

While working with sprites in pygame, I eventually realized (due to minimal difference) that one cannot move a sprite by a fraction of a pixel. Albeit being a logical process, I came to the conclusion that equal velocity is unattainable at different angles.

For example:

xmove = 5
ymove = -5

I define the movement increments of the two coordinates of the sprite ball that leaves a platform on the bottom of the screen traveling at a 45° angle. I wish for this direction to change relative to the obstacles that it will encounter.

The following determines the collision of the sprite with the "walls" of the screen:

if ball.rect.left<=0:
    xmove = 5
elif ball.rect.right>=700:
    xmove = -5  
elif ball.rect.top<=0:
    ymove = 5
elif ball.rect.bottom>=400:
    ymove = -5

Here is some code to decide what happens after a collision with a player sprite (the platform) is detected:

if pygame.sprite.spritecollide(ball, player_sprite, False):# and ball.rect.bottom>=player.rect.top:
    ymove=-5
    if ball.rect.bottomright [0] in range (player.rect.topleft [0], player.rect.topleft [0] + 15):
        xmove = -5
    elif ball.rect.bottomleft [0] in range (player.rect.topright [0] - 14, player.rect.topright [0] + 1):
        xmove = +5
    elif ball.rect.bottomright [0] in range (player.rect.topleft [0] + 15, player.rect.topleft [0] + 26):
        xmove = -4
        ymove = -5.83
    elif ball.rect.bottomleft [0] in range (player.rect.topright [0] - 26, player.rect.topright [0] - 15):
        xmove = +4
        ymove = -5.83

The idea here is to have the ball bounce at different angles based on where it hits the platform, all while keeping the same velocity (distance traveled per tick)

So the problem here is that to compensate the decrease of one pixel on the x coordinate I use the Pythagorean theorem to output the y coordinate (-5.83). Being that pygame doesn't register fractions of a pixel, this will actually decrease the velocity of the ball significantly.

So my question is, how do I get around this (as I don't want to be limited to 45° angles...)?

2
With rounding? Will one pixel each way make that much difference? - jonrsharpe
@jonrsharpe yeah rounding makes a huge difference - Andres Stadelmann

2 Answers

1
votes

Because Pygame's Rect is meant to deal with pixels and pixels are fundamentally indivisible, you'll have to store the precise location of ball using floats. pygame.Rect will accept floats, but only stores integer floor values.

Consider giving ball new float attributes ball.x and ball.y (if those are not yet assigned) which keep the precise (decimal) location, and then applying your xmove and ymove values to those on update. After every move (presumably as part of that same update), revise ball.rect so that it's topleft matches the x and y values, using your favourite rounding method to give int values. You could also make ball.rect a property which generates a new Rect whenever it's called. Consider:

class MyBall:
    @property
    def rect(self):
        return pygame.Rect(round(self.x), round(self.y), *self.size)

    def __init__(self, startingLoc, size, *args, **kwargs):
        self.x = float(startingLoc[0])
        self.y = float(startingLoc[1])
        self.size = size  # Size does not need to be floats, but can be.
          # ...

Be aware that if you make ball.rect a property, in-place pygame.Rect operations will not work, since ball.rect is generated on-the-spot and its attributes are not saved by ball. Rect moves can only be accomplished by altering ball.x and y, but as long as you don't act on ball.rect directly (or call upon pygame to do so), it won't matter and you'll have your precise x and y locations for ball.

If you want to be super-precise, you can even use the floats in your player-ball collision detection by replacing if ball.rect.bottomright [0] in range (player.rect.topleft [0], player.rect.topleft [0] + 15): with something like if player.left <= ball.x <= player.left + 15: (and so forth). Be aware that range(..) will not work reliably with float values since the range consists of whole numbers only.

When you blit ball, you will have to send int values for its xy location. ball.rect.topleft is still probably the way to go (since it pulls those values indirectly from ball.x and y on-the-spot).

xmove and ymove do not need to be floats; as long as at least one element in an operation is a float, the result will also be a float. After you calculate the x and y displacement for ball according to its speed and direction, you can send those float values to ball.x and y, and it will retain the precision between frames.

2
votes

You should keep track of the "real" position of your object and only round the moment you draw you object on the screen. This prevents rounding errors to add up.


Also, consider using vectors to keep track of the direction of your object, since it makes it easy to change the direction with angles and keeping a constant speed.

Here's a little example class you could use:

import math

# some simple vector helper functions, stolen from http://stackoverflow.com/a/4114962/142637
def magnitude(v):
    return math.sqrt(sum(v[i]*v[i] for i in range(len(v))))

def add(u, v):
    return [ u[i]+v[i] for i in range(len(u)) ]

def sub(u, v):
    return [ u[i]-v[i] for i in range(len(u)) ]    

def dot(u, v):
    return sum(u[i]*v[i] for i in range(len(u)))

def normalize(v):
    vmag = magnitude(v)
    return [ v[i]/vmag  for i in range(len(v)) ]

class Ball(object):

    def __init__(self):
        self.x, self.y = (0, 0)
        self.speed = 2.5
        self.color = (200, 200, 200)
        self._direction = (1, 0)

    # the "real" position of the object
    @property
    def pos(self):
        return self.x, self.y

    # for drawing, we need the position as tuple of ints
    # so lets create a helper property
    @property
    def int_pos(self):
        return map(int, self.pos)

    def set_dir(self, direction):
        self._direction = normalize(direction)

    def set_dir_d(self, degrees):
        self.set_dir_r(math.radians(degrees))

    def set_dir_r(self, radians):
        self._direction = (math.cos(radians), math.sin(radians))

    def update(self):
        # apply the balls's speed to the vector
        move_vector = [c * self.speed for c in self._direction]
        # update position
        self.x, self.y = add(self.pos, move_vector)

    def draw(self):
        pygame.draw.circle(screen, self.color, self.int_pos, 4)

This way, you can alter the direction of the ball by just calling set_dir (provide a vector), set_dir_d (provide the desired direction in degreees) or set_dir_r (provide the desired direction in radians).

Calling update will move the ball at a constant speed (the speed field) according to its current direction.