Related
I have a SCNSphere that is climbing a 45 degree hill.
The node maintains a consistent speed until the same point at every level, at which point it unexpectedly drops in speed, here is a 10 second clip of the issue.
The drop in speed occurs at 8 seconds in this clip.
When the node reaches a z position of -240, it seems as though the entire game speed is cut in half.
I have tested this in the following ways always without success.
Tried testing without gravity.
Tried testing without colliding with the hill.
Tried testing without damping or friction.
Tried printing the nodes velocity to notice any changes, although the velocity remains at -5.0 on the z axis for the duration of the
level despite the significant drop in speed.
Tried printing the physicsWorld speed to notice any changes, although the speed remains at 1.0 for the duration of the
level despite the significant drop in speed.
Checked for a drop in frame rate although it maintains a frame rate of 60 fps, making only 14 draw calls in total with a poly count under 15k.
The sphere's velocity is updated at every frame in the renderer using the following function.
func updatePositions() {
if let playerPhysicsBod = playerNode.physicsBody {
playerPhysicsBod.velocity.x = (lastXPosition - playerNode.position.x) * 8
playerPhysicsBod.velocity.z = -5
print("player velocity is \(playerNode.physicsBody!.velocity)")
}
}
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
updatePositions()
updateSegments()
}
This project is a fresh one, and so there aren't many lines, I will include the entire code base below.
class GameViewController: UIViewController {
let scene = SCNScene(named: "art.scnassets/gameScene.scn")!
let jump = SCNScene(named: "art.scnassets/jump.scn")!.rootNode.childNode(withName: "jump", recursively: true)!
let box = SCNScene(named: "art.scnassets/box.scn")!.rootNode.childNode(withName: "box", recursively: true)!
var playerNode = SCNNode()
var cameraNode = SCNNode()
var lastXPosition = Float()
var floorSegments = [SCNNode]()
override func viewDidLoad() {
super.viewDidLoad()
// retrieve the SCNView
let scnView = self.view as! SCNView
// scnView.isJitteringEnabled = true
scnView.scene = scene
scnView.delegate = self
scnView.showsStatistics = true
setupCamera()
setupPlayer()
setupSegments()
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let tLocationX = touches.first?.location(in: self.view).x else { return }
let ratio = tLocationX / self.view.frame.maxX
lastXPosition = Float((5 * ratio) - 2.5)
}
func setupCamera() {
if let camera = scene.rootNode.childNode(withName: "camera", recursively: true) {
cameraNode = camera
cameraNode.name = "camera"
}
}
func setupPlayer() {
if let player = scene.rootNode.childNode(withName: "player", recursively: true) {
playerNode = player
playerNode.name = "player"
}
}
func setupSegments() {
if let segment = scene.rootNode.childNode(withName: "segment", recursively: true) {
floorSegments.append(segment)
}
}
}
extension GameViewController: SCNSceneRendererDelegate {
func updateSegments() {
playerNode.position = playerNode.presentation.position
if let lastSegmentClone = floorSegments.last?.clone() {
lastSegmentClone.childNodes.forEach { (node) in
node.removeFromParentNode()
}
if abs(playerNode.position.z - lastSegmentClone.position.z) < 30 {
// set up next segment
lastSegmentClone.position = SCNVector3(lastSegmentClone.position.x, lastSegmentClone.position.y + 4, lastSegmentClone.position.z - 4)
floorSegments.append(lastSegmentClone)
// Add falling blocks to the segment
for _ in 0...2 {
let boxClone = box.clone()
let randomX = Int.random(in: -2...2)
let randomY = Int.random(in: 1...3)
boxClone.eulerAngles.z = Float(GLKMathDegreesToRadians(-45))
boxClone.position = SCNVector3(randomX, randomY, -randomY)
lastSegmentClone.addChildNode(boxClone)
}
// Add falling blocks to the segment
for (index,segment) in floorSegments.enumerated().reversed() {
if segment.position.z > playerNode.position.z + 5 {
floorSegments.remove(at: index)
segment.childNodes.forEach { (node) in
node.removeFromParentNode()
}
segment.removeFromParentNode()
}
}
scene.rootNode.addChildNode(lastSegmentClone)
}
}
}
func updatePositions() {
if let playerPhysicsBod = playerNode.physicsBody {
playerPhysicsBod.velocity.x = (lastXPosition - playerNode.position.x) * 8
playerPhysicsBod.velocity.z = -5
print("player velocity is \(playerNode.physicsBody!.velocity)")
}
}
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
updatePositions()
updateSegments()
}
}
It is difficult to judge w/o seeing scn files.
I hope there is no collision between the player and floor nodes...
Player's Position is a float, It might be inexact comparison for equality. I think you should avoid player's position adjustment, as it should be almost the same:
playerNode.position = playerNode.presentation.position
Why don't you keep reference on an empty segment node?
In the method setupSegments clone the segment and after removing children nodes, keep it. No point to always remove children nodes:
lastSegmentClone.childNodes.forEach { (node) in
node.removeFromParentNode()
}
Plus I think you should "reverse" the order in updateSegments:
if let lastSegment = floorSegments.last { // no cloning here....
if abs(playerNode.position.z - lastSegment.position.z) < 30 {
let lastSegmentClone = lastSegment.clone() // better to use emptySegmentNode
/* If empty segment then it's not needed to remove children nodes...
lastSegmentClone.childNodes.forEach { (node) in
node.removeFromParentNode() */
Also if you're removing parent node, children nodes are going to be removed automatically....
// Avoid commented code
/*segment.childNodes.forEach { (node) in
node.removeFromParentNode()
}*/
segment.removeFromParentNode()
No sure, but perhaps for floor segments it is better to use custom action for removal:
// calculate somehow wait duration, based on the player's position, velocity or use pure constant..
let removeAction = SCNAction.sequence([SCNAction.waitForDuration(2.0), SCNAction.removeFromParent()])
lastSegmentClone.run(removeAction)
In such case you just need a reference on the last floor node, and empty floor node.
I've been reading plenty of StackOverflow answers on how to move an object by dragging it across the screen. Some use hit tests against .featurePoints some use the gesture translation or just keeping track of the lastPosition of the object. But honestly.. none work the way everyone is expecting it to work.
Hit testing against .featurePoints just makes the object jump all around, because you dont always hit a featurepoint when dragging your finger. I dont understand why everyone keeps suggesting this.
Solutions like this one work: Dragging SCNNode in ARKit Using SceneKit
But the object doesnt really follow your finger, and the moment you take a few steps or change the angle of the object or the camera.. and try to move the object.. the x,z are all inverted.. and makes total sense to do that.
I really want to move objects as good as the Apple Demo, but I look at the code from Apple... and is insanely weird and overcomplicated I cant even understand a bit. Their technique to move the object so beautifly is not even close to what everyone propose online.
https://developer.apple.com/documentation/arkit/handling_3d_interaction_and_ui_controls_in_augmented_reality
There's gotta be a simpler way to do it.
Short answer:
To get this nice and fluent dragging effect like in the Apple demo project, you will have to do it like in the Apple demo project (Handling 3D Interaction). On the other side I agree with you, that the code might be confusing if you look at it for the first time. It is not easy at all to calculate the correct movement for an object placed on a floor plane - always and from every location or viewing angle. It’s a complex code construct, that is doing this superb dragging effect. Apple did a great job to achieve this, but didn’t make it too easy for us.
Full Answer:
Striping down the AR Interaction template for your needy results in a nightmare - but should work too if you invest enough time. If you prefer to begin from scratch, basically start using a common swift ARKit/SceneKit Xcode template (the one containing the space ship).
You will also require the entire AR Interaction Template Project from Apple. (The link is included in the SO question)
At the End you should be able to drag something called VirtualObject, which is in fact a special SCNNode. In Addition you will have a nice Focus Square, that can be useful for whatever purpose - like initially placing objects or adding a floor, or a wall. (Some code for the dragging effect and the focus square usage are kind of merged or linked together - doing it without the focus square will actually be more complicated)
Get started:
Copy the following files from the AR Interaction template to your empty project:
Utilities.swift (usually I name this file Extensions.swift, it contains some basic extensions that are required)
FocusSquare.swift
FocusSquareSegment.swift
ThresholdPanGesture.swift
VirtualObject.swift
VirtualObjectLoader.swift
VirtualObjectARView.swift
Add the UIGestureRecognizerDelegate to the ViewController class definition like so:
class ViewController: UIViewController, ARSCNViewDelegate, UIGestureRecognizerDelegate {
Add this code to your ViewController.swift, in the definitions section, right before viewDidLoad:
// MARK: for the Focus Square
// SUPER IMPORTANT: the screenCenter must be defined this way
var focusSquare = FocusSquare()
var screenCenter: CGPoint {
let bounds = sceneView.bounds
return CGPoint(x: bounds.midX, y: bounds.midY)
}
var isFocusSquareEnabled : Bool = true
// *** FOR OBJECT DRAGGING PAN GESTURE - APPLE ***
/// The tracked screen position used to update the `trackedObject`'s position in `updateObjectToCurrentTrackingPosition()`.
private var currentTrackingPosition: CGPoint?
/**
The object that has been most recently intereacted with.
The `selectedObject` can be moved at any time with the tap gesture.
*/
var selectedObject: VirtualObject?
/// The object that is tracked for use by the pan and rotation gestures.
private var trackedObject: VirtualObject? {
didSet {
guard trackedObject != nil else { return }
selectedObject = trackedObject
}
}
/// Developer setting to translate assuming the detected plane extends infinitely.
let translateAssumingInfinitePlane = true
// *** FOR OBJECT DRAGGING PAN GESTURE - APPLE ***
In viewDidLoad, before you setup the scene add this code:
// *** FOR OBJECT DRAGGING PAN GESTURE - APPLE ***
let panGesture = ThresholdPanGesture(target: self, action: #selector(didPan(_:)))
panGesture.delegate = self
// Add gestures to the `sceneView`.
sceneView.addGestureRecognizer(panGesture)
// *** FOR OBJECT DRAGGING PAN GESTURE - APPLE ***
At the very end of your ViewController.swift add this code:
// MARK: - Pan Gesture Block
// *** FOR OBJECT DRAGGING PAN GESTURE - APPLE ***
#objc
func didPan(_ gesture: ThresholdPanGesture) {
switch gesture.state {
case .began:
// Check for interaction with a new object.
if let object = objectInteracting(with: gesture, in: sceneView) {
trackedObject = object // as? VirtualObject
}
case .changed where gesture.isThresholdExceeded:
guard let object = trackedObject else { return }
let translation = gesture.translation(in: sceneView)
let currentPosition = currentTrackingPosition ?? CGPoint(sceneView.projectPoint(object.position))
// The `currentTrackingPosition` is used to update the `selectedObject` in `updateObjectToCurrentTrackingPosition()`.
currentTrackingPosition = CGPoint(x: currentPosition.x + translation.x, y: currentPosition.y + translation.y)
gesture.setTranslation(.zero, in: sceneView)
case .changed:
// Ignore changes to the pan gesture until the threshold for displacment has been exceeded.
break
case .ended:
// Update the object's anchor when the gesture ended.
guard let existingTrackedObject = trackedObject else { break }
addOrUpdateAnchor(for: existingTrackedObject)
fallthrough
default:
// Clear the current position tracking.
currentTrackingPosition = nil
trackedObject = nil
}
}
// - MARK: Object anchors
/// - Tag: AddOrUpdateAnchor
func addOrUpdateAnchor(for object: VirtualObject) {
// If the anchor is not nil, remove it from the session.
if let anchor = object.anchor {
sceneView.session.remove(anchor: anchor)
}
// Create a new anchor with the object's current transform and add it to the session
let newAnchor = ARAnchor(transform: object.simdWorldTransform)
object.anchor = newAnchor
sceneView.session.add(anchor: newAnchor)
}
private func objectInteracting(with gesture: UIGestureRecognizer, in view: ARSCNView) -> VirtualObject? {
for index in 0..<gesture.numberOfTouches {
let touchLocation = gesture.location(ofTouch: index, in: view)
// Look for an object directly under the `touchLocation`.
if let object = virtualObject(at: touchLocation) {
return object
}
}
// As a last resort look for an object under the center of the touches.
// return virtualObject(at: gesture.center(in: view))
return virtualObject(at: (gesture.view?.center)!)
}
/// Hit tests against the `sceneView` to find an object at the provided point.
func virtualObject(at point: CGPoint) -> VirtualObject? {
// let hitTestOptions: [SCNHitTestOption: Any] = [.boundingBoxOnly: true]
let hitTestResults = sceneView.hitTest(point, options: [SCNHitTestOption.categoryBitMask: 0b00000010, SCNHitTestOption.searchMode: SCNHitTestSearchMode.any.rawValue as NSNumber])
// let hitTestOptions: [SCNHitTestOption: Any] = [.boundingBoxOnly: true]
// let hitTestResults = sceneView.hitTest(point, options: hitTestOptions)
return hitTestResults.lazy.compactMap { result in
return VirtualObject.existingObjectContainingNode(result.node)
}.first
}
/**
If a drag gesture is in progress, update the tracked object's position by
converting the 2D touch location on screen (`currentTrackingPosition`) to
3D world space.
This method is called per frame (via `SCNSceneRendererDelegate` callbacks),
allowing drag gestures to move virtual objects regardless of whether one
drags a finger across the screen or moves the device through space.
- Tag: updateObjectToCurrentTrackingPosition
*/
#objc
func updateObjectToCurrentTrackingPosition() {
guard let object = trackedObject, let position = currentTrackingPosition else { return }
translate(object, basedOn: position, infinitePlane: translateAssumingInfinitePlane, allowAnimation: true)
}
/// - Tag: DragVirtualObject
func translate(_ object: VirtualObject, basedOn screenPos: CGPoint, infinitePlane: Bool, allowAnimation: Bool) {
guard let cameraTransform = sceneView.session.currentFrame?.camera.transform,
let result = smartHitTest(screenPos,
infinitePlane: infinitePlane,
objectPosition: object.simdWorldPosition,
allowedAlignments: [ARPlaneAnchor.Alignment.horizontal]) else { return }
let planeAlignment: ARPlaneAnchor.Alignment
if let planeAnchor = result.anchor as? ARPlaneAnchor {
planeAlignment = planeAnchor.alignment
} else if result.type == .estimatedHorizontalPlane {
planeAlignment = .horizontal
} else if result.type == .estimatedVerticalPlane {
planeAlignment = .vertical
} else {
return
}
/*
Plane hit test results are generally smooth. If we did *not* hit a plane,
smooth the movement to prevent large jumps.
*/
let transform = result.worldTransform
let isOnPlane = result.anchor is ARPlaneAnchor
object.setTransform(transform,
relativeTo: cameraTransform,
smoothMovement: !isOnPlane,
alignment: planeAlignment,
allowAnimation: allowAnimation)
}
// *** FOR OBJECT DRAGGING PAN GESTURE - APPLE ***
Add some Focus Square Code
// MARK: - Focus Square (code by Apple, some by me)
func updateFocusSquare(isObjectVisible: Bool) {
if isObjectVisible {
focusSquare.hide()
} else {
focusSquare.unhide()
}
// Perform hit testing only when ARKit tracking is in a good state.
if let camera = sceneView.session.currentFrame?.camera, case .normal = camera.trackingState,
let result = smartHitTest(screenCenter) {
DispatchQueue.main.async {
self.sceneView.scene.rootNode.addChildNode(self.focusSquare)
self.focusSquare.state = .detecting(hitTestResult: result, camera: camera)
}
} else {
DispatchQueue.main.async {
self.focusSquare.state = .initializing
self.sceneView.pointOfView?.addChildNode(self.focusSquare)
}
}
}
And add some control Functions:
func hideFocusSquare() { DispatchQueue.main.async { self.updateFocusSquare(isObjectVisible: true) } } // to hide the focus square
func showFocusSquare() { DispatchQueue.main.async { self.updateFocusSquare(isObjectVisible: false) } } // to show the focus square
From the VirtualObjectARView.swift COPY! the entire function smartHitTest to the ViewController.swift (so they exist twice)
func smartHitTest(_ point: CGPoint,
infinitePlane: Bool = false,
objectPosition: float3? = nil,
allowedAlignments: [ARPlaneAnchor.Alignment] = [.horizontal, .vertical]) -> ARHitTestResult? {
// Perform the hit test.
let results = sceneView.hitTest(point, types: [.existingPlaneUsingGeometry, .estimatedVerticalPlane, .estimatedHorizontalPlane])
// 1. Check for a result on an existing plane using geometry.
if let existingPlaneUsingGeometryResult = results.first(where: { $0.type == .existingPlaneUsingGeometry }),
let planeAnchor = existingPlaneUsingGeometryResult.anchor as? ARPlaneAnchor, allowedAlignments.contains(planeAnchor.alignment) {
return existingPlaneUsingGeometryResult
}
if infinitePlane {
// 2. Check for a result on an existing plane, assuming its dimensions are infinite.
// Loop through all hits against infinite existing planes and either return the
// nearest one (vertical planes) or return the nearest one which is within 5 cm
// of the object's position.
let infinitePlaneResults = sceneView.hitTest(point, types: .existingPlane)
for infinitePlaneResult in infinitePlaneResults {
if let planeAnchor = infinitePlaneResult.anchor as? ARPlaneAnchor, allowedAlignments.contains(planeAnchor.alignment) {
if planeAnchor.alignment == .vertical {
// Return the first vertical plane hit test result.
return infinitePlaneResult
} else {
// For horizontal planes we only want to return a hit test result
// if it is close to the current object's position.
if let objectY = objectPosition?.y {
let planeY = infinitePlaneResult.worldTransform.translation.y
if objectY > planeY - 0.05 && objectY < planeY + 0.05 {
return infinitePlaneResult
}
} else {
return infinitePlaneResult
}
}
}
}
}
// 3. As a final fallback, check for a result on estimated planes.
let vResult = results.first(where: { $0.type == .estimatedVerticalPlane })
let hResult = results.first(where: { $0.type == .estimatedHorizontalPlane })
switch (allowedAlignments.contains(.horizontal), allowedAlignments.contains(.vertical)) {
case (true, false):
return hResult
case (false, true):
// Allow fallback to horizontal because we assume that objects meant for vertical placement
// (like a picture) can always be placed on a horizontal surface, too.
return vResult ?? hResult
case (true, true):
if hResult != nil && vResult != nil {
return hResult!.distance < vResult!.distance ? hResult! : vResult!
} else {
return hResult ?? vResult
}
default:
return nil
}
}
You might see some errors in the copied function regarding the hitTest. Just correct it like so:
hitTest... // which gives an Error
sceneView.hitTest... // this should correct it
Implement the renderer updateAtTime function and add this lines:
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
// For the Focus Square
if isFocusSquareEnabled { showFocusSquare() }
self.updateObjectToCurrentTrackingPosition() // *** FOR OBJECT DRAGGING PAN GESTURE - APPLE ***
}
And finally add some helper functions for the Focus Square
func hideFocusSquare() { DispatchQueue.main.async { self.updateFocusSquare(isObjectVisible: true) } } // to hide the focus square
func showFocusSquare() { DispatchQueue.main.async { self.updateFocusSquare(isObjectVisible: false) } } // to show the focus square
At this point you might still see about a dozen errors and warnings in the imported files, this might occur, when doing this in Swift 5 and you have some Swift 4 files. Just let Xcode correct the errors. (Its all about renaming some code statements, Xcode knows best)
Go in VirtualObject.swift and search for this code block:
if smoothMovement {
let hitTestResultDistance = simd_length(positionOffsetFromCamera)
// Add the latest position and keep up to 10 recent distances to smooth with.
recentVirtualObjectDistances.append(hitTestResultDistance)
recentVirtualObjectDistances = Array(recentVirtualObjectDistances.suffix(10))
let averageDistance = recentVirtualObjectDistances.average!
let averagedDistancePosition = simd_normalize(positionOffsetFromCamera) * averageDistance
simdPosition = cameraWorldPosition + averagedDistancePosition
} else {
simdPosition = cameraWorldPosition + positionOffsetFromCamera
}
Outcomment or replace this entire block by this single line of code:
simdPosition = cameraWorldPosition + positionOffsetFromCamera
At this point you should be able to compile the project and run it on a device. You should see the Spaceship and a yellow focus square that should already work.
To start placing an Object, that you can drag you need some function to create a so called VirtualObject as I said in the beginning.
Use this example function to test (add it somewhere in the view controller):
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
if focusSquare.state != .initializing {
let position = SCNVector3(focusSquare.lastPosition!)
// *** FOR OBJECT DRAGGING PAN GESTURE - APPLE ***
let testObject = VirtualObject() // give it some name, when you dont have anything to load
testObject.geometry = SCNCone(topRadius: 0.0, bottomRadius: 0.2, height: 0.5)
testObject.geometry?.firstMaterial?.diffuse.contents = UIColor.red
testObject.categoryBitMask = 0b00000010
testObject.name = "test"
testObject.castsShadow = true
testObject.position = position
sceneView.scene.rootNode.addChildNode(testObject)
}
}
Note: everything you want to drag on a plane, must be setup using VirtualObject() instead of SCNNode(). Everything else regarding the VirtualObject stays the same as SCNNode
(You can also add some common SCNNode extensions as well, like the one to load scenes by its name - useful when referencing imported models)
Have fun!
I added some of my ideas to Claessons's answer. I noticed some lag when dragging the node around. I found that the node cannot follow the finger's movement.
To make the node move more smoothly, I added a variable that keeps track of the node that is currently being moved, and set the position to the location of the touch.
var selectedNode: SCNNode?
Also, I set a .categoryBitMask value to specify the category of nodes that I want to edit(move). The default bit mask value is 1.
The reason why we set the category bit mask is to distinguish between different kinds of nodes, and specify those that you wish to select (to move around, etc).
enum CategoryBitMask: Int {
case categoryToSelect = 2 // 010
case otherCategoryToSelect = 4 // 100
// you can add more bit masks below . . .
}
Then, I added a UILongPressGestureRecognizer in viewDidLoad().
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressed))
self.sceneView.addGestureRecognizer(longPressRecognizer)
The following is the UILongPressGestureRecognizer I used to detect a long press, which initiates the dragging of the node.
First, obtain the touch location from the recognizerView
#objc func longPressed(recognizer: UILongPressGestureRecognizer) {
guard let recognizerView = recognizer.view as? ARSCNView else { return }
let touch = recognizer.location(in: recognizerView)
The following code runs once when a long press is detected.
Here, we perform a hitTest to select the node that has been touched. Note that here, we specify a .categoryBitMask option to select only nodes of the following category: CategoryBitMask.categoryToSelect
// Runs once when long press is detected.
if recognizer.state == .began {
// perform a hitTest
let hitTestResult = self.sceneView.hitTest(touch, options: [SCNHitTestOption.categoryBitMask: CategoryBitMask.categoryToSelect])
guard let hitNode = hitTestResult.first?.node else { return }
// Set hitNode as selected
self.selectedNode = hitNode
The following code will run periodically until the user releases the finger.
Here we perform another hitTest to obtain the plane you want the node to move along.
// Runs periodically after .began
} else if recognizer.state == .changed {
// make sure a node has been selected from .began
guard let hitNode = self.selectedNode else { return }
// perform a hitTest to obtain the plane
let hitTestPlane = self.sceneView.hitTest(touch, types: .existingPlane)
guard let hitPlane = hitTestPlane.first else { return }
hitNode.position = SCNVector3(hitPlane.worldTransform.columns.3.x,
hitNode.position.y,
hitPlane.worldTransform.columns.3.z)
Make sure you deselect the node when the finger is removed from the screen.
// Runs when finger is removed from screen. Only once.
} else if recognizer.state == .ended || recognizer.state == .cancelled || recognizer.state == .failed{
guard let hitNode = self.selectedNode else { return }
// Undo selection
self.selectedNode = nil
}
}
Kind of late answer but I know I had some problems solving this as well. Eventually I figured out a way to do it by performing two separate hit tests whenever my gesture recognizer is called.
First, I perform a hit test for my 3d-object to detect if I'm currently pressing an object or not (as you would get results for pressing featurePoints, planes etc. if you don't specify any options). I do this by using the .categoryBitMaskvalue of SCNHitTestOption.
Keep in mind you have to assign the correct .categoryBitMask value to your object node and all it's child nodes beforehand in order for the hit test to work. I declare an enum I can use for that:
enum BodyType : Int {
case ObjectModel = 2;
}
As becomes apparent by the answer to my question about .categoryBitMask values I posted here, it is important to consider what values you assign your bitmask.
Below is the code i use in in conjunction with UILongPressGestureRecognizer in
order to select the object I'm currently pressing:
guard let recognizerView = recognizer.view as? ARSCNView else { return }
let touch = recognizer.location(in: recognizerView)
let hitTestResult = self.sceneView.hitTest(touch, options: [SCNHitTestOption.categoryBitMask: BodyType.ObjectModel.rawValue])
guard let modelNodeHit = hitTestResult.first?.node else { return }
After that I perform a 2nd hit test in order to find a plane I'm pressing on.
You can use the type .existingPlaneUsingExtent if you don't want to move your object further than the edge of a plane, or .existingPlane if you want to move your object indefinitely along a detected plane surface.
var planeHit : ARHitTestResult!
if recognizer.state == .changed {
let hitTestPlane = self.sceneView.hitTest(touch, types: .existingPlane)
guard hitTestPlane.first != nil else { return }
planeHit = hitTestPlane.first!
modelNodeHit.position = SCNVector3(planeHit.worldTransform.columns.3.x,modelNodeHit.position.y,planeHit.worldTransform.columns.3.z)
}else if recognizer.state == .ended || recognizer.state == .cancelled || recognizer.state == .failed{
modelNodeHit.position = SCNVector3(planeHit.worldTransform.columns.3.x,modelNodeHit.position.y,planeHit.worldTransform.columns.3.z)
}
I made a GitHub repo when I tried this out while also experimenting with ARAnchors. You can check it out if you want to see my method in practice, but I did not make it with the intention of anyone else using it so it's quite unfinished. Also, the development branch should support some functionality for an object with more childNodes.
EDIT: ==================================
For clarification if you want to use a .scn object instead of a regular geometry, you need to iterate through all the child nodes of the object when creating it, setting the bitmask of each child like this:
let objectModelScene = SCNScene(named:
"art.scnassets/object/object.scn")!
let objectNode = objectModelScene.rootNode.childNode(
withName: "theNameOfTheParentNodeOfTheObject", recursively: true)
objectNode.categoryBitMask = BodyType.ObjectModel.rawValue
objectNode.enumerateChildNodes { (node, _) in
node.categoryBitMask = BodyType.ObjectModel.rawValue
}
Then, in the gesture recognizer after you get a hitTestResult
let hitTestResult = self.sceneView.hitTest(touch, options: [SCNHitTestOption.categoryBitMask: BodyType.ObjectModel.rawValue])
you need to find the parent node since otherwise you might be moving the individual child node you just pressed. Do this by searching recursively upwards through the node tree of the node you just found.
guard let objectNode = getParentNodeOf(hitTestResult.first?.node) else { return }
where you declare the getParentNode-method as follows
func getParentNodeOf(_ nodeFound: SCNNode?) -> SCNNode? {
if let node = nodeFound {
if node.name == "theNameOfTheParentNodeOfTheObject" {
return node
} else if let parent = node.parent {
return getParentNodeOf(parent)
}
}
return nil
}
Then you are free to perform any operation on the objectNode, as it will be the parent node of your .scn object, meaning that any transformation applied to it will also be applied to the child nodes.
As #ZAY mentioned out, Apple made it quite confusing in addition they used ARRaycastQuery which only works on iOS 13 and above. Therefore, I reached to a solution that works by using the current camera orientation to calculate the translation on a plane in the world coordinates.
First, by using this snippet we are able to get the current orientation the user is facing using quaternions.
private func getOrientationYRadians()-> Float {
guard let cameraNode = arSceneView.pointOfView else { return 0 }
//Get camera orientation expressed as a quaternion
let q = cameraNode.orientation
//Calculate rotation around y-axis (heading) from quaternion and convert angle so that
//0 is along -z-axis (forward in SceneKit) and positive angle is clockwise rotation.
let alpha = Float.pi - atan2f( (2*q.y*q.w)-(2*q.x*q.z), 1-(2*pow(q.y,2))-(2*pow(q.z,2)) )
// here I convert the angle to be 0 when the user is facing +z-axis
return alpha <= Float.pi ? abs(alpha - (Float.pi)) : (3*Float.pi) - alpha
}
Handle Pan Method
private var lastPanLocation2d: CGPoint!
#objc func handlePan(panGesture: UIPanGestureRecognizer) {
let state = panGesture.state
guard state != .failed && state != .cancelled else {
return
}
let touchLocation = panGesture.location(in: self)
if (state == .began) {
lastPanLocation2d = touchLocation
}
// 200 here is a random value that controls the smoothness of the dragging effect
let deltaX = Float(touchLocation.x - lastPanLocation2d!.x)/200
let deltaY = Float(touchLocation.y - lastPanLocation2d!.y)/200
let currentYOrientationRadians = getOrientationYRadians()
// convert delta in the 2D dimensions to the 3d world space using the current rotation
let deltaX3D = (deltaY*sin(currentYOrientationRadians))+(deltaX*cos(currentYOrientationRadians))
let deltaY3D = (deltaY*cos(currentYOrientationRadians))+(-deltaX*sin(currentYOrientationRadians))
// assuming that the node is currently positioned on a plane so the y-translation will be zero
let translation = SCNVector3Make(deltaX3D, 0.0, deltaY3D)
nodeToDrag.localTranslate(by: translation)
lastPanLocation2d = touchLocation
}
My goal is to set up all my platforms in the .sks file for easier design of my levels.
this is declared at the top of game scene.swift before didMove:
private var JumpThroughPlatformObject = SKSpriteNode()
and this is in DidMove:
if let JumpThroughPlatformObjectNode = self.childNode(withName: "//jumpThroughPlatform1") as? SKSpriteNode {
JumpThroughPlatformObject = JumpThroughPlatformObjectNode}
I reference the platform to get it's height from the .sks, since all my platforms are going to be the same height I only need to get it from one.
Below is what Im trying to use in my update method to turn off collisions until my player is totally above the platform. The main issue with only checking if my players velocity is greater than zero is: if the player is at the peak of a jump (his velocity slows to zero). if this happens and the player is inside a platform, he either instantly springs up to the top of the platform or gets launched downward.
I don't want my platforms to have to be 1 pixel high lines. I also need to have the player have a full collision box since he will be interacting with other types of environments. This leads me to believe that I somehow need to only register the top of the platform as a collision box and not the entire platform.
This if statement I wrote is supposed to take the y position of a platform and add half of its height to it, since the y position is based on the center of the sprite I figured this would put the collision for the platform on its top boundary.
I did the same for the player but in reverse. Putting the players collisions on only the bottom of his border. But its not working perfectly and I'm not sure why at this point.
if (JumpThroughPlatformObject.position.y + (JumpThroughPlatformObject.size.height / 2)) > (player.position.y - (player.size.height / 2))
The function below is giving me 3 main issues:
My players jump is always dy = 80. If I'm jumping up to a platform that position.y = 90, the players peak of the jump stops in the middle of the platform, but he teleports to the top of it instead of continuing to fall to the ground.
the left and right edges of the platforms still have full collision with the player if I'm falling
if my player is on a platform and there is another one directly above me, the player can't jump through it.
let zero:CGFloat = 0
if let body = player.physicsBody {
let dy = player.physicsBody?.velocity.dy
// when I jump dy is greater than zero else I'm falling
if (dy! >= zero) {
if (JumpThroughPlatformObject.position.y + (JumpThroughPlatformObject.size.height / 2)) > (player.position.y - (player.size.height / 2)) {
print(" platform y: \(JumpThroughPlatformObject.position.y)")
print ("player position: \(player.position.y)")
// Prevent collisions if the hero is jumping
body.collisionBitMask = CollisionTypes.saw.rawValue | CollisionTypes.ground.rawValue
}
}
else {
// Allow collisions if the hero is falling
body.collisionBitMask = CollisionTypes.platform.rawValue | CollisionTypes.ground.rawValue | CollisionTypes.saw.rawValue
}
}
Any advice would be greatly appreciated. I've been tearing my hair out for a couple days now.
EDIT in didBegin and didEnd:
func didBegin(_ contact: SKPhysicsContact) {
if let body = player.physicsBody {
let dy = player.physicsBody?.velocity.dy
let platform = JumpThroughPlatformObject
let zero:CGFloat = 0
if contact.bodyA.node == player {
// playerCollided(with: contact.bodyB.node!)
if (dy! > zero || body.node!.intersects(platform)) && ((body.node?.position.y)! - player.size.height / 2 < platform.position.y + platform.size.height / 2) {
body.collisionBitMask &= ~CollisionTypes.platform.rawValue
}
} else if contact.bodyB.node == player {
// playerCollided(with: contact.bodyA.node!)
isPlayerOnGround = true
if (dy! > zero || body.node!.intersects(platform)) && ((body.node?.position.y)! - player.size.height / 2 < platform.position.y + platform.size.height / 2) {
body.collisionBitMask &= ~CollisionTypes.platform.rawValue}
}
}
}
func didEnd(_ contact: SKPhysicsContact) {
if let body = player.physicsBody {
// let dy = player.physicsBody?.velocity.dy
// let platform = JumpThroughPlatformObject
if contact.bodyA.node == player {
body.collisionBitMask |= CollisionTypes.platform.rawValue
}else if contact.bodyB.node == player {
body.collisionBitMask |= CollisionTypes.platform.rawValue
}
}
}
Adding what I did, the player can no longer jump through the platform.
Here is a link to the project that I made for macOS and iOS targets:
https://github.com/fluidityt/JumpUnderPlatform
Basically, this all has to do with
Detecting collision of a platform
Then determining if your player is under the platform
Allow your player to go through the platform (and subsequently land on it)
--
SK Physics makes this a little complicated:
On collision detection, your player's .position.y or .velocity.dy
may already have changed to a "false" state in reference to satisfying the #2 check from above (meaning #3 will never happen). Also, your player will bounce off the platform on first contact.
There is no "automatic" way to determine when your player has finished passing through the object (thus to allow player to land on the platform)
--
So to get everything working, a bit of creativity and ingenuity must be used!
1: Detecting collision of a platform:
So, to tackle 1 is the simplest: we just need to use the built in didBegin(contact:)
We are going to be relying heavily on the 3 big bitMasks, contact, category, and collision:
(fyi, I don't like using enums and bitmath for physics because I'm a rebel idiot):
struct BitMasks {
static let playerCategory = UInt32(2)
static let jupCategory = UInt32(4) // JUP = JumpUnderPlatform
}
override func didBegin(_ contact: SKPhysicsContact) {
// Crappy way to do "bit-math":
let contactedSum = contact.bodyA.categoryBitMask + contact.bodyB.categoryBitMask
switch contactedSum {
case BitMasks.jupCategory + BitMasks.playerCategory:
// ...
}
--
Now, you said that you wanted to use the SKSEditor, so I have accommodated you:
// Do all the fancy stuff you want here...
class JumpUnderPlatform: SKSpriteNode {
var pb: SKPhysicsBody { return self.physicsBody! } // If you see this on a crash, then WHY DOES JUP NOT HAVE A PB??
// NOTE: I could not properly configure any SKNode properties here..
// it's like they all get RESET if you put them in here...
required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) }
}
--
Now for the player:
class Player: SKSpriteNode {
// If you see this on a crash, then WHY DOES PLAYER NOT HAVE A PB??
var pb: SKPhysicsBody { return self.physicsBody! }
static func makePlayer() -> Player {
let newPlayer = Player(color: .blue, size: CGSize(width: 50, height: 50))
let newPB = SKPhysicsBody(rectangleOf: newPlayer.size)
newPB.categoryBitMask = BitMasks.playerCategory
newPB.usesPreciseCollisionDetection = true
newPlayer.physicsBody = newPB
newPlayer.position.y -= 200 // For demo purposes.
return newPlayer
}
}
2. (and dealing with #4): Determining if under platform on contact:
There are many ways to do this, but I chose to use the player.pb.velocity.dy approach as mentioned by KOD to keep track of the player's position... if your dy is over 0, then you are jumping (under a platform) if not, then you are either standing still or falling (need to make contact with the platform and stick to it).
To accomplish this we have to get a bit more technical, because again, the physics system and the way SK works in its loop doesn't always mesh 100% with how we think it should work.
Basically, I had to make an initialDY property for Player that is constantly updated each frame in update
This initialDY will give us the correct data that we need for the first contact with the platform, allowing us to tell us to change the collision mask, and also to reset our player's CURRENT dy to the initial dy (so the player doesn't bounce off).
3. (and dealing with #5): Allow player to go through platform
To go through the platform, we need to play around with the collisionBitMasks. I chose to make the player's collision mask = the player's categoryMask, which is probably not the right way to do it, but it works for this demo.
You end up with magic like this in didBegin:
// Check if jumping; if not, then just land on platform normally.
guard player.initialDY > 0 else { return }
// Gives us the ability to pass through the platform!
player.pb.collisionBitMask = BitMasks.playerCategory
Now, dealing with #5 is going to require us to add another piece of state to our player class.. we need to temporarily store the contacted platform so we can check if the player has successfully finished passing through the platform (so we can reset the collision mask)
Then we just check in didFinishUpdate if the player's frame is above that platform, and if so, we reset the masks.
Here are all of the files , and again a link to the github:
https://github.com/fluidityt/JumpUnderPlatform
Player.swift:
class Player: SKSpriteNode {
// If you see this on a crash, then WHY DOES PLAYER NOT HAVE A PB??
var pb: SKPhysicsBody { return self.physicsBody! }
// This is set when we detect contact with a platform, but are underneath it (jumping up)
weak var platformToPassThrough: JumpUnderPlatform?
// For use inside of gamescene's didBeginContact (because current DY is altered by the time we need it)
var initialDY = CGFloat(0)
}
// MARK: - Funkys:
extension Player {
static func makePlayer() -> Player {
let newPlayer = Player(color: .blue, size: CGSize(width: 50, height: 50))
let newPB = SKPhysicsBody(rectangleOf: newPlayer.size)
newPB.categoryBitMask = BitMasks.playerCategory
newPB.usesPreciseCollisionDetection = true
newPlayer.physicsBody = newPB
newPlayer.position.y -= 200 // For demo purposes.
return newPlayer
}
func isAbovePlatform() -> Bool {
guard let platform = platformToPassThrough else { fatalError("wtf is the platform!") }
if frame.minY > platform.frame.maxY { return true }
else { return false }
}
func landOnPlatform() {
print("resetting stuff!")
platformToPassThrough = nil
pb.collisionBitMask = BitMasks.jupCategory
}
}
// MARK: - Player GameLoop:
extension Player {
func _update() {
// We have to keep track of this for proper detection of when to pass-through platform
initialDY = pb.velocity.dy
}
func _didFinishUpdate() {
// Check if we need to reset our collision mask (allow us to land on platform again)
if platformToPassThrough != nil {
if isAbovePlatform() { landOnPlatform() }
}
}
}
JumpUnderPlatform & BitMasks.swift (respectively:)
// Do all the fancy stuff you want here...
class JumpUnderPlatform: SKSpriteNode {
var pb: SKPhysicsBody { return self.physicsBody! } // If you see this on a crash, then WHY DOES JUP NOT HAVE A PB??
required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) }
}
struct BitMasks {
static let playerCategory = UInt32(2)
static let jupCategory = UInt32(4)
}
GameScene.swift:
-
MAKE SURE YOU HAVE THE TWO NODES IN YOUR SKS EDITOR:
-
// MARK: - Props:
class GameScene: SKScene, SKPhysicsContactDelegate {
// Because I hate crashes related to spelling errors.
let names = (jup: "jup", resetLabel: "resetLabel")
let player = Player.makePlayer()
}
// MARK: - Physics handling:
extension GameScene {
private func findJup(contact: SKPhysicsContact) -> JumpUnderPlatform? {
guard let nodeA = contact.bodyA.node, let nodeB = contact.bodyB.node else { fatalError("how did this happne!!??") }
if nodeA.name == names.jup { return (nodeA as! JumpUnderPlatform) }
else if nodeB.name == names.jup { return (nodeB as! JumpUnderPlatform) }
else { return nil }
}
// Player is 2, platform is 4:
private func doContactPlayer_X_Jup(platform: JumpUnderPlatform) {
// Check if jumping; if not, then just land on platform normally.
guard player.initialDY > 0 else { return }
// Gives us the ability to pass through the platform!
player.physicsBody!.collisionBitMask = BitMasks.playerCategory
// Will push the player through the platform (instead of bouncing off) on first hit
if player.platformToPassThrough == nil { player.pb.velocity.dy = player.initialDY }
player.platformToPassThrough = platform
}
func _didBegin(_ contact: SKPhysicsContact) {
// Crappy way to do bit-math:
let contactedSum = contact.bodyA.categoryBitMask + contact.bodyB.categoryBitMask
switch contactedSum {
case BitMasks.jupCategory + BitMasks.playerCategory:
guard let platform = findJup(contact: contact) else { fatalError("must be platform!") }
doContactPlayer_X_Jup(platform: platform)
// Put your other contact cases here...
// case BitMasks.xx + BitMasks.yy:
default: ()
}
}
}
// MARK: - Game loop:
extension GameScene {
// Scene setup:
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
addChild(player)
}
// Touch handling: (convert to touchesBegan for iOS):
override func mouseDown(with event: NSEvent) {
// Make player jump:
player.pb.applyImpulse(CGVector(dx: 0, dy: 50))
// Reset player on label click (from sks file):
if nodes(at: event.location(in: self)).first?.name == names.resetLabel {
player.position.y = frame.minY + player.size.width/2 + CGFloat(1)
}
}
override func update(_ currentTime: TimeInterval) {
player._update()
}
func didBegin(_ contact: SKPhysicsContact) {
self._didBegin(contact)
}
override func didFinishUpdate() {
player._didFinishUpdate()
}
}
I HOPE THIS HELPS SOME!
You just need a condition that let's you know if you are in a body. I also cleaned up your code to avoid accidently putting in the wrong categories
if let body = player.physicsBody, let dy = body.velocity.dy {
// when I am jumping or I am in a platform, then do not register
if (dy > zero || body.node.intersects(platform) && (body.node.position.y - body.node.size.height/2 != platform.position.y + platform.size.height / 2) {
body.collisionBitMask &= ~CollisionTypes.platform.rawValue
}
else {
// Allow collisions if the hero is falling
body.collisionBitMask |= CollisionTypes.platform.rawValue
Well, The answers above work well but those are very completed.
Simple answer is use Platform effector 2D component. which applies various “platform” behavior such as one-way collisions, removal of side-friction/bounce etc.
Check out this Unity's official tutorial for more clearance.
Any idea how I could implement a shop in my spriteKit game that users could buy different players with coins they have earned in game? any tutorials out there?
This is a multi-step project that took me about 500 loc (more without using .SKS) Here is the link to github finished project: https://github.com/fluidityt/ShopScene
Note, I am using a macOS SpriteKit project because it launches much faster on my computer. Simply change mouseDown() to touchesBegan() to get this to run on iOS.
First edit your GameScene.sks to look like this: (saves a bunch of time coding labels)
Make sure that you name everything EXACTLY as we need this to detect touch:
"entershop", "getcoins", "coinlabel", "levellabel"
This is the main "gameplay" scene and as you click coins++ you get levels and can move around. Clicking the shop will enter the shop.
Here is our GameScene.swift which matches this SKS:
import SpriteKit
class GameScene: SKScene {
let player = Player(costume: Costume.defaultCostume)
lazy var enterNode: SKLabelNode = { return (self.childNode(withName: "entershop") as! SKLabelNode) }()
lazy var coinNode: SKLabelNode = { return (self.childNode(withName: "getcoins" ) as! SKLabelNode) }()
lazy var coinLabel: SKLabelNode = { return (self.childNode(withName: "coinlabel") as! SKLabelNode) }()
lazy var levelLabel: SKLabelNode = { return (self.childNode(withName: "levellabel") as! SKLabelNode) }()
override func didMove(to view: SKView) {
player.name = "player"
if player.scene == nil { addChild(player) }
}
override func mouseDown(with event: NSEvent) {
let location = event.location(in: self)
if let name = atPoint(location).name {
switch name {
case "entershop": view!.presentScene(ShopScene(previousGameScene: self))
case "getcoins": player.getCoins(1)
default: ()
}
}
else {
player.run(.move(to: location, duration: 1))
}
}
override func update(_ currentTime: TimeInterval) {
func levelUp(_ level: Int) {
player.levelsCompleted = level
levelLabel.text = "Level: \(player.levelsCompleted)"
}
switch player.coins {
case 10: levelUp(2)
case 20: levelUp(3)
case 30: levelUp(4)
default: ()
}
}
};
Here you can see that we have a few other things going on not yet introduced: Player and Costume
Player is a spritenode subclass (it doubles as a data model and a UI element). Our player is just a colored square that gets moved around when you click the screen
The player wears something of Costume type, which is just a model that keeps track of data such as price, name, and the texture for the player to display.
Here is Costume.swift:
import SpriteKit
/// This is just a test method should be deleted when you have actual texture assets:
private func makeTestTexture() -> (SKTexture, SKTexture, SKTexture, SKTexture) {
func texit(_ sprite: SKSpriteNode) -> SKTexture { return SKView().texture(from: sprite)! }
let size = CGSize(width: 50, height: 50)
return (
texit(SKSpriteNode(color: .gray, size: size)),
texit(SKSpriteNode(color: .red, size: size)),
texit(SKSpriteNode(color: .blue, size: size)),
texit(SKSpriteNode(color: .green, size: size))
)
}
/// The items that are for sale in our shop:
struct Costume {
static var allCostumes: [Costume] = []
let name: String
let texture: SKTexture
let price: Int
init(name: String, texture: SKTexture, price: Int) { self.name = name; self.texture = texture; self.price = price
// This init simply adds all costumes to a master list for easy sorting later on.
Costume.allCostumes.append(self)
}
private static let (tex1, tex2, tex3, tex4) = makeTestTexture() // Just a test needed to be deleted when you have actual assets.
static let list = (
// Hard-code any new costumes you create here (this is a "master list" of costumes)
// (make sure all of your costumes have a unique name, or the program will not work properly)
gray: Costume(name: "Gray Shirt", texture: tex1 /*SKTexture(imageNamed: "grayshirt")*/, price: 0),
red: Costume(name: "Red Shirt", texture: tex2 /*SKTexture(imageNamed: "redshirt")*/, price: 5),
blue: Costume(name: "Blue Shirt", texture: tex3 /*SKTexture(imageNamed: "blueshirt")*/, price: 25),
green: Costume(name: "Green Shirt", texture: tex4 /*SKTexture(imageNamed: "greenshirt")*/, price: 50)
)
static let defaultCostume = list.gray
};
func == (lhs: Costume, rhs: Costume) -> Bool {
// The reason why you need unique names:
if lhs.name == rhs.name { return true }
else { return false }
}
The design of this struct is twofold.. first is to be a blueprint for a Costume object (which holds the name, price, and texture of a costume), and second it serves as a repository for all of your costumes via a hard-coded static master list property.
The function at the top makeTestTextures() is just an example for this project. I did this just so that way you can copy and paste instead of having to download image files to use.
Here is the Player.swift, which can wear the costumes in the list:
final class Player: SKSpriteNode {
var coins = 0
var costume: Costume
var levelsCompleted = 0
var ownedCostumes: [Costume] = [Costume.list.gray] // FIXME: This should be a Set, but too lazy to do Hashable.
init(costume: Costume) {
self.costume = costume
super.init(texture: costume.texture, color: .clear, size: costume.texture.size())
}
func getCoins(_ amount: Int) {
guard let scene = self.scene as? GameScene else { // This is very specific code just for this example.
fatalError("only call this func after scene has been set up")
}
coins += amount
scene.coinLabel.text = "Coins: \(coins)"
}
func loseCoins(_ amount: Int) {
guard let scene = self.scene as? GameScene else { // This is very specific code just for this example.
fatalError("only call this func after scene has been set up")
}
coins -= amount
scene.coinLabel.text = "Coins: \(coins)"
}
func hasCostume(_ costume: Costume) -> Bool {
if ownedCostumes.contains(where: {$0.name == costume.name}) { return true }
else { return false }
}
func getCostume(_ costume: Costume) {
if hasCostume(costume) { fatalError("trying to get costume already owned") }
else { ownedCostumes.append(costume) }
}
func wearCostume(_ costume: Costume) {
guard hasCostume(costume) else { fatalError("trying to wear a costume you don't own") }
self.costume = costume
self.texture = costume.texture
}
required init?(coder aDecoder: NSCoder) { fatalError() }
};
Player has a lot of functions, but they all could be handled elsewhere in the code. I just went for this design decision, but don't feel like you need to load up your classes with 2 line methods.
Now we are getting to the more nitty-gritty stuff, since we have set up our:
Base scene
Costume list
Player object
The last two things we really need are:
1. A shop model to keep track of inventory
2. A shop scene to display inventory, UI elements, and handle the logic of whether or not you can buy items
Here is Shop.swift:
/// Our model class to be used inside of our ShopScene:
final class Shop {
weak private(set) var scene: ShopScene! // The scene in which this shop will be called from.
var player: Player { return scene.player }
var availableCostumes: [Costume] = [Costume.list.red, Costume.list.blue] // (The green shirt wont become available until the player has cleared 2 levels).
// var soldCostumes: [Costume] = [Costume.defaultCostume] // Implement something with this if you want to exclude previously bought items from the store.
func canSellCostume(_ costume: Costume) -> Bool {
if player.coins < costume.price { return false }
else if player.hasCostume(costume) { return false }
else if player.costume == costume { return false }
else { return true }
}
/// Only call this after checking canBuyCostume(), or you likely will have errors:
func sellCostume(_ costume: Costume) {
player.loseCoins(costume.price)
player.getCostume(costume)
player.wearCostume(costume)
}
func newCostumeBecomesAvailable(_ costume: Costume) {
if availableCostumes.contains(where: {$0.name == costume.name}) /*|| soldCostumes.contains(costume)*/ {
fatalError("trying to add a costume that is already available (or sold!)")
}
else { availableCostumes.append(costume) }
}
init(shopScene: ShopScene) {
self.scene = shopScene
}
deinit { print("shop: if you don't see this message when exiting shop then you have a retain cycle") }
};
The idea was to have the fourth costume only be available at a certain level, but I've run out of time to implement this feature, but most of the supporting methods are there (you just need to implement the logic).
Also, Shop can pretty much just be a struct, but I feel that it's more flexible as a class for now.
Now, before jumping into ShopScene, our biggest file, let me tell you about a couple of design decisions.
First, I'm using node.name to handle touches / clicks. This lets me use the .SKS and the regular SKNode types quickly and easily. Normally, I like to subclass SKNodes and then override their own touchesBegan method to handle clicks. You can do it either way.
Now, in ShopScene you have buttons for "buy", "exit" which I have used as just regular SKLabelNodes; but for the actual nodes that display the costume, I have created a subclass called CostumeNode.
I made CostumeNode so that way it could handle nodes for displaying the costume's name, price, and doing some animations. CostumeNode is just a visual element (unlike Player).
Here is CostumeNode.swift:
/// Just a UI representation, does not manipulate any models.
final class CostumeNode: SKSpriteNode {
let costume: Costume
weak private(set) var player: Player!
private(set) var
backgroundNode = SKSpriteNode(),
nameNode = SKLabelNode(),
priceNode = SKLabelNode()
private func label(text: String, size: CGSize) -> SKLabelNode {
let label = SKLabelNode(text: text)
label.fontName = "Chalkduster"
// FIXME: deform label to fit size and offset
return label
}
init(costume: Costume, player: Player) {
func setupNodes(with size: CGSize) {
let circle = SKShapeNode(circleOfRadius: size.width)
circle.fillColor = .yellow
let bkg = SKSpriteNode(texture: SKView().texture(from: circle))
bkg.zPosition -= 1
let name = label(text: "\(costume.name)", size: size)
name.position.y = frame.maxY + name.frame.size.height
let price = label(text: "\(costume.price)", size: size)
price.position.y = frame.minY - price.frame.size.height
addChildrenBehind([bkg, name, price])
(backgroundNode, nameNode, priceNode) = (bkg, name, price)
}
self.player = player
self.costume = costume
let size = costume.texture.size()
super.init(texture: costume.texture, color: .clear, size: size)
name = costume.name // Name is needed for sorting and detecting touches.
setupNodes(with: size)
becomesUnselected()
}
private func setPriceText() { // Updates the color and text of price labels
func playerCanAfford() {
priceNode.text = "\(costume.price)"
priceNode.fontColor = .white
}
func playerCantAfford() {
priceNode.text = "\(costume.price)"
priceNode.fontColor = .red
}
func playerOwns() {
priceNode.text = ""
priceNode.fontColor = .white
}
if player.hasCostume(self.costume) { playerOwns() }
else if player.coins < self.costume.price { playerCantAfford() }
else if player.coins >= self.costume.price { playerCanAfford() }
else { fatalError() }
}
func becomesSelected() { // For animation / sound purposes (could also just be handled by the ShopScene).
backgroundNode.run(.fadeAlpha(to: 0.75, duration: 0.25))
setPriceText()
// insert sound if desired.
}
func becomesUnselected() {
backgroundNode.run(.fadeAlpha(to: 0, duration: 0.10))
setPriceText()
// insert sound if desired.
}
required init?(coder aDecoder: NSCoder) { fatalError() }
deinit { print("costumenode: if you don't see this then you have a retain cycle") }
};
Finally we have ShopScene, which is the behemoth file. It handles the data and logic for not only showing UI elements, but also for updating the Shop and Player models.
import SpriteKit
// Helpers:
extension SKNode {
func addChildren(_ nodes: [SKNode]) { for node in nodes { addChild(node) } }
func addChildrenBehind(_ nodes: [SKNode]) { for node in nodes {
node.zPosition -= 2
addChild(node)
}
}
}
func halfHeight(_ node: SKNode) -> CGFloat { return node.frame.size.height/2 }
func halfWidth (_ node: SKNode) -> CGFloat { return node.frame.size.width/2 }
// MARK: -
/// The scene in which we can interact with our shop and player:
class ShopScene: SKScene {
lazy private(set) var shop: Shop = { return Shop(shopScene: self) }()
let previousGameScene: GameScene
var player: Player { return self.previousGameScene.player } // The player is actually still in the other scene, not this one.
private var costumeNodes = [CostumeNode]() // All costume textures will be node-ified here.
lazy private(set) var selectedNode: CostumeNode? = {
return self.costumeNodes.first!
}()
private let
buyNode = SKLabelNode(fontNamed: "Chalkduster"),
coinNode = SKLabelNode(fontNamed: "Chalkduster"),
exitNode = SKLabelNode(fontNamed: "Chalkduster")
// MARK: - Node setup:
private func setUpNodes() {
buyNode.text = "Buy Costume"
buyNode.name = "buynode"
buyNode.position.y = frame.minY + halfHeight(buyNode)
coinNode.text = "Coins: \(player.coins)"
coinNode.name = "coinnode"
coinNode.position = CGPoint(x: frame.minX + halfWidth(coinNode), y: frame.minY + halfHeight(coinNode))
exitNode.text = "Leave Shop"
exitNode.name = "exitnode"
exitNode.position.y = frame.maxY - buyNode.frame.height
setupCostumeNodes: do {
guard Costume.allCostumes.count > 1 else {
fatalError("must have at least two costumes (for while loop)")
}
for costume in Costume.allCostumes {
costumeNodes.append(CostumeNode(costume: costume, player: player))
}
guard costumeNodes.count == Costume.allCostumes.count else {
fatalError("duplicate nodes found, or nodes are missing")
}
let offset = CGFloat(150)
func findStartingPosition(offset: CGFloat, yPos: CGFloat) -> CGPoint { // Find the correct position to have all costumes centered on screen.
let
count = CGFloat(costumeNodes.count),
totalOffsets = (count - 1) * offset,
textureWidth = Costume.list.gray.texture.size().width, // All textures must be same width for centering to work.
totalWidth = (textureWidth * count) + totalOffsets
let measurementNode = SKShapeNode(rectOf: CGSize(width: totalWidth, height: 0))
return CGPoint(x: measurementNode.frame.minX + textureWidth/2, y: yPos)
}
costumeNodes.first!.position = findStartingPosition(offset: offset, yPos: self.frame.midY)
var counter = 1
let finalIndex = costumeNodes.count - 1
// Place nodes from left to right:
while counter <= finalIndex {
let thisNode = costumeNodes[counter]
let prevNode = costumeNodes[counter - 1]
thisNode.position.x = prevNode.frame.maxX + halfWidth(thisNode) + offset
counter += 1
}
}
addChildren(costumeNodes)
addChildren([buyNode, coinNode, exitNode])
}
// MARK: - Init:
init(previousGameScene: GameScene) {
self.previousGameScene = previousGameScene
super.init(size: previousGameScene.size)
}
required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented")}
deinit { print("shopscene: if you don't see this message when exiting shop then you have a retain cycle") }
// MARK: - Game loop:
override func didMove(to view: SKView) {
anchorPoint = CGPoint(x: 0.5, y: 0.5)
setUpNodes()
select(costumeNodes.first!) // Default selection.
for node in costumeNodes {
if node.costume == player.costume { select(node) }
}
}
// MARK: - Touch / Click handling:
private func unselect(_ costumeNode: CostumeNode) {
selectedNode = nil
costumeNode.becomesUnselected()
}
private func select(_ costumeNode: CostumeNode) {
unselect(selectedNode!)
selectedNode = costumeNode
costumeNode.becomesSelected()
if player.hasCostume(costumeNode.costume) { // Wear selected costume if owned.
player.costume = costumeNode.costume
buyNode.text = "Bought Costume"
buyNode.alpha = 1
}
else if player.coins < costumeNode.costume.price { // Can't afford costume.
buyNode.text = "Buy Costume"
buyNode.alpha = 0.5
}
else { // Player can buy costume.
buyNode.text = "Buy Costume"
buyNode.alpha = 1
}
}
// I'm choosing to have the buttons activated by searching for name here. You can also
// subclass a node and have them do actions on their own when clicked.
override func mouseDown(with event: NSEvent) {
guard let selectedNode = selectedNode else { fatalError() }
let location = event.location(in: self)
let clickedNode = atPoint(location)
switch clickedNode {
// Clicked empty space:
case is ShopScene:
return
// Clicked Buy / Leave:
case is SKLabelNode:
if clickedNode.name == "exitnode" { view!.presentScene(previousGameScene) }
if clickedNode.name == "buynode" {
// guard let shop = shop else { fatalError("where did the shop go?") }
if shop.canSellCostume(selectedNode.costume) {
shop.sellCostume(selectedNode.costume)
coinNode.text = "Coins: \(player.coins)"
buyNode.text = "Bought"
}
}
// Clicked a costume:
case let clickedCostume as CostumeNode:
for node in costumeNodes {
if node.name == clickedCostume.name {
select(clickedCostume)
}
}
default: ()
}
}
};
There's a lot to digest here, but pretty much everything happens in mouseDown() (or touchesBegan for iOS). I had no need for update() or other every-frame methods.
So how did I make this? The first step was planning, and I knew there were several design decisions to make (which may not have been the best ones).
I knew that I needed a certain set of data for my player and shop inventory, and that those two things would also need UI elements.
I chose to combine the data + UI for Player by making it a Sprite subclass.
For the shop, I knew that the data and UI elements would be pretty intense, so I separated them (Shop.swift handling the inventory, Costume.swift being a blueprint, and CostumeNode.swift handling most of the UI)
Then, I needed to link the data to the UI elements, which meant that I needed a lot of logic, so I decided to make a whole new scene to handle logic pertaining just to entering and interacting with the shop (it handles some graphics stuff too).
This all works together like this:
Player has a costume and coins
GameScene is where you collect new coins (and levels)
ShopScene handles most of the logic for determining which UI elements to display, while CostumeNode has the functions for animating the UI.
ShopScene also provides the logic for updating the Player's texture (costume) and coins through Shop.
Shop just manages the player inventory, and has the data with which to populate more CostumeNodes
When you are done with the shop, your GameScene instance is immediately resumed where you left off prior to entering
So the question you may have is, "how do I use this in my game??"
Well, you aren't going to be able to just copy and paste it. A lot of refactoring will likely be needed. The takeaway here is to learn the basic system of the different types of data, logic, and actions that you will need to create, present, and interact with a shop.
Here is the github again:
https://github.com/fluidityt/ShopScene
I'm using the scene editor in SpriteKit to place color sprites and assign them textures using the Attributes Inspector. My problem is trying to figure out how to reference those sprites from my GameScene file. For example, I'd like to know when a sprite is a certain distance from my main character.
Edit - code added
I'm adding the code because for some reason, appzYourLife's answer worked great in a simple test project, but not in my code. I was able to use Ron Myschuk's answer which I also included in the code below for reference. (Though, as I look at it now I think the array of tuples was overkill on my part.) As you can see, I have a Satellite class with some simple animations. There's a LevelManager class that replaces the nodes from the scene editor with the correct objects. And finally, everything gets added to the world node in GameScene.swift.
Satellite Class
func spawn(parentNode:SKNode, position: CGPoint, size: CGSize = CGSize(width: 50, height: 50)) {
parentNode.addChild(self)
createAnimations()
self.size = size
self.position = position
self.name = "satellite"
self.runAction(satAnimation)
self.physicsBody = SKPhysicsBody(circleOfRadius: size.width / 2)
self.physicsBody?.affectedByGravity = false
self.physicsBody?.categoryBitMask = PhysicsCategory.satellite.rawValue
self.physicsBody?.contactTestBitMask = PhysicsCategory.laser.rawValue
self.physicsBody?.collisionBitMask = 0
}
func createAnimations() {
let flyFrames:[SKTexture] = [textureAtlas.textureNamed("sat1.png"),
textureAtlas.textureNamed("sat2.png")]
let flyAction = SKAction.animateWithTextures(flyFrames, timePerFrame: 0.14)
satAnimation = SKAction.repeatActionForever(flyAction)
let warningFrames:[SKTexture] = [textureAtlas.textureNamed("sat8.png"),
textureAtlas.textureNamed("sat1.png")]
let warningAction = SKAction.animateWithTextures(warningFrames, timePerFrame: 0.14)
warningAnimation = SKAction.repeatActionForever(warningAction)
}
func warning() {
self.runAction(warningAnimation)
}
Level Manager Class
import SpriteKit
class LevelManager
{
let levelNames:[String] = ["Level1"]
var levels:[SKNode] = []
init()
{
for levelFileName in levelNames {
let level = SKNode()
if let levelScene = SKScene(fileNamed: levelFileName) {
for node in levelScene.children {
switch node.name! {
case "satellite":
let satellite = Satellite()
satellite.spawn(level, position: node.position)
default: print("Name error: \(node.name)")
}
}
}
levels.append(level)
}
}
func addLevelsToWorld(world: SKNode)
{
for index in 0...levels.count - 1 {
levels[index].position = CGPoint(x: -2000, y: index * 1000)
world.addChild(levels[index])
}
}
}
GameScene.swift - didMoveToView
world = SKNode()
world.name = "world"
addChild(world)
physicsWorld.contactDelegate = self
levelManager.addLevelsToWorld(self.world)
levelManager.levels[0].position = CGPoint(x:0, y: 0)
//This does not find the satellite nodes
let satellites = children.flatMap { $0 as? Satellite }
//This does work
self.enumerateChildNodesWithName("//*") {
node, stop in
if (node.name == "satellite") {
self.satTuple.0 = node.position
self.satTuple.1 = (node as? SKSpriteNode)!
self.currentSatellite.append(self.satTuple)
}
}
The Obstacle class
First of all you should create an Obstacle class like this.
class Obstacle: SKSpriteNode { }
Now into the scene editor associate the Obstacle class to your obstacles images
The Player class
Do the same for Player, create a class
class Player: SKSpriteNode { }
and associate it to your player sprite.
Checking for collisions
Now into GameScene.swift change the updated method like this
override func update(currentTime: CFTimeInterval) {
/* Called before each frame is rendered */
let obstacles = children.flatMap { $0 as? Obstacle }
let player = childNodeWithName("player") as! Player
let obstacleNearSprite = obstacles.contains { (obstacle) -> Bool in
let distance = hypotf(Float(player.position.x) - Float(obstacle.position.x), Float(player.position.y) - Float(obstacle.position.y))
return distance < 100
}
if obstacleNearSprite {
print("Oh boy!")
}
}
What does it do?
The first line retrieves all your obstacles into the scene.
the second line retrieves the player (and does crash if it's not present).
Next it put into the obstacleNearSprite constant the true value if there is at least one Obstacle at no more then 100 points from Player.
And finally use the obstacleNearSprite to print something.
Optimizations
The updated method gets called 60 times per second. We put these 2 lines into it
let obstacles = children.flatMap { $0 as? Obstacle }
let player = childNodeWithName("player") as! Player
in order to retrieves the sprites we need. With the modern hardware it is not a problem but you should save references to Obstacle and Player instead then searching for them in every frame.
Build a nice game ;)
you will have to loop through the children of the scene and assign them to local objects to use in your code
assuming your objects in your SKS file were named Obstacle1, Obstacle2, Obstacle3
Once in local objects you can check and do whatever you want with them
let obstacle1 = SKSpriteNode()
let obstacle2 = SKSpriteNode()
let obstacle3 = SKSpriteNode()
let obstacle3Location = CGPointZero
func setUpScene() {
self.enumerateChildNodesWithName("//*") {
node, stop in
if (node.name == "Obstacle1") {
self.obstacle1 = node
}
else if (node.name == "Obstacle2") {
self.obstacle2 = node
}
else if (node.name == "Obstacle3") {
self.obstacle3Location = node.position
}
}
}