rayaantaneja
rayaantaneja

Reputation: 1748

How to implement a Move action in SpriteKit

SpriteKit has an action called .moveBy(x:y:duration:). I wanted to try and implement this action myself and found it surprisingly difficult.

static func moveX(by amount: CGFloat, duration: TimeInterval) -> SKAction {
    let speed = amount / duration
    var lastElapsedTime = 0.0
    return .customAction(withDuration: duration) { node, elapsedTime in
        let timePassed = elapsedTime - lastElapsedTime
        node.position.x += timePassed * speed
        lastElapsedTime = elapsedTime
    }
}

The initial implementation seems deceivingly simple, however, problems start to emerge when you consider what happens if the same instance of this SKAction is executed multiple times by the same node, but with different start times.

Eg.

let move = SKAction.moveX(by: 5.0, duration: 1)
node.run(.group[move, .sequence[.wait(forDuration: 0.25), move]])

In this situation, the lastElapsedTime variable breaks as it is being written to two times while running on the same node (I'm aware this problem exists even when running the on different nodes. However, I managed to get around this problem by using Key-Value pairs. So it isn't a concern). In such situations the timePassed won't be accurate.

Does anyone know how I can implement this custom action and have it work even if the same instance runs on the same node at different times? Keep in mind the spacing between elapsedTime isn't the same as the timing mode isn't always linear. Any ideas will be greatly appreciated.

Upvotes: 1

Views: 163

Answers (1)

Sweeper
Sweeper

Reputation: 274520

As you have found out, the reason why your attempt doesn't work is because there is only one lastElapsedTime. As soon as the second action (the one that starts after 0.25 seconds) starts, the single lastElapsedTime variable gets overwritten with the elapsed time of the second action.

In other words, your closure is simultaneously fed the elapsed time of both the first and the second action, and you cannot distinguish which one it is.

A simple way to fix this is to not reuse the move action twice. Create two of them.

let move1 = SKAction.moveX(by: 5.0, duration: 1)
let move2 = SKAction.moveX(by: 5.0, duration: 1)
node.run(.group([move1, .sequence([.wait(forDuration: 0.25), move2]])))

Now there are two lastElapsedTimes.

The built in move is not implemented using customAction. Compare the types that are created by these methods:

type(of: SKAction.moveBy(x: 100, y: 100, duration: 1)) // SKMove
type(of: SKAction.customAction(withDuration: 1, actionBlock: { _, _ in })) // SKCustomAction

You can find the headers of these here: SKMove.h SKCustomAction.h. They respectively have SKCMove and SKCCustomAction as their members.

There seems to be such a class for every built-in action. It is possible that the factories in SKAction just calls the factories in these class.

I have also found that there is a method in SKCMove called cpp_updateWithTargetForTime(SKCNode*, double). This also seems to be in all the action classes prefixed SKC. Presumably, the actual implementation of how the action is performed is written in this method.

That method probably can access things that our code cannot, including but not limited to, the time since the last frame, and so is able to calculate how much the node should move.

Technically, you can get the time since last frame in your scene too, like this:

// in MyScene
var timeSinceLastFrame: Double = 0
var lastFrame: Double?
override func update(_ currentTime: TimeInterval) {
    if let lastFrame = self.lastFrame {
        timeSinceLastFrame = currentTime - lastFrame
    }
    lastFrame = currentTime
}

Then you can write a moveX method that works very similarly to the built-in one, but only in MyScene.

static func moveX(by amount: CGFloat, duration: CGFloat) -> SKAction {
    let speed = amount / duration
    return .customAction(withDuration: duration) { node, elapsedTime in
        if elapsedTime > 0 {
            let timePassed = (node.scene as! MyScene).timeSinceLastFrame
            node.position.x += timePassed * speed
        }
    }
}

Upvotes: 1

Related Questions