Level 3 Implementation

The third level is a puzzle. The main character is located in a room at the top of the level that contains three buttons. He will accomplish his goal or fail while staying in that room. The rest of the level consists of nine cells connected with gates that are controlled by the buttons in the upper room. Inside the cells are a mouse, a cat, and cheese. The cat is located in the upper-left cell, the mouse in the upper right, and the cheese in the bottom left.

The player must move the main character to press the buttons in a way that will open gates for the mouse, allowing it to reach the cheese while avoiding the cat. Each time the player opens a gate next to the cell occupied by the mouse, the mouse will move into the open cell. Afterward, the cat will move into a random cell as well. If the cat and mouse end up in the same cell, the cat will eat the mouse and the player will lose. If the mouse ends up in the cell with the cheese, the player will win.

This will require the following resources:

  • A tile level showing the upper room and the nine cells, divided by walls. The walls between each cell should include an open space because the gates will be entities that are drawn on top of the walls, not a part of the tile level.

  • The Tom sprite we used before.

  • The button entities, with unpressed and pressed frames. There will be three buttons, each a different color.

  • The gates (one vertically and one horizontally aligned), open and closed, with colored lights to indicate which buttons they will work with.

  • The mouse sprite.

  • The cat sprite.

  • The cheese sprite.

Since this level is a puzzle, the game logic here will be more complex than previous levels. There is still only one way to enter the win state (when the mouse reaches the cheese) and only one way to enter the lose state (when the cat captures the mouse), but we must also control the buttons and gates from the GameState level.

The AI logic will consist of the cat and the mouse. The mouse is simple: when a door opens, the mouse will move to the next cell. The cat will decide a random cell to move to and will open its own gate.

User input will be similar to Level 2, but now we will add a context-sensitive action. When the user touches the screen above the player’s avatar, we will check to see whether the touch is near a button; that context determines whether we react. Specifically, if the avatar is near a button, the resulting game change will be activated.

gsMazeLevel

Again, we will create a new GLESGameState class to represent our level:

#define buttons_length 3

@interface MazeLevel : GLESGameState {
    TileWorld* tileWorld;
    Tom* m_tom;
    Tom *cat, *mouse;
    Entity* cheese;
    MazeDoor* door[2][2][3];
    MazeButton* buttons[buttons_length];
    MazeState state;

    //todo; mcGuffin* cheese;
}

@end

As you can see, there are many types of entities in this level. As usual, we have Tom acting as the player’s avatar. We also have our trusty TileWorld behind the scene and the McGuffin entity (in this case, named cheese). Our new actors will be the CAT and MOUSE along with the MazeDoor and MazeButton.

There will be one CAT and one MOUSE. They will each start in different corners of the “maze” portion of the level, and they will be controlled directly by the game logic.

There will be three MazeButtons, all accessible to the player at the top of the level in the “control room” area. When the player performs a context-sensitive action, it will trigger game logic that sets off one of the buttons and the corresponding MazeDoor.

There are nine cells, and there is a MazeDoor between each cell.

TileWorld

Figure 4-9 shows the TileWorld for Level 3.

Level 3 mockup

Figure 4-9. Level 3 mockup

The level map doesn’t have any simple duplicated tiles, so it gets sliced up into the tile strip in Figure 4-10.

Level 3 tiles

Figure 4-10. Level 3 tiles

Here is the lvl3_idx.txt tile index and physics information (“1” represents impassable and “0” represents normal tiles):

10x15

0,1,2,3,4,5,6,7,8,9
10,11,12,13,14,15,16,17,18,19
20,21,22,23,24,25,26,27,28,29
30,31,32,33,34,35,36,37,38,39
40,41,42,43,44,45,46,47,48,49
50,51,52,53,54,55,56,57,58,59
60,61,62,63,64,65,66,67,68,69
70,71,72,73,74,75,76,77,78,79
80,81,82,83,84,85,86,87,88,89
90,91,92,93,94,95,96,97,98,99
100,101,102,103,104,105,106,107,108,109
110,111,112,113,114,115,116,117,118,119
120,121,122,123,124,125,126,127,128,129
130,131,132,133,134,135,136,137,138,139
140,141,142,143,144,145,146,147,148,149

1,0,0,0,0,0,0,0,0,1
1,0,0,0,0,0,0,0,0,1
1,0,0,0,0,0,0,0,0,1
1,0,0,0,0,0,0,0,0,1
1,0,0,0,0,0,0,0,0,1
1,1,1,1,1,1,1,1,1,1
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0

Notice that the physics flags define only the control room area the player will be bounded by. There is nothing to represent the cell walls that the cat and mouse must stay in. This is because their positions will be entirely defined by game logic; we will not need entity-to-world collision detection for them.

Buttons

The MazeButton entity itself is very simple. It merely shows an on or off state. It will render the states using the appropriate color for each button, which are all placed in one sprite for the usual efficiency in image loading. Because the buttons will be the target of a context-sensitive action, we will also need a way to determine whether each button is eligible for an action based on its proximity to the player’s avatar:

//MazeButton.h
@interface MazeButton : Entity {
    int color;
}

- (id) initWithPos:(CGPoint) pos sprite:(Sprite*)spr color:(int)col;
//used to trigger the pressed animation.
- (void) press;
//used to determine if this button is under the specified entity
- (bool) under:(Entity*)other;

@end

The integer will store which color the button represents; this will be useful both for displaying the appropriate sprite and for letting the game logic know which button was pressed during a context-sensitive action:

MazeButton.m
@implementation MazeButton

- (id) initWithPos:(CGPoint) pos sprite:(Sprite*)spr color:(int)col {
    [super initWithPos:pos sprite:spr];
    color = col;
    [self press]; //probably should start with up image.  using down for now...
    return self;
}

static const NSString* down[] = {
        @"down",
        @"down-green",
        @"down-red",
    };


- (void) press {

    sprite.sequence = down[color];
}

- (bool) under:(Entity*)other {
    return distsquared(worldPos, other.position) < TILE_SIZE*TILE_SIZE;
}

@end

Initialization consists of storing the color and calling the press function.

The press function sets the sprite to render the appropriate color animation. Based on the sprite data in the .plist for buttons.png, the “down” animation will automatically reset to the “up” animation once it is complete.

Finally, the under: function will return true if the player is within one tile of the button when the function is called.

Doors

Similar to the MazeButton class, MazeDoors will also be very simple:

@interface MazeDoor : Entity {
}

- (id) initWithPos:(CGPoint) pos sprite:(Sprite*)spr ;

@end

This entity holds the sprite that represents the door at various states of being opened, closed, opening, closing, and closed with a colored light. However, since the door entity itself does not control these states (rather, the game logic will), we do not need special code in this class beyond initialization:

- (id) initWithPos:(CGPoint) pos sprite:(Sprite*)spr
{
    [super initWithPos:pos sprite:spr];
    sprite.sequence = @"closed";
    return self;
}

The initialization function simply sets the first animation of the door as closed and leaves the rest to our game logic.

Cat and Mouse

The cat and mouse entities reuse the Tom class because the animation sequences are the same. Meanwhile, they have no direct AI. The AI is located in the game logic and merely commands the entities in the same way as user input commands Tom.

We could have named the Tom class the “externally controlled avatar” to make it more general. But that just sounds silly.

User Input

This level’s context-sensitive action will take place inside the touchesEnded: function. This time, instead of immediately calling moveToPosition on Tom when we receive a single tap, we will check the distance between the tap and each button. If it is within a certain distance, we will interpret the input as a command to activate the action associated with the button. Therefore, we search on the three buttons and perform the appropriate game logic if we find one near enough to the touch:

- (void) touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event
{
    UITouch* touch = [touches anyObject];
    CGPoint touchpos = [tileWorld worldPosition:[self touchPosition:touch]];
    float dist = distsquared(touchpos, m_tom.position);
    if(dist < 30*30){
        if(state != WAITING_FOR_PLAYER) return;
        //figure out if the player has stomped a button.
        int buttonpressed = −1;
        for(int i=0;i<buttons_length;i++){
            if([buttons[i] under:m_tom]){
                [buttons[i] press];
                buttonpressed = i;
                break;
            }
        }
        if(buttonpressed == −1) return;

/// code snipped - performing game logic

        }
    } else {
        [m_tom moveToPosition:touchpos];
    }
}

Game Logic

Most of the code in this level is going to be about game logic. In fact, apart from the random relocation of the cat entity, there is no AI. The game proceeds purely on user input and the rules of the maze game.

To begin, we’ll explicitly list all of the states the maze game can be in:

typedef enum MazeState {
    MOVING_MOUSE=0,
    MOVING_CAT,
    WAITING_FOR_PLAYER,
    GOT_CHEESE, //winning state
    MOUSE_KILLED, //losing state
} MazeState;

We start in the WAITING_FOR_PLAYER state as we are waiting for the player to walk over to a button and activate it. Once the player has made her selection, we will enter the MOVING_MOUSE state as we open the corresponding door and move the mouse into the next cell.

When that is complete, we must determine whether the mouse has reached the cheese, in which case, we can move to the GOT_CHEESE state.

Otherwise, the game will automatically advance to the MOVING_CAT state, where we will randomly open a door and move the cat into a new cell.

Lastly, we determine whether the cat has entered the same cell as the mouse, and if so, we enter the MOUSE_KILLED state; otherwise, we return to the WAITING_FOR_PLAYER state and the cycle begins anew.

Initialization

Armed with this simple state machine, we can initialize our game. As usual, we place our initialization code into the setupWorld: function.

We begin by allocating the TileWorld. Because this level was designed to fit the screen exactly, without the need to scroll, we can set the camera to (0, 0) and not have to update it again.

Next, we allocate the main character as usual, placing him in the top control room area:

- (void) setupWorld {
    tileWorld = [[TileWorld alloc] initWithFrame:self.frame];
    [tileWorld loadLevel:@"lvl3_idx.txt" withTiles:@"lvl3_tiles.png"];
    [tileWorld setCamera:CGPointMake(0, 0)];

    Animation* tomanim = [[Animation alloc] initWithAnim:@"tom_walk.png"];
    m_tom = [[Tom alloc] initWithPos:CGPointMake(5.0f*TILE_SIZE, 12*TILE_SIZE)
                         sprite:[Sprite spriteWithAnimation:tomanim]];
    [tileWorld addEntity:m_tom];
    [tomanim autorelease];

The main character is followed by the cat, mouse, and McGuffin entities:

    Animation* catanim = [[Animation alloc] initWithAnim:@"cat.png"];
    cat = [[Tom alloc] initWithPos:CGPointMake(2*TILE_SIZE, 8*TILE_SIZE)
                       sprite:[Sprite spriteWithAnimation:catanim]];
    [tileWorld addEntity:cat];
    [catanim release];

    Animation* mouseanim = [[Animation alloc] initWithAnim:@"mouse.png"];
    mouse = [[Tom alloc] initWithPos:CGPointMake(8*TILE_SIZE, 8*TILE_SIZE)
                         sprite:[Sprite spriteWithAnimation:mouseanim]];
    [tileWorld addEntity:mouse];
    [mouseanim autorelease];


    Animation* goalanim = [[Animation alloc] initWithAnim:@"mcguffin.png"];
    cheese = [[Entity alloc]
                initWithPos:CGPointMake(2*TILE_SIZE, 2*TILE_SIZE+0.1f)
                sprite:[Sprite spriteWithAnimation:goalanim]];
    [tileWorld addEntity:cheese];
    [goalanim autorelease];

Now we begin adding the cell doors. As you may notice from the level mockup, the cells are all drawn without doors. This lets us overlay the door sprites onto the scene, allowing them to show the unobstructed passages underneath when they are opened.

There are nine cells, with a door between every two adjacent cells. That means two rows of three vertically aligned doors, and two columns of three horizontally aligned doors.

The central square has one door that doesn’t open. We’ve made this compromise in the design so that we need only three buttons; it’s not worth adding a fourth button just so that one cell can have a fourth door:

    Animation* vertdoor = [[Animation alloc] initWithAnim:@"mazedoor.png"];
    for(int x=0;x<2;x++){
        for(int y=0;y<3;y++) {
            door[0][x][y] =
                [[MazeDoor alloc] initWithPos:
                    CGPointMake((3*x+3.5f)*TILE_SIZE, (3*y+1.0f)*TILE_SIZE)
                                sprite:[Sprite spriteWithAnimation:vertdoor]];
            [tileWorld addEntity:door[0][x][y]];
        }
    }
    [vertdoor autorelease];

    Animation* horizdoor = [[Animation alloc] initWithAnim:
                                               @"mazedoor-horizontal.png"];
    for(int x=0;x<3;x++){
        for(int y=0;y<2;y++) {
            door[1][y][x] =
                [[MazeDoor alloc] initWithPos:
                    CGPointMake((3*x+2.0f)*TILE_SIZE, (3*y+3.5f)*TILE_SIZE)
                               sprite:[Sprite spriteWithAnimation:horizdoor]];
            [tileWorld addEntity:door[1][y][x]];
        }
    }
    [horizdoor autorelease];

Three buttons are located within the control room area. They need to be spaced out enough so that only one button is ever eligible for a context-sensitive action at any given time:

    Animation* buttonanim = [[Animation alloc] initWithAnim:@"buttons.png"];
    for(int i=0;i<buttons_length;i++){
        buttons[i] =
              [[MazeButton alloc] initWithPos:
                  CGPointMake((2.5f+2.5f*i)*TILE_SIZE,12*TILE_SIZE)
                     sprite:[Sprite spriteWithAnimation:buttonanim] color:i];
        [tileWorld addEntity:buttons[i]];
    }
    [buttonanim release];

Finally, because we are starting in the WAITING_FOR_PLAYER state, we need to show colored lights above the doors leading out of the mouse’s current cell. We are going to have to perform this action a lot, so let’s create a function named decorateDoors to handle this feature:

    state = WAITING_FOR_PLAYER;
    [self decorateDoors];
}

Context action

We already showed the code in touchesEnded that detects when players have performed a context-sensitive action; now we will examine the game logic that gets triggered when they do:

if(state != WAITING_FOR_PLAYER) return;
//figure out if the player has stomped a button.
int buttonpressed = −1;
for(int i=0;i<buttons_length;i++){
    if([buttons[i] under:m_tom]){
        [buttons[i] press];
        buttonpressed = i;
        break;
    }
}
if(buttonpressed == −1) return;

//perform action.
NSArray* moves;
int angle;

moves = [self possibleMoves:mouse];
if(buttonpressed < [moves count]){
    angle = [[moves objectAtIndex:buttonpressed] intValue];
    //open the chosen door
    [self doorFrom:mouse.position inDirection:angle].sprite.sequence = @"opening";

    //un-decorate the other possible doors.
    for(NSNumber* n in moves){
        int i = [n intValue];
        if (i==angle) continue;
        [self doorFrom:mouse.position inDirection:i].sprite.sequence = @"closed";
    }

    //move the char through the open door.
    CGPoint mousepos = mouse.position;
    //int angle = random() % 4;
    mousepos.y += TILE_SIZE*3*cheapsin[angle];
    mousepos.x += TILE_SIZE*3*cheapcos[angle];
    [mouse moveToPosition:mousepos];
    //wait for the mouse to stop moving.
    state = MOVING_MOUSE;
}

After determining which button the player pressed, we call the press function on that button to show graphically that the button has been pressed.

Remember, each cell has either two or three doors. (One wall in the central cell has a door that doesn’t open, as we explained earlier.) That means corner cells, which have only two adjacent cells, will have only two possible moves, whereas the side and center cells will have three.

After retrieving the list of possible moves the player could have made, we check to see whether the button that was pressed corresponds to one of those moves. If the mouse is in a corner cell and the third button is pressed, it would fail this test and we would not open any doors. If you look ahead, you will see that the state is set to MOVING_MOUSE only if this test passed. That means the player can press the wrong button and see it depressed, but no door will open and she will still be able to choose a different button.

Next, we retrieve the direction of the door from the moves array and use it to open the corresponding door. Retrieving the right door is slightly complicated, so we place it inside a subfunction that can return a door based on a cell and a direction from that cell:

- (MazeDoor*) doorFrom:(CGPoint) pos inDirection:(int) angle {
    int x = gamepos(pos.x);
    int y = gamepos(pos.y);
    if(angle == 1 || angle == 3){
        //moving left/right will find horizontal column doors only
        if(angle == 1) x--;
        return door[0][x][y]; //3 columns of 2 rows
    } else {
        //moving up/down will find verticle row doors only
        if(angle == 2) y--;
        return door[1][y][x];
        //x and y are reversed for horizontal doors,
        //so that we can squeeze 2x3 of them in.
    }
}

Once we have a door opening, we should remove the colored lights from the other possible doors by setting their animations to “closed”.

Finally, we determine the new mouse position and send it to the mouse actor, similar to the way we send the main character to a position when the player taps the screen.

Update

In the Update: function, we use a switch statement based on our current MazeState to perform the appropriate logic:

//clockwise from straight up, in opengl coords.
int cheapsin[] = { 1, 0, −1, 0 };
int cheapcos[] = { 0, −1, 0, 1 };

- (void) Update {
    float time = 0.033f;

    //move mouse, wait to stop.
    //move cat, wait to stop.
    //decorate doors, wait for button push.
    switch (state) {
        case MOVING_MOUSE:

In the MOVING_MOUSE state, we will be waiting for the mouse to finish moving (it was already told to begin moving at the start of the state when the player pressed one of the buttons). Once the mouse has reached its destination cell, we will check to see whether it found the cheese and move to the GOT_CHEESE state if it has.

Otherwise, we need to start moving the cat. We list the possible directions in which the cat can move and randomly select one. We then open the corresponding door and walk through it, just like we told the mouse to do in the touchesEnded: function:

            if([mouse doneMoving]){
                if (distsquared(mouse.position, cheese.position) < 16) {
                    //todo: win.
                    state = GOT_CHEESE;
                    NSLog(@"win condition triggered.");
                } else {
                    //time to move the cat.
                    NSArray* moves = [self possibleMoves:cat];
                    int angle = [[moves
                            objectAtIndex:random()%[moves count]] intValue];

                    MazeDoor* door_tmp = [self doorFrom:cat.position
                                    inDirection:angle];

                    door_tmp.sprite.sequence = @"opening";

                    //move the char through the open door.
                    CGPoint catpos = cat.position;
                    //int angle = random() % 4;
                    catpos.y += TILE_SIZE*3*cheapsin[angle];
                    catpos.x += TILE_SIZE*3*cheapcos[angle];
                    [cat moveToPosition:catpos];
                    state = MOVING_CAT;
                }
            }
            break;
        case MOVING_CAT:

In the MOVING_CAT state, we are waiting for the cat to finish walking to its new cell. Once it has, we should determine whether the cat caught the mouse, in which case, we move to the MOUSE_KILLED state; otherwise, we return to WAITING_FOR_PLAYER:

            if([cat doneMoving]){
                if(distsquared(mouse.position, cat.position) < 16){
                    //todo: lose.
                    state = MOUSE_KILLED;
                    NSLog(@"lose condition triggered.");
                } else {
                    [self decorateDoors];
                    state = WAITING_FOR_PLAYER;
                }
            }
            break;
        default:

The rest of the states do not require special processing in the update function. WAITING_FOR_PLAYER is just waiting for the player to activate a button, and the GOT_CHEESE and MOUSE_KILLED states are our win and lose states:

            //nothing interesting here...
            break;
    }

    [m_tom update:time];
    [cat update:time];
    [mouse update:time];
    for(int i=0;i<2;i++){
        for(int x=0;x<2;x++){
            for(int y=0;y<3;y++){
                [door[i][x][y] update:time];
            }
        }
    }
    for(int i=0;i<buttons_length;i++){
        [buttons[i] update:time];
    }
    //[tileWorld setCamera:[m_tom position]]; //unnecessary for this level
}

Finally, we make sure to update all of our entities so that they can animate their sprites.

Sounds

When the player presses a button, we want to play a click sound effect. We will add the code into the press: function of the MazeButton class:

static const NSString* down[] = {
        @"down",
        @"down-green",
        @"down-red",
    };


- (void) press {

    sprite.sequence = down[color];
    [g_ResManager playSound:@"tap.caf"];
}

Get iPhone Game Development now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.