Thursday, 1 September 2016

Tilt Maze

Son and I recently went off for a boy's weekend at the Grandparents. We knew that we'd be stuck on trains for a good few hours so we decided to take along the micro:bit so that we could embark upon our own Train Jam. Son chose to work on a maze game where you have to navigate your way to the exit. He also wanted keys and doors to make it more of a challenge.


We thought it might be tough to make this a fun game on such a limited device so we decided to keep things simple. With only 25 red LEDs it was tough to think how we could show the various elements of the maze. We decided that:
  1. The player would be a very bright pixel and always in the middle of the screen. The rest of the maze would scroll around you.
  2. Walls would be dim pixels.
  3. Doors would be slowly flashing pixels.
  4. Keys would be rapidly flashing pixels.
  5. The exit would be flashing in some cool way that made it look enticing.
Before we started on the actual game we knew that the movement mechanism would be important so to begin with we decided to prototype a couple of different control methods. We put a pixel in the middle of the LED display and tried different ways to move it around:
  1. Son's first idea was to tap the A button to move left and hold it to move down. Similarly, tapping the B button would move right while holding it would move up. This worked out pretty well and was very accurate but ultimately felt slow and sometimes we just got confused and moved in the wrong direction!
  2. Next up we tried using the micro:bit's accelerometer as a tilt controller: tilt the unit up, down, left or right to move. We added a delay so that movement didn't repeat too quickly and we set a minimum tilt amount so that you didn't move around when you were holding the device level. This method worked pretty well but it was too easy to accidentally move when you didn't want to. We added a sensitivity setting which let us tweak how far you needed to tilt the micro:bit before movement was triggered but no setting felt right - they were either too sensitive (leading to accidental movement) or not sensitive enough (meaning that you had to tilt so far that you couldn't comfortably see the display).
  3. Finally we tweaked the second method ever so slightly by adding a button press - you now had to tilt the micro:bit and press the A button to move. This felt much better. Not as satisfying as method 2 but much more accurate.
It's super important to prototype things wherever you can before starting on a big project. If we'd gone with our first method we might have been dissatisfied with the gameplay and not got around to finishing the project. To take it to an even further extreme - if we hadn't been able to make a satisfactory movement mechanic at all then there would have been little point in trying to build a fun game around it.

The code is probably the most complex thing we've written so far. Here it is  in full:

"""
 Tilt Maze

 Find the exit and escape the maze. Navigate the maze by tilting the micro:bit and pressing the A 
 button. Collect keys to open the doors.

 Developed by http://giggletronics.blogspot.co.uk/

 This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 
 International License. https://creativecommons.org/licenses/by-nc-sa/4.0/

"""


from microbit import *


# Map definition. You can change this to change the game.
# Key:
#   w = Wall
#   s = Start position
#   k = Key
#   d = Door
#   e = Exit
#   Space = Location that player can walk on
map_blocks = 'wwwwwwwwwwwwwwwwww'\
             'w    w k        kw'\
             'w s  d    k      w'\
             'w    wwdwwwwwwwwww'\
             'w k  w           w'\
             'wwwwwwwwwwwwwwwwdw'\
             'w          d  w  w'\
             'w e        w  w  w'\
             'w          w  w  w'\
             'w          w     w'\
             'wwwwwwwwwwwwwwwwww'
map_width = 18
map_height = 11


# A list of map blocks that should now be seen as clear spaces
map_blocks_cleared = []


# Player variables
num_keys = 0


# This is how sensitive the input is. Higher numbers mean that you need to tilt the micro:bit 
# further to move, lower numbers are move likely to trigger movement erroneously
SENSITIVITY = 200


# Some custom images
BLANK = Image('00000:00000:00000:00000:00000:')
KEY = Image('00000:00099:99999:99099:99000:')
DOOR0 = Image('99999:99999:99999:99999:99999:')
DOOR1 = Image('99099:99099:99099:99099:99099:')
DOOR2 = Image('90009:90009:90009:90009:90009:')
ALL_DOORS = [DOOR0, DOOR1, DOOR2, BLANK]


# Validates that the map is correctly formatted. Halts on error
def validate_map():
    # Helper function to show an error message and halt
    def show_error(error):
        full_error = "MAP ERROR: " + error
        print(full_error)
        display.scroll(full_error, loop=True)

    # The map should contain width * height blocks
    map_size = len(map_blocks)
    if map_size != map_width * map_height:
        show_error('Map size is incorrect')

    # Count the special map blocks
    door_count = 0
    key_count = 0
    start_count = 0
    for i in range(map_size):
        block = map_blocks[i]
        if block == 'w' or block == ' ' or block == 'e':
            pass
        elif block == 'd':
            door_count = door_count + 1
        elif block == 'k':
            key_count = key_count + 1
        elif block == 's':
            start_count = start_count + 1
        else:
            show_error('Unexpected map block \'' + block + '\' found')

    # Raise an error if the special block counts are wrong
    if door_count != key_count:
        show_error('Door and key counts do not match')
    if start_count != 1:
        show_error('Map should contain exactly 1 start point')


# Returns the map block at the given position
def get_map_block(x, y):
    # Clamp position
    map_x = min(max(x, 0), map_width - 1)
    map_y = min(max(y, 0), map_height - 1)
    block_number = map_x + map_y * map_width

    # Is this position still valid?
    if not block_number in map_blocks_cleared:
        # Yes, return the block from the map
        return map_blocks[block_number]
    else:
        # No, it's been cleared so return an empty space
        return ' '


# Returns the start x,y position on the map or 0,0 if it can't be found
def find_start():
    for y in range(map_height):
        for x in range(map_width):
            if get_map_block(x, y) == 's':
                return x, y
    return 0, 0


# Draws the current view of the world to the micro:bit display
def show_world(player_x, player_y):
    # Create a buffer to draw the map into
    screen = BLANK.copy()

    # Loop over each pixel in the buffer
    for y in range(5):
        map_y = player_y + y - 2
        for x in range(5):
            map_x = player_x + x - 2
            block = get_map_block(map_x, map_y)
            if block == 'w':
                # Wall - Display as a solid pixel
                screen.set_pixel(x, y, 4)
            elif block == 'd':
                # Door - Display as a slowly flashing pixel
                screen.set_pixel(x, y, 4 if (running_time() % 1000 < 500) else 0)
            elif block == 'k':
                # Key - Display as a rapidly flashing pixel
                screen.set_pixel(x, y, 8 if (running_time() % 200 < 100) else 0)
            elif block == 'e':
                # Exit - Display as a super-fast flashing pixel
                screen.set_pixel(x, y, int(running_time() / 50) % 10)

    # Overlay the player in the middle
    screen.set_pixel(2, 2, 9)

    # Copy to the micro:bit display
    display.show(screen)


# Shows the number of keys held
def show_keys():
    for i in range(3):
        display.show([KEY, str(num_keys)])


# Shows an animation for an opening door
def show_door_opening():
    display.show(ALL_DOORS)


# Collects the key at x,y
def collect_key_at(x, y):
    global num_keys
    global map_blocks_cleared
    
    # One more key for the player
    num_keys = num_keys + 1

    # Mark the door block as clear
    map_blocks_cleared.append(x + y * map_width)

    # Show the player how many keys they now hold
    show_keys()


# Tries to open the door at x,y
def try_to_open_door_at(x, y):
    global num_keys
    global map_blocks_cleared
    if num_keys > 0:
        # Player has enough keys... take one away
        num_keys = num_keys - 1

        # Mark the door block as clear
        map_blocks_cleared.append(x + y * map_width)
        
        # Show an animation of the door opening
        show_door_opening()
    else:
        # Player does not have enough keys.. show that
        show_keys()


# Check that the map is correct
validate_map()

# Setup for game by placing the player at the start position
player_x, player_y = find_start()

# Main loop. We stay in this loop until the player reaches the exit
while True:
    # Try to move when the player presses the A button
    if button_a.was_pressed():
        # Read the accelerometer and use it to calculate a new player position
        acc_x = accelerometer.get_x()
        acc_y = accelerometer.get_y()
        new_x = player_x
        new_y = player_y
        if abs(acc_x) > abs(acc_y):
            # X move is largest
            if acc_x < -SENSITIVITY:
                new_x = player_x - 1
            elif acc_x > SENSITIVITY:
                new_x = player_x + 1
        else:
            # Y move is largest
            if acc_y < -SENSITIVITY:
                new_y = player_y - 1
            elif acc_y > SENSITIVITY:
                new_y = player_y + 1

        # Has the player moved?
        if new_x != player_x or new_y != player_y:
            # Yes. Which type of block are they trying to move on to?
            block = get_map_block(new_x, new_y)
            if block == 'w':
                # Wall - Can't move here
                pass
            elif block == 'd':
                # Door - Try to open it
                try_to_open_door_at(new_x, new_y)

                # Clear button input
                button_a.was_pressed()
            elif block == 'k':
                # Key - Collect it
                collect_key_at(new_x, new_y)

                # Clear button input
                button_a.was_pressed()
            elif block == 'e':
                # Exit - Break out of this function
                break
            else:
                # Space - Move the player here
                player_x = new_x
                player_y = new_y

    # Show the map
    show_world(player_x, player_y)


# Shows a message congratulating the playing on winning
show_door_opening()
sleep(1000)
display.scroll("Congratulations! You have escaped the maze!", loop=True)

There's one interesting thing about this project - we started to hit the limits of the micro:bit memory. We will expand on what this means (and how to scrounge more memory!) in a future post but suffice it to say that, for now, we worked our way around the limits. Probably the most interesting technique we used to save memory is the one that deals with changes to the map. The map is represented as an array of letters (which we call map blocks), so 'w' represents a wall block, 'k' is a key, ' ' is for a space that we can walk on etc.. When the player picks up a key we ned to erase it from the map. Python is great at this and we could have simply made a new copy of the map with the key removed, then swap the maps over. This would work, but requires (at least) twice as much memory - that's too much. The solution is quite neat - we never remove blocks from the map, meaning that we never need to copy the map! Instead we maintain a list (called map_blocks_cleared) of all the blocks that we've "erased". Whenever we try to read from the array of map blocks we first look in this list and if the block we're looking for is in there then we return ' ' instead of what's really there. We wrap all of this functionality up in get_map_block() so that's very easy to query map blocks.

The Python and hex file are here:
tilt_maze.py
tilt_maze.hex

Don't forget that you can just copy the hex file to your device to run it. To play the game tilt the micro:bit in the direction you wish to move and press A. You're looking for the exit (a crazy flashing/strobing pixels) and you'll need to collect keys (rapidly flashing pixels) to open doors (slowly flashing pixels).

Once you've completed the game, how about looking at the source code and figuring out how to make your own maze? Please share your creations in the comments if you do!

No comments:

Post a Comment