April 4, 2014
The Tile System
One common approach to making a platformer is to use tile maps. Old NES and SNES games used this method, and in many cases it is still in use today. Although this engine will not require every scene to be tile-based, the next system to implement is a tile system.
I’ve been programming tile-based games for a while now — I learned it in ActionScript of all things, from a tutorial series by TonyPa. In fact, when this project originally started, it was a non-ECS tile-based engine. As I read up on different approaches when I wanted to make my engine more flexible, I came across the ECS model and decided to adapt my engine (which was mostly rewriting it). Now that everything else is in place, I can finally re-implement the tile-based portion of the engine within the larger architecture of the entity component system.
One of the great things about tile-based engines is that a level can be loaded into memory, but not all rendered at once. It’s easy to determine what portion of the level needs to be rendered at any given time. Similarly, collisions are very efficient — because every tile is aligned to a grid, tile collisions have spatial partitioning built-in, so for each entity, only a few tiles need to be checked for collisions.
The approach is simple. First, we will load the tile map data. This will likely involve loading a file, such as an xml file generated by the Tiled map editor or some other format specific to your game, and parsing it into meaningful data, which will be tile layers that contain 2-dimensional arrays of sprite positions. Second, we will add those layers to the tile system. Third, in the tile system’s update method, we will determine which tiles need to be rendered. Finally, we will create a special kind of collider and modify the collision system to handle tile collisions as well.
Tile Layers
To implement the tile system, we will start by creating a TileLayer class. The layer will have two main properties: an NSMutableArray of all the tiles in the layer — where tiles are integers that represent sprite positions — and another NSMutableArray of the sprites allocated to that layer. Not every tile will be on the screen at once, so the size of the sprites array will be smaller than the size of the tiles array. The tile layer class should also have an isVisible flag — if this is set to NO, then the sprites array will be empty, which will be the case for the collision layer if the developer wants to use a separate tile layer to represent collisions.
Rendering
Sprites are components, but we don’t want each tile to be considered an entity. Instead, each tile layer will be an entity with a number of sprites attached to it. We’ll create a method to add renderable children to a renderable component.
- (void)addSubRender:(LGRender *)render
{
[view addSubview:[render view]];
}
When we generate sprites for the tile layer, we will add them to a parent renderable object attached to the layer entity. This way, when we go to scroll the tiles, we need only scroll the container.
Colliding
We will implement a new type of collider, the tile collider. This will subclass the existing collider component, and contain a pointer to a tile layer — the collision layer. The file parser will determine that a specific layer is a collision layer, create a new entity for it with a (0, 0) transform and a tile collider, and add the entity to the scene.
The parsing of file data shouldn’t be a part of the engine, but of the game using the engine. That way, developers have control over how to store and load data. Perhaps it should contain an implementation for parsing common file types, like the .tmx XML file as mentioned above, but for now the tile system will only have a method to add a pre-loaded LGTileLayer object.
To make the collision system works with this new type of collider, we’ll create a new method: resolveTileCollisionsBetween:(LGEntity *)a and:(LGTileCollider *)tileCollider. At the beginning of the regular resolution method, we’ll check for a tile collider (which must be static, so it will always be entity B). If it’s a tile collider, use the tile method to calculate resolution; otherwise, calculate resolution as normal.
if([colliderB isMemberOfClass:[LGTileCollider class]])
{
resolution = [self resolveTileCollisionsBetween:a and:(LGTileCollider *)colliderB alreadyAdjusted:alreadyAdjustedA chainingAxis:axis];
}
else
{
resolution = [self calculateSmallestOverlapBetween:a and:b];
}
// Resolve the collision
We’re going to need some additional information in order to calculate the resolution vector due to tile collisions. As you can see, another property has been added: alreadyAdjustedA. To find tile collisions, we’ll want to know the original position of an entity, before any collision chain started. In order to find this, the alreadyAdjustedA property is the addition of all the deltas applied to entity A in the chain. By adding this vector to entity A’s transform, we will have the original position.
The reason we need to know the entity’s original position is that for tile collisions, we want to decompose into two parts, by axis. We’ll check for x collisions first by “undoing” the y motion, checking collisions, then “redoing” the motion. Then we’ll check for y collisions by “undoing” the x motion, checking collisions, then “redoing” the motion. Unlike the entity-to-entity case, where finding the axis of least penetration is good enough, the entity-to-tile case needs to be able to see things one-dimensionally. If we tried to use the axis of least penetration, the player would end up getting stuck at the edges of tiles at best, or zoomed across the floor at worst.
Thankfully, calculating this additional property is simple. The collision system’s main update loop passes in a CGPointZero, then subsequent chained calls add the part of the resolution vector given to entity A.
CGPoint resolutionA = [self resolveCollisionsBetween:a and:c ignoring:b withAdditionalMass:massB forceStatic:NO alreadyAdjustedA:deltaA collisionAxis:collisionAxis];
if(CGPointEqualToPoint(resolutionA, CGPointZero))
{
CGPoint resolutionB = [self resolveCollisionsBetween:b and:c ignoring:a withAdditionalMass:massA forceStatic:NO alreadyAdjustedA:deltaB collisionAxis:collisionAxis];
if(!CGPointEqualToPoint(resolutionB, CGPointZero))
{
// Resolution caused another collision -- move entity A back
[transformA addToPosition:resolutionB];
deltaA = [self translate:deltaA by:resolutionB];
}
}
else
{
// Resolution caused another collision -- move entity B back
[transformB addToPosition:resolutionA];
deltaB = [self translate:deltaB by:resolutionA];
}
return deltaA;
By adjusting deltaA after adjusting the transform, we can keep track of the object’s original position. Finally, we can use that information to calculate the resolution vector for entity-to-tile collisions.
LGTransform *transform = [a componentOfType:[LGTransform class]];
LGCollider *collider = [a componentOfType:[LGCollider class]];
LGPhysics *physics = [a componentOfType:[LGPhysics class]];
CGPoint position = [self translate:[transform position] by:[collider offset]];
int tileX, tileY;
// Decompose the collision along each axis
if(axis != LGCollisionAxisY)
{
position.y -= [physics velocity].y + alreadyAdjusted.y;
for(int i = 0; i < 2; i++)
{
BOOL isRight = i == 0;
BOOL shouldBreak = NO;
// Check one column of tiles
tileX = (int) floor((position.x + (isRight ? [collider size].width : 0)) / [tileCollider tileSize].width);
// Check a range of rows
int fromY = (int) floor( (position.y + 1) / [tileCollider tileSize].height );
int toY = (int) floor( (position.y + [collider size].height - 1) / [tileCollider tileSize].height );
for(tileY = fromY; tileY <= toY; tileY++)
{
if( [[tileCollider collisionLayer] collidesAtRow:tileY andCol:tileX] )
{
if(isRight)
{
position.x = tileX * [tileCollider tileSize].width - [collider size].width;
}
else
{
position.x = (tileX + 1) * [tileCollider tileSize].width;
}
if(physics != nil)
{
[physics setVelocityX:0];
}
// Only need to find one collision, so stop here
shouldBreak = YES;
break;
}
}
if(shouldBreak)
{
break;
}
}
position.y += [physics velocity].y + alreadyAdjusted.y;
}
if(axis != LGCollisionAxisX)
{
// Same as above, for x-axis
}
position = [self untranslate:position by:[collider offset]];
return CGPointMake(position.x - [transform position].x, position.y - [transform position].y);
Basically, we're looping through each tile the entity touches and checking if it causes a collision -- and if it does, we're adjusting the position. At the end, we convert that to a resolution vector by subtracting out the original position.
Camera and Scrolling
To scroll the tile world, we will need a way to track where we want to scroll. To do this, we'll create a camera component and add it to some entity that can move. Eventually, we'll add it to the player so that the scrolling is centered around the player's movement. In general, though, this can be attached to any entity -- we could have levels with fixed cameras, or levels where the camera only moves one way, and so on.
The camera component will need a few properties. First, it will have a CGPoint offset of how far away the origin of the camera should be compared to the entity's transform. Second, it will have a CGSize that is the size of the visible area -- this will generally be the screen size. Finally, it will have a CGRect bounds, that is the allowable area in which the camera can move. These bounds prevent the camera from going outside the edge of the rendered world, for example.
The camera system is very simple, and requires only a few lines of code in the update method.
CGRect frame = [[self.scene rootView] frame]; double xmin = [camera size].width - [camera bounds].size.width - [camera bounds].origin.x; frame.origin.x = round(MIN( 0, MAX( xmin, - [camera offset].x - [cameraTransform position].x ) ) ); double ymin = [camera size].height - [camera bounds].size.height - [camera bounds].origin.y; frame.origin.y = round( MIN( 0, MAX( ymin, - [camera offset].y - [cameraTransform position].y ) ) ); [[self.scene rootView] setFrame:frame];
Scrolling is the slightly more tricky part. What we'll do is have the tile system accept the camera entity, then use the camera to determine what tiles need to be rendered at any given frame. What we'll do in the update method is check the top-left sprite to determine whether the bottom row or right-most column needs to be shifted to the top or left, then we'll check the bottom-right sprite to determine whether the top row or left-most column needs to be shifted to the bottom or right. We'll stick it in a while loop so that, if necessary, the tile system can shift multiple rows or columns. Here's what we'll do to see if the left-most column needs to move to the right, for example:
BOOL canShift = YES;
while([[leftMostSprite view] frame].origin.x + [sprite size].width < [camera offset].x + [cameraTransform position].x && canShift)
{
for(LGTileLayer *layer in layers)
{
canShift = [layer shiftRight];
}
leftMostSprite = [visibleLayer spriteAtRow:0 andCol:0];
}
The shiftRight method gets all sprites from the left-most column, removes them from their respective rows, then adds them back to their respective rows (but as the right-most sprite this time). It also updates their view frames accordingly and switches the sprite position to the new position based on the tiles array. The method returns whether the shift was possible -- it returns NO when attempting to scroll outside the boundaries of the tile map.
- (BOOL)shiftRight
{
int tileX = offsetX + [parent visibleX];
if(tileX >= [[tiles objectAtIndex:0] count])
{
return NO;
}
if(isVisible)
{
for(int i = 0; i < [parent visibleY]; i++)
{
int tileY = offsetY + i;
// Get the left-most sprite
LGSprite *s = [[sprites objectAtIndex:i] objectAtIndex:0];
// Move it from its old place
[[sprites objectAtIndex:i] removeObject:s];
// Move it to its new place
[[sprites objectAtIndex:i] addObject:s];
// Swap out its texture
[s setPosition:[[self tileAtRow:tileY andCol:tileX] intValue]];
// Adjust its frame
CGRect frame = [[s view] frame];
frame.origin.x = tileX * [[parent sprite] size].width;
frame.origin.y = tileY * [[parent sprite] size].height;
[[s view] setFrame:frame];
}
}
offsetX++;
return YES;
}
At this point, the pieces are in place to create a simple level using the framework and implemented parts of the engine.