The first step in constructing our game will be to focus on the foundation of the game world: the tile engine. The tile engine is responsible for loading the tiles that represent the level and drawing them to the screen. If the level is larger than the viewable area of the screen, we will keep track of a “camera offset” that will be used to draw the portion of the level we are interested in, as well as to draw the rest of the objects on top of the level with the same offset.
To begin, we will start with an image that represents our level (see Figure 4-1). This image was constructed out of tiles that are 32×32 pixels wide and high. The level is 15 tiles wide by 25 tiles high, or 375 tiles total. However, because it was created from a small set of tiles, we need to load only the unique tiles into memory; we need only 32 tiles (see Figure 4-2).
That’s more than 90% less memory! However, we’re also going to need to store some data that tells us which unique tile image to use when rendering the level. So, in addition to the unique tile image, we will also need an index array, such as:
1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,11,12,13,14,15,0,0,0,0,2 1,0,10,0,0,16,17,18,19,20,0,0,0,0,2 1,0,0,0,0,21,22,23,24,25,0,0,4,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,10,0,0,0,4,0,0,0,0,2 1,0,4,5,0,0,0,0,0,0,0,0,0,0,2 1,0,0,10,0,0,0,0,0,0,0,0,7,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,5,6,0,0,0,0,0,0,0,2 1,3,4,0,0,10,0,0,0,0,0,0,0,0,2 1,0,0,0,0,0,0,0,0,0,8,9,0,0,2 1,0,0,0,0,0,0,0,0,0,10,0,0,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,0,0,0,0,0,0,0,10,8,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,0,0,0,4,0,0,0,0,0,2 1,0,0,0,3,4,0,0,0,0,0,9,0,0,2 1,0,0,0,0,0,0,0,0,0,5,6,0,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2 1,0,0,0,0,0,0,0,0,0,0,0,0,0,2
The preceding code describes the tile indexes of a 15×25-tile map. The top-left tile uses the second frame (index “1” because indexes start at “0”). The next frame uses frame index “0,” and so on.
As we discussed in Animation, sprites are
textures that contain multiple images that should be drawn in succession
to represent an animation. Each image in the sprite texture is called a
frame. However, the class we presented in Chapter 3 for rendering textures, GLTexture
, is not suitable for drawing
frames.
Specifically, we want to consider the unique tile strip (Figure 4-2) to be like a film strip.
Each frame is exactly the same width, so finding the location of a
single frame in the texture is as easy as x =
frame*frameWidth;
where frameWidth
is the same as the tile
size.
The iPhone also limits us to a maximum of 1,024 for both the width and the height of the texture. That means that if our unique tiles are 32×32 and we have more than 32 tiles in either direction (because 32 * 32 = 1,024), we have to wrap them to keep our image width or height from exceeding 1,024.
Warning
The OpenGL ES implementation on the iPhone will try to automatically scale down any texture larger than 1,024 in height or width. This could cause unintended side effects for any images that are too large; if you are noticing problems with your textures, make sure they aren’t too big.
That means the calculation for the frame offset becomes:
int x = (frame * tileSize) % 1024; int row = (( frame * tileSize ) - x) / 1024; int y = row * tileSize;
Because of the number of mathematical operations involved in
calculating this position, it is more efficient to store the results
after the first calculation and reuse them, rather than recalculating
the frame for each tile in every loop. Therefore, we will create
a Tile
class that can
store the offset into a GLTexture
, like
so:
//Tile.h typedef enum { UNWALKABLE = 1, WATER = 2, EMUTEARS = 4, } PhysicsFlags; @interface Tile : NSObject { NSString* textureName; CGRect frame; PhysicsFlags flags; } @property (nonatomic, copy) NSString* textureName; @property (nonatomic) CGRect frame; - (void) drawInRect:(CGRect)rect; - (Tile*) initWithTexture:(NSString*)texture withFrame:(CGRect) _frame; @end //Tile.m @implementation Tile @synthesize textureName; @synthesize frame; - (Tile*) init { [super init]; flags = 0; return self; } - (Tile*) initWithTexture:(NSString*)texture withFrame:(CGRect) _frame { [self init]; self.textureName = texture; self.frame = _frame; return self; } - (void) drawInRect:(CGRect)rect { [[g_ResManager getTexture:textureName] drawInRect:rect withClip:frame withRotation:0]; } - (void) dealloc { [super dealloc]; } @end
Notice the PhysicsFlags
enumeration. We will use this later for entity-to-world collision
detection.
With the unique tile index array data, we can now write a class
that will render the level to the screen using the Tile
class. To do this, we will create a new
class named TileWorld
that
a GLESGameState
can use.
When the GameState
is
initialized, it will create the TileWorld
and load the level data; when the
GameState
is rendering, it will tell
the TileWorld
to draw itself before
anything else.
This class needs to keep a list of tiles, along with a rectangle that describes what portion of the screen to render those tiles to. In addition, our levels may be larger than the viewable area, so we will need to keep a “camera” offset to pan our rendering to display the portion of the level we want to see:
//TileWorld.h //global tile size. #define TILE_SIZE 32 @class Tile; @class Entity; @interface TileWorld : NSObject { Tile*** tiles; CGRect view; //typically will be the same rect as the screen. in pixels. considered to be in opengl coordinates (0, 0 in bottom left) int world_width, world_height; //in tiles. int camera_x, camera_y; //in pixels, relative to world origin (0, 0). // view will be centered on this point. NSMutableArray* entities; } - (TileWorld*) initWithFrame:(CGRect) frame; - (void) loadLevel:(NSString*) levelFilename withTiles:(NSString*)imageFilename; - (void) draw; - (void) setCamera:(CGPoint)position; //utility function - (CGPoint) worldPosition:(CGPoint)screenPosition; - (void) addEntity:(Entity*) entity; - (Tile*) tileAt:(CGPoint)worldPosition; - (BOOL) walkable:(CGPoint) point;
There are a number of things to note here before we move on to the
implementation. First, what is that TILE_SIZE
definition? We stated earlier that
we would be using 32×32 pixel tiles. This definition helps us
avoid magic numbers.
A magic number is a certain numeric value that is used over and
over again in code, and it is bad because you cannot tell when, for
example, a value of 32 is used to represent a tile size or some other
concept. It’s also bad because if you wanted to change the tile size to
some other value, such as 16, you’d have to do a search and replace,
being careful to replace only the instances of 32 that represent tile
size. You should always use a define
for values instead of magic numbers, to make your intended use
clear.
The next important piece of code is the Tile*** tiles;
line. This represents a
dynamically allocated 2D array of Tile
pointers. Because the tile engine could
be used to represent a level of any width or height, we must allocate
the tiles dynamically.
Finally, you will notice the NSMutableArray* entities;
definition. This
array will keep a list of objects we wish to draw in every frame.
Because objects in front should be drawn on top of objects behind them,
we need to sort those objects and draw them in order (from back to
front). We also need to offset the rendering position of those objects
by the same camera offset the level tiles are being drawn with.
You can view the implementation of the TileWorld
class in the TileWorld.mm file.
Let’s hone in on the loadLevel:
function, which accepts a text file in the following format:
[height]x[width] [comma separated tile texture indexes] [comma separated tile physics flags]
First, it loads the file contents into an NSString
. Next, it
parses out the width and height values from the first line by
separating them using the componentsSeparatedByString:
function of
NSString
to grab the characters to
the left and right of the x
character. Then it calls the intValue
function to parse the string into
an integer value for each.
Once we have the width and height of the level in tiles, we know
how many Tile
object pointers must
be allocated. This is done in the allocateWidth: height:
function.
Next, we parse through the file and create a new Tile
object for each tile in the level,
initializing it with a CGRect
representing the frame position of the unique tile index found in the
.txt file, as we discussed in
Drawing Tiles.
Once all of the tiles have been created, we skip past the second
blank line and start parsing the physics flags. For each tile, we read
in the value and initialize the flags
property of the corresponding Tile
object.
Finally, we can release the original file data. Remember, since
we have allocated the Tiles
array
and objects, we will need to deallocate them when we unload the
level.
Now that we have the Tile
s
initialized, we can render the level. Keep in mind that the TileWorld
instance will be a member of
a GLESGameState
class,
which will be calling the draw:
function:
-(void) draw { CGFloat xoff = -camera_x + view.origin.x + view.size.width/2; CGFloat yoff = -camera_y + view.origin.y + view.size.height/2; CGRect rect = CGRectMake(0, 0, TILE_SIZE, TILE_SIZE); for(int x=0;x<world_width;x++){ rect.origin.x = x*TILE_SIZE + xoff; //optimization: don't draw offscreen tiles. // Useful only when world is much larger than screen, // which our emu level happens to be. if(rect.origin.x + rect.size.width < view.origin.x || rect.origin.x > view.origin.x + view.size.width) { continue; } for(int y=0;y<world_height;y++){ rect.origin.y = y*TILE_SIZE + yoff; if(rect.origin.y + rect.size.height < view.origin.y || rect.origin.y > view.origin.y + view.size.height) { continue; } [tiles[x][y] drawInRect:rect]; } } if(entities){ [entities sortUsingSelector:@selector(depthSort:)]; for(Entity* entity in entities){ [entity drawAtPoint:CGPointMake(xoff, yoff)]; } } }
First we calculate the offset between the world and the viewable area of the screen. We are basically transforming between world coordinates and screen coordinates.
As an optimization, we also initialize a rectangle the size of a
tile for use in the Tile
object’s
drawInRect:
function. Rather than
creating the rectangle for each tile, we can set it up outside the
for
loop and have to change only
the x and y positions,
saving us some processing power.
You may recall from Chapter 2 that
another form of graphics optimization is culling. Inside the for
loop, we begin by culling any columns of
Tile
s that wouldn’t show up in the
viewable area. Then we start the inner for
loop to draw the actual Tile
s, first being sure to cull any
individual tiles in that row that are outside the viewable
area.
Now we get back to the entities array. We will discuss the
Entity
class in more detail later,
but recall that entities need to be sorted and drawn in order from
back to front so that they overlap each other correctly. Because the
entities
variable here is
an NSMutableArray
we
can use the sortUsingSelector
method which allows us to apply a comparison function we’ve written
for this purpose.
The comparison function is expected to be called as a message on
one object, with a single parameter being an object of the same type
that should be compared. The return value must be NSOrderedAscending
, NSOrderedSame
, or NSOrderedDescending
based on the result of
the comparison.
A default comparison function is available for use with simple
types such as NSString
and NSNumber
. However,
because we are comparing entities, we must write a custom
comparison.
Our custom function is named depthSort:
and is located in the Entity.m file:
- (NSComparisonResult) depthSort:(Entity*) other { if (self->worldPos.y > other->worldPos.y) return NSOrderedAscending; if (self->worldPos.y < other->worldPos.y) return NSOrderedDescending; //the logical thing to do at this point is return NSOrderedSame, but that //causes flickering when two items are overlapping at the same y. Instead, //one must be drawn over the other deterministically... we use the memory //addresses of the entities for a tie-breaker. if (self < other) return NSOrderedDescending; return NSOrderedAscending; }
The function compares the y
values of the objects, returning NSOrderedAscending
if the “other” Entity
has a smaller y
value. Recall that y = 0 at the top of the
screen, so the smaller the y
value,
the farther “up” on the screen the object will be drawn. We want
things closer to the bottom of the screen to be considered “close” and
things at the top to be considered “far,” so returning the objects in
ascending order by y coordinate will make sure
that entities that are higher on the screen (farther away) will be
drawn first, and entities lower on the screen (closer) will be drawn
last (thereby showing on top of the others).
We mentioned that the tiles are able to be offset so that the viewable area shows the part of the level we are interested in. We call it the camera offset because it’s like a camera panning around inside the level area. In particular, we want to be able to set the camera offset to follow our main character as he walks around the level.
By calling setCamera:
on our
TileWorld
with a target position (in world coordinates), we can
calculate the offset necessary to draw the tiles so that they will be
visible within the screen coordinates. If we offset a point within the
tile rectangle by the negated value of that point, it will show up at
the top left of the viewable area.
However, we want the main character to show up in the center of
the screen, not the top left. Furthermore, if the character walks
close to the left side of the world, rather than staying in the middle
of the screen (thereby causing the viewable area to extend beyond the
level and show empty space), we want to force the camera to stay
within the bounds of the level and instead cause the character to
leave the center of the screen and get close to the edge. The same
condition applies for the top, bottom, and right sides of the screen.
Therefore, when the intended camera position is sent to setCamera:
, we do a
bounds check on each side of the viewable area to make
sure the camera stays put, like so:
-(void) setCamera:(CGPoint)position { camera_x = position.x; camera_y = position.y; if (camera_x < 0 + view.size.width/2) { camera_x = view.size.width/2; } if (camera_x > TILE_SIZE*world_width - view.size.width/2) { camera_x = TILE_SIZE*world_width - view.size.width/2; } if (camera_y < 0 + view.size.height/2) { camera_y = view.size.height/2; } if (camera_y > TILE_SIZE*world_height - view.size.height/2) { camera_y = TILE_SIZE*world_height - view.size.height/2; } }
Apart from drawing the tiles and entities at their appropriate
offsets, the last feature of the TileEngine
is
physics. Remember that each Tile
in
the tiles
array has a physics flag
that was initialized when we loaded the level file.
During the GameState
update
function, we will be checking entity-to-world collision by asking
the TileWorld
for each
entity if it is colliding with any tiles that have special physics
flags.
To this end, we provide the tileAt:
function, which will return the
Tile
object located at a particular
point (in world coordinates). Furthermore, as a convenience, we will
also provide the walkable:
function
to determine whether the Tile
has
any special physical properties we should worry about.
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.