(译)如何使用box2d来做碰撞检测(且仅用来做碰撞检测)
原文链接地址:http://www.raywenderlich.com/606/how-to-use-box2d-for-just-collision-detection-with-cocos2d-iphone
程序截图:
当你使用cocos2d来制作一个游戏的时候,有时,你可能想使用cocos2d的action来移动游戏中的对象,而不是直接使用Box2d物理引擎来做。然而,这并不是说你不能使用box2d提供的强大的碰撞检测功能!
这个教程的目的,就是一步一步地向你展示如何仅使用box2d来做碰撞检测---没有物理效果。我们将创建一个简单的demo,里面有一辆车在屏幕上奔驰,当它撞到一只猫后就会放声大笑。是的,我明白,这样做太残忍了。
在这篇教程中,我们会引入一些新的、有趣的概念,比如使用sprite sheets,利用Zwoptex工具来制作它。还会涉及box2d的debug drawing和VertexHelper这个工具。
这个简单假设你已经阅读过前面的一系列的cocos2d和box2d的教程了(如果没有,最好把前面的教程先看一遍),或者你有相关经验也可以。
还有,在我忘记之前--特别要感谢Kim在评论中建议我写一篇这样的教程。
Sprites and Sprite Sheets
在我们开始之前,需要简单地介绍一下sprites和spritesheets。
目前为止,当我们使用cocos2d的时候,我们都是直接使用CCSprite类,然后传递一个精灵图片给它。但是,根据cocos2d最佳实践,如果使用spritesheet来做的话,那样会极大地提高效率。
上图就是一个spritesheet的样例,这个图在cocos2d的样例代码中可以找到。简言之,spritesheet就是一张大的图片,它能够被裁成许多小的子图片。为了指定spritesheet中的每个子图片的位置,你需要为每个图片指定一个坐标,通常是一个矩形。比如,下面的代码展示了,如何把spritesheet中前四张图片抠出来:
CCSpriteSheet *sheet = [CCSpriteSheet
spriteSheetWithFile:@"grossini_dance_atlas.jpg" capacity:1];
CCSprite *sprite1 = [CCSprite spriteWithTexture:sheet.texture rect:
CGRectMake(85*0, 121*1, 85, 121)];
CCSprite *sprite2 = [CCSprite spriteWithTexture:sheet.texture rect:
CGRectMake(85*1, 121*1, 85, 121)];
CCSprite *sprite3 = [CCSprite spriteWithTexture:sheet.texture rect:
CGRectMake(85*2, 121*1, 85, 121)];
CCSprite *sprite4 = [CCSprite spriteWithTexture:sheet.texture rect:
CGRectMake(85*3, 121*1, 85, 121)];
你可以会说,写一大堆硬编码的坐标太麻烦了,太烦人了!幸运的是,Robert Payne已经写好了一个非常方便地web工具,叫做Zwoptex,它可以创建精灵表单(spritesheet),同时会导出每个子图片的坐标,这样你在cocos2d里面使用这些子图片就会非常方便了。
制作精灵表单
在我们开始之前,你需要一张图片。你可以下载我老婆制作的车和猫的图片,或者使用你自己的。接下来,打开浏览器,加载Zwoptex主页。你将会看到下面的屏幕:
一旦加载完毕后,点击Zwoptex的File菜单,然后点Import Images。选择你刚刚下载的车和猫的图片,然后点click,这时,你的图片应该出现会互相重叠在一起。拖动其中一张,这样会看得更清楚一些。
注意,这些图片已经被自动地把图片周围的空白部分给去掉了。这并不是我们想要的(后面你会知道为什么),因此,用鼠标把这两张图片都框住,然后选择菜单Modify\Untrim Selected Images。
现在,我们的图片看起来非常好了。点击Arrange\Complex by Height(no spacing),然后它们会排列地更加整齐。
最后,让我们把画布(canvas)的大小调整为合适的大小。点击Modify\Canvas Width,并把它设置为128px。同样的,点击Modify\Canvas Height,然后把它设置为64px。你的屏幕最后看起来应该是下面这个样子。
最后,是时候导出sprite sheet和相应的坐标了!点击File\Export Texture,然后保存sprite sheet为“sprites.jpg”。然后点击File\Export Coordinates“并且保存为”sprites.plist“。注意,这里必须把spritesheet和坐标文件的名字取成相同的,因为,spritesheet为假设它的坐标文件为相应名字的plist文件。
接下来,打开sprites.plist。你会看到Zwopte已经自动地帮你把原图中每一个子图片的坐标计算出来了,并且存储成了一个plist文件。我们接下来就可以使用这些坐标,而不用手工去输入它们了!
从Sprite sheet中添加我们的精灵
好,是时候写一些代码了!
打开Xcode并创建一个新的工程,选择 cocos2d-0.99.1 Box2d Application template,取名为Box2DCollision。然后把自带的样例代码全部删除,你可能从弹球的教程中找到具体的做法。
当然,我们要在HelloWorldScene.mm的顶部加入下面一行代码:
#define PTM_RATIO 32.0
如果你不明白我在这里讲的是些什么,你可能需要看看弹球的教程来获取更多的信息。
接下来,让我们把sprite sheet和坐标plist文件都加入到工程中去。把sprites.jpg和sprites.plist文件都拖到Resouces文件夹中,同时确保 “Copy items into destination group’s folder (if needed)”被复选中。
然后,在HelloWorldScene.h文件的HelloWorld类中,添加下面的成员变量:
CCSpriteSheet *_spriteSheet;
现在,让我们修改HelloWorldScene.mm中的init方法来加载我们的spritesheet和plist文件。具体修改如下:
- (id)init {
if ((self=[super init])) {
// Create our sprite sheet and frame cache
_spriteSheet = [[CCSpriteSheet spriteSheetWithFile:@"sprites.jpg"
capacity:2] retain];
[[CCSpriteFrameCache sharedSpriteFrameCache]
addSpriteFramesWithFile:@"sprites.plist"];
[self addChild:_spriteSheet];
[self spawnCar];
[self schedule:@selector(secondUpdate:) interval:1.0];
}
return self;
}
第一件事情就是创建一个spritesheet对象,它是一个可以用来高效地绘制它的所有的CCSprite结点的对象。很明显,这些CCSprite必须共享相同的纹理(texture)。当我们把车子和猫加入到场景中的时候,我们需要将它们当作spritesheet的孩子加进去。
然后,我们使用CCSpriteFrameCache类来加载坐标属性列表文件。这个函数会自动地查找一个与之同名的图片(即sprites.jpg).这也就是前面说的,为什么要把”sprites.jpg“和"sprites.plist”取成相同名字的原因。
在这之后,我们调用一个函数在场景中显示一辆车。同时,还设置一个计时器,每隔一秒调用一次secondUpdate函数。
接下来,让我们实现spawnCar方法。我们的做法是让车子永远地在屏幕中间做路径为三角形的运动。在init函数的上面添加下面函数代码:
- (void)spawnCar {
CCSprite *car = [CCSprite spriteWithSpriteFrameName:@"car.jpg"];
car.position = ccp(100, 100);
car.tag = 2;
[car runAction:[CCRepeatForever actionWithAction:
[CCSequence actions:
[CCMoveTo actionWithDuration:1.0 position:ccp(300,100)],
[CCMoveTo actionWithDuration:1.0 position:ccp(200,200)],
[CCMoveTo actionWithDuration:1.0 position:ccp(100,100)],
nil]]];
[_spriteSheet addChild:car];
}
注意,这里创建sprite的时候,不是使用spriteWithFile,而是使用spriteWithSpriteFrameName。这里指的是使用spritesheet纹理中代表Car的图片来创建精灵。
还有一点需要注意,我们不是把Car作为HelloWorld层的函数添加进去,而是把Car作为Spritesheet的孙子添加进去的。
这个函数的后面部分你应该比较熟悉了。因此,让我们添加一些猫吧!在上面的spawnCar方法后面添加下面的方法:
- (void)spawnCat {
CGSize winSize = [CCDirector sharedDirector].winSize;
CCSprite *cat = [CCSprite spriteWithSpriteFrameName:@"cat.jpg"];
int minY = cat.contentSize.height/2;
int maxY = winSize.height - (cat.contentSize.height/2);
int rangeY = maxY - minY;
int actualY = arc4random() % rangeY;
int startX = winSize.width + (cat.contentSize.width/2);
int endX = -(cat.contentSize.width/2);
CGPoint startPos = ccp(startX, actualY);
CGPoint endPos = ccp(endX, actualY);
cat.position = startPos;
cat.tag = 1;
[cat runAction:[CCSequence actions:
[CCMoveTo actionWithDuration:1.0 position:endPos],
[CCCallFuncN actionWithTarget:self
selector:@selector(spriteDone:)],
nil]];
[_spriteSheet addChild:cat];
}
- (void)spriteDone:(id)sender {
CCSprite *sprite = (CCSprite *)sender;
[_spriteSheet removeChild:sprite cleanup:YES];
}
- (void)secondUpdate:(ccTime)dt {
[self spawnCat];
}
你应该对上面的代码线路熟悉了,如果不熟悉,建议看相关的教程后再继续。编译并运行代码,如果一切ok,你将会看到一辆车在屏幕上来回动,同时有一只猫从右至左穿过屏幕。接下来,我们将添加一些碰撞检测的代码。
为这些精灵创建Box2d的body
接下来的一步就是为每个精灵创建一个body,这样box2d就能知道它们的位置了,这样的话,当碰撞发生的时候,我们就可以被告知了。下面所做的事情和之前的教程做法差不多。
然后,这一次,我们不是更新box2d的body,然后再更新sprite。这里,我们是先更新sprite(使用action或者别的),然后再更新box2d的body。
因此,让我们首先创建world。打开HelloWorldScene.h,并在文件顶部添加下面的代码:
#import "Box2D.h"
然后在HelloWorld类中添加下面的成员变量:
b2World *_world;
然后在HeloWorldScene.mm的init方法中加入下列代码:
b2Vec2 gravity = b2Vec2(0.0f, 0.0f);
bool doSleep = false;
_world = new b2World(gravity, doSleep);
注意,这里有两件事情非常重要!首先,我们把重力向量设置成(0,0)。因为我们并不想让这些对象自动运动,而是人为控制其运动。其次,我们告诉box2d不能让这些对象sleep。这一点非常重要,因为我们是人为地移动对象,所以必须设置。
然后,在spawnCat方法上面添加下列代码:
- (void)addBoxBodyForSprite:(CCSprite *)sprite {
b2BodyDef spriteBodyDef;
spriteBodyDef.type = b2_dynamicBody;
spriteBodyDef.position.Set(sprite.position.x/PTM_RATIO,
sprite.position.y/PTM_RATIO);
spriteBodyDef.userData = sprite;
b2Body *spriteBody = _world->CreateBody(&spriteBodyDef);
b2PolygonShape spriteShape;
spriteShape.SetAsBox(sprite.contentSize.width/PTM_RATIO/2,
sprite.contentSize.height/PTM_RATIO/2);
b2FixtureDef spriteShapeDef;
spriteShapeDef.shape = &spriteShape;
spriteShapeDef.density = 10.0;
spriteShapeDef.isSensor = true;
spriteBody->CreateFixture(&spriteShapeDef);
}
这段代码对你来说应该比较熟悉了---它和我们在breakout教程中是一样的。然后,这里有一点区别,我们把shape定义的isSensor成员设置成了true。
根据Box参考手册,如果你想让对象之间有碰撞检测但是又不想让它们有碰撞反应,那么你就需要把isSensor设置成true。这正是我们想要的效果!
接下来,在spawnCat的最后一行addChild之前添加下列代码:
[self addBoxBodyForSprite:cat];
同样的,你需要在spawnCar的相应位置添加下列代码:
[self addBoxBodyForSprite:car];
当我们的sprites被销毁的时候,我们需要销毁Box2d的body。因此,把你的spriteDone方法改写成下面的形式:
- (void)spriteDone:(id)sender {
CCSprite *sprite = (CCSprite *)sender;
b2Body *spriteBody = NULL;
for(b2Body *b = _world->GetBodyList(); b; b=b->GetNext()) {
if (b->GetUserData() != NULL) {
CCSprite *curSprite = (CCSprite *)b->GetUserData();
if (sprite == curSprite) {
spriteBody = b;
break;
}
}
}
if (spriteBody != NULL) {
_world->DestroyBody(spriteBody);
}
[_spriteSheet removeChild:sprite cleanup:YES];
}
现在,最重要的一部分,我们需要根据sprite的位置改变来更新box2d的body。因此,在init方法里面添加下面一行代码:
[self schedule:@selector(tick:)];
然后,把tick方法写成下面的形式:
- (void)tick:(ccTime)dt {
_world->Step(dt, 10, 10);
for(b2Body *b = _world->GetBodyList(); b; b=b->GetNext()) {
if (b->GetUserData() != NULL) {
CCSprite *sprite = (CCSprite *)b->GetUserData();
b2Vec2 b2Position = b2Vec2(sprite.position.x/PTM_RATIO,
sprite.position.y/PTM_RATIO);
float32 b2Angle = -1 * CC_DEGREES_TO_RADIANS(sprite.rotation);
b->SetTransform(b2Position, b2Angle);
}
}
}
这个方法和我们之前在breakout中写的tick方法差不多,唯一的差别就是,我们现在是基于cocos2d的精灵位置来更新box2d的body位置。
编译并运行工程,可能看起来和之前并没有什么差别。你可能会想,到底我们刚刚写了这么多代码有用没啊!好,接下来我就会展示给你看---激活debug drawing!
激活 Box2D 的Debug Drawing
因为,你是使用box2d的模板建的项目,所以里面已经包含了一个GLES-Render.h和GLES-Render.mm文件,如果想要激活debug drawing,有这两个文件就足够了!
接下来,让我们在HelloWorldScene.h的顶部包含下面的头文件:
#import "GLES-Render.h"
然后,在HelloWorld类中添加以下成员变量:
GLESDebugDraw *_debugDraw;
// Enable debug draw
_debugDraw = new GLESDebugDraw( PTM_RATIO );
_world->SetDebugDraw(_debugDraw);
uint32 flags = 0;
flags += b2DebugDraw::e_shapeBit;
_debugDraw->SetFlags(flags);
这段代码是创建一个GLESDebug类,并且把它注册到world对象里面。我们传递一个flag来指定我们需要显示的仿真细节---这里我们只想显示box2d的shape。对于你可以设置的所有的flag标志,可以参考b2WorldCallbacks.h文件。
接下来,我们需要添加一个draw方法。在init方法后面添加draw方法:
-(void) draw
{
glDisable(GL_TEXTURE_2D);
glDisableClientState(GL_COLOR_ARRAY);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
_world->DrawDebugData();
glEnable(GL_TEXTURE_2D);
glEnableClientState(GL_COLOR_ARRAY);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
}
老实说,我现在仍然对opengl陌生,因此,我并不是很清楚这里的opengl代码做了些什么事。然后,这是激活debug draw的标准代码,它可以工作!:)
最后需要注意的一点,在dealloc函数中添加清理代码:
- (void)dealloc {
delete _world;
delete _debugDraw;
[super dealloc];
}
现在,编译并运行工程,你将会看到所有的box2d shape周围都有一个粉红色的矩形区域。如果一切ok的话,你会看到这些粉红色的shape会跟着sprite运动。
碰撞检测
现在,是时候压死几只猫了!
像之前的breakout游戏一样,我们将往world对象里面注册一个contact listener对象。你可以下载我为这个教程制作的contact listener代码,然后把 MyContactListener.h 和 MyContactListener.mm两个文件添加到工程中去:
同时,你还可以下载我为本教程制作的$$230^$^。
回到代码。在HelloWorldScene.h中添加下面的代码:
#import "MyContactListener.h"
#import "SimpleAudioEngine.h"
然后,在HelloWord类中添加下面成员变量:
MyContactListener *_contactListener;
然后,在init方法中加入下面的代码:
// Create contact listener
_contactListener = new MyContactListener();
_world->SetContactListener(_contactListener);
// Preload effect
[[SimpleAudioEngine sharedEngine] preloadEffect:@"hahaha.caf"];
最后,在tick方法的底部加入下面的代码:
std::vector<b2Body *>toDestroy;
std::vector<MyContact>::iterator pos;
for(pos = _contactListener->_contacts.begin();
pos != _contactListener->_contacts.end(); ++pos) {
MyContact contact = *pos;
b2Body *bodyA = contact.fixtureA->GetBody();
b2Body *bodyB = contact.fixtureB->GetBody();
if (bodyA->GetUserData() != NULL && bodyB->GetUserData() != NULL) {
CCSprite *spriteA = (CCSprite *) bodyA->GetUserData();
CCSprite *spriteB = (CCSprite *) bodyB->GetUserData();
if (spriteA.tag == 1 && spriteB.tag == 2) {
toDestroy.push_back(bodyA);
} else if (spriteA.tag == 2 && spriteB.tag == 1) {
toDestroy.push_back(bodyB);
}
}
}
std::vector<b2Body *>::iterator pos2;
for(pos2 = toDestroy.begin(); pos2 != toDestroy.end(); ++pos2) {
b2Body *body = *pos2;
if (body->GetUserData() != NULL) {
CCSprite *sprite = (CCSprite *) body->GetUserData();
[_spriteSheet removeChild:sprite cleanup:YES];
}
_world->DestroyBody(body);
}
if (toDestroy.size() > 0) {
[[SimpleAudioEngine sharedEngine] playEffect:@"hahaha.caf"];
}
这段代码和我们之前的breakout教程中的代码基本上差不多,因此你应该比较熟悉了。
还有一件事别忘了,往dealloc方法里面加入清理代码!这个灰常重要!
delete _contactListener;
[_spriteSheet release];
再运行一下工程看看!
调整shape的边界
你可能已经注意到了,我们的box2d shape的边界并不是和sprite的边界完全吻合。对于有些碰撞检测要求不是特别精确的游戏,这也许够了,但是,有些游戏确不行!我们需要严格地定义shape的边界和精灵的边界重合在一起。
在Box2d里面,你可以通过指定shape的顶点来指定shape的形状。然后,硬编码这些顶点数据会很耗时,而且容易出错。幸运的是, Johannes Fahrenkrug已经开发出了一个非常方便的工具,叫做 VertexHelper,它可以用来非常方便地定义顶点,而且可以导出box2d所需要的格式的数据。
好了,先去下载VertexHelper吧。他是一个Mac应用程序,同时包含了源代码,因此,你只需要打开 VertexHelper.xcodeproj,然后编译并运行就可以了。当运行工程的时候,你会看到下面的屏幕输出:(为了方便起见,你可以把编译好的工程放到Application文件夹下面,以后就直接打开就可以了)
继续,把sprites.jpg拖到VertexHelper中,拖的时候,放置在 “Drop Sprite Image here”标签上面。在Rows/Cols部分,把相应的数字设置成1和2.VertexHelper会自动地把图片划分成两个部分。
接下来,把”Edit Mode"复选框打上勾,紧接着,你需要按照逆时针方向在精灵的四周定义一些顶点。注意,box2d将自动地把最后一个顶点与第一个顶点连接起来,因此,我们不需要连接它们。
另外一件非常重要的事情是,当你定义顶点的时候,你需要确保定义的多边形是凸多边形。这意味着多边形的内部没有一个角大于180度。或者说是,多边形内任何两个顶点的连线都在多边形的内部。如果你对这个定义不是很清楚的话,建议百度一下凸多边形和凹多边形。当然,也可以看看下面这个js制作的demo。
最后,注意box2d定义了b2_maxPolygonVertices,它限制了每一个shape最多可以定义的顶点的个数,默认值是8.当然,你可以在b2Settings.h中把这个值改掉。但是,这个教程中,我们只需要8个顶点就够了。
(这个过程最好用一个视频来演示一下,不过翻不了墙,也看不了了。不过自己多摸索一下,这个工具还是很容易使用的)
一旦你做完之后,在下拉列表中选择Box2d,Style选择“Initialization”。然后把右边生成的代码copy下来并粘贴到工程中去。
好,让我们把工程中的shape定义换一下。打开HelloWorldScene.mm,然后修改 addBoxBodyForSprite方法。首先注释掉一些代码,如下图所示:
/*spriteShape.SetAsBox(sprite.contentSize.width/PTM_RATIO/2,
sprite.contentSize.height/PTM_RATIO/2);*/
if (sprite.tag == 1) {
// Uncomment this and replace the number with the number of vertices
// for the cat that you defined in VertexHelper
//int num = 6;
//b2Vec2 verts[] = {b2Vec2(4.5f / PTM_RATIO, -17.7f / PTM_RATIO),
//b2Vec2(20.5f / PTM_RATIO, 7.2f / PTM_RATIO),
//b2Vec2(22.8f / PTM_RATIO, 29.5f / PTM_RATIO),
//b2Vec2(-24.7f / PTM_RATIO, 31.0f / PTM_RATIO),
//b2Vec2(-20.2f / PTM_RATIO, 4.7f / PTM_RATIO),
//b2Vec2(-11.7f / PTM_RATIO, -17.5f / PTM_RATIO)};
// Then add this
//spriteShape.Set(verts, num);
} else {
// Do the same thing as the above, but use the car data this time
}
再多啰嗦一句:在选择Style的时候一定要选择“Initialization”,因为b2PolygonShape:Set方法对于“Initialization”会自动计算shape的法向量和质心。
一旦做完之后,编译并运行工程。这时,你会看到shape的边界基本上和sprite的边界吻合在一起了,当然碰撞检测就会更加真实了!
恩,你已经看到了使用box2d来做碰撞检测的强大了!
发挥想象,去做更多好玩的物理效果游戏吧!
总结!
这里有本教程的完整源代码。
注意,这里用来做碰撞检测的代码,仅仅是box2d里面实现碰撞检测的一种方式。Lam在cocos2d论坛里面提出了另外一种方法,使用b2CollidePolygons来做碰撞检测,详情请参考这里。如果你只想做碰撞检测,你可能想看看lam的实现。
对于其它开发者全体在他们的项目里面使用cocos2d和box2d,我非常之感兴趣!你在你的项目中使用box2d吗?或者仅仅用它来做碰撞检测,或者根本不使用它?或者如果你使用box2d来做碰撞检测,你是怎么做的呢?
欢迎大家踊跃发言,分享自己的经验和看法,谢谢!