Problem
When the node hierarchy is encoded, as is common during application state preservation or a “game save”, nodes running SKAction
actions with code blocks must be handled specially, since the code blocks cannot be encoded.
Example 1: Delayed Callback after Animation
Here, an orc has been killed. It is animated to fade out and then remove itself from the node hierarchy:
SKAction *fadeAction = [SKAction fadeOutWithDuration:3.0];
SKAction *removeAction = [SKAction removeFromParent];
[orcNode runAction:[SKAction sequence:@[ fadeAction, removeAction ]]];
If the orc node is encoded and then decoded, the animation will restore properly and complete as expected.
But now the example is modified to use a code block that runs after the fade. Perhaps the code cleans up some game state once the orc is (finally) dead.
SKAction *fadeAction = [SKAction fadeOutWithDuration:3.0];
SKAction *removeAction = [SKAction removeFromParent];
SKAction *cleanupAction = [SKAction runBlock:^{
[self orcDidFinishDying:orcNode];
}];
[orcNode runAction:[SKAction sequence:@[ fadeAction, removeAction, cleanupAction ]]];
Unfortunately, the code block will not encode. During application state preservation (or game save), if this sequence is running, a warning will be issued:
SKAction: Run block actions can not be properly encoded, Objective-C blocks do not support NSCoding.
After decoding, the orc will fade and be removed from parent, but the cleanup method orcDidFinishDying:
will not be called.
What is the best way to work around this limitation?
Example 2: Tweening
The SKAction
customActionWithDuration:actionBlock:
seems a beautiful fit for tweening. My boilerplate code for this kind of thing is this:
SKAction *slideInAction = [SKAction customActionWithDuration:2.0 actionBlock:^(SKNode *node, CGFloat elapsedTime){
CGFloat normalTime = (CGFloat)(elapsedTime / 2.0);
CGFloat normalValue = BackStandardEaseInOut(normalTime);
node.position = CGPointMake(node.position.x, slideStartPositionY * (1.0f - normalValue) + slideFinalPositionY * normalValue);
}];
Unfortunately, customActionWithDuration:actionBlock:
cannot be encoded. If the game is saved during the animation, it will not restore properly on game load.
Again, what is the best way to work around this limitation?
Imperfect Solutions
Here are solutions I have considered but don’t like. (That said, I’d love to read answers that successfully champion one of these.)
Imperfect Solution: Use
performSelector:onTarget:
rather thanrunBlock:
in the animation. This solution is imperfect because arguments cannot be passed to the invoked selector; context for the call can only be expressed by the target and the name of the selector. Not great.Imperfect Solution: During encoding, remove the
SKAction
sequence from any relevant nodes and advance the program state as if the sequence had completed. In the first example, that would mean setting the nodealpha
immediately to0.0
, removing the orc node from parent, and callingorcDidFinishDying:
. This is an unfortunate solution for at least two reasons: 1) It requires special handling code during encoding; 2) Visually, the node won’t get a chance to finish its animation.Imperfect Solution: During encoding, remove the
SKAction
code blocks from any relevant nodes, and recreate them during decoding. This is non-trivial.Imperfect Solution: Never use
SKAction
code blocks, especially after a delay. Never rely on the completion of an animation in order to restore good app state. (If you need to schedule a future event in an encodable way, build your own event queue not using code blocks.) This solution is imperfect becauserunBlock
andcustomActionWithDuration:actionBlock:
are just so damn useful, and it would be a shame (and a recurring trap for newbies) to consider them evil.
Encodable lightweight objects can model the kinds of
SKAction
code blocks that we want to use (but can’t).Code for the below ideas is here.
Replacement for
runBlock
The first encodable lightweight object replaces
runBlock
. It can make an arbitrary callback with one or two arguments.The caller instantiates the lightweight object and sets its properties: target, selector, and arguments.
The lightweight object is triggered in a
runAction
animation by the standard no-argument[SKAction performSelector:onTarget:]
. For this triggering action, the target is the lightweight object and the selector is a designated “execute” method.The lightweight object conforms to
NSCoding
.As a bonus, the triggering
SKAction
retains a strong reference to the lightweight object, and so both will be encoded along with the node running the actions.A version of this lightweight object could be made that retains the target weakly, which might be nice and/or necessary.
Here is a draft of a possible interface:
And an accompanying implementation:
And an example of using it:
Replacement for
customActionWithDuration:actionBlock:
A second encodable lightweight object replaces
customActionWithDuration:actionBlock:
. This one is not so simple, however.Again, it is triggered by the no-argument
[SKAction performSelector:onTarget:]
, invoking a designatedexecute
method.A
customActionWithDuration:actionBlock:
has a duration. But the triggeringperformSelector:onTarget:
does not. The caller must insert a companionwaitForDuration:
action into her sequence if it depends on duration.The lightweight object is initialized with a target, selector, node, and duration.
When it is triggered, the lightweight object tracks its own elapsed time and periodically calls the selector on the target, passing it the node and the elapsed time.
The lightweight object conforms to
NSCoding
. On decoding, if already triggered, it resumes calling the selector for the remainder of its configured duration.Limitations
I have implemented a version of these proposed classes. Through light use I've already found an important limitation: Nodes encoded with a running
SKAction
sequence restart the sequence from the beginning upon decoding.