This is a blog post by iOS Tutorial Team member Gustavo Ambrozio, a software engineer with over 20 years experience, over 3 years of iOS experience and founder of CodeCrop Software.
This is the second part of a two part tutorial series where we’ll build a cool catapult type game from scratch using Cocos2D and Box2D!
In the first part of the series, we added the catapult into the scene, with the ability to shoot dangerous acorns.
In this second and final part of the series, we’re going to flesh this out into a complete game, and add targets to shoot at and game logic.
If you don’t have it already, grab the sample project where we left it off in the last tutorial.
Without further ado, let’s get shooting!
Creating Targets
The targets creation won’t be anything very complicated as it’s mostly what you already know. Since we’re gonna have to add a bunch of targets let’s create a method to create one body and then we can call this method a bunch of times.
First let’s create a few variables to keep track of our new bodies. In the .h file add these variables:
NSMutableSet *targets; NSMutableSet *enemies; |
Go back to the implementation file and add the releases to the dealloc before we forget:
[targets release]; [enemies release]; |
Then add a helper method to create a target right above resetGame:
- (void)createTarget:(NSString*)imageName atPosition:(CGPoint)position rotation:(CGFloat)rotation isCircle:(BOOL)isCircle isStatic:(BOOL)isStatic isEnemy:(BOOL)isEnemy { CCSprite *sprite = [CCSprite spriteWithFile:imageName]; [self addChild:sprite z:1]; b2BodyDef bodyDef; bodyDef.type = isStatic?b2_staticBody:b2_dynamicBody; bodyDef.position.Set((position.x+sprite.contentSize.width/2.0f)/PTM_RATIO, (position.y+sprite.contentSize.height/2.0f)/PTM_RATIO); bodyDef.angle = CC_DEGREES_TO_RADIANS(rotation); bodyDef.userData = sprite; b2Body *body = world->CreateBody(&bodyDef); b2FixtureDef boxDef; if (isCircle) { b2CircleShape circle; circle.m_radius = sprite.contentSize.width/2.0f/PTM_RATIO; boxDef.shape = &circle; } else { b2PolygonShape box; box.SetAsBox(sprite.contentSize.width/2.0f/PTM_RATIO, sprite.contentSize.height/2.0f/PTM_RATIO); boxDef.shape = &box; } if (isEnemy) { boxDef.userData = (void*)1; [enemies addObject:[NSValue valueWithPointer:body]]; } boxDef.density = 0.5f; body->CreateFixture(&boxDef); [targets addObject:[NSValue valueWithPointer:body]]; } |
The method has a lot of parameters because we’ll have different types of targets and different ways of adding them to the scene. But don’t worry, it’s pretty simple, let’s go over it bit by bit.
First we load the sprite using the file name we pass to the method. To make it easier to position the objects the position we pass to the method is the position of the bottom left corner of where we want the target. Since the position Box2d uses is the center position we have to use the sprite’s size to set the position of the body.
We then define the body’s fixture depending on the desired shape we want. It can be a circle (for the enemies mostly) or a rectangle. Again the size of the fixture is derived from the sprite’s size.
Then, if this is an enemy (enemies will “explode” later on and I want to keep track of them to know if the level is over) I add it to the enemies set and set the fixture userData to 1. The userData is usually set to a struct or a pointer to another object but in this case we just want to “tag” these fixtures as being enemies’ fixtures. Why we need this will become clear when I show you how to detect if an enemy should be destroyed.
We then create the body’s fixture and add it to an array of targets so we can keep track of them.
Now it’s time to call this method a bunch of times to make complete our scene. This is a very big method because I have to call the createTarget method for every object I want to add on the scene.
Here are the sprites we’ll be using on the scene:
Now all we have to do is place these at the right positions. I got these by trial and error, but you can just use these premade positions! :] Add the following method after createTarget:
- (void)createTargets { [targets release]; [enemies release]; targets = [[NSMutableSet alloc] init]; enemies = [[NSMutableSet alloc] init]; // First block [self createTarget:@"brick_2.png" atPosition:CGPointMake(675.0, FLOOR_HEIGHT) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_1.png" atPosition:CGPointMake(741.0, FLOOR_HEIGHT) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_1.png" atPosition:CGPointMake(741.0, FLOOR_HEIGHT+23.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_3.png" atPosition:CGPointMake(672.0, FLOOR_HEIGHT+46.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_1.png" atPosition:CGPointMake(707.0, FLOOR_HEIGHT+58.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_1.png" atPosition:CGPointMake(707.0, FLOOR_HEIGHT+81.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"head_dog.png" atPosition:CGPointMake(702.0, FLOOR_HEIGHT) rotation:0.0f isCircle:YES isStatic:NO isEnemy:YES]; [self createTarget:@"head_cat.png" atPosition:CGPointMake(680.0, FLOOR_HEIGHT+58.0f) rotation:0.0f isCircle:YES isStatic:NO isEnemy:YES]; [self createTarget:@"head_dog.png" atPosition:CGPointMake(740.0, FLOOR_HEIGHT+58.0f) rotation:0.0f isCircle:YES isStatic:NO isEnemy:YES]; // 2 bricks at the right of the first block [self createTarget:@"brick_2.png" atPosition:CGPointMake(770.0, FLOOR_HEIGHT) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_2.png" atPosition:CGPointMake(770.0, FLOOR_HEIGHT+46.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; // The dog between the blocks [self createTarget:@"head_dog.png" atPosition:CGPointMake(830.0, FLOOR_HEIGHT) rotation:0.0f isCircle:YES isStatic:NO isEnemy:YES]; // Second block [self createTarget:@"brick_platform.png" atPosition:CGPointMake(839.0, FLOOR_HEIGHT) rotation:0.0f isCircle:NO isStatic:YES isEnemy:NO]; [self createTarget:@"brick_2.png" atPosition:CGPointMake(854.0, FLOOR_HEIGHT+28.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_2.png" atPosition:CGPointMake(854.0, FLOOR_HEIGHT+28.0f+46.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"head_cat.png" atPosition:CGPointMake(881.0, FLOOR_HEIGHT+28.0f) rotation:0.0f isCircle:YES isStatic:NO isEnemy:YES]; [self createTarget:@"brick_2.png" atPosition:CGPointMake(909.0, FLOOR_HEIGHT+28.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_1.png" atPosition:CGPointMake(909.0, FLOOR_HEIGHT+28.0f+46.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_1.png" atPosition:CGPointMake(909.0, FLOOR_HEIGHT+28.0f+46.0f+23.0f) rotation:0.0f isCircle:NO isStatic:NO isEnemy:NO]; [self createTarget:@"brick_2.png" atPosition:CGPointMake(882.0, FLOOR_HEIGHT+108.0f) rotation:90.0f isCircle:NO isStatic:NO isEnemy:NO]; } |
Pretty simple stuff – just calls our helper method for each target we want to add. Now add this to that bottom of the resetGame method:
[self createTargets]; |
If you run the project now you won’t be able to see this part of the scene unless you throw a bullet at it. So just until we have the scene figured out, let’s add a line to the end of the init method to make it easier for us to check of the scene is as we envisioned:
self.position = CGPointMake(-480, 0); |
This will show us the right half of the scene instead of the left half. Now run the project and you should see the scene of targets!
You can play a little with the scene before moving on to the next step if you want. For example, comment out the 2 bricks at the right of the first block and run the project again and see what happens.
Now remove the line that changes the position from the init method and run again. Now throw a bullet at the targets and watch the destruction!
Cool! The squirrel attack is underway!
Rapid Fire
Before we move on to do some collision detection let’s add some code to attach another bullet after we throw one just so we can make some more destruction.
Add a new method called resetBullet above resetGame to do this:
- (void)resetBullet { if ([enemies count] == 0) { // game over // We'll do something here later } else if ([self attachBullet]) { [self runAction:[CCMoveTo actionWithDuration:2.0f position:CGPointZero]]; } else { // We can reset the whole scene here // Also, let's do this later } } |
On this method we first check to see if we destroyed all the enemies. This won’t happen now as we’re still not destroying the enemies but let’s prepare for this already.
If there still are enemies, we try to attach another bullet. Remember that the attachBullets method returns YES if there are more bullets to attach and NO otherwise. So, if there are more bullets I run a cocos2d action that resets the position of the view to the left half of the scene so I can see my catapult again.
If there are no more bullets we’ll have to reset the whole scene, but this will come later on. Let’s have some fun first.
We now have to call this method at an appropriate time. But what is this appropriate time. Maybe when the bullet finally stops moving after we released it? Maybe a few seconds after we hit the first target? Well, that’s all open for discussion. To keep things simple for now, let’s call this method a few seconds after we release the bullet.
As you remember we do this on the tick method when we destroy the weld joint. So go to that method and find the DestroyJoint call we added and add this line right after:
[self performSelector:@selector(resetBullet) withObject:nil afterDelay:5.0f]; |
This will wait 5 seconds and then call resetBullet. Now run the project and watch the mayhem!
One last thing for this section. The mayhem is not very natural in my opinion because of one little detail: the objects that collide with the right border of the scene all stay there as if leaning against a wall but there’s no wall there in our world. The objects should fall to the right of the scene but they don’t.
This happens because when we build our world (well, actually this came with the initial cocos2d project) we added fixtures to the 4 corners of the scene. We now can see that the right corner probably should not exist.
So go to the init method and remove this lines:
// remove these lines under the comment "right" groundBox.SetAsEdge(b2Vec2(screenSize.width*2.0f/PTM_RATIO,screenSize.height/PTM_RATIO), b2Vec2(screenSize.width*2.0f/PTM_RATIO,0)); groundBody->CreateFixture(&groundBox,0); |
Now run the project again. It should be a little more natural.
Well, as natural as a world of war hungry squirrels, anyway.
The repository tag for this point in the tutorial is TargetsCreation.
It’s Raining Cats and Dogs
We’re almost there! All we need now is a way to detect that the enemies should be destroyed.
We can do this using collision detection but there’s a little problem with a simple collision detection. Our enemies are already colliding with the blocks so simply detecting if the enemies are colliding with something is not enough because the enemies would be destroyed right away.
We could say that for the enemies to be destroyed they have to collide with a bullet. This would be very easy to do but then some enemies would be very hard to destroy. Take the dogs between the two blocks on our scene. It’s very hard to hit it with a bullet but it’s not hard if we throw one of the blocks at it. But we already established that a simple collision with the blocks is not going to work.
What we can do is try to determine the strength of the collision and then determine that we have to destroy an enemy based on a minimum strength.
To do this using Box2d we have to create a contact listener. Ray already explained how to create one during the second part of the breakout game tutorial. So if you haven’t read the tutorial or don’t remember it go ahead and read at least the beginning of this second part. I’ll wait….
There will be some differences though. First we will use a std::set instead of a std::vector. The difference is that the set does not allow duplicate entries so if we have multiple impacts on a target we don’t have to worry about adding them twice to our list.
Another difference is that we’ll have to use the postSolve method as this is where we’ll be able to retrieve the impulse of a contact and thus determine if we have to destroy an enemy.
Control-click on your main classes folder and choose New File. Click iOS\C and C++ on the left, choose “C++ file”, and click Next. Name your file MyContactListener.cpp, and click Save. If it created a .h for you too you’re set, otherwise repeat the above but select “Header File” to create a header file called MyContactLister.h as well.
Open the newly created MyContactLister.h and add this code:
#import "Box2D.h" #import <set> #import <algorithm> class MyContactListener : public b2ContactListener { public: std::set<b2Body*>contacts; MyContactListener(); ~MyContactListener(); virtual void BeginContact(b2Contact* contact); virtual void EndContact(b2Contact* contact); virtual void PreSolve(b2Contact* contact, const b2Manifold* oldManifold); virtual void PostSolve(b2Contact* contact, const b2ContactImpulse* impulse); }; |
This is almost the same thing as Ray did except for a set instead of a vector.
Next go to MyContactListener.cpp file and add this:
#include "MyContactListener.h" MyContactListener::MyContactListener() : contacts() { } MyContactListener::~MyContactListener() { } void MyContactListener::BeginContact(b2Contact* contact) { } void MyContactListener::EndContact(b2Contact* contact) { } void MyContactListener::PreSolve(b2Contact* contact, const b2Manifold* oldManifold) { } void MyContactListener::PostSolve(b2Contact* contact, const b2ContactImpulse* impulse) { bool isAEnemy = contact->GetFixtureA()->GetUserData() != NULL; bool isBEnemy = contact->GetFixtureB()->GetUserData() != NULL; if (isAEnemy || isBEnemy) { // Should the body break? int32 count = contact->GetManifold()->pointCount; float32 maxImpulse = 0.0f; for (int32 i = 0; i < count; ++i) { maxImpulse = b2Max(maxImpulse, impulse->normalImpulses[i]); } if (maxImpulse > 1.0f) { // Flag the enemy(ies) for breaking. if (isAEnemy) contacts.insert(contact->GetFixtureA()->GetBody()); if (isBEnemy) contacts.insert(contact->GetFixtureB()->GetBody()); } } } |
As I mentioned we only implement the PostSolve method.
First we determine if the contact we’re processing involves at least one enemy. Remember on the createTarget method that we “tagged” the enemies using the fixture’s userData? That’s why we did it. See, I told you you’d understand soon.
Every contact can have one or more contact points and every contact point has a normal impulse. This impulse is basically the force of the contact. We then determine the maximum impact force and determine if this should destroy the enemy.
If we determine we should destroy the enemy we add the body object to our set so we can destroy it later. Remember that, as Ray said on his tutorial, we can’t destroy the enemy’s body during the contact processing. So we hold this on our set and will process it later.
The value I used as the impulse that should destroy the enemy is something you’ll have to test for yourself. This will vary depending on the masses of the objects, the speed the bullet is impacting and some other factors. My suggestion is to start small and increase the value to determine what’s an acceptable value for your gameplay.
Now that we have our listener code let’s instantiate and use it. Go back to HelloWorldLayer.h and add the include of our new C++ class to the imports:
#import "MyContactListener.h" |
And add a variable to hold our listener:
MyContactListener *contactListener; |
No go to the implementation file and add this o the end of the init method:
contactListener = new MyContactListener(); world->SetContactListener(contactListener); |
Now we have to add some code to actually destroy the enemies. Again this is going to be at the end of the tick method:
// Check for impacts std::set<b2Body*>::iterator pos; for(pos = contactListener->contacts.begin(); pos != contactListener->contacts.end(); ++pos) { b2Body *body = *pos; CCNode *contactNode = (CCNode*)body->GetUserData(); [self removeChild:contactNode cleanup:YES]; world->DestroyBody(body); [targets removeObject:[NSValue valueWithPointer:body]]; [enemies removeObject:[NSValue valueWithPointer:body]]; } // remove everything from the set contactListener->contacts.clear(); |
We simply iterate though the set of our contact listener and destroy all the bodies and their associated sprites. We also remove them from the enemies and targets NSSet so we can determine if we have eliminated all the enemies.
Finally we clear the contact listener’s set to make it ready for the next time tick gets called and so that we don’t try to destroy those bodies again.
Go ahead and run the game. I promise you it’s even cooler now!
But you can say there’s something missing here. Something that really tell us we destroyed those freaking enemies. So let’s add some polish to our game and make these little squirrel haters explode.
Cocos2d will make it very easy to do this using particles. I won’t go into a lot of details here since this topic can even have a tutorial of it’s own. If you want to dig deeper on this topic you can have a look at Chapter 14 of Rod and Ray’s Cocos2d book. In the meantime I’ll show you how to do this and how to try out other cocos2d built-in particles.
Let’s change the inside of the loop we just added to this:
b2Body *body = *pos; CCNode *contactNode = (CCNode*)body->GetUserData(); CGPoint position = contactNode.position; [self removeChild:contactNode cleanup:YES]; world->DestroyBody(body); [targets removeObject:[NSValue valueWithPointer:body]]; [enemies removeObject:[NSValue valueWithPointer:body]]; CCParticleSun* explosion = [[CCParticleSun alloc] initWithTotalParticles:200]; explosion.autoRemoveOnFinish = YES; explosion.startSize = 10.0f; explosion.speed = 70.0f; explosion.anchorPoint = ccp(0.5f,0.5f); explosion.position = position; explosion.duration = 1.0f; [self addChild:explosion z:11]; [explosion release]; |
We first store the position of the enemy’s sprite so we know where to add the particle. Then we add an instance of CCParticleSun in this position. Pretty easy right? Go ahead and run the game!
Pretty cool, right?
CCParticleSun is one of many pre-configured CCParticleSystem sub-classes that comes with cocos2d. Command-click on CCParticleSun in Xcode and you’ll be taken to the CCParticlesExamples.m file. This file has a lot of sub-classes of CCParticleSystem that you can experiment with. And you may think that CCParticleExplosion would look better for us but you’d be wrong, at least in my opinion. But go ahead and try it out with a bunch of particle systems and see what happens.
One thing that I sneaked up on you is the texture file used on this particle. If you look inside the code for CCParticleSun you’ll notice it uses a file called fire.png. This file was already added to the project some time ago when we added all the image files.
The repository tag for this point in the tutorial is EnemiesExploding.
Finishing Touches
Before we wrap this up let’s add a way to reset everything in case we run out of bullets or enemies. This is gonna be pretty easy as we have most of our scene creation in separate methods anyway.
The best way to reset our game would be to call resetGame. But if you simply do this you’ll end up with a bunch of old targets and enemies on your scene. So we’ll have to add some cleanup to our resetGame method to take care of this. Fortunately we have kept references of everything so it’s gonna be pretty easy.
So go ahead and add this to the beginning of the resetGame method:
// Previous bullets cleanup if (bullets) { for (NSValue *bulletPointer in bullets) { b2Body *bullet = (b2Body*)[bulletPointer pointerValue]; CCNode *node = (CCNode*)bullet->GetUserData(); [self removeChild:node cleanup:YES]; world->DestroyBody(bullet); } [bullets release]; bullets = nil; } // Previous targets cleanup if (targets) { for (NSValue *bodyValue in targets) { b2Body *body = (b2Body*)[bodyValue pointerValue]; CCNode *node = (CCNode*)body->GetUserData(); [self removeChild:node cleanup:YES]; world->DestroyBody(body); } [targets release]; [enemies release]; targets = nil; enemies = nil; } |
This is pretty simple. We just go through the sets we have and remove the bodies and the associated sprites from the scene. The conditional is because we will not have the sets defined the first time we call this method because we didn’t create anything yet.
Now let’s call resetGame at the appropriate times. If you remember we left some conditions blank on our resetBullet method. Well, this is the appropriate place. So go there and replace the two comments we left about doing something later for this:
[self performSelector:@selector(resetGame) withObject:nil afterDelay:2.0f]; |
Run the game and you’ll see that when the enemies or the bullets run out the game will reset and you’ll be able to play it again without having to re-run the project.
Let’s add just one more nice little detail to our game. When the game starts you can’t see the targets so you don’t know what you have to destroy. Let’s fix this, again, changing something on resetGame.
At the end of resetGame we call 3 methods:
[self createBullets:4]; [self attachBullet]; [self attachTargets]; |
Let’s change this a bit and add some action:
[self createBullets:4]; [self createTargets]; [self runAction:[CCSequence actions: [CCMoveTo actionWithDuration:1.5f position:CGPointMake(-480.0f, 0.0f)], [CCCallFuncN actionWithTarget:self selector:@selector(attachBullet)], [CCDelayTime actionWithDuration:1.0f], [CCMoveTo actionWithDuration:1.5f position:CGPointZero], nil]]; |
Now we’ll create the bullets and targets and then start a sequence of actions. These actions will run one after the other.
The first will move our scene to the right so we can see our targets. When this movement finishes we’ll call the method that attaches the bullet. This will make the bullet get attached when we’re not seeing it so we’ll avoid the very crude attachment we have now. Then we’ll wait a second so you can get your strategy straight.
And finally… we’ll move back to the left so you can start the destruction!
Want More?
I had a blast working on this tutorial, and everyone I showed it to really liked it (or are all really nice to me!)
If you guys are interested, I’d love to keep working on this some more and make a full “Catapult Game Starter Kit” for sale on this site, kind of like the Space Game Starter Kit.
The full Catapult Game Starter Kit would take the above tutorial and game and expand it to include the following features:
- Adding finger scrolling to review the scene
- New artwork and enemies!
- Sound effects, music, and animation!
- Tons polish to make the game feel better
- Using LevelHelper to easily create multiple levels
- Adding a neat level selection scene
- Adding a title scene and main menu
- Creating power ups
- Scores and Game Center support!
- Anything else you guys want (post if you have an idea!)
Before I put the time and energy into creating this though, I’d like to check if this is something you’re interested in.
0 comments:
Post a Comment