2011年12月18日星期日

How To Make A Multi-directional Scrolling Shooter Part 2

How To Make A Multi-directional Scrolling Shooter Part 2:
Learn how to make a multi-scrolling tank shooting game!
Learn how to make a multi-scrolling tank shooting game!

This is a multi-part tutorial series where we show you how to make a cool multi-directional tank battle game for the iPhone.

In the first part of the series, we created a new Cocos2D 2.0 project with ARC support, added our tile map into the game, and added a tank we could move around with the accelerometer.

In this second and final part of the tutorial, we will make our tank shoot bullets, add enemy tanks, add some win/lose logic, and add some polish and finishing touches.

This project begins where we left off last time, so grab the project where we left it off last time if you don’t have it already.

So, shall we make a game?



Running and Gunning


Right now our tank can move, but he can’t shoot! Since shooting is to tanks as buying shoes is to my lovely wife, we better fix this ASAP ;]

We already made it so that the tank moves with the accelerometer, so we’re all free to make the tank shoot wherever the user taps. However, to make the game more fun, instead of just shooting once when the user taps, we’ll make the tank shoot continously!

So let’s modify our Tank class to add some methods for shooting. Make the following changes to Tank.h:


// Add inside @interface
CGPoint _shootVector;
double _timeSinceLastShot;
CCSprite * _turret;

// Add after @interface
@property (assign) BOOL shooting;
- (void)shootToward:(CGPoint)targetPosition;
- (void)shootNow;


Here we add an instance variable to keep track of the direction we’re shooting in, and how long it’s been since the last shot. We also keep track of a new sprite we’ll be adding on top of the tank – the tank’s turret!

Next open Tank.m and make the following changes:


// Add to top of file
#import "SimpleAudioEngine.h"

// Add right after @implementation
@synthesize shooting = _shooting;

// Add inside initWithLayer:type:hp
NSString *turretName = [NSString stringWithFormat:@"tank%d_turret.png", type];
_turret = [CCSprite spriteWithSpriteFrameName:turretName];
_turret.anchorPoint = ccp(0.5, 0.25);
_turret.position = ccp(self.contentSize.width/2, self.contentSize.height/2);
[self addChild:_turret];


Firt we synthesize our variables. Next we create a new sprite for the tank’s turret, and add it as a child of the tank. This is so that when we move the tank sprite, the turret moves along with it.

Note how we position the turret:

  • We modify the anchor point to be pretty close to the base of the turret. Why? Because whatever the anchor point is set to is the point where rotation is around, and we want the turret to rotate around its base.
  • We then set the position of the sprite to be the center of the tank. Since this sprite is a child of the tank, it’s position is relative to the bottom left corner of the tank. So we’re “pinning” the anchor point (the base of the turret) to around the middle of the tank.

Next add a new method to the bottom of the file:


- (void)shootToward:(CGPoint)targetPosition {

CGPoint offset = ccpSub(targetPosition, self.position);
float MIN_OFFSET = 10;
if (ccpLength(offset) < MIN_OFFSET) return;

_shootVector = ccpNormalize(offset);

}


We’ll call this method when the user taps a spot. We check to make sure the spot tapped is at least 10 points from the current position first (if it’s too close, it’s hard to tell where the user really wants to shoot). Then we normalize the vector (remember, this makes a vector of length one) so we have a vector in the direction we want to shoot in, and store that for future reference.

Add the method to actually shoot next:


- (void)shootNow {
// 1
CGFloat angle = ccpToAngle(_shootVector);
_turret.rotation = (-1 * CC_RADIANS_TO_DEGREES(angle)) + 90;

// 2
float mapMax = MAX([_layer tileMapWidth], [_layer tileMapHeight]);
CGPoint actualVector = ccpMult(_shootVector, mapMax); 

// 3
float POINTS_PER_SECOND = 300;
float duration = mapMax / POINTS_PER_SECOND;

// 4
NSString * shootSound = [NSString stringWithFormat:@"tank%dShoot.wav", _type];
[[SimpleAudioEngine sharedEngine] playEffect:shootSound];

// 5
NSString *bulletName = [NSString stringWithFormat:@"tank%d_bullet.png", _type];
CCSprite * bullet = [CCSprite spriteWithSpriteFrameName:bulletName];
bullet.tag = _type;
bullet.position = ccpAdd(self.position, ccpMult(_shootVector, _turret.contentSize.height));       
CCMoveBy * move = [CCMoveBy actionWithDuration:duration position:actualVector];
CCCallBlockN * call = [CCCallBlockN actionWithBlock:^(CCNode *node) {
[node removeFromParentAndCleanup:YES];
}];
[bullet runAction:[CCSequence actions:move, call, nil]];
[_layer.batchNode addChild:bullet];
}


There’s a bunch of code here so let’s go over it bit by bit.

  1. First we rotate the turret to face in the direction we want to shoot. There’s a handy function called ccpToAngle we can use that takes a vector and returns the angle of the vector in radians. We then convert this to degrees (which Cocos2D uses), multiply it by -1 because Cocos2D uses clockwise rotation. We also add on 90, which we need to do because our turret artwork is facing upwards (instead of to the right).
  2. Next we figure out how far we want to shoot the bullet. We basically want to shoot it a long way, so we get the max of the tile map’s width or height and multiply it by the direction we want to shoot.
  3. Next we figure out how long it should take the bullet to get to that spot. This is simple – we divide the length of the vector (the max of the tile map’s width or height) and divide it by the points per second we want the bullet to move.
  4. Gratuitous shooting sound effect! :]
  5. Finally we create a new bullet sprite, run an action on it to make it move (and disappear when it’s done moving) and add it to the layer’s batch node.

Next make these changes to Tank.m:


// Add new methods
- (BOOL)shouldShoot {

if (!self.shooting) return NO;   

double SECS_BETWEEN_SHOTS = 0.25;
if (_timeSinceLastShot > SECS_BETWEEN_SHOTS) {       
_timeSinceLastShot = 0;
return YES;       
} else {
return NO;
}
}

- (void)updateShoot:(ccTime)dt {

_timeSinceLastShot += dt;
if ([self shouldShoot]) {      
[self shootNow];       
}

}

// Add inside update
[self updateShoot:dt];


This is the code we need to make the tanks shoot continuosly. Every update loop we’ll call updateShoot. This just checks if it’s been at least 0.25 seconds since the last shot, and calls shootNow if so.

OK, finally done with Tank.m! Let’s try this out. Open up HelloWorldLayer.m, and replace ccTouchesBegan and ccTouchesMoved with the following:


- (void)ccTouchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {

UITouch * touch = [touches anyObject];
CGPoint mapLocation = [_tileMap convertTouchToNodeSpace:touch];

self.tank.shooting = YES;
[self.tank shootToward:mapLocation];  

}

- (void)ccTouchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {

UITouch * touch = [touches anyObject];
CGPoint mapLocation = [_tileMap convertTouchToNodeSpace:touch];

self.tank.shooting = YES;
[self.tank shootToward:mapLocation];  

}


So basically, we’re using taps to shoot now instead of move.

Build and run, and now you can tap the screen to unleash a hail of bullets! Mwuahaha, the power!

Tank shooting bullets in all directions

Note that the way we’re shooting bullets here really isn’t the best because we’re continously allocating bullets, and allocations are expensive on iOS. A better way is to preallocate an array of bullets, and re-use old bullets from the array when you want to shoot one. We cover how to do this in the Space Game Starter Kit.

Adding Some Enemies


We can’t have a tank game without enemies to shoot at, now can we?

So open up HelloWorldLayer.h and create an array for us to keep track of the enemy tanks in:


NSMutableArray * _enemyTanks;


Then open up HelloWorldLayer.m and add this code to the bottom of init to create a bunch of tanks:


_enemyTanks = [NSMutableArray array];
int NUM_ENEMY_TANKS = 50;
for (int i = 0; i < NUM_ENEMY_TANKS; ++i) {

Tank * enemy = [[Tank alloc] initWithLayer:self type:2 hp:2];
CGPoint randSpot;
BOOL inWall = YES;

while (inWall) {           
randSpot.x = CCRANDOM_0_1() * [self tileMapWidth];
randSpot.y = CCRANDOM_0_1() * [self tileMapHeight];
inWall = [self isWallAtPosition:randSpot];               
}

enemy.position = randSpot;
[_batchNode addChild:enemy];
[_enemyTanks addObject:enemy];

}


This code should be pretty self explanitory. We create a bunch of tanks at random spots (as long as they aren’t in walls).

Build and run, and you should see tanks all around the map! Since we were careful to allow our tank class to choose the artwork based on the type, they’re differently colored too!

Enemy Tanks on the map

Shooting Enemies


Of course, they’re just sitting around not doing anything, which is no fun! Let’s add some basic logic to these tanks by subclassing the Tank class and overriding some of the methods. We’ll call the class RandomTank, because our tank is going to move around more or less randomly.

So create a new file with the iOS\Cocoa Touch\Objective-C class template. Name the class RandomTank and make it a subclass of Tank. Open up RandomTank.h and replace it with the following:


#import "Tank.h"

@interface RandomTank : Tank {
double _timeForNextShot;
}

@end


This adds a instance variable we keep track of how many seconds till we shoot next.

Move to RandomTank.m and replace it with the following:


#import "RandomTank.h"
#import "HelloWorldLayer.h"

@implementation RandomTank

- (id)initWithLayer:(HelloWorldLayer *)layer type:(int)type hp:(int)hp {

if ((self = [super initWithLayer:layer type:type hp:hp])) {
[self schedule:@selector(move:) interval:0.5];
}
return self;

}

- (BOOL)shouldShoot {

if (ccpDistance(self.position, _layer.tank.position) > 600) return NO;

if (_timeSinceLastShot > _timeForNextShot) {       
_timeSinceLastShot = 0;
_timeForNextShot = (CCRANDOM_0_1() * 3) + 1;
[self shootToward:_layer.tank.position];
return YES;
} else {
return NO;
}
}

- (void)calcNextMove {

// TODO  

}

- (void)move:(ccTime)dt {

if (self.moving && arc4random() % 3 != 0) return;   
[self calcNextMove];

}

@end


Here we schedule a move method to be called every half a second. Jumping down to the implementation, every time it calls it has a 1 in 3 chane to move the tank to another direction. We’re gonna skip over implementing that right now and focus on the shooting though.

As far as the shooting goes, we first check to make sure the enemy tank is close enough to the hero tank. We don’t want enemies far away shooting at our tank in this game, or it would be too hard.

We then figure out a random time for the next shot – somewhere between 1-4 seconds. If it’s reached that time, we update the target to wherever the tank is and go ahead and shoot.

Let’s try this out! Make the following chages in HelloWorldLayer.m:


// Add to top of file
#import "RandomTank.h"

// Modify the line in init to create a RandomTank instead of a normal tank
RandomTank * enemy = [[RandomTank alloc] initWithLayer:self type:2 hp:2];


That’s it! Compile and run, and now the tanks will shoot at you when you draw near!

Shooting enemies

Moving Enemies


So far our enemies are shooting, but we haven’t finished making them move.

To keep things simple for this game, the strategy we want to take is:

  1. Pick a nearby random spot.
  2. Make sure there’s a clear path to that spot. If so, move toward it!
  3. Otherwise, return to step 1.

The only tricky thing is “making sure there’s a clear path to that spot.” Given a start and end tile coordinate, how can we walk through all of the tiles that the tank would move between and make sure they’re all clear?

Luckily, this is a solved problem, and James McNeill has an excellent blog post on the matter. We’ll just take his implementation and plug it in.

So go back to RandomTank.m and replace the calcNextMove method with the following:


// From http://playtechs.blogspot.com/2007/03/raytracing-on-grid.html
- (BOOL)clearPathFromTileCoord:(CGPoint)start toTileCoord:(CGPoint)end
{
int dx = abs(end.x - start.x);
int dy = abs(end.y - start.y);
int x = start.x;
int y = start.y;
int n = 1 + dx + dy;
int x_inc = (end.x > start.x) ? 1 : -1;
int y_inc = (end.y > start.y) ? 1 : -1;
int error = dx - dy;
dx *= 2;
dy *= 2;

for (; n > 0; --n)
{
if ([_layer isWallAtTileCoord:ccp(x, y)]) return FALSE;

if (error > 0)
{
x += x_inc;
error -= dy;
}
else
{
y += y_inc;
error += dx;
}
}

return TRUE;
}

- (void)calcNextMove {

BOOL moveOK = NO;
CGPoint start = [_layer tileCoordForPosition:self.position];
CGPoint end;

while (!moveOK) {

end = start;
end.x += CCRANDOM_MINUS1_1() * ((arc4random() % 10) + 3);
end.y += CCRANDOM_MINUS1_1() * ((arc4random() % 10) + 3);

moveOK = [self clearPathFromTileCoord:start toTileCoord:end];   
}   

CGPoint moveToward = [_layer positionForTileCoord:end];

self.moving = YES;
[self moveToward:moveToward];   

}


Don’t worry about how the first method works (although you can read the blog post if you’re curious) – just know it checks to see if there is a wall at any coordinate inbetween the start and end tile coordinates, and returns FALSE if so.

In calcNextMove, we follow our algorithm as described above. Pretty straigtforward eh?

That’s it – build and run, and now you have moving enemies!

Collisions, Explosions, and Exits


Now that we have enemies to shoot at and bullets to dodge, I know what you guys want… tons of explosions, and a way to win the game!

Glad to oblige. Make the following changes to HelloWorldLayer.h:


// Add before @interface
typedef enum {
kEndReasonWin,
kEndReasonLose
} EndReason;

// Add inside @interface
CCParticleSystemQuad * _explosion;
CCParticleSystemQuad * _explosion2;
BOOL _gameOver;
CCSprite * _exit;


Next switch to HelloWorldLayer.m and add this to the bottom of init:


_explosion = [CCParticleSystemQuad particleWithFile:@"explosion.plist"];
[_explosion stopSystem];
[_tileMap addChild:_explosion z:1];

_explosion2 = [CCParticleSystemQuad particleWithFile:@"explosion2.plist"];
[_explosion2 stopSystem];
[_tileMap addChild:_explosion2 z:1];

_exit = [CCSprite spriteWithSpriteFrameName:@"exit.png"];
CGPoint exitTileCoord = ccp(98, 98);
CGPoint exitTilePos = [self positionForTileCoord:exitTileCoord];
_exit.position = exitTilePos;
[_batchNode addChild:_exit];

self.scale = 0.5;


Here we create the two types of explosions we’ll be using and add them to the tile map, but make sure they’re turned off. When we want to use them, we’ll move them to where they should run and start them with resetSystem.

We also add an exit to the bottom right corner of the map. Once the tank reaches this spot, you win!

Finally note we set the scale of the layer to 0.5 because this game works a lot better when you can see more of the map at a time.

Next add these new methods right before update:


- (void)restartTapped:(id)sender {
[[CCDirector sharedDirector] replaceScene:[CCTransitionZoomFlipX transitionWithDuration:0.5 scene:[HelloWorldLayer scene]]];  
}

- (void)endScene:(EndReason)endReason {

if (_gameOver) return;
_gameOver = true;

CGSize winSize = [CCDirector sharedDirector].winSize;

NSString *message;
if (endReason == kEndReasonWin) {
message = @"You win!";
} else if (endReason == kEndReasonLose) {
message = @"You lose!";
}

CCLabelBMFont *label;
if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
label = [CCLabelBMFont labelWithString:message fntFile:@"TanksFont.fnt"];
} else {
label = [CCLabelBMFont labelWithString:message fntFile:@"TanksFont.fnt"];
}
label.scale = 0.1;
label.position = ccp(winSize.width/2, winSize.height * 0.7);
[self addChild:label];

CCLabelBMFont *restartLabel;
if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
restartLabel = [CCLabelBMFont labelWithString:@"Restart" fntFile:@"TanksFont.fnt"];   
} else {
restartLabel = [CCLabelBMFont labelWithString:@"Restart" fntFile:@"TanksFont.fnt"];   
}

CCMenuItemLabel *restartItem = [CCMenuItemLabel itemWithLabel:restartLabel target:self selector:@selector(restartTapped:)];
restartItem.scale = 0.1;
restartItem.position = ccp(winSize.width/2, winSize.height * 0.3);

CCMenu *menu = [CCMenu menuWithItems:restartItem, nil];
menu.position = CGPointZero;
[self addChild:menu];

[restartItem runAction:[CCScaleTo actionWithDuration:0.5 scale:4.0]];
[label runAction:[CCScaleTo actionWithDuration:0.5 scale:4.0]];

}


These are my handy “game over” methods that I use very often when prototyping games. The most dedicated tutorial readers among you may remember this method from several other tutorials ;] Anyway we will use this to restart the game, and I won’t cover it in detail here since it’s pretty simple stuff.

Then add this code to the beginning of update:


// 1
if (_gameOver) return;

// 2
if (CGRectIntersectsRect(_exit.boundingBox, _tank.boundingBox)) {
[self endScene:kEndReasonWin];
}

// 3
NSMutableArray * childrenToRemove = [NSMutableArray array];
// 4
for (CCSprite * sprite in self.batchNode.children) {
// 5
if (sprite.tag != 0) { // bullet     
// 6       
if ([self isWallAtPosition:sprite.position]) {
[childrenToRemove addObject:sprite];
continue;
}
// 7
if (sprite.tag == 1) { // hero bullet
for (int j = _enemyTanks.count - 1; j >= 0; j--) {
Tank *enemy = [_enemyTanks objectAtIndex:j];
if (CGRectIntersectsRect(sprite.boundingBox, enemy.boundingBox)) {

[childrenToRemove addObject:sprite];
enemy.hp--;
if (enemy.hp <= 0) {
[[SimpleAudioEngine sharedEngine] playEffect:@"explode3.wav"];
_explosion.position = enemy.position;
[_explosion resetSystem];
[_enemyTanks removeObject:enemy];
[childrenToRemove addObject:enemy];
} else {
[[SimpleAudioEngine sharedEngine] playEffect:@"explode2.wav"];
}
}
}
}
// 8
if (sprite.tag == 2) { // enemy bullet                
if (CGRectIntersectsRect(sprite.boundingBox, self.tank.boundingBox)) {                   
[childrenToRemove addObject:sprite];
self.tank.hp--;

if (self.tank.hp <= 0) {
[[SimpleAudioEngine sharedEngine] playEffect:@"explode2.wav"];                       
_explosion.position = self.tank.position;
[_explosion resetSystem];
[self endScene:kEndReasonLose];
} else {
_explosion2.position = self.tank.position;
[_explosion2 resetSystem];
[[SimpleAudioEngine sharedEngine] playEffect:@"explode1.wav"];                       
}
}
}
}
}
for (CCSprite * child in childrenToRemove) {
[child removeFromParentAndCleanup:YES];
}


This is our collision detection and game logic. Let’s walk through it bit by bit.

  1. We’re starting to keep track of whether the game is over, and there’s no need to do any of this if the game is over. This is set when we call the endScene method we just added.
  2. If the tank intersects the exit, the player wins!
  3. We’re about to start checking for collisions, and sometimes when things collide a sprite might be removed from the scene (for example, if a bullet intersects with a tank or a wall the bullet will be removed). However since we’re iterating through the list of the children, we can’t modify the list as we’re iterating it. So we simply add what we want to remove to an array, and remove when we’re all done.
  4. We set a tag on bullet sprites so we could identify them easy – equal to the type of the tank that fired it (1 or 2). We don’t have a tag on anything else, so if there’s a tag we know it’s a bullet.
  5. If there’s a wall where the bullet is, remove the bullet.
  6. If it’s a bullet the hero shot, check to see if it hit any enamy tanks. If it does, reduce the enemy’s HP (and destroy it if it’s dead). Also play some gratuitous explosion sound effects and possibly an explosion particle system!
  7. Similarly, if it’s an enemy bullet check to see if it’s hit the player and react appropriately. Game over if the player reaches 0 HP.

As the last step, put this at the beginning of accelerometer:didAccelerate, ccTouchesBegan, and ccTouchesMoved:


if (_gameOver) return;


Build and run, and see if you can beat the game – and no fair hacking the code to win! ;]

You win!

The Final Challenge


As a final challenge to this project, why not try adding a HUD layer to this project yourself? Here’s the specifications:

  • There should be a line that shows your current number of lives.
  • There should be a line that says “Exit:” with an arrow next to it. The arrow should point in the direction of the exit, relative ot the tank.

This should be a good review of working with multiple layers, passing data between layers, and working with some vector math functions. If you get stuck, you might want to review the How to Create a HUD Layer with Cocos2D tutorial… or you could always cheat by looking at the solution at the end of the article ;]

Happy hacking!

Where To Go From Here?


Here is an example project with all of the code from the above tutorial series (including the optional HUD challenge).

At this point, you have the basics of a pretty fun game! Feel free to add additional features, change out the artwork, and use it as a basis for an app of your own!

If you have any questions or comments on this tutorial, please join the forum discussion below!

How To Make A Multi-directional Scrolling Shooter Part 2 is a post from: Ray Wenderlich

没有评论:

发表评论