Tile Map Using CoreAnimation

This week I’m going to talk about how I implemented a tilemap in Objective-C using CoreAnimation. I’d like to take the time to once again say that this code is definitely not optimized so your milage may vary. It’s funny because a tile map in some ways feels like a very dated beast. I suppose in a lot of ways it is, but it definitely has a unique feel that can give a very nostalgic vibe. Much in the same way a physical oil painting can compare to digital paintings.  But enough about why you might want to use a tile map, I’m sure you already have strong feelings for them one way or another.

In case you haven’t seen in, there’s an alpha video of the latest game I’m working on. It’s a 2D sidescroller digging game and it uses a tile map for levels. I tried to keep a very MVC friendly approach when implementing my tile map. The basic way it works is that you have a model class which stores the value determining which square each tile should have. Below you can see my tile map with the values overlaid on top of them. I’m not going to talk about the model side very much here because you can implement it however you want, 2d array, dictionary, etc. I used a dictionary with the keys being a simple hash made from the X and Y coordinates, eg. “X6Y10″

For the view portion of the system there are two parts: Tiles and the Tile Map. The Tiles are very similar to the AFSprite class I talked about last post so definitely head over there if you haven’t read it already.  Each tile basically works the same way the sprite did to only show one of the tiles from the tile set above based on the index specified.

+ (id) tileWithTexture:(NSString *)texture {
	return [[[AFTile alloc] initWithTexture:texture] autorelease];
}

//Initializes the texture for the image with some basic properties
- (id) initWithTexture:(NSString*)texture {
	self = [super init];
	if (self) {
		UIImage *tileset = [UIImage imageNamed:texture];
		self.contents = (id)(tileset.CGImage);
		textureWidth = tileset.size.width;
		textureHeight = tileset.size.height;
		tileWidth = 32;
		tileHeight = 32;
		index = -1;
	}
	return self;
}

#pragma mark Sprite Stuff

//Update the display on changes to properties
+ (BOOL) needsDisplayForKey:(NSString *)key {
	if ([key isEqualToString:@"index"] || [key isEqualToString:@"tileWidth"] || [key isEqualToString:@"tileHeight"]) {
		return YES;
	}

	return [super needsDisplayForKey:key];
}

- (void) display {
	//Set the boundries to show the specified tile
	self.bounds = CGRectMake(0,0,tileWidth,tileHeight);

	//Converting the linear index to X Y coordinates
	int x = (index % (textureWidth/tileWidth)) * tileWidth;
	int y = floor((double)index / (textureWidth/tileWidth)) * tileHeight;

	//[0,1] values for the position and width
	self.contentsRect = CGRectMake((float)x/textureWidth, (float)y/textureHeight, (float)tileWidth/textureWidth, (float)tileHeight/textureHeight);

	(index < 0) ? [self setHidden:YES] : [self setHidden:NO];;
}

- (id ) actionForKey:(NSString *)key {
	//if ([key isEqualToString:@"bounds"]||[key isEqualToString:@"position"])
		return nil;
	//return [super actionForKey:key];
}

#pragma mark setters

//Set needs display when the index changes
- (void) setIndex:(int)i {
	index = i;
	[self setNeedsDisplay];
}

//Or the width
- (void) setTileWidth:(int)width {
	tileWidth = width;
	[self setNeedsDisplay];
}

//Or the height
- (void) setTileHeight:(int)height {
	tileHeight = height;
	[self setNeedsDisplay];
}

As I said most of this should make sense if you read my last post. The next class is the tile map which holds a bunch of tiles and displays the portions of the model that are viewable on screen.  I like to think of it as a lens in a way, when you hold it over the model it shows the images of the appropriate tiles. Now one option is to use a static grid of tiles that never move. In this case the minimum movement of the background would be one full tile width or height. This is a totally legitimate option and can really simplify things. I’d recommend thinking about whether or not it could work for you. But for my case I want the world to scroll smoothly as the character moves around.

Now depending on the size of the world you’re talking about maybe you can just create a tile instance for each coordinate of your world. Perhaps you’re making a Bomberman style game and you only need the arena slightly larger than the screen. That might work but for my game I don’t even know how big the levels are going to be but I want to keep my options open incase I go with “BIG”. At some point creating a tile for each coordinate of the world just isn’t going to be feasible anymore. So I leaned towards something I’ve used a lot during iOS development: Tables.  The UITableView work really well at handling indefinitely long list and in my opinion is implemented pretty well. Tables are so versatile they’re used often when you can’t tell something is a table.

I made this quick little video to hopefully help people visualize what’s going on. Basically I make one screen worth of tiles then reuse them when they get scrolled off screen to avoid having to make new instances.  It’s just like a 2D table.  Actually I make an extra 1 tile border around the screen’s worth of tiles just to help prevent any flickering on the edges before the reused tiles are there.

+ (id) tilemapFromModel:(AFLevelModel*)model {
	return [[[AFTileMap alloc] initFromModel:model] autorelease];
}

- (id) initFromModel:(AFLevelModel*)model {
	self = [super init];
    if (self){
		self.texture = [model tileset];
		self.tileWidth = [model tileWidth];
		self.tileHeight = [model tileHeight];
		self.mapModel = model;

		visibleTiles = [NSMutableSet new];
		recycledTiles = [NSMutableSet new];
		tilesByCoord = [NSMutableDictionary new];
		self.frame = CGRectMake(0, 0, self.tileWidth*[model mapWidth], self.tileHeight*[model mapHeight]);
	}
	return self;
}

The initialization is pretty straight forward. There are two NSMutableSet’s which will hold the tiles.  The tilesByCoord dictionary is one of the very unoptimized parts of this code currently as I needed a faster way to be able to locate a specific tile based on its X,Y coordinates and sets have no order or keys inherently to them. tilesByCoord and visibleTiles will get combined at some point.

- (void) updateTileAtCoord:(AFCoord*)coord {
    //update the tile
    [[tilesByCoord objectForKey:[NSNumber numberWithInt:10000*coord.x+coord.y]] setIndex:[self.mapModel getValueForTileAt:coord]];
}

This method is for changing a specific tile called when the model changes. You can see the tilesByCoord dictionary stores each tile with an NSNumber key based off its X,Y coordinates. This is one of the latest things I’ve added and the reason why I needed to be able to locate the tile via X,Y. Tiles don’t change often so this targeted approach seemed to give the best performance while not taking a lot of implementation time (Something that’s important when working on a game solo as a side project to a 40h a week job!).

- (void) configureTile:(AFTile*)tile forCoord:(AFCoord*)coord {
    //Sets the properties for the new/reused tile
	[tile setPosition:CGPointMake((coord.x+0.5)*tileWidth,(coord.y+0.5)*tileHeight)];

	[tile setTileWidth: tileWidth];
	[tile setTileHeight:tileWidth];
	[tile setCoord:coord];
	[tile setIndex: [self.mapModel getValueForTileAt:coord]];
}

This method sets up new tiles once their created. Everything is pretty self explanatory here I think. AFCoord is a simple class made to basically wrap an X,Y coordinate and provide a few methods commonly used with them. It’s an interesting discussion of whether something as small as this should just be a struct but that’s a discussion for another post. I’ve stated before the approach I’m taking with this project is to default every thing to Objective-C and then go back and change things that need to be changed once things are implemented and profiled.

- (AFTile*) dequeueRecycledTile {
	//Grab an object from the recycled tiles and return it (Or nil if none)
	AFTile* recycledTile = [recycledTiles anyObject];
	if (recycledTile) {
		[[recycledTile retain] autorelease];
		[recycledTiles removeObject:recycledTile];
	}
	return recycledTile;
}

This method grabs any object from the recycledTile’s set and returns it. All the tiles in recycledTiles are offscreen and up for grabs, we don’t care which one it is. The [[recycledTile retain] autorelease] line is there to prevent the object from having a retain count of 0 after it’s removed from the set while also making it so we don’t have to worry about releasing it somewhere later.

- (id ) actionForKey:(NSString*)key {
	return nil;
}

You’ve seen this before, it’s just to disable the implicit animations CALayer provides for us.

So now for the meat of the class:

//Update the tiles to the new position.
- (void) updateTiles {

	//Find the XY ranges visible
	CGRect visibleBounds = self.superlayer.bounds;
	visibleBounds = CGRectInset(visibleBounds, -tileWidth, -tileHeight); //Buffer of one tile around the outside
	int minX = (int) floorf( visibleBounds.origin.x / tileWidth);
	int maxX = (int) floorf((visibleBounds.origin.x + visibleBounds.size.width) / tileWidth);
	int minY = (int) floorf( visibleBounds.origin.y / tileHeight);
	int maxY = (int) floorf((visibleBounds.origin.y + visibleBounds.size.height) / tileHeight);

	//Remove offscreen tiles
	for (AFTile *tile in visibleTiles) {
		if (!CGRectIntersectsRect(visibleBounds, tile.frame)) {
			//The quite literal corner case (And edge case)
			if (CGRectGetMinX(visibleBounds) != CGRectGetMaxX(tile.frame) &&
				CGRectGetMinY(visibleBounds) != CGRectGetMaxY(tile.frame) &&
				CGRectGetMaxX(visibleBounds) != CGRectGetMinX(tile.frame) &&
				CGRectGetMaxY(visibleBounds) != CGRectGetMinY(tile.frame)) {

				[recycledTiles addObject:tile];
				[tile removeFromSuperlayer];
				[tilesByCoord removeObjectForKey:[NSNumber numberWithInt:tile.coord.x*10000+tile.coord.y]];
			}
		}
	}

	//Remove them from visible set
	[visibleTiles minusSet:recycledTiles];

	for (int i = minX; i <= maxX; i++) {
		for (int j = minY; j <= maxY; j++) {
            //Coord for the location
   			AFCoord *tileLocation = [AFCoord coordWithX:i andY:j];

            //Check if the tile is displayed already
			if ([tilesByCoord objectForKey:[NSNumber numberWithInt:10000*i+j]] == nil) {
                //Dequeue a tile or create a new one if none are available
				AFTile *newTile = [self dequeueRecycledTile];

				if (newTile == nil) {
					newTile = [AFTile tileWithTexture:texture];
				}

				[self configureTile:newTile forCoord:tileLocation];
				[self addSublayer:newTile];

				[visibleTiles addObject:newTile];
				[tilesByCoord setObject:newTile forKey:[NSNumber numberWithInt:10000*i+j]];
			}
		}
	}

}

Lets start from the top and work our way through it. The first chunk of code is some math to determine what portion of the world is visible on the screen and also convert that to the tile space. Note the Inset of negative tileWidth and tileHeight, this adds that one tile border I mentioned earlier. Next we remove any tiles that aren't on screen. Each tile is compared with the screen to see if it's still there. If it's not we add the tile to the recycledTiles set marking that it's offscreen and ready to be reused. Now you might ask why I'm doing a set subtraction after the loop instead of removing it from the set individually like I do with the tilesByCoord dictionary. The answer to that lies in the for loop. We're iterating through the objects in visibleTiles so we don't want to modify visibleTiles within the loop or bad things can happen.

Next there's a 2d loop which loops through the visible X,Y coordinates at the beginning of the method. Now for each coordinate it checks if a tile is being displayed for that coordinate, if there isn't it first requests a dequeued tile. If no dequeued tile was supplied, e.g. when the tile map is first created, it will make a new one. Either way this tile gets configured and added to the layer.

It's pretty simple when you break it down into individual tasks, but there's still plenty of room for improvement. For one the visibleTiles and tilesByCoord are redundant and need to be combined. Second, it's probably not necessary to loop through all the coordinates and check them individually when I'm looping through visible tiles earlier in the method. One idea is to keep a cache of the coordinates of tiles that are still visible when I remove offscreen tiles. Then I can only loop through the coordinates that need tiles instead of going through all of them. Either way I'm not going to worry about it too much until I get it more complete and profile it. It seems to be running fine for now.

Not sure what the topic for next blog will be but be sure to check back in two weeks to find out!

One Response to “Tile Map Using CoreAnimation”

  1. Byron says:

    Good article. One thing I found useful when working with CA and tile maps is that if things start feeling “chopping” when adding and removing layers you can disable animations for that call.

    [code]
    - (void) updateTiles {
    [CATransaction begin];
    [CATransaction setValue:(id)kCFBooleanTrue
    forKey:kCATransactionDisableActions];
    // all your add sublayer stuff here. Because it's wrapped in this call you won't get the over head of each tile having transitions applied as they are added.
    [CATransaction commit];
    }
    [/code]