Subscribe to MentalFloss - PyBlog        RSS Feed
-----

Pygame - Eight-Direction Rotation

Icon Leave Comment
DISCLAIMER: I don't know the best way or 'right' way to do this. I just cobbled something together and it works nice.

Posted Image

In this update, we'll get rotating turrets. Now, the way I'm doing it is to have 8 images pre-rotated as opposed to rotating in game. This allows for the rotation algorithm to not skew the pixels too badly and as far as I can tell, it's favored to do it this way instead. Generally, you can get away with a rotated sprite facing each of the cardinal directions. So, that's what I've done.

However, I would like this to be a fun learning adventure for you readers as well so we are going to work through the problem piece by piece. Hopefully when finished, you can have your own turrets in your games that rotate accordingly. Sound fun? I think so!

The first thing to understand when it comes to rotation is that we are operating on an origin of the screen that is not the top-left pixel and thus, we are using polar coordinates. A polar coordinate describes the vector's angle and length from the origin. Our origin will be the center of the screen for now but eventually will be wherever the turret is located.

So, dig yourself up an 8-direction sprite that you want to rotate and let's get cracking! First, we need to see what we're dealing with so here's a program that puts the sprite at the center of the screen and draws lines out from it for each of the cardinal directions (no angles yet!)

Starting with our skeleton (in case you aren't keeping up with my work):

import math
import pygame
from pygame.locals import *

SCREENSIZE = SCREEN_X, SCREEN_Y = (640,480)
SCREENCENTER = CENTER_X, CENTER_Y = (SCREEN_X/2, SCREEN_Y/2)
X = 0
Y = 1

def main():
    pygame.init()
    screen = pygame.display.set_mode(SCREENSIZE)
    
    in_game = True
    while in_game:
        for event in pygame.event.get():
            if event.type == QUIT:
                in_game = False
                
        pygame.display.flip()

if __name__ == '__main__':
    main()



And we have a lovely blank screen. Let's draw a circle the size of our screen (so we'll use CENTER_Y as our radius):

import math
import pygame
from pygame.locals import *

SCREENSIZE = SCREEN_X, SCREEN_Y = (640,480)
SCREENCENTER = CENTER_X, CENTER_Y = (SCREEN_X/2, SCREEN_Y/2)
X = 0
Y = 1

GREEN = (0,255,0)

def main():
    pygame.init()
    screen = pygame.display.set_mode(SCREENSIZE)
    
    in_game = True
    while in_game:
        for event in pygame.event.get():
            if event.type == QUIT:
                in_game = False
        
        pygame.draw.circle(screen,          # Surface
                           GREEN,           # Color
                           SCREENCENTER,    # Position
                           int(CENTER_Y),   # Radius
                           1)               # Width
                
        pygame.display.flip()

if __name__ == '__main__':
    main()



Now, we want to draw lines for each of the cardinal directions so to do that we can easily just create a list of coordinates. Here's what they are:

"""
 (-1,-1),(0,-1),(1,-1)

        NW N NE
          \|/
(-1,0)  W--*--E  (1,0)
          /|\
        SW S SE
        
  (-1,1),(0,1),(1,1)
"""



To do this, we scale our vector by the radius of the circle (the vector's magnitude becomes the radius of the circle). To do that, you just multiply each component by the radius. So, here's the code to do that:

import math
import pygame
from pygame.locals import *

SCREENSIZE = SCREEN_X, SCREEN_Y = (640,480)
SCREENCENTER = CENTER_X, CENTER_Y = (SCREEN_X/2, SCREEN_Y/2)
X = 0
Y = 1

GREEN = (0,255,0)
directions = [(-1,-1),(0,-1),(1,-1),(-1,0),(1,0),(-1,1),(0,1),(1,1)]

def normalize((x, y)):
    mag = get_magnitude((x, y))
    if mag > 0:
        return (x / mag, y / mag)


def get_magnitude((x, y)):
    a = x**2.0
    b = y**2.0
    c = math.sqrt(a + b)
    return c

def add((x, y), (dx, dy)):
    return ((x + dx), (y + dy))

def sub((x, y), (dx, dy)):
    return ((x - dx), (y - dy))

def mul((x, y), scale):
    return (float(x * scale), float(y * scale))

def main():
    pygame.init()
    screen = pygame.display.set_mode(SCREENSIZE)
    
    in_game = True
    while in_game:
        for event in pygame.event.get():
            if event.type == QUIT:
                in_game = False
        
        pygame.draw.circle(screen,          # Surface
                           GREEN,           # Color
                           SCREENCENTER,    # Position
                           int(CENTER_Y),   # Radius
                           1)               # Width
        
        for direction in directions:
            normal = normalize(direction)
            line   = mul(normal, CENTER_Y)
            plot   = add(SCREENCENTER, line)
            
            pygame.draw.line(screen,
                 GREEN,
                 SCREENCENTER,
                 plot,
                 1)
        
        pygame.display.flip()

if __name__ == '__main__':
    main()



So, at the top, you have each of the directions: directions = [(-1,-1),(0,-1),(1,-1),(-1,0),(1,0),(-1,1),(0,1),(1,1)].

Now, of course we could have used (0.5,0.5) instead of (1,1) to keep them unit vectors and avoid having to normalize our vectors but then we couldn't reuse this for our movement calculations if we want to. Do it how you want.

Anyway, to the point of it -- we first normalize our vector which turns it into a unit vector. This is done by first finding the magnitude of the vector (which is just its length -- and the vector itself is the hypotenuse of a right triangle so we use pythagorean's theorem) and then dividing each component by the magnitude. Viola! unit vector. Simple.

Next, we take our unit vector and scale it by the radius so that we have a magnitude of the radius itself.

Finally, we offset the screen (remember everything starts 0,0 at top-left) by adding the SCREENCENTER's location to our new location which is done by adding X to X and Y to Y.

We draw the line -- do it for each direction -- and this is what we get:

Posted Image

So, that's pretty cool right? Take a break and go create that for yourself. There's more.



See any problems? What are the boundaries of the sprite's sight -- how do you know when to switch the sprite image? There's no clear-cut boundaries and so what we do now is divide the circle on an offset. What offset you ask? Well, we have 8 directions which makes it 360/8 = 45. Half of 45 is 22.5. Therefore, our offset should be -22.5 degrees but we continue to plot every 45 degrees of course.

So, with that in mind, I created this new code that draws those lines in red. Here's what I came up with:

import math
import pygame
from pygame.locals import *

SCREENSIZE = SCREEN_X, SCREEN_Y = (640,480)
SCREENCENTER = CENTER_X, CENTER_Y = (SCREEN_X/2, SCREEN_Y/2)
X = 0
Y = 1

RED   = (255,0,0)
GREEN = (0,255,0)
directions = [(-1,-1),(0,-1),(1,-1),(-1,0),(1,0),(-1,1),(0,1),(1,1)]

def ptoc((a, r)):
    a = float(a)
    r = float(r)
    y = -math.sin(a) * r
    x = math.cos(a) * r
    return (x, y)
    
def get_angle(origin, location):
    dx = float(location[0]) - float(origin[0])
    dy = -(float(location[1]) - float(origin[1]))
    r = math.atan2(dy, dx)
    r %= 2 * math.pi
    deg = math.degrees(r)
    return (deg, r)
    
def normalize((x, y)):
    mag = get_magnitude((x, y))
    if mag > 0:
        return (x / mag, y / mag)


def get_magnitude((x, y)):
    a = x**2.0
    b = y**2.0
    c = math.sqrt(a + b)
    return c

def add((x, y), (dx, dy)):
    return ((x + dx), (y + dy))

def sub((x, y), (dx, dy)):
    return ((x - dx), (y - dy))

def mul((x, y), scale):
    return (float(x * scale), float(y * scale))

def main():
    pygame.init()
    screen = pygame.display.set_mode(SCREENSIZE)
    values_printed = False
    in_game = True
    while in_game:
        for event in pygame.event.get():
            if event.type == QUIT:
                in_game = False
        
        pygame.draw.circle(screen,          # Surface
                           GREEN,           # Color
                           SCREENCENTER,    # Position
                           int(CENTER_Y),   # Radius
                           1)               # Width
        
        for direction in directions:
            normal = normalize(direction)
            line   = mul(normal, CENTER_Y)
            plot   = add(SCREENCENTER, line)
            
            pygame.draw.line(screen,
                 GREEN,
                 SCREENCENTER,
                 plot,
                 1)
        
        for i in xrange(-22, 360, 45):
            offset   = math.radians(i + .5)
            location = ptoc((offset, CENTER_Y)) # convert polar to cartesian
            normal = normalize(location) # normalize the vector
            line = mul(normal, CENTER_Y) # scale unit vector by radius
            plot = add(SCREENCENTER, line) # true location to plot
                 
            pygame.draw.line(screen,
                             RED,
                             SCREENCENTER,
                             plot,
                             1)
                             
            if not values_printed:
                print i
                
        values_printed = True
        
        pygame.display.flip()

if __name__ == '__main__':
    main()



I introduced a few functions here. ptoc() converts polar coordinates to cartesian coordinates and get_angle() takes two cartesian coordinates and finds out the angle.

In terms of the loop, I start it at -22 and loop by 45 degrees until reaching 360. What pops up is this:

Posted Image

And you'll notice that I had it print the values as well so here's those:

-22
23
68
113
158
203
248
293
338



we can now use those for our if statements to check which sprite to use. Could this have all been figured out without doing this stuff? Sure, it's pretty self-explanatory but this is the way we learn -- by doing the small steps. We now have visual aids describing how the sprite should know where it's supposed to look too.




I created the base to work with here:

import math
import pygame
from pygame.locals import *

SCREENSIZE = SCREEN_X, SCREEN_Y = (640,480)
SCREENCENTER = CENTER_X, CENTER_Y = (SCREEN_X/2, SCREEN_Y/2)
X = 0
Y = 1

RED   = (255,0,0)
GREEN = (0,255,0)
directions = [(-1,-1),(0,-1),(1,-1),(-1,0),(1,0),(-1,1),(0,1),(1,1)]

def ptoc((a, r)):
    a = float(a)
    r = float(r)
    y = -math.sin(a) * r
    x = math.cos(a) * r
    return (x, y)
    
def get_angle(origin, location):
    dx = float(location[0]) - float(origin[0])
    dy = -(float(location[1]) - float(origin[1]))
    r = math.atan2(dy, dx)
    r %= 2 * math.pi
    deg = math.degrees(r)
    return (deg, r)
    
def normalize((x, y)):
    mag = get_magnitude((x, y))
    if mag > 0:
        return (x / mag, y / mag)


def get_magnitude((x, y)):
    a = x**2.0
    b = y**2.0
    c = math.sqrt(a + b)
    return c

def add((x, y), (dx, dy)):
    return ((x + dx), (y + dy))

def sub((x, y), (dx, dy)):
    return ((x - dx), (y - dy))

def mul((x, y), scale):
    return (float(x * scale), float(y * scale))

class RotatingSprite(pygame.sprite.Sprite):
    def __init__(self, group):
        pygame.sprite.Sprite.__init__(self, group)
        
        self.images = {
            'north' : pygame.image.load('res/orange_n.png').convert(),
            'ne'    : pygame.image.load('res/orange_ne.png').convert(),
            'nw'    : pygame.image.load('res/orange_nw.png').convert(),
            'south' : pygame.image.load('res/orange_s.png').convert(),
            'se'    : pygame.image.load('res/orange_se.png').convert(),
            'sw'    : pygame.image.load('res/orange_sw.png').convert(),
            'west'  : pygame.image.load('res/orange_w.png').convert(),
            'east'  : pygame.image.load('res/orange_e.png').convert()
        }
        
        # add transparency
        for (key,value) in self.images.iteritems():
            value = value.set_colorkey((2, 73, 148))
            
        self.image = self.images['south']
        self.rect  = self.image.get_rect()
        self.rect.move_ip(sub(SCREENCENTER, self.rect.center))
        
        
        
    def update(self):
        pass


def main():
    pygame.init()
    screen = pygame.display.set_mode(SCREENSIZE)
    background = pygame.Surface(screen.get_size())
    group = pygame.sprite.Group()
    sprite = RotatingSprite(group)
    in_game = True
    while in_game:
        for event in pygame.event.get():
            if event.type == QUIT:
                in_game = False
        
        pygame.draw.circle(screen,          # Surface
                           GREEN,           # Color
                           SCREENCENTER,    # Position
                           int(CENTER_Y),   # Radius
                           1)               # Width
        
        for direction in directions:
            normal = normalize(direction)
            line   = mul(normal, CENTER_Y)
            plot   = add(SCREENCENTER, line)
            
            pygame.draw.line(screen,
                 GREEN,
                 SCREENCENTER,
                 plot,
                 1)
        
        for i in xrange(-22, 360, 45):
            offset   = math.radians(i + .5)
            location = ptoc((offset, CENTER_Y)) # convert polar to cartesian
            normal = normalize(location) # normalize the vector
            line = mul(normal, CENTER_Y) # scale unit vector by radius
            plot = add(SCREENCENTER, line) # true location to plot
                 
            pygame.draw.line(screen,
                             RED,
                             SCREENCENTER,
                             plot,
                             1)
                             
        group.clear(screen,background)
        group.update()
        group.draw(screen)
        pygame.display.flip()

if __name__ == '__main__':
    main()



I won't get into sprites here. They're actually a little weird to work with -- and my transparency isn't working but that's ok... beside the point.

Anyway, we now have to fill in the update with the correct view. I figure we'll just have the plane track the mouse pointer. Here's the update code in the sprite class:

    def update(self):
        deg,rad = get_angle(self.rect.center, self.target)
        if 337.5 <= deg or deg < 22.5:
            self.image = self.images['east']
        elif 22.5 <= deg < 67.5:
            self.image = self.images['ne']
        elif 67.5 <= deg < 112.5:
            self.image = self.images['north']
        elif 112.5 <= deg < 157.5:
            self.image = self.images['nw']
        elif 157.5 <= deg < 202.5:
            self.image = self.images['west']
        elif 202.5 <= deg < 247.5:
            self.image = self.images['sw']
        elif 247.5 <= deg < 292.5:
            self.image = self.images['south']
        elif 292.5 <= deg < 337.5:
            self.image = self.images['se']
        else:
            print 'ERROR: %r' % deg



Here's the main code:

    while in_game:
        for event in pygame.event.get():
            if event.type == QUIT:
                in_game = False
            if event.type == MOUSEMOTION:
                sprite.target = pygame.mouse.get_pos()



Don't forget to add self.target = SCREENCENTER to the sprite class's init constructor. Hope you had fun.

Posted Image

0 Comments On This Entry

 

Trackbacks for this entry [ Trackback URL ]

There are no Trackbacks for this entry

November 2014

S M T W T F S
      1
2345678
9101112131415
16171819202122
232425262728 29
30      

Tags

    Recent Entries

    Recent Comments

    Search My Blog

    0 user(s) viewing

    0 Guests
    0 member(s)
    0 anonymous member(s)