Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why does my SCNAction sequence stop working intermittently?

I'm trying to move an SCNNode around a scene, constrained to a GKGridGraph. Think along the lines of PacMan but in 3D.

I have a ControlComponent which handles the movement of my SCNNode. The logic should go like this...

  1. Set the queuedDirection property.
  2. If the isMoving flag is false, call the move() method
  3. Use the GKGridGraph to evaluate the next move

    3.1 If the entity can move to the GKGridGraphNode using the direction property, nextNode = nodeInDirection(direction)

    3.2 If the entity can move to the GKGridGraphNode using the queuedDirection property nextNode = nodeInDirection(queuedDirection)

    3.3 If the entity can not move to a node with either direction, set the isMoving flag to false and return.

  4. Create the moveTo action
  5. Create a runBlock which calls the move() method
  6. Apply the moveTo and runBlock actions as a sequence to the SCNNode

I've pasted in the full class below. But I'll explain the problem I'm having first. The above logic works, but only intermitently. Sometimes the animation stops working almost immediatley, and sometimes it runs for up to a minute. But at some point, for some reason, it just stops working - setDirection() will fire, move() will fire , the SCNNode will move once space in the specified direction and then the move() method just stops being called.

I'm not 100% convinced my current approach is correct so I'm happy to hear if there's a more idiomatic SceneKit/GameplayKit way to do this.

Here's the full class, but I think the important bit's are the setDirection() and move() methods.

import GameplayKit
import SceneKit

enum BRDirection {
    case Up, Down, Left, Right, None
}

class ControlComponent: GKComponent {

    var level: BRLevel!
    var direction: BRDirection = .None
    var queuedDirection: BRDirection?
    var isMoving: Bool = false
    var speed: NSTimeInterval = 0.5

    //----------------------------------------------------------------------------------------

    init(level: BRLevel) {
        self.level = level
        super.init()
    }

    //----------------------------------------------------------------------------------------

    func setDirection( nextDirection: BRDirection) {
        self.queuedDirection = nextDirection;
        if !self.isMoving {
            self.move()
        }
    }

    //----------------------------------------------------------------------------------------

    func move() {

        let spriteNode: SCNNode = (self.entity?.componentForClass(NodeComponent.self)!.node)!
        var nextNode = nodeInDirection( direction )

        if let _ = self.queuedDirection {
            let attemptedNextNode = nodeInDirection(self.queuedDirection! )
            if let _ = attemptedNextNode {
                nextNode = attemptedNextNode
                self.direction = self.queuedDirection!
                self.queuedDirection = nil
            }
        }

        // Bail if we don't have a valid next node
        guard let _ = nextNode else {
            self.direction = .None
            self.queuedDirection = nil
            self.isMoving = false
            return
        }

        // Set flag
        self.isMoving = true;

        // convert graphNode coordinates to Scene coordinates
        let xPos: Float = Float(nextNode!.gridPosition.x) + 0.5
        let zPos: Float = Float(nextNode!.gridPosition.y) + 0.5
        let nextPosition: SCNVector3 = SCNVector3Make(xPos, 0, zPos)

        // Configure actions
        let moveTo = SCNAction.moveTo(nextPosition, duration: speed)
        let repeatAction = SCNAction.runBlock( { _ in self.move() } )
        let sequence = SCNAction.sequence([ moveTo, repeatAction ])
        spriteNode.runAction( sequence )

    }


    //----------------------------------------------------------------------------------------

    func getCurrentGridGraphNode() -> GKGridGraphNode {

        // Acces the node in the scene and gett he grid positions
        let spriteNode: SCNNode = (self.entity?.componentForClass(NodeComponent.self)!.node)!

        // Account for visual offset
        let currentGridPosition: vector_int2 = vector_int2(
            Int32( floor(spriteNode.position.x) ),
            Int32( floor(spriteNode.position.z) )
        )

        // return unwrapped node
        return level.gridGraph.nodeAtGridPosition(currentGridPosition)!

    }


    //----------------------------------------------------------------------------------------

    func nodeInDirection( nextDirection:BRDirection? ) -> GKGridGraphNode? {
        guard let _ = nextDirection else { return nil }
        let currentGridGraphNode = self.getCurrentGridGraphNode()
        return self.nodeInDirection(nextDirection!, fromNode: currentGridGraphNode)
    }

    //----------------------------------------------------------------------------------------


    func nodeInDirection( nextDirection:BRDirection?, fromNode node:GKGridGraphNode ) -> GKGridGraphNode? {

        guard let _ = nextDirection else { return nil }

        var nextPosition: vector_int2?

        switch (nextDirection!) {
        case .Left:
            nextPosition = vector_int2(node.gridPosition.x + 1, node.gridPosition.y)
            break

        case .Right:
            nextPosition = vector_int2(node.gridPosition.x - 1, node.gridPosition.y)
            break

        case .Down:
            nextPosition = vector_int2(node.gridPosition.x, node.gridPosition.y - 1)
            break

        case .Up:
            nextPosition = vector_int2(node.gridPosition.x, node.gridPosition.y + 1)
            break;

        case .None:
            return nil
        }

        return level.gridGraph.nodeAtGridPosition(nextPosition!)

    }

}
like image 376
gargantuan Avatar asked Dec 31 '15 17:12

gargantuan


1 Answers

I'll have to answer my own question. First off, it's a bad question because I'm trying to do things incorrectly. The two main mistakes I made were

  1. My compnent was trying to do too much
  2. I wasn't using the updateWithDeltaTime method.

This is how the code and behaviour should be structured using GameplayKit's entity component structure. I'll try to epxlain how all the prices fit together at the end.

NodeComponent

This component is responsible for managing the actual SCNNode that represents my game character. I've moved the code for animating the character out of the ControlComponent and into this component.

class NodeComponent: GKComponent {

    let node: SCNNode
    let animationSpeed:NSTimeInterval = 0.25
    var nextGridPosition: vector_int2 {

        didSet {
            makeNextMove(nextGridPosition, oldPosition: oldValue)
        }

    }

    init(node:SCNNode, startPosition: vector_int2){
        self.node = node
        self.nextGridPosition = startPosition
    }


    func makeNextMove(newPosition: vector_int2, oldPosition: vector_int2) {

        if ( newPosition.x != oldPosition.x || newPosition.y != oldPosition.y ){

            let xPos: Float = Float(newPosition.x)
            let zPos: Float = Float(newPosition.y)
            let nextPosition: SCNVector3 = SCNVector3Make(xPos, 0, zPos)
            let moveTo =  SCNAction.moveTo(nextPosition, duration: self.animationSpeed)

            let updateEntity = SCNAction.runBlock( { _ in
                (self.entity as! PlayerEntity).gridPosition = newPosition
            })

            self.node.runAction(SCNAction.sequence([moveTo, updateEntity]), forKey: "move")

        }

    }

}

Note that every time the components gridPosition property is set, the makeNextMove method is called.

Control Component

My initial example was trying to do too much. This compnents sole responsibility now is to evaluate the next gridPosition for it's entity's NodeComponent. Note that because of updateWithDeltaTime, it will evaluate the next move as often as that method is called.

class ControlComponent: GKComponent {

    var level: BRLevel!
    var direction: BRDirection = .None
    var queuedDirection: BRDirection?


    init(level: BRLevel) {
        self.level = level
        super.init()
    }

    override func updateWithDeltaTime(seconds: NSTimeInterval) {
        self.evaluateNextPosition()
    }


    func setDirection( nextDirection: BRDirection) {
        self.queuedDirection = nextDirection
    }


    func evaluateNextPosition() {
        var nextNode = self.nodeInDirection(self.direction)

        if let _ = self.queuedDirection {

            let nextPosition = self.entity?.componentForClass(NodeComponent.self)?.nextGridPosition
            let targetPosition = (self.entity! as! PlayerEntity).gridPosition
            let attemptedNextNode = self.nodeInDirection(self.queuedDirection)

            if (nextPosition!.x == targetPosition.x && nextPosition!.y == targetPosition.y){
                if let _ = attemptedNextNode {
                    nextNode = attemptedNextNode
                    self.direction = self.queuedDirection!
                    self.queuedDirection = nil
                }
            }

        }

        // Bail if we don't have a valid next node
        guard let _ = nextNode else {
            self.direction = .None
            return
        }

        self.entity!.componentForClass(NodeComponent.self)?.nextGridPosition = nextNode!.gridPosition

    }


    func getCurrentGridGraphNode() -> GKGridGraphNode {
        // Access grid position
        let currentGridPosition = (self.entity as! PlayerEntity).gridPosition

        // return unwrapped node
        return level.gridGraph.nodeAtGridPosition(currentGridPosition)!
    }


    func nodeInDirection( nextDirection:BRDirection? ) -> GKGridGraphNode? {
        guard let _ = nextDirection else { return nil }
        let currentGridGraphNode = self.getCurrentGridGraphNode()
        return self.nodeInDirection(nextDirection!, fromNode: currentGridGraphNode)
    }


    func nodeInDirection( nextDirection:BRDirection?, fromNode node:GKGridGraphNode? ) -> GKGridGraphNode? {

        guard let _ = nextDirection else { return nil }
        guard let _ = node else { return nil }

        var nextPosition: vector_int2?

        switch (nextDirection!) {
        case .Left:
            nextPosition = vector_int2(node!.gridPosition.x + 1, node!.gridPosition.y)
            break

        case .Right:
            nextPosition = vector_int2(node!.gridPosition.x - 1, node!.gridPosition.y)
            break

        case .Down:
            nextPosition = vector_int2(node!.gridPosition.x, node!.gridPosition.y - 1)
            break

        case .Up:
            nextPosition = vector_int2(node!.gridPosition.x, node!.gridPosition.y + 1)
            break;

        case .None:
            return nil
        }

        return level.gridGraph.nodeAtGridPosition(nextPosition!)

    }

}

GameViewController

Here's where everything pieces together. There's a lot going on in the class, so I'll post only what's relevant.

class GameViewController: UIViewController, SCNSceneRendererDelegate {


    var entityManager: BREntityManager?
    var previousUpdateTime: NSTimeInterval?;
    var playerEntity: GKEntity?

    override func viewDidLoad() {
        super.viewDidLoad()

        // create a new scene
        let scene = SCNScene(named: "art.scnassets/game.scn")!

        // retrieve the SCNView
        let scnView = self.view as! SCNView

        // set the scene to the view
        scnView.scene = scene

        // show statistics such as fps and timing information
        scnView.showsStatistics = true

        scnView.delegate = self

        entityManager = BREntityManager(level: level!)

        createPlayer()

        configureSwipeGestures()

        scnView.playing = true

    }

    func createPlayer() {

        guard let playerNode = level!.scene.rootNode.childNodeWithName("player", recursively: true) else {
            fatalError("No player node in scene")
        }

        // convert scene coords to grid coords
        let scenePos = playerNode.position;
        let startingGridPosition = vector_int2(
            Int32( floor(scenePos.x) ),
            Int32( floor(scenePos.z) )
        )

        self.playerEntity = PlayerEntity(gridPos: startingGridPosition)
        let nodeComp = NodeComponent(node: playerNode, startPosition: startingGridPosition)
        let controlComp = ControlComponent(level: level!)
        playerEntity!.addComponent(nodeComp)
        playerEntity!.addComponent(controlComp)
        entityManager!.add(playerEntity!)

    }

    func configureSwipeGestures() {
        let directions: [UISwipeGestureRecognizerDirection] = [.Right, .Left, .Up, .Down]
        for direction in directions {
            let gesture = UISwipeGestureRecognizer(target: self, action: Selector("handleSwipe:"))
            gesture.direction = direction
            self.view.addGestureRecognizer(gesture)
        }
    }

    func handleSwipe( gesture: UISwipeGestureRecognizer ) {

        let controlComp = playerEntity!.componentForClass(ControlComponent.self)!

        switch gesture.direction {
        case UISwipeGestureRecognizerDirection.Up:
            controlComp.setDirection(BRDirection.Up)
            break

        case UISwipeGestureRecognizerDirection.Down:
            controlComp.setDirection(BRDirection.Down)
            break

        case UISwipeGestureRecognizerDirection.Left:
            controlComp.setDirection(BRDirection.Left)
            break

        case UISwipeGestureRecognizerDirection.Right:
            controlComp.setDirection(BRDirection.Right)
            break

        default:
            break
        }

    }

    // MARK: SCNSceneRendererDelegate

    func renderer(renderer: SCNSceneRenderer, updateAtTime time: NSTimeInterval) {
        let delta: NSTimeInterval
        if let _ = self.previousUpdateTime {
            delta = time - self.previousUpdateTime!
        }else{
            delta = 0.0
        }

        self.previousUpdateTime = time

        self.entityManager!.update(withDelaTime: delta)

    }

}

Entity Manager

I picked up this tip following this Ray Wenderlich tutorial. Essentially, it's a bucket to keep all your entities and components in to simplify to work of managing and updating them. I highly reccomend giving that tutorial a walthrough to understand this better.

class BREntityManager {

    var entities = Set<GKEntity>()
    var toRemove = Set<GKEntity>()
    let level: BRLevel

    //----------------------------------------------------------------------------------

    lazy var componentSystems: [GKComponentSystem] = {
        return [
            GKComponentSystem(componentClass: ControlComponent.self),
            GKComponentSystem(componentClass: NodeComponent.self)
        ]
    }()

    //----------------------------------------------------------------------------------

    init(level:BRLevel) {
        self.level = level
    }

    //----------------------------------------------------------------------------------

    func add(entity: GKEntity){

        if let node:SCNNode = entity.componentForClass(NodeComponent.self)!.node {
            if !level.scene.rootNode.childNodes.contains(node){
                level.scene.rootNode.addChildNode(node)
            }
        }

        entities.insert(entity)

        for componentSystem in componentSystems {
            componentSystem.addComponentWithEntity(entity)
        }
    }

    //----------------------------------------------------------------------------------

    func remove(entity: GKEntity) {

        if let _node = entity.componentForClass(NodeComponent.self)?.node {
            _node.removeFromParentNode()
        }

        entities.remove(entity)
        toRemove.insert(entity)
    }

    //----------------------------------------------------------------------------------

    func update(withDelaTime deltaTime: NSTimeInterval) {

        // update components
        for componentSystem in componentSystems {
            componentSystem.updateWithDeltaTime(deltaTime)
        }

        // remove components
        for curRemove in toRemove {
            for componentSystem in componentSystems {
                componentSystem.removeComponentWithEntity(curRemove)
            }
        }

        toRemove.removeAll()
    }

} 

So how does all this fit together

The ControlComponent.setDirection() method can be called at any time.

If an entity or component implements an updateWithDeltaTime method, it should be called every frame. It took me a little while to figure out how to do this with SceneKit as most GameplayKit example's are set up for SpriteKit, and SKScene has a very conventient updatet method.

For SceneKit, We have to make the GameVierwController the SCNSceneRendererDelegate for the SCNView. Then, using the rendererUpdateAtTime method, we can call updateAtDeltaTime on the Entity Manager which handles calling the same method on all entities and components every frame.

Note You have to manually set the playing property to true for this to work.

Now we move to the actual animation. ControlComponent is evaluating what the NodeComponents next grid position should be every frame, using the direction property (You can ignore the queuedDirection property, it's an implementation detail).

This means that the NodeComponents.makeNextMove() method is also being called every frame. Whenever newPosition and oldPosition are not the same, the animation is applied. And whenever the animation finishes, the node's gridPosition is updated.

Now, as to why my intial method for animating my SCNNode didn't work, I've no idea. But at least it forced me to better understand how to use GameplayKit's Entity/Component design.

like image 151
gargantuan Avatar answered Nov 09 '22 21:11

gargantuan