Collision Notification Protocol with Swift and SpriteKit
How to implement contact notifications with a Swift protocol to delegate hit test logic on collisions between entities on top of GameplayKit and SpriteKit.
This is the third and final post about the implementation of how to efficiently handle hit testing on top of SpriteKit and GameplayKit in Swift.
I've previously written about how I've implemented and configured the hit tests using components in Implementing a GKComponent for Contact Tests with SpriteKit. I also further touch on the implementation in my Devlog #3: Collision Detection. Check them out if you haven't already.
Contact Notification Protocol
The goal has from the first line of code been to end up with a streamlined collision detection handling between entities. A system, that once implemented, I can keep assigning to different entities and it takes care of all the heavy lifting as automatic as possible. While at the same time keeping the code neat and well organized.
Using the Entity Component System design pattern with GameplayKit and Swift has been a great experience so far and has made organizing the game logic and mechanics very natural.
Continuing from my previous posts on the subject, what I had left to do was to actually let the entities know when they have been hit or have hit something. I didn't want to end up with huge didBegin(_:)
and didEnd(_:)
methods for SKPhysicsContactDelegate
, so I opted to use a Swift protocol to delegate the logic elsewhere.
That allows me keep all the logic in a different place than where the actual detection happens. Instead the logic can be contained within each entity or delegate it even further away to a GKComponent
or a GKState
.
This is the protocol I ended up with.
protocol ContactNotifiable {
func contactDidBegin(with entity: GKEntity)
func contactDidEnd(with entity: GKEntity)
}
Apart from having methods to let an entity know that it collided with another entity, it also gets a reference to the entity it collided with. By passing in a reference to the entity it collided with it's easy to determine what logic to run and to get access to any relevant data. Like how much damage that entity does.
An entity that needs to be aware of collisions needs to implement these two methods which is pretty much all there is to it. A solution that is beautiful in its simplicity.
Adopting the Protocol
How each entity deals with the contact notifications spans a wide range in complexity. When an entity needs to receive notifications it adopts the ContactNotifiable
protocol.
We have the most basic version where I can let the logic live in the entity itself. A bullet would be such a case where all I'm doing on detected contact is to remove the bullet from the game. The damage done by the bullet is handled by the entity getting hit.
So the Bullet GKEntity
would get this implementation to handle the contact notifications.
extension Bullet: ContactNotifiable {
func contactDidBegin(with entity: GKEntity) {
addComponent(RemoveComponent())
}
func contactDidEnd(with entity: GKEntity) {}
}
In the bullet example above there would also be another entity involved, probably an Enemy entity, which would also receive a contact notification. The Enemy GKEntity
class adopts the ContactNotifiable
protocol as well.
When an Enemy collides there are many paths of action that might be taken so in contrast to the Bullet entity I did not want that much logic in the GKEntity
class. So in the Enemy case I use a finite state machine based on GKStateMachine
to take care of the different scenarios that might occur.
The actual state machine is implemented as a GKComponent
. The StateMachineComponent
, which I'm also reusing for other entities that needs to use states.
So an enemy getting hit by a bullet would tell the state machine to enter the exploding GKState
when it gets hit.
extension Enemy: ContactNotifiable {
func contactDidBegin(with entity: GKEntity) {
guard let stateMachine = component(ofType: StateMachineComponent.self)?.stateMachine else { return }
if entity is Bullet {
stateMachine.enter(EnemyExplodeState.self)
}
}
func contactDidEnd(with entity: GKEntity) {}
}
So far we haven't used the contactDidEnd(_:)
method from the ContactNotifiable
protocol. A use case when that method is convenient would be when the Player is taking damage while in contact with an Enemy entity.
extension Player: ContactNotifiable {
func contactDidBegin(with entity: GKEntity) {
if entity is Enemy {
// Code to begin take damage...
}
}
func contactDidEnd(with entity: GKEntity) {
if entity is Enemy {
// Code to end take damage...
}
}
}
Delegate Notifications
Finally we just need to delegate the contact events using SpriteKit's SKPhysicsContactDelegate
to the entities that should receive contact notifications.
The implementation looks something like this where we check each entity involved in the collision if it would want to get a notification. If it is a ContactNotifable
entity we call the method and pass in the entity it collided with and then the custom logic in the entity can take over. Swift protocols are beautiful.
extension LevelScene: SKPhysicsContactDelegate {
func didBegin(_ contact: SKPhysicsContact) {
let entityA = contact.bodyA.node?.entity
let entityB = contact.bodyB.node?.entity
if let notifiableEntity = entityA as? ContactNotifiable, let otherEntity = entityB {
notifiableEntity.contactDidBegin(with: otherEntity)
}
if let notifiableEntity = entityB as? ContactNotifiable, let otherEntity = entityA {
notifiableEntity.contactDidBegin(with: otherEntity)
}
}
func didEnd(_ contact: SKPhysicsContact) {
let entityA = contact.bodyA.node?.entity
let entityB = contact.bodyB.node?.entity
if let notifiableEntity = entityA as? ContactNotifiable, let otherEntity = entityB {
notifiableEntity.contactDidEnd(with: otherEntity)
}
if let notifiableEntity = entityB as? ContactNotifiable, let otherEntity = entityA {
notifiableEntity.contactDidEnd(with: otherEntity)
}
}
}
Conclusion
That wraps it up for collision handling in this game project. By using a GKComponent
to define the collision configuration and a ContactNotifiable
protocol to delegate the logic to where it belongs the best, we have ended up with a solution that is solid, fast to work with and that can scale when more entities gets added to the game.