碰撞检测也称冲突检测,是游戏程序中的一个非常重要的功能,用于检测游戏画面中的物体是否发生碰撞,进而可以采取相应的措施应对此碰撞。绝大多数的游戏引擎都提供了对碰撞检测的支持。
本篇我们将介绍Cocos2d的碰撞检测功能。
Cocos2d中为碰撞检测提供支持的模块是cocos.collision_model。通过该模块,我们可以检测两个物体是否发生碰撞、指定物体与哪些物体发生碰撞、指定物体的指定距离范围内都有哪些物体、哪些物体发生了碰撞等情形。
其原理是:按照指定的单元格(cell)大小把待检测区域划分为一系列的网格(grid),然后通过空间哈希(spatial hashing)的方式在内部维护一张表格,用来记录每个单元格分别都与哪些物体重叠,以此为基础检测物体之间的碰撞或者邻近。
另外,为简单起见,需要把待检测的物体(object)抽象为简单的几何图形(shape),如圆形或者矩形。这样一来,两个物体是否发生碰撞的问题就简化为了两个物体的图形是否发生重叠的问题,如图1所示。
▍图1 碰撞检测原理
待检测区域被划分为若干个尺寸均为cell_width×cell_height的cell,这些cell在整体上形成了一个grid。区域中分布着一些用简单几何图形(圆形、矩形或两者混用)表示的待检测的object。内部用一张哈希表维护cell与object的重叠关系。
cocos.collision_model模块中的CollisionManagerGrid类负责核心功能的实现,由它维护cell与object之间的哈希关系,并对碰撞情况进行统一管理。除此之外,该模块还定义了CircleShape与AARectShape类,用来表示物体被抽象的几何图形。
图1中的grid左下角坐标(xmin,ymin)、右上角坐标(xmax,ymax)、cell宽度cell_width、cell高度cell_height都是在初始化CollisionManagerGrid类时必须指定的。(xmin,ymin)与 (xmax,ymax)在一般情况下为屏幕的左下角坐标与右上角坐标,也可以把它们设置为任意想要进行碰撞检测的区域。cell_width和cell_height均为估值,如果所有待检测物体的宽度都相同,那么cell_width一般取值为1.25×待检测物体的宽度;如果待检测物体的宽度不同,那么cell_width取值为1.25×所有待检测物体宽度的最大值。不过在这种情况下,要排除非常大的物体,此检测方法对超大尺寸的物体无效。cell_height与cell_width的取值情况类似。
那么,如何设置物体的基本几何图形呢?这需要在物体类的_ _init_ _()函数中指定。如何把CollisionManagerGrid与物体关联起来呢?CollisionManagerGrid类提供了一系列方法用来管理那些需要进行碰撞检测的物体,如把物体添加到grid中,把物体从grid中移除等。
只谈原理的话比较抽象,现在讲解在代码中如何实现目标物体的碰撞检测。
前面讲过,碰撞检测时需要把待检测的物体抽象为简单的几何图形,要么为圆形,要么为矩形,这又该如何实现呢?只要在物体类的_ _init_ _()中为其添加一个名为cshape的属性,并为其赋值CircleShape或AARectShape类的实例对象即可。CollisionManagerGrid正是根据物体对象的cshape属性计算物体之间的位置关系的。CircleShape与AARectShape都是cocos.collision_model模块中定义的类,分别代表圆形和矩形,它们均继承自Cshape类。
示例如下。
from cocos.sprite import Sprite
from cocos.collision_model import AARectShape, CircleShape
from cocos.euclid import Vector2
class Actor(Sprite):
def __init__(self, pos):
super().__init__('obj.png')
self.position = pos
self.cshape = AARectShape(Vector2(self.x, self.y), self.width/2, self.height/2)
#self.cshape = CircleShape(Vector2(self.x, self.y), self.width/2)
def update_cshape(self):
self.cshape.center = Vector2(self.x, self.y)
上述代码定义了一个继承自Sprite的Actor类,由于在后面需要对Actor进行碰撞检测,因此在_ _init_ _()中添加了一个self.cshape属性,并将其赋值为AARectShape的实例对象,表示把Actor抽象为矩形。
AARectShape构造函数的原型为:
__init__(center, half_width, half_height)
其中包含以下3个参数。
●center:矩形中心点,类型必须为cocos.euclid.Vector2。
● half_width:矩形宽度的一半。
● half_height:矩形高度的一半。
这是一个各边平行(垂直)于坐标轴的矩形。
上述代码把矩形的中心点设置为Actor的中心点。由于Sprite类的self.position,self.x,self.y默认就是其中心点的位置,所以这里把AARectShape构造函数的center参数指定为Vector2(self.x,self.y)。之所以把中心点构造成Vevtor2的对象,而不是直接赋值(self.x,self.y),是因为这样可以方便地进行向量计算。然后,设置half_width与half_height参数分别为Actor宽度与高度的一半。
这里,我们也可以使用CircleShape初始化cshape属性。
CircleShape类构造函数的原型为:
__init__(center, r)
其中包含以下两个参数。
●center:圆形圆心,类型必须为cocos.euclid.Vector2。
● r:圆形半径。
在上述代码中,如果使用CircleShape初始化self.cshape,则可以同样令其center参数为Vector2(self.x,self.y),表示把Actor的中心点当作圆形的圆心,半径r可设为宽度的一半。
那么,在初始化cshape属性时,究竟该使用AARectShape还是CircleShape呢?答案是具体情况具体分析。一般来说,使用AARectShape就足够了,它比CircleShape的性能更好,但是如果有物体发生旋转或者物体的外观为圆球形,那么使用CircleShape会更为精确。
再回过头看前面的代码,Actor类还定义了一个self.update_cshape()方法,并在其中更新了self.cshape的center属性。为什么要这么做呢?这是因为,Actor的position会随时发生改变,但是改变position并不会令cshape的位置随之改变,所以需要经常在position改变之后手动更新cshape的位置。我们通过指定cshape的center属性值更新它的位置,该center实际上等同于其构造函数参数中的center,只不过后者所指定的只是它的初始值。下面让cshape的center与Actor的position时刻保持一致,即如上代码中的self.cshape.center=Vector2(self.x,self.y)(注:self.x与self.y分别为self.position的横坐标与纵坐标)。因此,如果在代码的其他位置更新过self.position(或者self.x或者self.y),千万不要忘记调用update_cshape()更新cshape的位置。
下面需要在对Actor进行管理的类(如Layer派生类)中创建一个CollisionManagerGrid的实例对象,以管理各种待检测目标。
代码如下。
from cocos.layer import Layer
from cocos.collision_model import CollisionManagerGrid
class MyLayer(Layer):
def __init__(self):
super().__init__()
self.cm = CollisionManagerGrid(0, 640, 0, 480, 64*1.25, 64*1.25)
上述代码定义了一个MyLayer类,用来管理和显示Actor,我们在它的__init__()中创建一个CollisionManagerGrid的实例对象,并把它赋值给self.cm。后续将使用此self.cm管理各种待检测目标。
CollisionManagerGrid构造函数的原型为:
__init__(xmin, xmax, ymin, ymax, cell_width, cell_height)
其中包含以下六个参数。
● xmin:待检测矩形区域x坐标的最小值。
● xmax:待检测矩形区域x坐标的最大值。
● ymin:待检测矩形区域y坐标的最小值。
● ymax:待检测矩形区域y坐标的最大值。
● cell_width:网格中每个cell的宽度。
● cell_height:网络中每个cell的高度。
结合前文可知,在初始化CollisionManagerGrid时,等同于初始化了一个有待进行碰撞检测的矩形区域的网格,(xmin,ymin)是该矩形区域的左下角坐标,(xmax,ymax)是该矩形区域的右上角坐标。并且前面也介绍过,一般是把待检测区域设置为整个屏幕,将cell_width和cell_height设置为目标物体宽度与高度的1.25倍。由此,在代码中设置xmin=0,xmax=640,ymin=0,ymax=480,cell_width=64×1.25,cell_height=64×1.25(这里假设屏幕窗口尺寸为640×480,Actor尺寸为64×64)。
创建CollisionManagerGrid类的实例对象self.cm后,接下来就可以利用它对目标物体进行管理了。在给出示例代码之前,让我们先了解一下CollisionManagerGrid类的一些常用方法。
add(obj)
把obj添加到CollisionManagerGrid进行管理,使obj成为已知对象(known object)。known object是一种特有的称呼,我们把所有通过add()函数添加到CollisionManagerGrid的物体对象都称为known object。与之相对应,把所有包含cshape属性的物体都称为可冲突对象(collidableobject)。从前面的逻辑中可以看出,要想使物体成为known object,它必须首先是collidable object。参数obj为待检测物体类的对象,必须包含cshape属性,即obj必须为collidable object。
在同一个CollisionManagerGrid所管理的所有known object中,它们的cshape可以为相同类型,也可以为不同类型,允许CircleShape与AARectShape混用。
remove_tricky(obj)
与add()相反,该函数使obj由known object变为unknown object,此后CollisionManagerGrid将不再关心该obj。
clear()
将CollisionManagerGrid中的所有known object清空。
objs_colliding(obj)
返回所有与obj相碰撞的known object的集合。注意:参数obj可以不必为known object,但它必须为collidable object。
iter_colliding(obj)
与objs_colliding()功能类似,也是返回所有与obj相碰撞的known object,只不过该函数并不返回集合,而是一个生成器。参数obj可以不必为known object,但必须是collidable object。
除了以上这几个方法以外,CollisionManagerGrid类还定义了一些其他有用的方法,如they_collide(),any_near(),objs_near(),objs_near_wdistance(),ranked_objs_near(),iter_all_collisions()等,鉴于篇幅有限,不再一一介绍。
学习了上面几个方法后,接下来学习如何使用它们。
由于在add(obj)时CollisionManagerGrid内部的哈希表(cell与object的对应关系)就已经确定,它绑定的是obj被add时刻的位置信息,所以如果obj的位置在后面发生变化,哈希表是不会随之更新的,此时调用remove_tricky(),objs_colliding(),iter_colliding()将得到错误结果。因此,在add()之后及remove_tricky()/objs_colliding()/iter_colliding()之前,obj不能有位置变化,换句话说,add()时刻obj的位置必须与remove_tricky()/objs_colliding()/iter_colliding()时刻obj的位置保持一致。
我们知道,objs_colliding(),iter_colliding()函数的参数所表示的待检测对象(称为“主角”)是不必成为known object的,所以把“主角”除外,其他待检测对象在不同的情况下有不同的碰撞检测解决方案。
如果待检测对象(不包括主角)是始终保持静止的,那么只需要在最一开始时add它们一次。
假设画面中有三个Food、一个Player,其中Food是保持静止的,只有Player不断移动,我们可以按照以下方法实现碰撞检测。
class MyLayer(Layer):
def __init__(self):
super().__init__()
for i in range(3):
self.add(Food(i*100, 40))
self.player = Player((640/2, 480/2))
self.add(self.player)
self.cm = CollisionManagerGrid(0, 640, 0, 480, 64*1.25, 64*1.25)
for _, node in self.children:
if isinstance(node, Food):
self.cm.add(node)
self.schedule(self.update)
def update(self, dt):
self.player.move()
self.collide()
def collide(self):
for food in self.cm.objs_colliding(self.player):
self.cm.remove_tricky(food)
self.remove(food)
# do something else
游戏中总共有四个角色,三个为食物(Food对象)、一个为主角(Player对象),Food类与Player类均继承自Actor类,即它们的对象都是colliable object。我们想要让主角在碰到食物时把食物吃掉,所以后续会对主角与食物进行碰撞检测。通过CollisionManagerGrid的objs_colliding()或者iter_colliding()方法可以判断哪些Food对象与Player对象发生了碰撞,由此,我们需要把所有Food对象都添加到CollisionManagerGrid,使它们成为known object,而Player对象作为被检测的“主角”,则不必成为known object。由于所有食物都是保持静止的,在add()之后不必担心它们的位置变化,所以我们可以放心地在_ _init_ _()中把所有Food对象add到CollisionManagerGrid,此后在循环中进行碰撞检测。
如上代码,在_ _init_ _()中,我们为MyLayer添加了三个Food对象及一个Player对象作为其子节点,初始化了一个CollisionManagerGrid的对象self.cm用来进行碰撞检测管理。然后通过for循环获取了当前节点下所有类型为Food的子节点,执行self.cm.add()使它们都成为known object,然后发起了一项函数任务self.update(),让它周期性地被执行。self.update()函数所做的事情是:更新self.player的位置,让它不断移动,同时调用self.collide()进行碰撞检测。self.collide()是进行碰撞检测的关键函数,其中,我们通过objs_colliding()检测所有与self.player发生碰撞的Food对象,并遍历获取Food对象集合,调用self.cm.remove_tricky()把它们从known object列表中删除,同时调用self.remove()把它们从屏幕上删除,即主角把食物“吃掉”了。当然,在检测出碰撞发生后,你还可以做一些其他的事情,如更新得分。
如果待检测对象(不包括主角)是不断移动的,那么最好的检测方式就是每帧都进行clear和add操作。
假设屏幕上有三个Enemy、一个Player,它们的位置都是每帧更新一次,即不断移动,则需要按照以下方法实现碰撞测试。
class MyLayer(Layer):
def __init__(self):
super().__init__()
self.enemy1 = Enemy((100, 40))
self.add(self.enemy1)
self.enemy2 = Enemy((200, 40))
self.add(self.enemy2)
self.enemy3 = Enemy((300, 40))
self.add(self.enemy3)
self.player = Player((640/2, 480/2))
self.add(self.player)
self.cm = CollisionManagerGrid(0, 640, 0, 480, 64*1.25, 64*1.25)
self.schedule(self.update)
def update(self, dt):
self.player.move()
self.enemy1.move()
self.enemy2.move()
self.enemy3.move()
self.collide()
def collide(self):
self.cm.clear()
for _, node in self.children:
self.cm.add(node)
for enemy in self.cm.objs_colliding(self.player):
self.remove(enemy)
游戏中总共有四个角色,三个为敌人(Enemy对象)、一个为主角(Player对象),Enemy类与Player类都继承自Actor类。后续需要判断哪些敌人与主角发生了碰撞,因此需要把所有Enemy对象都add进CollisionManagerGrid;又由于敌人是不断移动的,所以需要在其位置更新后再add一次,并同时进行碰撞检测,这就需要反复不断地对CollisionManagerGrid执行clear与add操作。
上述代码与前面一段代码十分类似,差别在于:self.update()在更新self.player位置的同时也更新了敌人的位置;其次,add的位置不在_ _init_ _()中,而是在self.collide()中。在self.collide()中,首先调用self.cm.clear清空known object列表,然后在for循环下调用self.cm.add(),把当前节点下的所有子节点都添加到known object列表,注意:这里也把self.player添加为了known object。其实,无论self.player是不是known object,都不会影响检测结果。另外,在检测到碰撞发生时,上述代码并没有调用self.cm.remove_tricky()清除发生碰撞的敌人,这是因为在执行self.remove()后,发生碰撞的敌人已经从当前节点下删除了,当重新add时,该敌人已经不是当前节点的子节点了,所以它不会再有机会出现在known object列表中。当然,当碰撞发生时,也可以不删除发生碰撞的Enemy对象,这取决于实际需求。由于self.collide()函数发生在敌人的位置改变之后,因此在其中进行add和碰撞检测是十分安全的。
最后,总结一下碰撞检测中的一些关键点。
● 所有待检测对象(collidable object)类都必须包含cshape属性。
●cshape属性的类型可以是AARectShape,也可以是CircleShape,一般用AARectShape,在有旋转发生或者有圆球形物体的情况下,最好用CircleShape。
● 在待检测对象的位置改变之后,也需要更新cshape的位置。
● CollisionManagerGridcell尺寸为物体最大尺寸的1.25倍,超大尺寸物体不适用。
● 被检测的主角就是objs_colliding()/iter_colliding()的参数,其可以不必成为known object。
● Known object一定是collidable object,反之不然。
● 针对保持静止的物体,只需在最开始时add一次。
● 针对保持移动的物体,需要每帧进行clear与add操作。
● 程序中允许存在多个CollisionManagerGrid对象。
● 如果既有静止的物体,也有移动的物体,那么可以使用两个CollisionManagerGrid,把它们分开管理。
● 同一个物体可以同时成为多个CollisionManagerGrid对象的known object。
为了让读者对Collision具有更加充分的了解,下面给大家展示一个完整的示例程序——简单的《猫吃老鼠》游戏,如图2所示。
▍图2 《猫吃老鼠》游戏
屏幕上有5只老鼠,它们的初始位置都在窗口的最左边;屏幕上还有一只猫,它的初始位置在窗口的右下角,按下方向键可以改变猫的位置,当猫遇到老鼠时,猫会把老鼠吃掉。
该程序有两个源文件:一个是actor.py,另一个是main.py。actor.py中定义了游戏的各个角色类,main.py中定义了图层类和程序入口。
我们把游戏分成两种情况:一种是老鼠保持静止,另一种是老鼠不断移动。代码分别如下。
actor.py
from cocos.sprite import Sprite
from cocos.collision_model import AARectShape
from cocos.euclid import Vector2
from pyglet.window import key
class Actor(Sprite):
def __init__(self, image, pos):
super().__init__(image)
self.position = pos
self.cshape = AARectShape(Vector2(self.x, self.y), self.width/2, self.height/2)
def update_cshape(self):
self.cshape.center = Vector2(self.x, self.y)
class Mouse(Actor):
def __init__(self, pos):
super().__init__('mouse.png', pos)
class Cat(Actor):
def __init__(self, pos):
super().__init__('cat.png', pos)
def move(self, pressed):
if pressed[key.LEFT]:
if self.x > 0:
self.x -= 3
elif pressed[key.RIGHT]:
if self.x < 500:
self.x += 3
elif pressed[key.UP]:
if self.y < 300:
self.y += 3
elif pressed[key.DOWN]:
if self.y > 0:
self.y -= 3
self.update_cshape()
main.py
from collections import defaultdict
from cocos.director import director
from cocos.scene import Scene
from cocos.layer import ColorLayer
from cocos.collision_model import CollisionManagerGrid
from actor import Mouse, Cat
class MainLayer(ColorLayer):
is_event_handler = True
def __init__(self):
super().__init__(220, 220, 220, 255)
for i in range(5):
mouse = Mouse((24, 300 * (6-i-1)/6))
self.add(mouse)
self.cat = Cat((468, 32))
self.add(self.cat)
self.pressed = defaultdict(int)
self.schedule(self.update)
self.cm = CollisionManagerGrid(0, 500, 0, 300, 48*1.25, 48*1.25)
for _, node in self.children:
if isinstance(node, Mouse):
self.cm.add(node)
def on_key_press(self, k, _):
self.pressed[k] = 1
def on_key_release(self, k, _):
self.pressed[k] = 0
def update(self, dt):
self.cat.move(self.pressed)
self.collide()
def collide(self):
for mouse in self.cm.objs_colliding(self.cat):
self.cm.remove_tricky(mouse)
self.remove(mouse)
if __name__ == "__main__":
director.init(caption='CatMouse', width=500, height=300)
main_layer = MainLayer()
main_scene = Scene()
main_scene.add(main_layer)
director.run(main_scene)
老鼠不断左右移动,当老鼠遇到窗口边界时,老鼠就会向相反方向移动。与上一种情况相比,在这种情况下,代码的大部分都是相同的,只有细微的差别。
actor.py
--snip--
class Mouse(Actor):
def __init__(self, pos):
super().__init__('mouse.png', pos)
self.direction = 1
self.schedule(self.move)
def move(self, dt):
self.x += self.direction
if self.x >= 500 or self.x < 0:
self.direction *= -1
self.update_cshape()
--snip--
main.py
--snip--
class MainLayer(ColorLayer):
--snip--
def collide(self):
self.cm.clear()
for _, node in self.children:
self.cm.add(node)
for mouse in self.cm.objs_colliding(self.cat):
self.remove(mouse)
--snip--
上述两段代码不做过多解释,留给读者自行参考和体会。