Опубликован: 06.08.2013 | Доступ: свободный | Студентов: 974 / 64 | Длительность: 27:51:00
Лекция 9:

Star Pusher

< Лекция 8 || Лекция 9: 123 || Лекция 10 >

Recursive Functions

Before you can learn how the floodFill() function works, you need to learn about recursion. Recursion is a simple concept: A recursive function is just a function that calls itself, like the one in the following program: (don’t type the letters at the beginning of each line though)

A. def passFortyTwoWhenYouCallThisFunction(param):
B.     print('Start of function.')
C.     if param != 42:
D.         print('You did not pass 42 when you called this function.')
E.         print('Fine. I will do it myself.')
F.         passFortyTwoWhenYouCallThisFunction(42) # this is the recursive call
G.     if param == 42:
H.         print('Thank you for passing 42 when you called this function.')
I.     print('End of function.')
J.
K. passFortyTwoWhenYouCallThisFunction(41)

(In your own programs, don’t make functions have names as long as passFortyTwoWhenYouCallThisFunction(). I’m just being stupid and silly. Stupilly.)

When you run this program, the function gets defined when the def statement on line A executes. The next line of code that is executed is line K, which calls passFortyTwoWhenYouCallThisFunction() and passes (gasp!) 41. As a result, the function calls itself on line F and passes 42. We call this call the recursive call.

This is what our program outputs:

Start of function.
You did not pass 42 when you called this function.
Fine. I will do it myself.
Start of function.
Thank you for passing 42 when you called this function.
End of function.
End of function.

Notice that the "Start of function." and "End of function." text appears twice. Let’s figure out what exactly happens and what order it happens in. On line K, the function is called and 41 is passed for the param parameter. Line B prints out "Start of function.". The condition on line C will be True (since 41 != 42) so Line C and D will print out their messages. Line F will then make a call, recursively, to the function and passes 42 for the param parameter. So execution starts on line B again and prints out "Start of function.". Line C’s condition this time is False, so it skips to line G and finds that condition to be True. This causes line H to be called and displays "Thank you…" on the screen. Then the last line of the function, line I, will execute to print out "End of function." and the function returns to the line that called it.

But remember, the line of code that called the function was line F. And in this original call, param was set to 41. The code goes down to line G and checks the condition, which is False (since 41 == 42 is False) so it skips the print() call on line H. Instead, it runs the print() call on line I which makes "End of function." display for a second time.

Since it has reached the end of the function, it returns to the line of code that called this function call, which was line K. There are no more lines of code after line K, so the program terminates.

Note that local variables are not just local to the function, but to a specific call of the function.

Stack Overflows

Each time a function is called, the Python interpreter remembers which line of code made the call. That way when the function returns Python knows where to resume the execution. Remembering this takes up a tiny bit of memory. This isn’t normally a big deal, but take a look at this code:

def funky():
  funky()

funky()

If you run this program, you’ll get a large amount of output which looks like this:

...
File "C:\test67.py", line 2, in funky
funky()
File "C:\test67.py", line 2, in funky
funky()
File "C:\test67.py", line 2, in funky
funky()
File "C:\test67.py", line 2, in funky
funky()
File "C:\test67.py", line 2, in funky
funky()
RuntimeError: maximum recursion depth exceeded

The funky() function does nothing but call itself. And then in that call, the function calls itself again. Then it calls itself again, and again, and again. Each time it calls itself, Python has to remember what line of code made that call so that when the function returns it can resume the execution there. But the funky() function never returns, it just keeps making calls to itself.

This is just like the infinite loop bug, where the program keeps going and never stops. To prevent itself from running out of memory, Python will cause an error after you are a 1000 calls deep and crash the program. This type of bug is called a stack overflow.

This code also causes a stack overflow, even though there are no recursive functions:

def spam():
  eggs()

def eggs():
  spam()

spam()

When you run this program, it causes an error that looks like this:

...
File "C:\test67.py", line 2, in spam
eggs()
File "C:\test67.py", line 5, in eggs
spam()
File "C:\test67.py", line 2, in spam
eggs()
File "C:\test67.py", line 5, in eggs
spam()
File "C:\test67.py", line 2, in spam
eggs()
RuntimeError: maximum recursion depth exceeded

Preventing Stack Overflows with a Base Case

In order to prevent stack overflow bugs, you must have a base case where the function stops make new recursive calls. If there is no base case then the function calls will never stop and eventually a stack overflow will occur. Here is an example of a recursive function with a base case. The base case is when the param parameter equals 2.

def fizz(param):
  print(param)
  if param == 2:
    return
  fizz(param - 1)

  fizz(5)

When you run this program, the output will look like this:

5
4
3
2

This program does not have a stack overflow error because once the param parameter is set to 2, the if statement’s condition will be True and the function will return, and then the rest of the calls will also return in turn.

Though if your code never reaches the base case, then this will cause a stack overflow. If we changed the fizz(5) call to fizz(0), then the program’s output would look like this:

File "C:\rectest.py", line 5, in fizz
fizz(param - 1)
File "C:\rectest.py", line 5, in fizz
fizz(param - 1)
File "C:\rectest.py", line 5, in fizz
fizz(param - 1)
File "C:\rectest.py", line 2, in fizz
print(param)
RuntimeError: maximum recursion depth exceeded

Recursive calls and base cases will be used to perform the flood fill algorithm, which is described next.

The Flood Fill Algorithm

The flood fill algorithm is used in Star Pusher to change all of the floor tiles inside the walls of the level to use the "inside floor" tile image instead of the "outside floor" tile (which all the tiles on the map are by default). The original floodFill() call is on line 295. It will convert any tiles represented with the ' ' string (which represents an outdoor floor) to a 'o' string (which represents an indoor floor).

513. def floodFill(mapObj, x, y, oldCharacter, newCharacter):
514.     """Changes any values matching oldCharacter on the map object to
515.     newCharacter at the (x, y) position, and does the same for the
516.     positions to the left, right, down, and up of (x, y), recursively."""
517.
518.     # In this game, the flood fill algorithm creates the inside/outside
519.     # floor distinction. This is a "recursive" function.
520.     # For more info on the Flood Fill algorithm, see:
521.     #   http://en.wikipedia.org/wiki/Flood_fill
522.     if mapObj[x][y] == oldCharacter:
523.         mapObj[x][y] = newCharacter

Line 522 and 523 converts the tile at the XY coordinate passed to floodFill() to the newCharacter string if it originally was the same as the oldCharacter string.

525.     if x < len(mapObj) - 1 and mapObj[x+1][y] == oldCharacter:
526.         floodFill(mapObj, x+1, y, oldCharacter, newCharacter) # call right
527.     if x > 0 and mapObj[x-1][y] == oldCharacter:
528.         floodFill(mapObj, x-1, y, oldCharacter, newCharacter) # call left
529.     if y < len(mapObj[x]) - 1 and mapObj[x][y+1] == oldCharacter:
530.         floodFill(mapObj, x, y+1, oldCharacter, newCharacter) # call down
531.     if y > 0 and mapObj[x][y-1] == oldCharacter:
532.         floodFill(mapObj, x, y-1, oldCharacter, newCharacter) # call up

These four if statements check if the tile to the right, left, down, and up of the XY coordinate are the same as oldCharacter, and if so, a recursive call is made to floodFill() with those coordinates.

To better understand how the floodFill() function works, here is a version that does not use recursive calls, but instead uses a list of XY coordinates to keep track of which spaces on the map should be checked and possibly changed to newCharacter.

def floodFill(mapObj, x, y, oldCharacter, newCharacter):
  spacesToCheck = []
  if mapObj[x][y] == oldCharacter:
    spacesToCheck.append((x, y))
  while spacesToCheck != []:
    x, y = spacesToCheck.pop()
    mapObj[x][y] = newCharacter

    if x < len(mapObj) - 1 and mapObj[x+1][y] == oldCharacter:
      spacesToCheck.append((x+1, y)) # check right
    if x > 0 and mapObj[x-1][y] == oldCharacter:
      spacesToCheck.append((x-1, y)) # check left
    if y < len(mapObj[x]) - 1 and mapObj[x][y+1] == oldCharacter:
      spacesToCheck.append((x, y+1)) # check down
    if y > 0 and mapObj[x][y-1] == oldCharacter:
      spacesToCheck.append((x, y-1)) # check up

If you would like to read a more detailed tutorial on recursion that uses cats and zombies for an example, go to http://invpy.com/recursivezombies.

Drawing the Map

535. def drawMap(mapObj, gameStateObj, goals):
536.     """Draws the map to a Surface object, including the player and
537.     stars. This function does not call pygame.display.update(), nor
538.     does it draw the "Level" and "Steps" text in the corner."""
539.
540.     # mapSurf will be the single Surface object that the tiles are drawn
541.     # on, so that it is easy to position the entire map on the DISPLAYSURF
542.     # Surface object. First, the width and height must be calculated.
543.     mapSurfWidth = len(mapObj) * TILEWIDTH
544.     mapSurfHeight = (len(mapObj[0]) - 1) * (TILEHEIGHT - TILEFLOORHEIGHT)
+ TILEHEIGHT
545.     mapSurf = pygame.Surface((mapSurfWidth, mapSurfHeight))
546.     mapSurf.fill(BGCOLOR) # start with a blank color on the surface.

The drawMap() function will return a Surface object with the entire map (and the player and stars) drawn on it. The width and height needed for this Surface have to be calculated from mapObj (which is done on line 543 and 544). The Surface object that everything will be drawn on is created on line 545. To begin with, the entire Surface object is painted to the background color on line 546.

548.     # Draw the tile sprites onto this surface.
549.     for x in range(len(mapObj)):
550.         for y in range(len(mapObj[x])):
551.             spaceRect = pygame.Rect((x * TILEWIDTH, y * (TILEHEIGHT -
TILEFLOORHEIGHT), TILEWIDTH, TILEHEIGHT))

The set of nested for loops on line 549 and 550 will go through every possible XY coordinate on the map and draw the appropriate tile image at that location.

552.             if mapObj[x][y] in TILEMAPPING:
553.                 baseTile = TILEMAPPING[mapObj[x][y]]
554.             elif mapObj[x][y] in OUTSIDEDECOMAPPING:
555.                 baseTile = TILEMAPPING[' ']
556.
557.             # First draw the base ground/wall tile.
558.             mapSurf.blit(baseTile, spaceRect)

The baseTile variable is set to the Surface object of the tile image to be drawn at the iteration’s current XY coordinate. If the single-character string is in the OUTSIDEDECOMAPPING dictionary, then TILEMAPPING[' '] (the single-character string for the basic outdoor floor tile) will be used.

560.             if mapObj[x][y] in OUTSIDEDECOMAPPING:
561.                 # Draw any tree/rock decorations that are on this tile.
562.                 mapSurf.blit(OUTSIDEDECOMAPPING[mapObj[x][y]], spaceRect)

Additionally, if the tile was listed in the OUTSIDEDECOMAPPING dictionary, the corresponding tree or rock image should be drawn on top of the tile that was just drawn at that XY coordinate.

563.             elif (x, y) in gameStateObj['stars']:
564.                 if (x, y) in goals:
565.                     # A goal AND star are on this space, draw goal first.
566.                     mapSurf.blit(IMAGESDICT['covered goal'], spaceRect)
567.                 # Then draw the star sprite.
568.                 mapSurf.blit(IMAGESDICT['star'], spaceRect)

If there is a star located at this XY coordinate on the map (which can be found out by checking for (x, y) in the list at gameStateObj['stars']), then a star should be drawn at this XY coordinate (which is done on line 568). Before the star is drawn, the code should first check if there is also a goal at this location, in which case, the "covered goal" tile should be drawn first.

569.             elif (x, y) in goals:
570.                 # Draw a goal without a star on it.
571.                 mapSurf.blit(IMAGESDICT['uncovered goal'], spaceRect)

If there is a goal at this XY coordinate on the map, then the "uncovered goal" should be drawn on top of the tile. The uncovered goal is drawn because if execution has reached the elif statement on line 569, we know that the elif statement’s condition on line 563 was False and there is no star that is also at this XY coordinate.

573.             # Last draw the player on the board.
574.             if (x, y) == gameStateObj['player']:
575.                 # Note: The value "currentImage" refers
576.                 # to a key in "PLAYERIMAGES" which has the
577.                 # specific player image we want to show.
578.                 mapSurf.blit(PLAYERIMAGES[currentImage], spaceRect)
579.
580.     return mapSurf

Finally, the drawMap() function checks if the player is located at this XY coordinate, and if so, the player’s image is drawn over the tile. Line 580 is outside of the nested for loops that began on line 549 and 550, so by the time the Surface object is returned, the entire map has been drawn on it.

Checking if the Level is Finished

583. def isLevelFinished(levelObj, gameStateObj):
584.     """Returns True if all the goals have stars in them."""
585.     for goal in levelObj['goals']:
586.         if goal not in gameStateObj['stars']:
587.             # Found a space with a goal but no star on it.
588.             return False
589.     return True

The isLevelFinished() function returns True if all the goals are covered stars. Some levels could have more stars than goals, so it’s important to check that all the goals are covered by stars, rather than checking if all the stars are over goals.

The for loop on line 585 goes through the goals in levelObj['goals'] (which is a list of tuples of XY coordinates for each goal) and checks if there is a star in the gameStateObj['stars'] list that has those same XY coordinates (the not in operators work here because gameStateObj['stars'] is a list of those same tuples of XY coordinates). The first time the code finds a goal with no star at the same position, the function returns False.

If it gets through all of the goals and finds a star on each of them, isLevelFinished() returns True.

592. def terminate():
593.     pygame.quit()
594.     sys.exit()

This terminate() function is the same as in all the previous programs.

597. if __name__ == '__main__':
598.     main()

After all the functions have been defined, the main() function is called on line 602 to begin the game.

Summary

In the Squirrel Eat Squirrel game, the game world was pretty simple: just an infinite green plain with grass images randomly scattered around it. The Star Pusher game introduced something new: having uniquely designed levels with tile graphics. In order to store these levels in a format that the computer can read, they are typed out into a text file and code in the program reads those files and creates the data structures for the level.

Really, rather than just make a simple game with a single map, the Star Pusher program is more of a system for loading custom maps based on the level file. Just by modifying the level file, we can change where walls, stars, and goals appear in the game world. The Star Pusher program can handle any configuration that the level file is set to (as long as it passes the assert statements that ensure the map makes sense).

You won’t even have to know how to program Python to make your own levels. A text editor program that modifies the starPusherLevels.txt file is all that anyone needs to have their own level editor for the Star Pusher game.

For additional programming practice, you can download buggy versions of Star Pusher from http://invpy.com/buggy/starpusher and try to figure out how to fix the bugs.

< Лекция 8 || Лекция 9: 123 || Лекция 10 >