SKAction on multiple nodes one after another

Hi. I am stuck trying to figure out how I can perform a series of actions (moving) on a number of nodes one after another. In other words, I want one node to move; once the move is over, the second node will move; etc.


Having read a few sources, I ended up with something along these lines:


let dispatchGroup = DispatchGroup()
       
numberOfNodes = 6
       
var i=0
       
        while numberOfNodes > 0
        {
            let positionToMove = CGPoint(x: 400, y: 400)
            let moveAction = SKAction.move(to: positionToMove, duration: 1.0)
            let waitAction = SKAction.wait(forDuration: 5)
            let sequenceAction = SKAction.sequence([waitAction,moveAction])
           
            dispatchGroup.enter()
           
            arrayOfNodes[i].run(sequenceAction, completion: {
                dispatchGroup.leave()
            })
           
            i+=1
            numberOfNodes -= 1
        }

        dispatchGroup.notify(queue: DispatchQueue.main, execute: {
            print("COMPLETED GROUP")
        })


Still, I have all of my nodes moving at the same time, after which the notification is received. How can I make the nodes move one after another and then get a notification?


Thanks a lot!

Accepted Reply

I think you're overthinking this a bit. It's a relatively easy thing to accomplish, especially when the movement animation duration is the same for each node and you want each node to start animating right after its predecessor has finished moving. It would be slightly more difficult if you had specific node durations and delays. Though, even then it would only require two additional duration arrays to follow.


Just quickly, some things that should be corrected in your code:


  • Using DispatchQueue with SpriteKit is a big no-no. SpriteKit is like a cardboard box inside another cardboard box, which is iOS. DispatchQueue kinda functions system-wide, so you're looking at timing outside of the SpriteKit universe. So, what you want is to tie timing in with SpriteKit's time, so whenever it does something (such as call a global isPaused variable), your game follows game time, not iOS time. In short, when controlling time in SpriteKit, always use SKActions. However, for frame updates, use the update() function.
  • You might want to move your variables and initiations outside of your loop, so that it doesn't do all that work again and again within each loop cycle.
  • You should always rely on the array to determine how many items, in this case nodes, there are, instead of having a separate variable for it.
  • There are smarter and more secure ways in Swift to handle the loop iteration value than iterating one yourself in code. The same goes for controlling remaining nodes in the loop.
  • There is a built in completion handler for SKActions, which you can use to determine when your sequence is done.


Here's my version of what you're trying to do:


// This assumes arrayOfNodes already exists.
        
let moveDuration = 1.0
        
let moveAction = SKAction.move(to: CGPoint(x: 400.0, y: 400.0), duration: moveDuration)
        
for (nodeIndex, node) in arrayOfNodes.enumerated() {
   node.run(SKAction.sequence([SKAction.wait(forDuration: moveDuration * Double(nodeIndex)), moveAction]), completion: {
      if nodeIndex == arrayOfNodes.endIndex - 1 {
         print("COMPLETED GROUP")
      }
   })
}


So, first we set moveDuration outside of the loop, because it'll be used for moveAction's duration as well as to control delays for each node's movement inside the loop. We then set moveAction using your coordinates and moveDuration. The easiest, most readable and most secure loop to use is a For loop that cycles directly through the nodes in your array, and it's also enumerated, so you can extract the loop iteration value nodeIndex. For each cycle of the loop, the corresponding node in your array is given a run command with a sequence of a delay and then moveAction. The duration of the delay is determined by multiplying moveDuration with nodeIndex, which makes the node at index 0 have a 0.0 delay, then node at index 1 have 1.0 delay, ..., etc. At the end of each run command there's a completion handler, in which the completed message is print only if nodeIndex happens to be the endIndex of the array. This means the print is only done when the last node in the array is reached. In Swift, endIndex of an array means the last index after your last item, so you also need to decrement it back by 1.


I didn't test the code, but I don't see why it wouldn't work. Famous last words... 😀. Anyhow, try it out and ask away if you have any further questions.

Replies

I think you're overthinking this a bit. It's a relatively easy thing to accomplish, especially when the movement animation duration is the same for each node and you want each node to start animating right after its predecessor has finished moving. It would be slightly more difficult if you had specific node durations and delays. Though, even then it would only require two additional duration arrays to follow.


Just quickly, some things that should be corrected in your code:


  • Using DispatchQueue with SpriteKit is a big no-no. SpriteKit is like a cardboard box inside another cardboard box, which is iOS. DispatchQueue kinda functions system-wide, so you're looking at timing outside of the SpriteKit universe. So, what you want is to tie timing in with SpriteKit's time, so whenever it does something (such as call a global isPaused variable), your game follows game time, not iOS time. In short, when controlling time in SpriteKit, always use SKActions. However, for frame updates, use the update() function.
  • You might want to move your variables and initiations outside of your loop, so that it doesn't do all that work again and again within each loop cycle.
  • You should always rely on the array to determine how many items, in this case nodes, there are, instead of having a separate variable for it.
  • There are smarter and more secure ways in Swift to handle the loop iteration value than iterating one yourself in code. The same goes for controlling remaining nodes in the loop.
  • There is a built in completion handler for SKActions, which you can use to determine when your sequence is done.


Here's my version of what you're trying to do:


// This assumes arrayOfNodes already exists.
        
let moveDuration = 1.0
        
let moveAction = SKAction.move(to: CGPoint(x: 400.0, y: 400.0), duration: moveDuration)
        
for (nodeIndex, node) in arrayOfNodes.enumerated() {
   node.run(SKAction.sequence([SKAction.wait(forDuration: moveDuration * Double(nodeIndex)), moveAction]), completion: {
      if nodeIndex == arrayOfNodes.endIndex - 1 {
         print("COMPLETED GROUP")
      }
   })
}


So, first we set moveDuration outside of the loop, because it'll be used for moveAction's duration as well as to control delays for each node's movement inside the loop. We then set moveAction using your coordinates and moveDuration. The easiest, most readable and most secure loop to use is a For loop that cycles directly through the nodes in your array, and it's also enumerated, so you can extract the loop iteration value nodeIndex. For each cycle of the loop, the corresponding node in your array is given a run command with a sequence of a delay and then moveAction. The duration of the delay is determined by multiplying moveDuration with nodeIndex, which makes the node at index 0 have a 0.0 delay, then node at index 1 have 1.0 delay, ..., etc. At the end of each run command there's a completion handler, in which the completed message is print only if nodeIndex happens to be the endIndex of the array. This means the print is only done when the last node in the array is reached. In Swift, endIndex of an array means the last index after your last item, so you also need to decrement it back by 1.


I didn't test the code, but I don't see why it wouldn't work. Famous last words... 😀. Anyhow, try it out and ask away if you have any further questions.

Thank you, iniitamo.


I see your point and your code works. I had additional layers of complexity on top (e.g., locations had to be different for multiple players), but I may have solved this using your logic.


P.S. Did not know that using DispatchQueue in SpriteKit is not recommended. I may drop another note on that matter later.


Thanks a lot!

Cool, nice to be of service 🙂!


For multiple locations, for example, you can access those using the same nodeIndex value, if they happen to be in an array with the same order of items. Sometimes when I have a group of things coming from data, I actually don't loop through nodes themselves, but rather an array of item names or id's. So then since I've used that same array to create the nodes and their respective array, the order is the same. But that's just being clean. It's reasonable that if your main focus is the nodes themselves, you should loop through them and access any other information with their iteration value.


Yeah, I guess the only reason you might ever use DispatchQueue in SpriteKit would be to fetch data from a server, which would be an iOS centric operation, not so much a SpriteKit one. It's best to just think of SKActions as the driving force of your game. It feels a little weird to use them for delays in code, because you think of them as visual animations usually, but that is the correct way in SpriteKit.

I had same problem. Here is my solution.


/// 190926:   NodeAction is node bound with an action but waiting for a comletion argument to be given
public typealias NodeAction = (_ completion: @escaping () -> Void) -> Void

extension SKNode { // 190926

    public func doAction(_ action:SKAction) -> NodeAction {
        let actionWithCompletion = { [weak self] (completion:@escaping () -> Void) in
            guard let this = self else { return }
            this.run(action, completion: completion)
        }
        return actionWithCompletion
    }

    public static func execute(nodeActions:[NodeAction]) {
        if let nodeAction = nodeActions.first {
            let rest = Array(nodeActions.dropFirst())
            nodeAction { execute(nodeActions: rest) }
        }
    }
}


To do what you want:


let arrayOfNodes:[SKNode] = []
let positionToMove = CGPoint(x: 400, y: 400)
var nodeActions:[NodeAction] = []
for node in arrayOfNodes {
    nodeActions.append(node.doAction(SKAction.wait(forDuration: 5)))
    nodeActions.append(node.doAction(SKAction.move(to: positionToMove, duration: 1.0)))
}
SKNode.execute(nodeActions: nodeActions)

I also the following additional idea which I have not tested:


extension SKNode {

    func doActionBlock(_ block: @escaping () -> Void) -> NodeAction { // 190322 give this a try ???
        let action = SKAction.run(block)
        let actionWithCompletion = { [weak self] (completion:@escaping () -> Void) in
            guard let this = self else { return }
            this.run(action, completion: completion)
        }
        return actionWithCompletion
    }
}


Of course, you don't have to use it just for an array. In general, you declare "var nodeActions:[NodeAction] = [ ]" and then add the actions you want to happen. Since "node" is usually just the scene, you can omit it.


Here is an example where I am doing a demo of leading a card in a game:


var nodeActions:[NodeAction] = []
nodeActions.append(doAction(SKAction.wait(forDuration: DemoThinker.demoThinkingDuration)))
if shouldKibitz {
    nodeActions.append(doAction(SKAction.kibitzAction(gameEvent: gameEvent, gameScene: self)))
}
nodeActions.append(doAction(SKAction.demoLeadCardEventAction(for: gameEvent, gameScene: self, cardNode: cardNode)))
nodeActions.append(doAction(SKAction.unhighlight(node: cardNode)))
nodeActions.append(doAction(SKAction.setupDragPileNode(cardNode: cardNode, gameScene: self)))
nodeActions.append(doAction(SKAction.faceup(cardNode: cardNode, faceup: true)))
nodeActions.append(dragPileNode.doAction(SKAction.group([highlightWhenDiscardingAction, moveToAction]))) /// moveTo action)
nodeActions.append(doAction(SKAction.wait(forDuration: didDiscardDropWaitTime)))
nodeActions.append(doAction(SKAction.removeDemoBubbleNodeAction(gameScene: self)))
nodeActions.append(doAction(SKAction.removeYellowDotAction(player: player, gameScene: self)))
nodeActions.append(doAction(SKAction.unhighlightDiscardPileOutlineNode(player: player, gameScene: self)))
nodeActions.append(doAction(SKAction.demoRestHandleLeadCardEvent(for: gameEvent, gameScene: self)))
SKNode.execute(nodeActions: nodeActions)