A while back in the weekly tutorial vote, you guys said you wanted a tutorial on how to make a multi-directional scrolling shooter. Your wish is my command! :]
In this tutorial series, we’ll make a tile-based game where you drive a tank around using the accelerometer. Your goal is to get to the exit, without being blasted by enemy tanks!
To see what we’ll make, check out this video:
In this first part of the series, you’ll get some hands on experience with Cocos2D 2.X, porting it to use ARC, using Cocos2D vector math functions, working with tile maps, and much more!
This tutorial assumes you have some basic knowledge of Cocos2D. If you are new to Cocos2D, you may wish to check out some of the other Cocos2D tutorials on this site first. In particular, you should review the tile-based game tutorial before this tutorial.
Rev up your coding engines, and let’s begin!
Getting Started
We’re going to use Cocos2D 2.X in this project, so go ahead and download it if you don’t have it already.
Double click the tar to unarchive it, then install the templates with the following commands:
cd ~/Downloads/cocos2d-iphone-2.0-beta ./install-templates.sh -f -u
Next create a new project in Xcode with the iOS/cocos2d/cocos2d template, and name it Tanks.
We want to use ARC in this project to make memory management simpler, but by default the template isn’t set up to use ARC. So let’s fix that by performing the following 5 steps:
- Control-click the libs folder in your Xcode project and click Delete. Then click Delete again to delete the files permanently. This removes the Cocos2D files from our project – but that’s OK, because we will link in the project separately in a minute. We are doing this so we can set up our project to use ARC (but allow the Cocos2D code to be non-ARC).
- Find where you downloaded Cocos2D 2.0 to, and find the cocos2d-ios.xcodeproj inside. Drag that into your project.
- Click on your project, select the Tanks target, and go to the Build Phases tab. Expand hte Link Binary With Libraries section, click the + button, select libcocos2d.a and libCocosDenhion.a from the list, and click add.
- Click the Build Settings tab and scroll down to the Search Paths section. Set Always Search User Paths to YES, double click User Header Search Paths, and enter in the path to the directory where you’re storing Cocos2D 2.0. Make sure Recursive is checked.
- From the main menu go to Edit\Refactor\Convert to Objective-C ARC. Select all of the files from the dropdown and go through the wizard. It should find no problems, so just finish up the conversion.
And that’s it! Build and run and make sure everything still works OK – you should see the normal Hello World screen.
But you might notice that it’s in portrait mode. We want landscape mode for our game, so open RootViewController.m and make sure shouldAutorotateToInterfaceOrientation looks like the following:
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation { return ( UIInterfaceOrientationIsLandscape( interfaceOrientation ) ); } |
Build and run and now we have a landscape game with the latest and greatest version of Cocos2D 2.0, ARC compatibile. w00t!
Adding the Resources
First things first – download the resources for this project and drag the two folders inside (Art and Sounds) into your project. Make sure “Copy items into destination group’s folder” is checked, and “Create groups for any added folders” is selected, and click Finish.
Here’s what’s inside:
- Two particle effects I made with Particle Designer – two different types of explosions.
- Two sprite sheets I made with Texture Packer. One contains the background tiles, and one contains the foreground sprites.
- A font I made with Glyph Designer that we’ll use in the HUD and game over menu.
- Some background music I made with Garage Band.
- Some sound effects I made with cxfr.
- The tile map itself, which I made with Tiled.
The most important thing here is obviously the tile map. I recommend you download Tiled if you don’t have it already, and use it to open up tanks.tmx to take a look.
As you can see, it’s a pretty simple map with just three types of tiles – water, grass, and wood (for bridges). If you right click on the water tile and click Properties, you’ll see that it has a property for “Wall” defined, which we’ll be referring to in code later:
There’s just one layer (named “Background”), and we don’t add anything onto the map for the sprites like the tanks or the exit – we’ll add those in code.
Feel free to modify this map to your desire! For more info on using Tiled, see our earlier tile-based game tutorial.
Adding the Tile Map and Helpers
Next let’s add the tile map to our scene. As you know, this is ridiculously easy in Cocos2D.
Open HelloWorldLayer.h and add two instance variables into HelloWorldLayer:
CCTMXTiledMap * _tileMap; CCTMXLayer * _bgLayer; |
We’re keeping track of the tile map and the one and only layer inside (the background layer) in these variables, because we’ll need to refer to them often.
Then open HelloWorldLayer.m and replace the init method with the following:
-(id) init { if( (self=[super init])) { _tileMap = [CCTMXTiledMap tiledMapWithTMXFile:@"tanks.tmx"]; [self addChild:_tileMap]; _bgLayer = [_tileMap layerNamed:@"Background"]; } return self; } |
Here we just create the tile map, add it to the layer, and get a reference to the background layer in the tile map.
Build and run, and you’ll see the bottom left corner of the map:
In this game we want to start our tank in the upper left corner. To make this easy, let’s build up a series of helper methods. I use these helper methods in almost any tile-based game app I work on, so you might find these handy to use in your own projects as well.
First, we need some methods to get the height and width of the tile map in points. Add these in HelloWorldLayer.m, above init:
- (float)tileMapHeight { return _tileMap.mapSize.height * _tileMap.tileSize.height; } - (float)tileMapWidth { return _tileMap.mapSize.width * _tileMap.tileSize.width; } |
The mapSize property on a tile map returns the size in number of tiles (not points) so we have to multiply the result by the tileSize to get the size in points.
Next, we need some methods to check if a given position is within the tile map – and likewise for tile coordinate.
In case you forgot what a tile coordinate is, each tile in the map has a coordinate, starting with (0,0) for the upper left and (99,99) for the bottom right (in our case). Here’s a screenshot from the earlier tile-based game tutorial:
So add these methods that will verify positions/tile coordinates right after the tileMapWidth method:
- (BOOL)isValidPosition:(CGPoint)position { if (position.x < 0 || position.y < 0 || position.x > [self tileMapWidth] || position.y > [self tileMapHeight]) { return FALSE; } else { return TRUE; } } - (BOOL)isValidTileCoord:(CGPoint)tileCoord { if (tileCoord.x < 0 || tileCoord.y < 0 || tileCoord.x >= _tileMap.mapSize.width || tileCoord.y >= _tileMap.mapSize.height) { return FALSE; } else { return TRUE; } } |
These should be pretty self-explanitory. Obviously negative positions/coordinates would be outside of the map, and the upper bound is the width/height of the map, in points or tiles respectively.
Next, add methods to convert between positions and tile coordinates:
- (CGPoint)tileCoordForPosition:(CGPoint)position { if (![self isValidPosition:position]) return ccp(-1,-1); int x = position.x / _tileMap.tileSize.width; int y = ([self tileMapHeight] - position.y) / _tileMap.tileSize.height; return ccp(x, y); } - (CGPoint)positionForTileCoord:(CGPoint)tileCoord { int x = (tileCoord.x * _tileMap.tileSize.width) + _tileMap.tileSize.width/2; int y = [self tileMapHeight] - (tileCoord.y * _tileMap.tileSize.height) - _tileMap.tileSize.height/2; return ccp(x, y); } |
The first method converts from a position to a tile coordinate. Converting the x coordinate is easy – it just divides the number of points by the points per tile (discarding the fraction) to get the tile number it’s inside. The y coordinate is similar, except it first has to subtract the y value from the tile map height to “flip” the y value, because positions have 0 at the bottom, but tile coordinates have 0 at the top.
The second method does the oppostie – tile coordinate to position. This is pretty much the same idea, but notice that there are a lot of potential points inside a tile that this method could return. We choose to return the center of the tile here, because that works nicely with Cocos2D since you often want to place a sprite at the center of a tile.
Now that we have this handy library built up, we can now build a routine to allow scrolling the map to center something (namely our tank) within the view. Add this next:
-(void)setViewpointCenter:(CGPoint) position { CGSize winSize = [[CCDirector sharedDirector] winSize]; int x = MAX(position.x, winSize.width / 2 / self.scale); int y = MAX(position.y, winSize.height / 2 / self.scale); x = MIN(x, [self tileMapWidth] - winSize.width / 2 / self.scale); y = MIN(y, [self tileMapHeight] - winSize.height/ 2 / self.scale); CGPoint actualPosition = ccp(x, y); CGPoint centerOfView = ccp(winSize.width/2, winSize.height/2); CGPoint viewPoint = ccpSub(centerOfView, actualPosition); _tileMap.position = viewPoint; } |
The easiest way to explain this is through a picture:
To make a given point centered, we move the tile map itself. If we subtract our “goal” position from the center of the view, we’ll get the “error” and we can move the map that amount.
The only tricky part is there are certain points we shouldn’t be able to set in the center. If we try to center the map on a position less than half the window size, then empty “black” space would be visible to the user, which isn’t very nice. Same thing for if we try to center a position on the very top of the map. So these checks take care of that.
Now that we have the helper methods in place, let’s try it out! Add the following inside the init method:
CGPoint spawnTileCoord = ccp(4,4); CGPoint spawnPos = [self positionForTileCoord:spawnTileCoord]; [self setViewpointCenter:spawnPos]; |
Build and run, and now you’ll see the upper left of the map – where we’re about to spawn our tank!
Adding the Tank
Time to add our hero into the mix!
Create a new file with the iOS\Cocoa Touch\Objective-C class template, enter Tank for the class, and make it a subclass of CCSprite. Then open Tank.h and replace it with the following:
#import "cocos2d.h" @class HelloWorldLayer; @interface Tank : CCSprite { int _type; HelloWorldLayer * _layer; CGPoint _targetPosition; } @property (assign) BOOL moving; @property (assign) int hp; - (id)initWithLayer:(HelloWorldLayer *)layer type:(int)type hp:(int)hp; - (void)moveToward:(CGPoint)targetPosition; @end |
Let’s cover the instance variablers/properties inside this class:
- type: We have two types of tanks, so this is either 1 or 2. Based on this we can select the proper sprites.
- layer: We’ll need to call some methods in the layer later on from within the tank class, so we store a reference here.
- targetPosition: The tank always has a position it’s trying to move toward. We store that here.
- moving: Keeps track of whether the tank is currently trying to move or not.
- hp: Keeps track of the tank’s HP, which we’ll be using later.
Next open Tank.m and replace it with the following:
#import "Tank.h" #import "HelloWorldLayer.h" @implementation Tank @synthesize moving = _moving; @synthesize hp = _hp; - (id)initWithLayer:(HelloWorldLayer *)layer type:(int)type hp:(int)hp { NSString *spriteFrameName = [NSString stringWithFormat:@"tank%d_base.png", type]; if ((self = [super initWithSpriteFrameName:spriteFrameName])) { _layer = layer; _type = type; self.hp = hp; [self scheduleUpdateWithPriority:-1]; } return self; } - (void)moveToward:(CGPoint)targetPosition { _targetPosition = targetPosition; } - (void)updateMove:(ccTime)dt { // 1 if (!self.moving) return; // 2 CGPoint offset = ccpSub(_targetPosition, self.position); // 3 float MIN_OFFSET = 10; if (ccpLength(offset) < MIN_OFFSET) return; // 4 CGPoint targetVector = ccpNormalize(offset); // 5 float POINTS_PER_SECOND = 150; CGPoint targetPerSecond = ccpMult(targetVector, POINTS_PER_SECOND); // 6 CGPoint actualTarget = ccpAdd(self.position, ccpMult(targetPerSecond, dt)); // 7 CGPoint oldPosition = self.position; self.position = actualTarget; } - (void)update:(ccTime)dt { [self updateMove:dt]; } @end |
The initializer is pretty straightforward – it just squirrels away the variables passed in, and schedules an update method to be called. You might not have known that you can schedule an update method on any CCNode – but now you do! :] And note the priority is set to -1, because we want this update to run BEFORE the layer’s update (which is run at the default priority of 0).
moveToward just updates the target position – updateMove is where all the action is, and this is called once per frame. Let’s go over what this method does bit by bit:
- If moving is false, just bail. Moving will be false when the app first begins.
- Subtract the current position from the target position, to get a vector that points in the direction of where we’re going.
- Check the length of that line, and see if it’s less than 10 points. If it is, we’re “close enough” and we just return.
- Make the directional vector a unit vector (length of 1) by calling ccpNormalize. This makes it easy to make the line any length we want next.
- Multiply the vector by however fast we want the tank to travel in a second (150 here). The result is a vector in points/1 second the tank should travel.
- This method is being called several times a second, so we multiply this vector by the delta time (around 1/60 of a second) to figure out how much we should actually travel.
- Set the position of the tank to what we figured out. We also keep track of the old position in a local variable, which we’ll use soon.
Now let’s put our new tank class to use! Make the following changes to HelloWorldLayer.h:
// Before the @interface @class Tank; // After the @interface @property (strong) Tank * tank; @property (strong) CCSpriteBatchNode * batchNode; |
And the following changes to HelloWorldLayer.m:
// At the top of the file #import "Tank.h" // Right after the @implementation @synthesize batchNode = _batchNode; @synthesize tank = _tank; // Inside init _batchNode = [CCSpriteBatchNode batchNodeWithFile:@"sprites.png"]; [_tileMap addChild:_batchNode]; [[CCSpriteFrameCache sharedSpriteFrameCache] addSpriteFramesWithFile:@"sprites.plist"]; self.tank = [[Tank alloc] initWithLayer:self type:1 hp:5]; self.tank.position = spawnPos; [_batchNode addChild:self.tank]; self.isTouchEnabled = YES; [self scheduleUpdate]; // After init - (void)ccTouchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { UITouch * touch = [touches anyObject]; CGPoint mapLocation = [_tileMap convertTouchToNodeSpace:touch]; self.tank.moving = YES; [self.tank moveToward:mapLocation]; } - (void)ccTouchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { UITouch * touch = [touches anyObject]; CGPoint mapLocation = [_tileMap convertTouchToNodeSpace:touch]; self.tank.moving = YES; [self.tank moveToward:mapLocation]; } - (void)update:(ccTime)dt { [self setViewpointCenter:self.tank.position]; } |
Nothing too fancy here – we create a batch node for the sprites and add it as a child of the tile map (so that we can scroll the tile map and have the sprites in the batch node scroll along with it).
We then create a tank and add it to the batch node. We set up touch routines to call the moveToward method we wrote earlier, and on each update keep the view centered on the tank.
Build and run, and now you can tap the screen to scroll your tank all around the map, in any direction!
Checking for Walls
So far so good, except there’s one major problem – our tank can roll right across the water! This tank does not have the submersive upgrade yet, so we have to nerf him a bit :]
To do this we need to add a couple more helper methods to HelloWorldLayer.h. Add these methods right above init:
-(BOOL)isProp:(NSString*)prop atTileCoord:(CGPoint)tileCoord forLayer:(CCTMXLayer *)layer { if (![self isValidTileCoord:tileCoord]) return NO; int gid = [layer tileGIDAt:tileCoord]; NSDictionary * properties = [_tileMap propertiesForGID:gid]; if (properties == nil) return NO; return [properties objectForKey:prop] != nil; } -(BOOL)isProp:(NSString*)prop atPosition:(CGPoint)position forLayer:(CCTMXLayer *)layer { CGPoint tileCoord = [self tileCoordForPosition:position]; return [self isProp:prop atTileCoord:tileCoord forLayer:layer]; } - (BOOL)isWallAtTileCoord:(CGPoint)tileCoord { return [self isProp:@"Wall" atTileCoord:tileCoord forLayer:_bgLayer]; } - (BOOL)isWallAtPosition:(CGPoint)position { CGPoint tileCoord = [self tileCoordForPosition:position]; if (![self isValidPosition:tileCoord]) return TRUE; return [self isWallAtTileCoord:tileCoord]; } - (BOOL)isWallAtRect:(CGRect)rect { CGPoint lowerLeft = ccp(rect.origin.x, rect.origin.y); CGPoint upperLeft = ccp(rect.origin.x, rect.origin.y+rect.size.height); CGPoint lowerRight = ccp(rect.origin.x+rect.size.width, rect.origin.y); CGPoint upperRight = ccp(rect.origin.x+rect.size.width, rect.origin.y+rect.size.height); return ([self isWallAtPosition:lowerLeft] || [self isWallAtPosition:upperLeft] || [self isWallAtPosition:lowerRight] || [self isWallAtPosition:upperRight]); } |
These are just helper methods we’ll use to check if a given tile coordinate/position/rectangle has the “Wall” property. I’m not going to go over these because they are just review from our earlier tile-based game tutorial.
Open up HelloWorldLayer.h and predeclare all of these methods so we can access them from outside the class if we want:
- (float)tileMapHeight; - (float)tileMapWidth; - (BOOL)isValidPosition:(CGPoint)position; - (BOOL)isValidTileCoord:(CGPoint)tileCoord; - (CGPoint)tileCoordForPosition:(CGPoint)position; - (CGPoint)positionForTileCoord:(CGPoint)tileCoord; - (void)setViewpointCenter:(CGPoint) position; - (BOOL)isProp:(NSString*)prop atTileCoord:(CGPoint)tileCoord forLayer:(CCTMXLayer *)layer; - (BOOL)isProp:(NSString*)prop atPosition:(CGPoint)position forLayer:(CCTMXLayer *)layer; - (BOOL)isWallAtTileCoord:(CGPoint)tileCoord; - (BOOL)isWallAtPosition:(CGPoint)position; - (BOOL)isWallAtRect:(CGRect)rect; |
Then make the following changes to Tank.m:
// Add right before updateMove - (void)calcNextMove { } // Add at bottom of updateMove if ([_layer isWallAtRect:[self boundingBox]]) { self.position = oldPosition; [self calcNextMove]; } |
The new code in updateMove checks to see if we’ve moved into a position that is colliding with a wall. If it does, it moves back to the old position and calls calcNextMove. Right now this method does absolutely nothing, but later on we’ll override this in a subclass.
Build and run, and now you should no longer be able to sail across the sea!
Adding Accelerometer Support
For this game, we don’t actually want to move the tank by tapping, because we want to be able to shoot wherever the user taps.
So to move the tank, we’ll use the accelerometer for input. Add these new methods to HelloWorldLayer.m:
- (void)onEnterTransitionDidFinish { self.isAccelerometerEnabled = YES; } - (void)accelerometer:(UIAccelerometer *)accelerometer didAccelerate:(UIAcceleration *)acceleration { #define kFilteringFactor 0.75 static UIAccelerationValue rollingX = 0, rollingY = 0, rollingZ = 0; rollingX = (acceleration.x * kFilteringFactor) + (rollingX * (1.0 - kFilteringFactor)); rollingY = (acceleration.y * kFilteringFactor) + (rollingY * (1.0 - kFilteringFactor)); rollingZ = (acceleration.z * kFilteringFactor) + (rollingZ * (1.0 - kFilteringFactor)); float accelX = rollingX; float accelY = rollingY; float accelZ = rollingZ; CGPoint moveTo = _tank.position; if (accelX > 0.5) { moveTo.y -= 300; } else if (accelX < 0.4) { moveTo.y += 300; } if (accelY < -0.1) { moveTo.x -= 300; } else if (accelY > 0.1) { moveTo.x += 300; } _tank.moving = YES; [_tank moveToward:moveTo]; //NSLog(@"accelX: %f, accelY: %f", accelX, accelY); } |
We set isAccelerometerEnabled in onEnterTransitionDidFinish (I had trouble getting it to work if I put it in init because the scene wasn’t “running” at that point).
The first part of this method comes directly from Apple sample code, to filter the accelerometer values so it’s not so “jiggly”. Don’t worry if you don’t understand this, all you really need to know is that it makes things more smooth. If you’re insatiably curious, this is called a high-pass filter, and you can read about it on Wikipedia’s high pass filter entry.
We check the acceleration in the x and y axis, and set the move target for the tank based on that.
That’s it – build and run (make sure your home button is to your left), and now you should be able to move your tank around the map with the accelerometer!
Gratuituous Music
I can’t leave ya guys hanging without some gratuituous music! :] Just make the following mods to HelloWorldLayer.m:
// Add to top of file #import "SimpleAudioEngine.h" // Add to end of init [[SimpleAudioEngine sharedEngine] playBackgroundMusic:@"bgMusic.caf"]; [[SimpleAudioEngine sharedEngine] preloadEffect:@"explode.wav"]; [[SimpleAudioEngine sharedEngine] preloadEffect:@"tank1Shoot.wav"]; [[SimpleAudioEngine sharedEngine] preloadEffect:@"tank2Shoot.wav"]; |
Build and run, and enjoy some groovy tunes as you explore! :]
Where To Go From Here?
Here is an example project with all of the code form the tutorial series so far.
Stay tuned for the rest of the series, where we’ll start adding shooting, enemies, and action!
In the meantime, if you have any questions or comments on the tutorial so far, please join the forum discussion below!
How To Make A Multi-Directional Scrolling Shooter Part 1 is a post from: Ray Wenderlich
没有评论:
发表评论