Tilemaps with compiled sprites

Creating large tile maps is a challenging thing to do with opengles, especially with older IOS hardware. Once done however you can design very large levels with minimal collision code. For more info on tile based games there is a very good (if old) set of articles here. Depending on what type of game is being built multiple approaches can be used. This will focus on using compiled sprites to do the rendering.

Advantages: Fast rendering of very large maps.

Disadvantages: Compilation is slow (this could be helped by modifying the compilation code).

First we need to build a generic map parser that we can use to parse maps and textures.

This tutorial was written for Sparrow 1.x. In Sparrow 2, you have to replace “SPCompiledSprite” with a normal “SPSprite” and call “flatten” instead of “compile”.

Header:

#import <Foundation/Foundation.h>
#import "Sparrow.h"
 
@interface SXTilemap : SPSprite{
	NSMutableData *data;
	NSMutableArray *tiles;
 
	int tileWidth;
	int tileHeight;
	int mapWidth;
	int mapHeight;
 
	int viewportWidth;
	int viewportHeight;
 
	BOOL hasData;
	BOOL hasTileImage;
 
	SPPoint *camera;
	SPPoint *oldCam;
 
	BOOL flagForRefresh;
 
	BOOL updating;
}
 
@property (nonatomic, assign) int tileWidth;
@property (nonatomic, assign) int tileHeight;
@property (nonatomic, assign) int mapWidth;
@property (nonatomic, assign) int mapHeight;
@property (nonatomic, retain) NSMutableData *data;
@property (nonatomic, retain) NSMutableArray *tiles;
@property (nonatomic, retain) SPPoint *camera;
 
- (id)initWithViewportSize:(CGSize)vpSize CSVData:(NSString*)csvData tileSize:(CGSize)tileSize andTileImage:(UIImage*)tileImage;
- (void)createFromCSV:(NSString*)csvData;
- (void)createTilesFromImage:(UIImage*)img;
- (void)changeTileId:(int)tileId forX:(int)xPos andY:(int)yPos;
 
- (BOOL)mapReady;
 
- (void)updatePosition;
- (void)updateData;
- (void)update:(SPEnterFrameEvent*)event;
- (void)startUpdates;
- (void)stopUpdates;
 
@end

And the implementation:

#import "SXTilemap.h"
 
// internal methods
@interface SXTilemap()
- (void)initVars;
@end
 
@implementation SXTilemap
 
@synthesize mapWidth;
@synthesize mapHeight;
@synthesize tileWidth;
@synthesize tileHeight;
@synthesize data;
@synthesize tiles;
@synthesize camera;
 
- (id)init{
    self = [super init];
	[self initVars];
    return self;
}
 
- (void)initVars{
	self.data = [[[NSMutableData alloc] init] autorelease];
	self.camera = [[[SPPoint alloc] initWithX:0 y:0] autorelease];
	self.tiles = [[[NSMutableArray alloc] init] autorelease];
	oldCam = [[SPPoint alloc] initWithX:0 y:0];
	tileWidth = 16;
	tileHeight = 16;
	viewportWidth = 320;
	viewportHeight = 480;
}
 
- (id)initWithViewportSize:(CGSize)vpSize CSVData:(NSString*)csvData tileSize:(CGSize)tileSize andTileImage:(UIImage*)tileImage{
	self = [super init];
	[self initVars];
	tileWidth = tileSize.width;
	tileHeight = tileSize.height;
	viewportWidth = vpSize.width;
	viewportHeight = vpSize.height;
	[self createFromCSV:csvData];
	[self createTilesFromImage:tileImage];
	return self;
}
 
- (void)createFromCSV:(NSString*)csvData{
	csvData = [csvData stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceCharacterSet]]; // trim whitespace
	NSArray *rows = [csvData componentsSeparatedByString:@"\n"];
	mapHeight = [rows count];
	mapWidth = [[[rows objectAtIndex:0] componentsSeparatedByString:@","] count];
 
	int amount = ((mapWidth) * (mapHeight)) + mapWidth;
	[data setLength:amount*sizeof(int)];
 
	int *pixels = [data mutableBytes];
 
	hasData = YES;
 
	NSArray *colData;
	int i = 0;
	for(NSString *row in rows){
		colData = [row componentsSeparatedByString:@","];
		for(NSString *val in colData){
			pixels[i] = [val intValue];
			i++;
		}
	}
	hasData = YES;
	if([self mapReady]){
		[self startUpdates];
		flagForRefresh = YES;
	}
}
 
- (void)createTilesFromImage:(UIImage*)img{
   int amount = floor(img.size.width/tileWidth);
   if(tiles.count > 0){
	   [tiles removeAllObjects];
   }
   SPTexture *tilesTexture = [[SPTexture alloc] initWithContentsOfImage:img];
   for(int i=0; i<amount; i++){
	   SPRectangle *rect = [[SPRectangle alloc] initWithX:i*(tileWidth) y:0 width:tileWidth height:tileHeight];
	   SPSubTexture *tileTexture = [[SPSubTexture alloc] initWithRegion:rect ofTexture:tilesTexture];
	   SPImage *tileImage = [[SPImage alloc] initWithTexture:tileTexture];
	   [tiles addObject:tileImage];
	   [rect release];
	   [tileTexture release];
	   [tileImage release];
   }
   [tilesTexture release];
   hasTileImage = YES;
	if([self mapReady]){
		[self startUpdates];
		flagForRefresh = YES;
	}
}
 
- (BOOL)mapReady{
	return hasData && hasTileImage;
}
 
- (void)startUpdates{
	if(!updating){
		[self addEventListener:@selector(update:) atObject:self forType:SP_EVENT_TYPE_ENTER_FRAME];
		updating = YES;
	}
}
 
- (void)stopUpdates{
	if(updating){
		[self removeEventListener:@selector(update:) atObject:self forType:SP_EVENT_TYPE_ENTER_FRAME];
		updating = NO;
	}
}
 
- (void)updatePosition{
	// override for scrolling
}
 
- (void)updateData{
	// override for when overalldata has been changed
}
 
- (void)update:(SPEnterFrameEvent*)event{
	if(oldCam.x != camera.x || oldCam.y != camera.y){
		if(!flagForRefresh){
			[self updatePosition];
			oldCam.x = camera.x;
			oldCam.y = camera.y;
		}
	}
	if(flagForRefresh){
		[self updateData];
		flagForRefresh = NO;
	}
}
 
- (void)changeTileId:(int)tileId forX:(int)xPos andY:(int)yPos{
	int *pixels = [data mutableBytes];
	int tileIndex = (yPos * mapWidth) + xPos;
	pixels[tileIndex] = tileId;
}
 
- (void)dealloc{
	self.camera = nil;
	self.tiles = nil;
	self.data = nil;
	[oldCam release];
	[self stopUpdates];
    [super dealloc];
}
 
@end

This will leave you with a generic system for doing you own rendering code. It is based on using an image strip that contains the tiles in a row. So a map that contains 2 16×16 tiles would have an image with the dimensions 32×16.

The map data is then parse off a csv file and flattened out into a standard c buffer for speed.

1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,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,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,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,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,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,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,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,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,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,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,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,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,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,1
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1

You access the index of the tile you want like so:

int *pixels = [data mutableBytes]; // data is an objective-c wrapper
int tileIndex = (yPos * mapWidth) + xPos; // here is the position in the flat array
pixels[tileIndex] = tileId;

We keep a camera variable so that we are still able to position the tile map as a normal sparrow sprite on the stage. To scroll the tile map just update camera.x and camera.y.

We also leave empty methods for updatePosition and updateData. updateData will let us know all the data may have changed so we need to do a full rerender of the map. updatePosition lets us know the camera has changed.

Now that we have a nice generic setup we will build another class to actually do the rendering. Using compiled sprites in this case.

Header:

#import <Foundation/Foundation.h>
#import "Sparrow.h"
#import "SPCompiledSprite.h"
#import "SXTilemap.h"
 
#define SX_COMPILED_TILEMAP_RENDER_COMPLETE_EVENT @"renderComplete" 
 
@interface SXCompiledTileMap : SXTilemap {
	int quadrantsX;
	int quadrantsY;
	int quadrantWidth;
	int quadrantHeight;
	NSMutableDictionary *quadrants;
	NSMutableArray *imagePool;
 
	BOOL hasCompiled;
 
	int indexX;
	int indexY;
}
 
@property(nonatomic, readonly) BOOL hasCompiled;
 
- (void)drawQuadrantAtX:(int)xPos andY:(int)yPos;
- (void)showRelavantQuadrants;
- (void)showQuad:(SPCompiledSprite*)quad atX:(int)xPos andY:(int)yPos;
- (void)prerenderTick:(SPEnterFrameEvent*)event;
 
@end

And the implementation:

#import "SXCompiledTileMap.h"
 
@implementation SXCompiledTileMap
 
@synthesize hasCompiled;
 
- (id)initWithViewportSize:(CGSize)vpSize CSVData:(NSString *)csvData tileSize:(CGSize)tileSize andTileImage:(UIImage *)tileImage{
	self = [super initWithViewportSize:vpSize CSVData:csvData tileSize:tileSize andTileImage:tileImage];
	hasCompiled = NO;
	quadrants = [[NSMutableDictionary alloc] initWithCapacity:30];
	return self;
}
 
- (void)updateData{
	[super updateData];
 
	hasCompiled = NO;
 
	quadrantsX = ceil(mapWidth/floor(viewportWidth/tileWidth));
	quadrantsY = ceil(mapHeight/floor(viewportHeight/tileHeight));
	quadrantWidth = floor(viewportWidth/tileWidth);
	quadrantHeight = floor(viewportHeight/tileHeight);
 
	if(!imagePool){
		imagePool = [[NSMutableArray alloc] initWithCapacity:quadrantWidth * quadrantHeight];
	}
 
	int yy, xx;
 
	indexX = 0;
	indexY = 0;
 
	SPImage *img;
	for(yy=0; yy<quadrantHeight; yy++){
		for(xx=0; xx<quadrantWidth; xx++){
			img = [[SPImage alloc] initWithWidth:tileWidth height:tileHeight];
			[imagePool addObject:img];
		}
	}
	[self addEventListener:@selector(prerenderTick:) atObject:self forType:SP_EVENT_TYPE_ENTER_FRAME];
}
 
- (void)prerenderTick:(SPEnterFrameEvent*)event{
	[self drawQuadrantAtX:indexX andY:indexY];
	if(indexX < quadrantsX){
		indexX += 1;
	}else{
		indexX = 0;
		indexY += 1;
	}
	if(indexX >= quadrantsX && indexY >= quadrantsY){
		hasCompiled = YES;
		[self dispatchEvent:[SPEvent eventWithType:SX_COMPILED_TILEMAP_RENDER_COMPLETE_EVENT]];
		[self removeEventListener:@selector(prerenderTick:) atObject:self forType:SP_EVENT_TYPE_ENTER_FRAME];
		[self showRelavantQuadrants];
	}
}
 
- (void)updatePosition{
	[super updatePosition];
	if(!hasCompiled) return;
	[self showRelavantQuadrants];
}
 
- (void)showRelavantQuadrants{
	int quadX = floor(camera.x/viewportWidth);
	int quadY = floor(camera.y/viewportHeight);
 
	NSString *quadId0 = [NSString stringWithFormat:@"%d_%d", quadX, quadY];
	NSString *quadId1 = [NSString stringWithFormat:@"%d_%d", quadX + 1, quadY];
	NSString *quadId2 = [NSString stringWithFormat:@"%d_%d", quadX + 1, quadY + 1];
	NSString *quadId3 = [NSString stringWithFormat:@"%d_%d", quadX, quadY + 1];
 
	SPCompiledSprite *spr;
 
 
	float xPos;
	float yPos;
 
	while([self numChildren] > 0){
		[self removeChildAtIndex:0];
	}
 
	spr = [quadrants objectForKey:quadId0];
	xPos = ((quadX * quadrantWidth) * tileWidth) - camera.x;
	yPos = ((quadY * quadrantHeight) * tileHeight) - camera.y;
	[self showQuad:spr atX:xPos andY:yPos];
 
	spr = [quadrants objectForKey:quadId1];
	xPos += (quadrantWidth * tileWidth);
	[self showQuad:spr atX:xPos andY:yPos];
 
	spr = [quadrants objectForKey:quadId2];
	yPos += (quadrantHeight * tileHeight);
	[self showQuad:spr atX:xPos andY:yPos];
 
	spr = [quadrants objectForKey:quadId3];
	xPos -= (quadrantWidth * tileWidth);
	[self showQuad:spr atX:xPos andY:yPos];
 
}
 
- (void)showQuad:(SPCompiledSprite*)quad atX:(int)xPos andY:(int)yPos{
	if(quad){
		quad.x = xPos;
		quad.y = yPos;
		[self addChild:quad];
	}
}
 
- (void)drawQuadrantAtX:(int)xPos andY:(int)yPos{
	int startX, startY, endX, endY;
 
	startX = xPos * quadrantWidth;
	startY = yPos * quadrantHeight;
	endX = startX + quadrantWidth;
	endY = startY + quadrantHeight;
 
	if(startX > mapWidth) return;
	if(startY > mapHeight) return;
	if(endX > mapWidth) endX = mapWidth;
	if(endY > mapHeight) endY = mapHeight;
 
	SPCompiledSprite *quad = [[[SPCompiledSprite alloc] init] autorelease];
	NSString *quadId = [NSString stringWithFormat:@"%d_%d", xPos, yPos];
	[quadrants setObject:quad forKey:quadId];
 
	int tileIndex;
	int *pixels = [data mutableBytes];
 
	float xOffset = 0;
	float yOffset = 0;
 
	int i = 0;
 
	for(int yy=startY; yy<endY; yy++){
		for(int xx=startX; xx<endX; xx++){
			tileIndex = (yy * mapWidth) + xx;
			SPImage *img = [imagePool objectAtIndex:i];
			img.texture = [[tiles objectAtIndex:pixels[tileIndex]] texture];
			img.x = xOffset;
			img.y = yOffset;
			[quad addChild:img];
			xOffset += tileWidth;
			i ++;
		}
		xOffset = 0;
		yOffset += tileHeight;
	}
	[quad compile];
 
	while([quad numChildren] > 0){
		[quad removeChildAtIndex:0];
	}
}
 
- (void)dealloc{
	if(!hasCompiled && [self hasEventListenerForType:SX_COMPILED_TILEMAP_RENDER_COMPLETE_EVENT]){
		[self removeEventListener:@selector(prerenderTick:) atObject:self forType:SP_EVENT_TYPE_ENTER_FRAME];
	}
	[quadrants removeAllObjects];
	[quadrants release];
	while([self numChildren] > 0) {
		[self removeChildAtIndex:0];
	}
	[super dealloc];
}
 
@end

What this class does is break the map up into distinct quadrants, then converts all those quadrants to compiled sprites. When you scroll through the map it will show only the quadrants that need to be displayed. Because quadrant compilation is very slow it also splits the pre-rendering into an enter frame tick, so that you can add a loading bar or what not. When rendering is complete the class will dispatch a SPEvent: SX_COMPILED_TILEMAP_RENDER_COMPLETE_EVENT.

Hopefully this helps people at least get something into their game to try out. I have put sample files here.

A note on pre-compilation: Pre-compilation could be greatly sped up. Right now SPCompiledSprite does a lot of malloc/dealloc, and a lot of checking. If the Vertex Buffers were optimized for internal use of the class then preprocessing would be much much faster. This is something I mean to get to at some point when I have a little more time. Thought is that verticies and indices could be a shared buffer between all quadrants, then the textures would be the only thing that changed between them. If they were held onto as persistant instance variables then presentation to opengl would be a lot faster, especially if you needed to switch out a tile in the middle of your game.

  tutorials/tilemaps_with_compiled_sprites.txt · Last modified: 2013/05/30 11:40 by daniel
 
Powered by DokuWiki