Reputation: 1748
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
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 lastElapsedTime
s.
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