Heart Click in Functions

We are going to rework Heart Click to use Functions to structure it in a clean and maintainable manner.

Heart Click has the following basic operations:

  • setup an initial set of resources (images and sounds) and state (a random direction)
  • played a sound on load/startup
  • then we started a loop where we:
    • updated our state (and/or exited) based on the user’s actions
    • updated our state based on our simulation (motion, collisions)
    • drew the game on-screen

That suggests that each of those bullet-points should likely be their own function. But first we need to deal with what state means above...

Passing Around State

We mention state a lot in the description above. State here is a collection of bits and pieces, each of which has a name or role to play in the game. Some pieces of state are actually resources that we’ve loaded from disk. Some are values we are tracking in order to update our game’s simluation.

How can we pass around that state? We could define a separate parameter for each function in which to pass around each part of the state the function needed:

def update_simulation( direction, heart_rectangle, screen ):
    ...
    return direction, heart_rectangle

direction,heart_rectangle = update_simulation( direction, heart_rectangle, screen )

but as our game-state gets more complex this is going to get rather annoying.

What we want is a data structure that lets us pass around all of the state as a single “thing” to which we can attach all of the various bits of state. We could use Lists to do this, and it would look something like this (you’ll see this a lot in low-level languages):

state = [ heart_image, heart_rectangle, award_image, direction, ... ]
...
state[3] = state[3][1],state[3][0]

But while it would work, it’s somewhat clumsy and limiting refering to individual bits of state via indices. Low-level languages often work around that by doing something like this:

DIRECTION = 3
...
state = [ heart_image, heart_rectangle, award_image, direction, ... ]
...
state[DIRECTION] = state[DIRECTION][1],state[DIRECTION][0]

Dictionaries allow us to pass around our game state with each bit of state given an easy-to-read key. You should play with Dictionaries a bit to understand how they work and what they let you do before you continue.

Refactoring into Functions (Setup State)

We want to define a function for each logical operation, giving the function an obvious name. Let’s start with the function to setup our state.

def setup_state():
    state = {}
    return state

We have lots of code that is involved in setting up state, which looks like this in the original Heart Click game:

HERE = os.path.dirname(__file__)
heart_filename = os.path.join(HERE,'heart.png')
heart = pygame.image.load(heart_filename)
heart = heart.convert_alpha(screen)
heart_rectangle = heart.get_rect(center=(150, 150))
award_filename = os.path.join(HERE,'award.png')
award = pygame.image.load(award_filename)
award = award.convert_alpha(screen)
direction = (random.randint(-5,5),random.randint(-5,5))
instructions = pygame.mixer.Sound(os.path.join(HERE,'clicktowin.ogg'))
instructions.play()
congratulations = pygame.mixer.Sound(os.path.join(HERE,'youwin.ogg'))

What does that code actually need to run?

  • it uses the screen object (to convert images on load)
  • it uses the value __file__ to calculate where our resources (images, sounds) are
    • this is one of those rare cases where we likely want to use a global variable as the value of __file__ truly is a static value looked up once on module load.
    • we’ll define a global variable RESOURCES that tells people reading our code that we’re referring to the directory where resources are stored rather than using HERE directly.

So it sounds as though we should have a single parameter (screen) to our function:

def setup_state(screen):
    state = {}
    return state

Now we can fill in the rest of our setup_state function. Instead of storing the state as global variables, we are going to store the state in the state dictionary.

heart = pygame.image.load(heart_filename).convert_alpha(screen)
heart_rectangle = heart.get_rect(center=(150, 150))
# becomes
state['heart'] = pygame.image.load(heart_filename).convert_alpha(screen)
heart_rectangle = state['heart'].get_rect(center=(150, 150))

Review Setup State

The whole setup_state function looks something like this:

def setup_state(screen):
    """Setup the game to run on the given screen"""
    state = {}
    
    heart_filename = os.path.join(RESOURCES, 'heart.png')
    state['heart'] = pygame.image.load(heart_filename).convert_alpha(screen)
    state['heart_rectangle'] = state['heart'].get_rect(center=screen.get_rect().center)
    
    award_filename = os.path.join(RESOURCES,'award.png')
    state['award'] = pygame.image.load(award_filename).convert_alpha( screen )
    state['direction'] = (random.randint(-5,5),random.randint(-5,5))
    state['instructions'] = pygame.mixer.Sound(os.path.join(RESOURCES,'clicktowin.ogg'))
    # start the instructions playing as soon as the game loads
    state['congratulations'] = pygame.mixer.Sound(os.path.join(RESOURCES,'youwin.ogg'))
    
    # we explicitly choose which image to display...
    state['current_image'] = state['heart']
    return state

Refactoring User Input

Let’s build up our check_user_input function. We could do this in either of two ways; we could process all events in a single operation, or we could process a single event with the function.

If we decide to process a single event, then the function needs to process (update) our state, and take an event to use for the updates:

def check_user_input( state, event ):
    ...

If we wanted to process all events, then we would just pass in the state and include the event-getting loop in our check_user_input function:

def check_user_input( state ):
    event = pygame.event.poll()
    while not (event.type == pygame.NOEVENT):
        ...

Main Function

In low-level languages (such as C), there is an entry point, normally named main, which is where the program will start executing. In Python the script actually starts executing at the top and goes to the bottom, but we have the concept of a “script” versus a “module”. We normally isolate the code that is the “script” into a function that we often name “main”. In our game, the “main” function is the actual code that runs the game:

def main():
    """Main is the traditional name for the overall script function"""
    clock = pygame.time.Clock()

    screen = pygame.display.set_mode((640, 480))
    pygame.mixer.init()
    pygame.display.init()

    state = setup_state(screen)
    state['instructions'].play()

    while True:
        
        event = pygame.event.poll()
        while not (event.type == pygame.NOEVENT):
            state = check_user_input( state, event )
            event = pygame.event.poll()
        
        state = update_simulation( state, screen )
        draw_game(state,screen)
        clock.tick(60)

#main_call_start
# this stanza reads as "if we are the main script, run function main"

but if we were to just define the function, it would not be run automatically (unlike in those low-level languages). Instead, we have to explicitly trigger the main function, but we only want to do so if the module is being run as a script (not being imported by something else).

# this stanza reads as "if we are the main script, run function main"
if __name__ == "__main__":
    main()
#main_call_stop

Keep Going

Keep refactoring your Heart Click game into a set of functions.

Sample Solution

import os
import pygame
import pygame.display
import pygame.time
import pygame.image
import random
import pygame.mixer

HERE = os.path.dirname(__file__)
# Note that the resources actually are *not* in the 
# same directory as the script in this case
RESOURCES = os.path.join( HERE, '..','heartclick' )

def setup_state(screen):
    """Setup the game to run on the given screen"""
    state = {}
    
    heart_filename = os.path.join(RESOURCES, 'heart.png')
    state['heart'] = pygame.image.load(heart_filename).convert_alpha(screen)
    state['heart_rectangle'] = state['heart'].get_rect(center=screen.get_rect().center)
    
    award_filename = os.path.join(RESOURCES,'award.png')
    state['award'] = pygame.image.load(award_filename).convert_alpha( screen )
    state['direction'] = (random.randint(-5,5),random.randint(-5,5))
    state['instructions'] = pygame.mixer.Sound(os.path.join(RESOURCES,'clicktowin.ogg'))
    # start the instructions playing as soon as the game loads
    state['congratulations'] = pygame.mixer.Sound(os.path.join(RESOURCES,'youwin.ogg'))
    
    # we explicitly choose which image to display...
    state['current_image'] = state['heart']
    return state

def check_user_input( state, event ):
    """Check for user input and update our game state"""
    if (
        event.type == pygame.QUIT or
        (event.type == pygame.KEYUP and event.key == pygame.K_ESCAPE)
    ):
        raise SystemExit(0)
    if event.type == pygame.MOUSEBUTTONDOWN:
        if state['heart_rectangle'].collidepoint(event.pos):
            channel = state['congratulations'].play()
            channel.set_endevent( pygame.QUIT )
            state['current_image'] = state['award']
            state['direction'] = (0,0)
    return state

def update_simulation( state, screen ):
    """Update our game's states based on simulation (time-based changes)
    
    screen is needed because we want to "bounce" at the edge of the screen
    """
    heart_rectangle = state['heart_rectangle']
    direction = state['direction']
    
    screen_rect = screen.get_rect()
    if heart_rectangle.top < 0 or heart_rectangle.bottom > screen_rect.bottom:
        direction = direction[0], -direction[1]
    if heart_rectangle.left < 0 or heart_rectangle.right > screen_rect.right:
        direction = -direction[0], direction[1]
    
    heart_rectangle = heart_rectangle.clamp( screen.get_rect())
    
    heart_rectangle = heart_rectangle.move(direction)
    
    if random.random() > .98:
        direction = direction[1],direction[0]
    
    # we have to update the state with the values we have calculated
    state['direction'] = direction
    state['heart_rectangle'] = heart_rectangle
    return state

def draw_game( state, screen ):
    """Draw our game's current state onto the given screen"""
    screen.fill((255,230,230))
    screen.blit( state['current_image'], state['heart_rectangle'])
    pygame.display.flip()

def main():
    """Main is the traditional name for the overall script function"""
    clock = pygame.time.Clock()

    screen = pygame.display.set_mode((640, 480))
    pygame.mixer.init()
    pygame.display.init()

    state = setup_state(screen)
    state['instructions'].play()

    while True:
        
        event = pygame.event.poll()
        while not (event.type == pygame.NOEVENT):
            state = check_user_input( state, event )
            event = pygame.event.poll()
        
        state = update_simulation( state, screen )
        draw_game(state,screen)
        clock.tick(60)

#main_call_start
# this stanza reads as "if we are the main script, run function main"
if __name__ == "__main__":
    main()
#main_call_stop

When you’re done, continue with Heart Click ++.