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.
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 MazeButton
s, 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.
Figure 4-9 shows the TileWorld
for
Level 3.
The level map doesn’t have any simple duplicated tiles, so it gets sliced up into the tile strip in Figure 4-10.
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.
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.
Similar to the MazeButton
class, MazeDoor
s 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.
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.
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]; } }
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.
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]; }
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.
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.
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.