SpriteKit:开启物理逐像素(Per-Pixel)碰撞检测后发现的问题及解决

下面是一个开源的iOS小游戏,类似于 Flappy Bird,玩家需要不停点击屏幕让小飞机从峭壁中穿过,如果飞机撞上峭壁或地面游戏宣告结束,否则每穿过一次峭壁,玩家加一分.

SpriteKit:开启物理逐像素(Per-Pixel)碰撞检测后发现的问题及解决_第1张图片

因为只是飞机和其他任何物理对象碰撞,而其他物理对象相互之间均不会发生碰撞.所以原作者将飞机的接触掩码(contact mask)设置为任何对象,将碰撞掩码设置为0,表示不和任何对象碰撞:

player.physicsBody!.contactTestBitMask = player.physicsBody!.collisionBitMask
player.physicsBody?.collisionBitMask = 0

player.physicsBody?.isDynamic = false
player.physicsBody?.allowsRotation = false

注意:以上对collisionBitMask的操作不是多此一举!因为默认collisionBitMask为-1,即表示和任何对象碰撞,将其赋值给contactTestBitMask表示和任何对象接触.而之后的一句将其设置为0表示将player不和任何其他对象碰撞.

游戏得分的触发机制在于作者将一个触发器放置在一对悬崖之后,如果player接触到触发器则表示玩家顺利通过悬崖,因此应该得分!画面类似如下:

SpriteKit:开启物理逐像素(Per-Pixel)碰撞检测后发现的问题及解决_第2张图片

我们分别将峭壁,地面和触发器分别命名为rock,ground和scoreDetect.作者只是简单创建它们的物理对象,并没有做进一步的设置.只是设置了触发器物理对象的名字:

rockCollision.name = rockCollisionName

然后作者在物理接触回调方法中这样判断player是否接触的是触发器或是其他什么会杀死它的东东:

func didBegin(_ contact: SKPhysicsContact) {
        //如果player接触到scoreDetect触发器则... 
        if contact.bodyA.node?.name == rockCollisionName || contact.bodyB.node?.name == rockCollisionName{
            if contact.bodyA.node == player{
                //contact.bodyB.node?.name = "WillDelete"
                contact.bodyB.node?.removeFromParent()
            }else{
                //contact.bodyB.node?.name = "WillDelete"
                contact.bodyA.node?.removeFromParent()
            }

            let sound = SKAction.playSoundFileNamed("coin.wav", waitForCompletion: false)
            run(sound)

            score += 1
            //直接退出方法
            return
        }
        //否则如果player不是接触到scoreDetect,则...
        if contact.bodyA.node == player || contact.bodyB.node == player{
        //...

代码逻辑很清楚,当player接触到触发器时,删除触发器同时增加玩家得分;否则player接触的就是其他东东,此时删除player,播放爆炸动画,游戏结束!

不过App运行的时候会有一个问题,就是在绝大多数情况下玩家接触到触发器后得分增加然后爆炸,只有极少数情况下玩家可以顺利穿越触发器.这会让玩家感到十分糟糕的体验.

为了排除player穿越触发器误触到rock的情况,先将rock之间的间距加大:

let rockDistance:CGFloat = 70 * 2

同时打开物理调试显示支持:

view.showsPhysics = true

结果测试发现player完全没有接触到rock时仍然爆炸.接着在didBegin(_ contact: SKPhysicsContact)开始处增加调试输出:

print("********** A:\(contact.bodyA.node) B:\(contact.bodyB.node) **********")

运行App发现,第一次player接触到触发器后,虽然删除了触发器,可是还会多次触发didBegin方法,而此时contact.body.node?.name都为nil.这表示在物理对象模拟中,会累计触发多次接触行为,而在第一次接触中将node删除并不会导致后续的已触发行为被删除.这样当第一次触发删除了scoreDetect后,再次进入didBegin就会进入后面的代码,导致程序运行到爆炸的逻辑分支.

但实际上也不是每次都会发生多次触发的情况,当只有一次触发时程序显然是正确的!这正是我们先前观察到的情况.

那为什么会发生这种情况呢!?首先是排除法,为了实现游戏的真实性,作者采用了逐像素(Per-Pixel)创建物理对象的方法:

player.physicsBody = SKPhysicsBody(texture: playerTexture, size: playerTexture.size())

不光是player,rock,ground等几个对象都是如此,现将上述player创建的方式修改如下:

player.physicsBody = SKPhysicsBody(rectangleOf: playerTexture.size())

即使用外网rect作为其物理边界,再次运行App,此时游戏运行非常完美,不再发生多次触碰了.分析得知,当玩家点击屏幕时为了游戏真实性会给player一个垂直方向的力:

case .playing:
        player.physicsBody?.velocity = CGVector(dx: 0, dy: 0)
        player.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 20))

然后时不时地再给player一个略微向下的旋转,模拟机头下坠,这是在游戏场景每次帧刷新里实现的:

override func update(_ currentTime: TimeInterval) {

        guard player != nil else {return}

        let value = player.physicsBody!.velocity.dy * 0.001
        let rotate = SKAction.rotate(byAngle: value, duration: 0.1)
        player.run(rotate)
    }

因为player这架小飞机启用了Per-Pixel物理边界所以棱角凸凹分明,再加上时不时地还会旋转翻滚,那么很自然的就可能发生其机体多个小棱角在短时间内多次接触其他对象的情况.当启用其Rect矩形边界时多次接触消失,也印证了这个猜测!

我不知道原作者在测试时是否碰到这个问题,但其在didBegin方法中的简单判断方法显然是有问题的.

知道了原因,解决也就很简单了,我们只要不通过node的name判断就可以了,在GameScene中新建一个结构:

struct Mask{
    static let player:UInt32 = 1
    static let rock:UInt32 = 2
    static let ground:UInt32 = 4
    static let scoreDetect:UInt32 = 8
}

按照上述定义分别赋值player,rock,ground以及scoreDetect的物理对象的分类掩码,然后将didBegin的判断方法改为如下代码:

if contact.bodyA.categoryBitMask == Mask.scoreDetect || contact.bodyB.categoryBitMask == Mask.scoreDetect{
    //...
    return
}

注意这个改变带来的直接结果就是,判断逻辑由name变为cateoryBitMask,不管scoreDetect有没有被删除都没有关系了,不管didBegin被触发多少次都无所谓了.

SpriteKit:开启物理逐像素(Per-Pixel)碰撞检测后发现的问题及解决_第3张图片

后期只需要将scoreDetect的长矩形改为不可见,同时关闭物理调试显示就OK了.

你可能感兴趣的:(碰撞检测,SpriteKit,物理像素,Per-Pixel)