Tutorial: How to implement a D-Pad
This is going to be the first tutorial on the internet explaining by detail how to implement a D-Pad so you can move your player in any direction!
I just started with SceneKit less than two weeks ago and already have plans on writing a tutorial series on it in parallel to Vapor. However this article here has to be published as soon as possible because of two simple reasons:
- I couldn’t find any resource on the internet explaining this very topic 😳
- It took me 5 days to figure it out myself and I want you to have it easier ✨
NOTE: I assume you have a very basic understanding about nodes and how to add them to a scene as well as cameras and lights. 😊
Expectation
We’ll start from a basic setup I’ve created so we can focus purely on the D-Pad.
And here is what we will have implemented by the end of this tutorial 😊
Index
1. Clone project with basic set up
2. Create an overlay to display D-Pad area
3. Create a virtual D-Pad area
4. Make the player rotate to desired direction
5. Make the player move to desired direction
1. Clone project with basic set up
First clone the basic project I have prepared on Github here: basic-setup.
2. Create an overlay to display D-Pad area
We are going to implement a SCNView that gets a SKScene as a overlay. No worries if you are not familiar with these classes it is simpler as it sounds 😊
So first create a new swift file and name it GameView. And then insert the following code:
import SceneKit
import SpriteKit/// Is used in Main.storyboard
/// under Identity Inspector
final class GameView: SCNView { override func awakeFromNib() {
super.awakeFromNib() setup2DOverlay()
} func setup2DOverlay() {
let viewHeight = bounds.size.height
let viewWidth = bounds.size.width let sceneSize = CGSize(width: viewWidth, height: viewHeight)
let skScene = SKScene(size: sceneSize)
skScene.scaleMode = .resizeFill
let dpadShape = SKShapeNode(circleOfRadius: 75)
dpadShape.strokeColor = .white
dpadShape.lineWidth = 2.0 dpadShape.position.x = dpadShape.frame.size.width / 2 + 10
dpadShape.position.y = dpadShape.frame.size.height / 2 + 10 skScene.addChild(dpadShape)
skScene.isUserInteractionEnabled = false overlaySKScene = skScene
}
}
Let me explain what we do here. We are using awakeFromNib() to set up our view. This is nothing special, apples documentation says this about it:
Typically, you implement
awakeFromNib
for […] additional set up […].
Okay now to the interesting part. in setup2DOverlay() we are declaring two variables to store the height and width of the view so it is more readable when we use it in the next line. You remember that we want to create an overlay right? That overlay better have the same height and the same width as the view. Our overlay will be a SKScene and we can initialize it with a size. That size must be of type CGSize so we initialize a CGSize with the width and height of our view and are then able to initialize our SKScene. The scaleMode is used to match our view. I know it is a bit weird, since we added the exact size to it, but you can try commenting it out later and see what happens 🤓
Next we create a simple Shape. We want our D-Pad to be a circle with a white border of width 2. So that is quite simple to understand right 😊 ?
Now to the positioning. The best way to start explaining it is by showing what happens if we don’t adjust the position:
Our circle is positioned at it’s center at the lower left corner of our view. Now we want to position our circle to the right by the half of its width. This would result in having our circle exact at the border. That’s why we add 10px to have a little space. Same goes for the height.
Time to add our circle (dpadShape) to our skScene. And since we will add our skScene as an overlay to our view (GameView), we need to deactivate user interaction. Now you might ask: but Martin, how can we then track the interaction with the D-Pad? I am glad you asked! The shape we created ist just for the looks. We will use it to see something on our screen. We could’ve also used an image that is transparent and has a circle. It has no functionality. Instead what we will do a little more down in this article is create another circle with the same size at the same position. A virtual representation.
But for now let’s go to GameViewController.swift and use our GameView:
import UIKit
import QuartzCore
import SceneKitclass GameViewController: UIViewController {
var gameView: GameView {
return view as! GameView
}
var scene: SCNScene! override func viewDidLoad() {
gameView = view as! SCNView // remove this line
scene = SCNScene()
gameView.scene = scene let lightNode = LightNode()
let cameraNode = CameraNode()
let floorNode = FloorNode()
let playerNode = PlayerNode() scene.rootNode.addChildNode(lightNode)
scene.rootNode.addChildNode(cameraNode)
scene.rootNode.addChildNode(floorNode)
scene.rootNode.addChildNode(playerNode) gameView.allowsCameraControl = true
}
}
Our project won’t run yet, there’s one last step left to make before we see first results! Just a quick reminder that our GameView is inheriting from SCNView. And since our view property is of type SCNView we can worry-free downcast with as! to GameView. Now this view property doesn’t come from nowhere. You can actually see it in Main.storyboard and that’s where we have to do our last step before running:
We have to select our GameView as Custom Class. If done you can finally run:
3. Create a virtual D-Pad area
This is simple, all we want is create another circle that has the same size and is at the same place as our visible D-Pad. But this time we are going to create a CGRect instead of a SKShapeNode. We will use CGRect’s contains() function and that function accepts a CGPoint which our touch location is a type of. So this makes it super easy to check whether a touch on our screen happened in our virtual D-Pad or not. More later on. For now in our GameView.swift add:
import SceneKit
import SpriteKitfinal class GameView: SCNView { override func awakeFromNib() {
...
} func setup2DOverlay() {
...
} func virtualDPad() -> CGRect {
var vDPad = CGRect(x: 0, y: 0, width: 150, height: 150) vDPad.origin.y = bounds.size.height - vDPad.size.height - 10
vDPad.origin.x = 10 return vDPad
}
}
First we have a CGRect of the size of our circle. The only gotcha about CGRect’s is, that they are not like a SKShapeNode located in the lower left corner but in the upper left corner. And additionally to this they are not positioned at their center but at their corner:
Now that we know where our virtual circle has it’s original position we can better understand what the next lines of code are for.
Since we are in our GameView and it is inheriting from SCNView we have access to its property bounds which can give us the height of the view. Now if we calculate the height of the view minus the height of our virtual D-Pad minus a margin of 10 we end up at the same y-position as our visible D-Pad. And a small adjustment to the x-position and we are exact at the place 🎉
4. Make the player rotate to desired direction
The first thing we do is declare a new property in PlayerNode.swift to store the angle where we want him to rotate to:
import SceneKitfinal class PlayerNode: SCNNode { override init() {
...
} required init?(coder aDecoder: NSCoder) {
...
} var directionAngle: SCNFloat = 0.0 {
didSet {
if directionAngle != oldValue {
// action that rotates the node to an angle in radian.
let action = SCNAction.rotateTo(
x: 0.0,
y: CGFloat(directionAngle),
z: 0.0,
duration: 0.1, usesShortestUnitArc: true
)
runAction(action)
}
}
}
}
NOTE: oldValue is a default parameter made available when no one is defined
So you can rotate a node on 3 axis. Around the y-axis, the x-axis and of course the z-axis. We want to rotate our player around the y-axis. And we can do that by providing a degree between 0° - 360° but converted into radian. Converting a degree into radian is simple:
360° in radian is 2 * pi
That means that 180° must be pi in radian.
So the following is true: 180° = 3.14
Of course rounded, because pi is super long 😊
If we now divide through 180 like so:180° = 3.14 | :180
1° = 0.01745We now know what 1° in radians is and that makes it easy for us if we want to convert for example 45° into radians:0.01745 * 45And get: 0.78525
That will help us later. I hope I explained it simple enough to memorize 😊!
A short visualization that makes it clear why we choose the y-axis for rotation:
Next let’s move to our GameViewController.swift and as a first step we move our playerNode variabel to global scope and add two now variables:
import UIKit
import QuartzCore
import SceneKitclass GameViewController: UIViewController {
var gameView: GameView {
return view as! GameView
}
var scene: SCNScene!
let playerNode = PlayerNode() // moved up here
var touch: UITouch? // added
var direction = float2(0, 0) // added override func viewDidLoad() {
scene = SCNScene()
gameView.scene = scene let lightNode = LightNode()
let cameraNode = CameraNode()
let floorNode = FloorNode()
let playerNode = PlayerNode() // delete this line scene.rootNode.addChildNode(lightNode)
scene.rootNode.addChildNode(cameraNode)
scene.rootNode.addChildNode(floorNode)
scene.rootNode.addChildNode(playerNode) gameView.allowsCameraControl = false // delete camera control
}
}
We want two things for our D-Pad. Whenever we touch our screen we first want to store the touch in a global scope, that’s what the touch variable is for. Then we want to determine whether the touch happened within our D-Pad. If yes, we want to calculate the direction to where our playerNode must rotate to using very simple math. No worries I’ll explain everything. But for now add:
import UIKit
import QuartzCore
import SceneKitclass GameViewController: UIViewController {
... override func viewDidLoad() {
...
}
}extension GameViewController { // store touch in global scope
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
touch = touches.first
} override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { if let touch = touch {
let touchLocation = touch.location(in: self.view)
if gameView.virtualDPad().contains(touchLocation) { let middleOfCircleX = gameView.virtualDPad().origin.x + 75
let middleOfCircleY = gameView.virtualDPad().origin.y + 75 let lengthOfX = Float(touchLocation.x - middleOfCircleX)
let lengthOfY = Float(touchLocation.y - middleOfCircleY) direction = float2(x: lengthOfX, y: lengthOfY)
direction = normalize(direction) let degree = atan2(direction.x, direction.y)
playerNode.directionAngle = degree
}
}
}
}
If you want you can run your project and see how wonderful it works ✨
Now to the explanation of the code. Within touchesBegan all we do is storing the first touch from the touches array in our global variable. We don’t care about a second touch on the screen. 🤓
In touchesMoved we store the location of the touch that happened on our view (our iPhone screen) and check if our virtual D-Pad contains this location. That way we make sure we only handle the touches within our D-Pad 👍🏻
In order to understand the next few lines of code you have to learn how to calculate the direction having nothing but the location of a touch. Alrighty then. Do you know what a vector is? You have worked with it in school whether you know it or not. For example if you look at a coordinate system that has a x-axis and a y-axis. A point within it is a vector. Simple 😊
A vector has two characteristics. It has a length and a direction. Imagine standing at the point (0, 0) and looking at your point at (3, 4). There you go. Now you have a length and a direction 😊
Note: The length of a vector is called magnitude.
If we want to display a vector (3, 4) in a coordinate system it looks like this:
That’s all about it! I mean you already see that with a point we indeed have a direction and even a length (magnitude). Now how does all this knowledge help us rotate our player? Well let me shortly show you something:
Do you remember that we want to provide a degree to our player converted into radian in order to rotate him around the y-axis? How do we get a degree from a location of a touch? We use very simple (forgotten 😅) math!
Did you know you can calculate the angle of a triangle if two sites are given?
tan α = opposite / adjacent
Let’s decrypt the formula. So α is our angle that we are looking for. Let us fill the formula with our values - yes we have them all 🤓! Have a look at this:
Looking from point (0, 0) our opposite is our y value and our adjacent is our x. All we do is some math to get α in degree. We can then convert it to radian:
Formula
tan α = opposite / adjacentCalculation of angle
tan α = 2 / 1
tan α = 2 | tan-1 (also known as atan)
α = 63,434948 (well let's round it)α = 63,43°
Now calculating the radian is easy since we know 1° = 0.01745 and the result would be 63.43 * 0.01745 = 1.10693 but let’s stop here because all I wanted for you is to understand how it works 😊
Let’s get back to our code because there are definitely some gotchas to know.
So first we store the middle of our circle from where we want to calculate the length of x and y of our touch location. Sure if the center of our virtual D-Pad were at (0, 0) of our view we could just grab y and x of the location right away and would have both lengths. But our virtual D-Pad is somewhere else in our view and you remember that it’s position x and y is at its corner? That’s why we add 75 because we know it’s having a width of 150 so the half of it gets us to the middle of it:
Did you know that you use float2 to store a point composed of two values? That is so cool! Okay here is the first gotcha you can’t use swifts native atan function in order to calculate the degree manually like:
let resultInDegree = atan(lengthOfY/lengthOfX)
There is a reason to it but we don’t really care because swift provides another function called atan2 and this function does all the work for us 😱! It calculates the angle for us if we pass in 2 points and the angle will be in radian already! Now that is awesome! So the only thing left is to assign the result to our player and he will rotate to that given angle 🙌🏻 !
Wait a second.. You sneaky just skipped a line of code! What is this normalize?
Well done Watson, well done. In fact I was hoping you would notice it. So what we do here is normalizing our point *cough I mean our vector. Do you remember that a vector has a length *cough I mean magnitude? The distance starting from (0, 0)? So to normalize a vector means nothing really nothing but changing it to have a magnitude of 1. The direction however stays as is:
And we do that because we will use the direction for our movement too and since we know that we can touch everywhere within our virtual D-Pad which means if you touch closer at the edge we will have a larger magnitude in comparison to if you would touch closer to the middle 🤓.
5. Make the player move to desired direction
So the first thing we do is implementing a function in our PlayerNode.swift in order to make him move:
import SceneKitfinal class PlayerNode: SCNNode { override init() {
...
} required init?(coder aDecoder: NSCoder) {
...
} var directionAngle: SCNFloat = 0.0 {
...
} let speed: Float = 0.1 func walkInDirection(_ direction: float3) {
let currentPosition = float3(position)
position = SCNVector3(currentPosition + direction * speed)
}
}
So what we do here is fairly simple. We store the players current position which is of type SCNVector3 and cast it to float3 and do that because we are about to apply addition on it and it does not work with SCNVector3 out of the box which is the type of position. Adding two vectors behaves like this:
We have our current position of our player at (3, 4) in red and add another vector which is our direction in blue and we end up with a new vector for our player in brown.
Do you remember that we have normalized our direction? It comes in handy because we can now control the speed by multiplying what ever amount we want with our direction and multiplying a simple number with a vector does nothing but enlarges the magnitude of a that vector.
NOTE: a simple number that is multiplied with a vector is called scalar
So our blue arrow could be 5x or 23x longer. And since we are assigning a new position, it will move the player immediately by this length to the new position which happens in one frame. It’s the same if you have a car that moves in 1 second only 5 meters that car is definitely slower than the car that moves in that same second 23 meters.
Alright let’s write our last piece of code. In GameViewController.swift add:
import UIKit
import QuartzCore
import SceneKitclass GameViewController: UIViewController {
... let cameraNode = CameraNode() // moved up here override func viewDidLoad() {
... let cameraNode = CameraNode() // delete this line gameView.delegate = self // added
gameView.isPlaying = true // added
}
}extension GameViewController {
...
}extension GameViewController: SCNSceneRendererDelegate { func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { let directionInV3 = float3(x: direction.x, y: 0, z: direction.y)
playerNode.walkInDirection(directionInV3) cameraNode.position.x = playerNode.presentation.position.x
cameraNode.position.z = playerNode.presentation.position.z + CameraNode.offset }
}
So we are extending our GameViewController by SCNSceneRenderDelegate which gives us several render functions:
From which we use the first one render:updateAtTime. And in order for these functions to be called we have to add ourself as a delegate. Also we added gameView.isPlaying = true which if missing would cause our render function to not be called if we don’t touch our screen or move our finger on the it. You could for fun run the project and see how wonderful it works ✨
In the next lines we are creating a vector with float3 using the values from our direction. Yes we are using the y value as z before passing that new vector into our walkInDirection function. If you remember the 3D graph where I talked about rotation. If you look at that graph again you should be able to understand why we want that 😊.
We also assign the new x and z position of the player to our camera so it ends following him. Inside CameraNode.swift you can find a static variable defining an offset. We want our camera to be a little before the player. That’s why we add it there. 😊
And that’s it! Oh boy! You successfully implemented a D-Pad 🚀 🎉 !!