iossprite-kitskactionuikit-state-preservation

What are good ways to work around the encoding limitation of SKAction code blocks during application state preservation?


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.)


Solution

  • 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.

    Here is a draft of a possible interface:

    @interface HLPerformSelector : NSObject <NSCoding>
    
    - (instancetype)initWithTarget:(id)target selector:(SEL)selector argument:(id)argument;
    
    @property (nonatomic, strong) id target;
    
    @property (nonatomic, assign) SEL selector;
    
    @property (nonatomic, strong) id argument;
    
    - (void)execute;
    
    @end
    

    And an accompanying implementation:

    @implementation HLPerformSelector
    
    - (instancetype)initWithTarget:(id)target selector:(SEL)selector argument:(id)argument
    {
      self = [super init];
      if (self) {
        _target = target;
        _selector = selector;
        _argument = argument;
      }
      return self;
    }
    
    - (instancetype)initWithCoder:(NSCoder *)aDecoder
    {
      self = [super init];
      if (self) {
        _target = [aDecoder decodeObjectForKey:@"target"];
        _selector = NSSelectorFromString([aDecoder decodeObjectForKey:@"selector"]);
        _argument = [aDecoder decodeObjectForKey:@"argument"];
      }
      return self;
    }
    
    - (void)encodeWithCoder:(NSCoder *)aCoder
    {
      [aCoder encodeObject:_target forKey:@"target"];
      [aCoder encodeObject:NSStringFromSelector(_selector) forKey:@"selector"];
      [aCoder encodeObject:_argument forKey:@"argument"];
    }
    
    - (void)execute
    {
      if (!_target) {
        return;
      }
      IMP imp = [_target methodForSelector:_selector];
      void (*func)(id, SEL, id) = (void (*)(id, SEL, id))imp;
      func(_target, _selector, _argument);
    }
    
    @end
    

    And an example of using it:

    SKAction *fadeAction = [SKAction fadeOutWithDuration:3.0];
    SKAction *removeAction = [SKAction removeFromParent];
    HLPerformSelector *cleanupCaller = [[HLPerformSelector alloc] initWithTarget:self selector:@selector(orcDidFinishDying:) argument:orcNode];
    SKAction *cleanupAction = [SKAction performSelector:@selector(execute) onTarget:cleanupCaller];
    [orcNode runAction:[SKAction sequence:@[ fadeAction, removeAction, cleanupAction ]]];
    

    Replacement for customActionWithDuration:actionBlock:

    A second encodable lightweight object replaces customActionWithDuration:actionBlock:. This one is not so simple, however.

    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.