How To Make A Game Like Fruit Ninja With Box2D and Cocos2D – Part 3
This is a post by iOS Tutorial Team Member Allen Tan, an iOS developer and co-founder at White Widget. You can also find him on Google+ and Twitter. Welcome to the third part of a tutorial series that shows you how to make a sprite cutting game similar to the game Fruit Ninja by Halfbrick […] By Allen Tan.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How To Make A Game Like Fruit Ninja With Box2D and Cocos2D – Part 3
35 mins
This is a post by iOS Tutorial Team Member Allen Tan, an iOS developer and co-founder at White Widget. You can also find him on Google+ and Twitter.
Welcome to the third part of a tutorial series that shows you how to make a sprite cutting game similar to the game Fruit Ninja by Halfbrick Studios.
In the first part, you covered how to make a Textured Polygon, and made a Watermelon out of it.
In the second part , you showed you how to use Box2D Ray Casting and some math to split the Textured Polygons.
In this final part, you will make this project look like a full-fledged game by adding gameplay, visual effects, and sound effects.
Again, if you are new to Cocos2D or Box2D, please check out the intro to Cocos2D and intro to Box2D on this site first.
This project starts where you left off in the last tutorial, so make sure you have a copy of the project from part 2. Also grab a copy of the resources for this tutorial if you haven’t done so yet. You will be adding some cool stuff from the kit to the project later on.
Tossing Fruits
So far you have only been drawing one static fruit on the scene. Before you can add the tossing mechanic, you must have different types of fruits. If you haven’t prepared all the fruit classes back in the first tutorial, you can use the copies in the Classes folder from the resources.
At this point you should have the following fruit classes in your project: Banana, Grapes, Pineapple, Strawberry, and Watermelon.
Switch to PolygonSprite.h and make the following changes:
// Add to top of file
typedef enum _State
{
kStateIdle = 0,
kStateTossed
} State;
typedef enum _Type
{
kTypeWatermelon = 0,
kTypeStrawberry,
kTypePineapple,
kTypeGrapes,
kTypeBanana,
kTypeBomb
} Type;
// Add inside @interface
State _state;
Type _type;
// Add after @interface
@property(nonatomic,readwrite)State state;
@property(nonatomic,readwrite)Type type;
Next, switch to PolygonSprite.mm and make the following changes:
// Add inside @implementation
@synthesize state = _state;
@synthesize type = _type;
// Add inside the if statement of initWithTexture
_state = kStateIdle;
// Add inside createBodyForWorld, right after setting the maskBits of the fixture definition
fixtureDef.isSensor = YES;
You added a type definition to PolygonSprite so that the game has a way to distinguish between the subclasses. Next, you create a state for each fruit. An idle state means the fruit can be tossed, while the tossed state means the fruit is still onscreen.
You also made the bodies of the PolygonSprites sensors, which means Box2D will not simulate but only “sense” collisions for these bodies. When you first toss sprites from the bottom, you don’t want them to suddenly collide with falling sprites. The player might lose without even seeing these sprites.
Next, make the following changes:
// Add inside the if statement of Banana.mm
self.type = kTypeBanana;
// Add inside the if statement of Bomb.mm
self.type = kTypeBomb;
// Add inside the if statement of Grapes.mm
self.type = kTypeGrapes;
// Add inside the if statement of Pineapple.mm
self.type = kTypePineapple;
// Add inside the if statement of Strawberry.mm
self.type = kTypeStrawberry;
// Add inside the if statement of Watermelon.mm
self.type = kTypeWatermelon;
Switch back to HelloWorldLayer.mm, and make these changes:
// Add to top of file
#import "Strawberry.h"
#import "Pineapple.h"
#import "Grapes.h"
#import "Banana.h"
#import "Bomb.h"
// Replace the initSprites method
-(void)initSprites
{
_cache = [[CCArray alloc] initWithCapacity:53];
for (int i = 0; i < 10; i++)
{
PolygonSprite *sprite = [[Watermelon alloc] initWithWorld:world];
sprite.position = ccp(-64*(i+1),-64);
[self addChild:sprite z:1];
[_cache addObject:sprite];
}
for (int i = 0; i < 10; i++)
{
PolygonSprite *sprite = [[Strawberry alloc] initWithWorld:world];
sprite.position = ccp(-64*(i+1),-64);
[self addChild:sprite z:1];
[_cache addObject:sprite];
}
for (int i = 0; i < 10; i++)
{
PolygonSprite *sprite = [[Pineapple alloc] initWithWorld:world];
sprite.position = ccp(-64*(i+1),-64);
[self addChild:sprite z:1];
[_cache addObject:sprite];
}
for (int i = 0; i < 10; i++)
{
PolygonSprite *sprite = [[Grapes alloc] initWithWorld:world];
sprite.position = ccp(-64*(i+1),-64);
[self addChild:sprite z:1];
[_cache addObject:sprite];
}
for (int i = 0; i < 10; i++)
{
PolygonSprite *sprite = [[Banana alloc] initWithWorld:world];
sprite.position = ccp(-64*(i+1),-64);
[self addChild:sprite z:1];
[_cache addObject:sprite];
}
for (int i = 0; i < 3; i++)
{
PolygonSprite *sprite = [[Bomb alloc] initWithWorld:world];
sprite.position = ccp(-64*(i+1),-64);
[self addChild:sprite z:1];
[_cache addObject:sprite];
}
}
You added a type value for all the PolygonSprite subclasses, and created 10 of each fruit, and 3 bombs to the game. You don't want them showing up yet, so you cast them offscreen for now.
Compile and run, and no fruit should be visible yet.
In our game, the fruits are going to be tossed from below the screen. They can be tossed all at the same time (simultaneously), or one by one (consecutively), with some randomness to the interval between tosses, the number of fruits, their position, toss height, and direction.
Having this much randomness in the game will make it more interesting.
Switch back to HelloWorldLayer.h and make the following changes:
// Add to top of file, below the calculate_determinant definition
#define frandom (float)arc4random()/UINT64_C(0x100000000)
#define frandom_range(low,high) ((high-low)*frandom)+low
#define random_range(low,high) (arc4random()%(high-low+1))+low
typedef enum _TossType
{
kTossConsecutive = 0,
kTossSimultaneous
}TossType;
// Add inside the @interface
double _nextTossTime;
double _tossInterval;
int _queuedForToss;
TossType _currentTossType;
Next, switch to HelloWorldLayer.mm and make the following changes:
// Add inside the init method
_nextTossTime = CACurrentMediaTime() + 1;
_queuedForToss = 0;
You defined functions that output random floats and integers given a range, and also created a type definition for the two kinds of tosses mentioned above.
Next, you defined the following variables for the game logic:
- nextTossTime: This is the time when a fruit, or a group of fruits will be tossed next. It is always compared against CACurrentMediaTime(), which is the current game time. You initialize it with 1 second more than the current time so that the first toss doesn't happen immediately after the game starts.
- tossInterval: This is the random number of seconds in between tosses. You will be adding this value to nextTossTime after every toss.
- queuedForToss: This is the random number of fruits that still need to be tossed for the current toss type.
- currentTossType: The toss type of the current toss interval. It is a random choice between simultaneous and consecutive.
Still in HelloWorldLayer.mm, add this method:
-(void)tossSprite:(PolygonSprite*)sprite
{
CGSize screen = [[CCDirector sharedDirector] winSize];
CGPoint randomPosition = ccp(frandom_range(100, screen.width-164), -64);
float randomAngularVelocity = frandom_range(-1, 1);
float xModifier = 50*(randomPosition.x - 100)/(screen.width - 264);
float min = -25.0 - xModifier;
float max = 75.0 - xModifier;
float randomXVelocity = frandom_range(min,max);
float randomYVelocity = frandom_range(250, 300);
sprite.state = kStateTossed;
sprite.position = randomPosition;
[sprite activateCollisions];
sprite.body->SetLinearVelocity(b2Vec2(randomXVelocity/PTM_RATIO,randomYVelocity/PTM_RATIO));
sprite.body->SetAngularVelocity(randomAngularVelocity);
}
This method assigns a random position to a sprite below the screen, and computes for a random velocity. The min and max values limit the velocity based on the sprites' position so that sprites don't get tossed too far left, or too far right.
The values here mostly come out of trial and error. If the sprite is at the leftmost position, an x-velocity of -25 to 75 is enough such that the sprite still lands within the screen bounds. If it's at the middle, then it's -50 to 50, and so on.
After computing all the randomness, it informs the system that the sprite has been tossed, activates the sprite's collision mask, and sets their initial velocity.
I mentioned before that you don't want sprites that are tossed to collide with falling sprites, so you must be wondering why we call activateCollisions here. This method only sets the category and mask bits of the sprites, but it doesn't change the fact that these sprites are still sensors.
It's important to change these bits because when the sprites are split, the new shapes aren't sensors anymore, and they will inherit these properties as well.
This method already sets the random position and velocity per fruit, so the next logical step is to create the mechanic that tosses fruits at random intervals.
Add this method to HelloWorldLayer.mm:
-(void)spriteLoop
{
double curTime = CACurrentMediaTime();
//step 1
if (curTime > _nextTossTime)
{
PolygonSprite *sprite;
int random = random_range(0, 4);
//step 2
Type type = (Type)random;
if (_currentTossType == kTossConsecutive && _queuedForToss > 0)
{
CCARRAY_FOREACH(_cache, sprite)
{
if (sprite.state == kStateIdle && sprite.type == type)
{
[self tossSprite:sprite];
_queuedForToss--;
break;
}
}
}
else
{ //step 3
_queuedForToss = random_range(3, 8);
int tossType = random_range(0,1);
_currentTossType = (TossType)tossType;
//step 4
if (_currentTossType == kTossSimultaneous)
{
CCARRAY_FOREACH(_cache, sprite)
{
if (sprite.state == kStateIdle && sprite.type == type)
{
[self tossSprite:sprite];
_queuedForToss--;
random = random_range(0, 4);
type = (Type)random;
if (_queuedForToss == 0)
{
break;
}
}
}
} //step 5
else if (_currentTossType == kTossConsecutive)
{
CCARRAY_FOREACH(_cache, sprite)
{
if (sprite.state == kStateIdle && sprite.type == type)
{
[self tossSprite:sprite];
_queuedForToss--;
break;
}
}
}
}
//step 6
if (_queuedForToss == 0)
{
_tossInterval = frandom_range(2,3);
_nextTossTime = curTime + _tossInterval;
}
else
{
_tossInterval = frandom_range(0.3,0.8);
_nextTossTime = curTime + _tossInterval;
}
}
}
Quite a lot of things happening here, so let's take them step by step:
- Step 1: Checks if it's time to toss fruits again by comparing the current time with the nextTossTime variable.
- Step 2: If there are still fruits queued to be tossed in consecutive mode, it tosses one random fruit and goes straight to step 6.
- Step 3: Chooses either consecutive or simultaneous tossing modes, and sets the number of fruits to be tossed.
- Step 4: Tosses random fruits simultaneously. Note that the range of fruit types only goes from 0 to 4 because you don't want to include the Bomb type.
- Step 5: Similar to step 2. It tosses the first fruit right after consecutive mode is selected and goes straight to step 6.
- Step 6: Sets the interval between toss times. Whenever a toss type runs out of fruit, you assign a longer interval, else, you assign short intervals because it means you are tossing fruits consecutively.
To run this method constantly, include it in the scheduled update. In HelloWorldLayer.mm, put this line inside the update method:
[self spriteLoop];
There's one more thing to do before you launch the game. Since our sprites will be coming from, and eventually falling below the screen, you should remove the walls that were created by default. Still in HelloWorldLayer.mm, make these changes:
// In the initPhysics method, replace gravity.Set(0.0f, -10.0f) with
gravity.Set(0.0f, -4.25f);
// Comment out or remove the following code from the initPhysics method
// bottom
groundBox.Set(b2Vec2(0,0), b2Vec2(s.width/PTM_RATIO,0));
groundBody->CreateFixture(&groundBox,0);
// top
groundBox.Set(b2Vec2(0,s.height/PTM_RATIO), b2Vec2(s.width/PTM_RATIO,s.height/PTM_RATIO));
groundBody->CreateFixture(&groundBox,0);
// left
groundBox.Set(b2Vec2(0,s.height/PTM_RATIO), b2Vec2(0,0));
groundBody->CreateFixture(&groundBox,0);
// right
groundBox.Set(b2Vec2(s.width/PTM_RATIO,s.height/PTM_RATIO), b2Vec2(s.width/PTM_RATIO,0));
groundBody->CreateFixture(&groundBox,0);
Aside from removing all the physical walls, you also make the gravity weaker because you don't want our sprites to be falling too fast.
Compile and run, and you should now be seeing your fruits rising and falling!
While playing the game, you will notice 3 issues.
- Eventually the fruit tossing will stop because you are not resetting the state of the original fruits and the cache runs out of things to toss.
- The more you slice, the more performance gets permanently worse. This is because you don't clean up the cut pieces when they fall offscreen and Box2D simulates all those pieces.
- When you cut the fruits, the new pieces stick together: This is because you simply divide the fruits into two without forcibly splitting them.
To address these issues, make the following changes to HelloWorldLayer.mm:
// Add inside the splitPolygonSprite method, right before [sprite deactivateCollisions]
sprite.state = kStateIdle;
// Add this method
-(void)cleanSprites
{
PolygonSprite *sprite;
//we check for all tossed sprites that have dropped offscreen and reset them
CCARRAY_FOREACH(_cache, sprite)
{
if (sprite.state == kStateTossed)
{
CGPoint spritePosition = ccp(sprite.body->GetPosition().x*PTM_RATIO,sprite.body->GetPosition().y*PTM_RATIO);
float yVelocity = sprite.body->GetLinearVelocity().y;
//this means the sprite has dropped offscreen
if (spritePosition.y < -64 && yVelocity < 0)
{
sprite.state = kStateIdle;
sprite.sliceEntered = NO;
sprite.sliceExited = NO;
sprite.entryPoint.SetZero();
sprite.exitPoint.SetZero();
sprite.position = ccp(-64,-64);
sprite.body->SetLinearVelocity(b2Vec2(0.0,0.0));
sprite.body->SetAngularVelocity(0.0);
[sprite deactivateCollisions];
}
}
}
//we check for all sliced pieces that have dropped offscreen and remove them
CGSize screen = [[CCDirector sharedDirector] winSize];
for (b2Body* b = world->GetBodyList(); b; b = b->GetNext())
{
if (b->GetUserData() != NULL) {
PolygonSprite *sprite = (PolygonSprite*)b->GetUserData();
CGPoint position = ccp(b->GetPosition().x*PTM_RATIO,b->GetPosition().y*PTM_RATIO);
if (position.x < -64 || position.x > screen.width || position.y < -64)
{
if (!sprite.original)
{
world->DestroyBody(sprite.body);
[self removeChild:sprite cleanup:YES];
}
}
}
}
}
// Add inside the update method, after [self checkAndSliceObjects]
[self cleanSprites];
Here you introduce state handling. The sprites start with the idle state, and the toss method changes this state. Since the toss method only chooses idle sprites, yo reset the state of original sprites that get sliced back to idle.
In the cleanSprites method, the first part of the code checks for all original sprites that dropped offscreen and resets everything back to their state before being tossed. The second part checks for all sliced pieces that are offscreen, destroys their Box2D body, and removes them from the scene.
Switch to HelloWorldLayer.h, and add this right below #define random_range(low,high):
#define midpoint(a,b) (float)(a+b)/2
Switch back to HelloWorldLayer.mm and make these changes to the splitPolygonSprite method:
// Add to the top part inside of the if (sprite1VerticesAcceptable && sprite2VerticesAcceptable) statement
b2Vec2 worldEntry = sprite.body->GetWorldPoint(sprite.entryPoint);
b2Vec2 worldExit = sprite.body->GetWorldPoint(sprite.exitPoint);
float angle = ccpToAngle(ccpSub(ccp(worldExit.x,worldExit.y), ccp(worldEntry.x,worldEntry.y)));
CGPoint vector1 = ccpForAngle(angle + 1.570796);
CGPoint vector2 = ccpForAngle(angle - 1.570796);
float midX = midpoint(worldEntry.x, worldExit.x);
float midY = midpoint(worldEntry.y, worldExit.y);
// Add after [self addChild:newSprite1 z:1]
newSprite1.body->ApplyLinearImpulse(b2Vec2(2*body1->GetMass()*vector1.x,2*body1->GetMass()*vector1.y), b2Vec2(midX,midY));
// Add after [self addChild:newSprite2 z:1]
newSprite2.body->ApplyLinearImpulse(b2Vec2(2*body2->GetMass()*vector2.x,2*body2->GetMass()*vector2.y), b2Vec2(midX,midY));
So that the two pieces don't stick together when the polygon splits, you need to apply some kind of force that changes their direction and velocity.
To get the direction, you compute for the world coordinates and angle of the cutting line, and get two normalized vector angles perpendicular to the center of this line. All Box2D angle values are in radians so the number 1.570796 is just the radian form of 90 degrees.
Next, you get the coordinates of the center of the cutting line so that you know where the pushing force originates.
The diagram below shows our intention:
To push the two pieces away, you apply a linear impulse originating from the center of the cutting line outward in both directions. The impulse is based on each body's mass so that the push effect is more or less equal for both sprites. A bigger sprite needs a bigger impulse, while a smaller sprite needs a smaller impulse.
Compile and run, and this time the fruits should split properly, and the game can be played endlessly.