iOS Developer's Cookbook: Visualizing Sprite Kit
Although Sprite Kit offers feedback in the form of number of properties that show frames per second, drawing counts, node counts, and (on iOS) physics body outlines, it offers a minimum of help in terms of tree and frame visualization. Determining exactly where items will appear and what size they'll occupy can be a tricky proposition. Here's an example of why confusion happens.
Take a look at Figure 1. It shows a scene with a single label node. The left screen shot uses a "right" horizontal alignment and a "baseline" vertical alignment. The second keeps the horizontal alignment and adjusts the vertical alignment to "top." In each case, the actual frame you want to work with is the left rectangle, which correctly surrounds its text. The right rectangle shows the frame you'd get if you tried to determine its edges using just the node's position attribute and its reported size.
Figure 1 In Sprite Kit, bounds and frames can get tricky. These rectangles are created programmatically as shape node children and are not a standard part of Sprite Kit
So why is there this disparity? It's intentional. Modes enable you to adjust the label with respect to its position attribute. Here's the code that created the label used in these images.
// Create the label SKLabelNode *label = [SKLabelNode labelNodeWithFontNamed:@"Futura"]; label.text = @"Hello World"; label.name = @"hello"; label.fontSize = 36; label.fontColor = [SKColor whiteColor]; // Place the label label.position = CGPointMake(CGRectGetMidX(scene.frame), CGRectGetMidY(scene.frame)); label.horizontalAlignmentMode = SKLabelHorizontalAlignmentModeRight; label.verticalAlignmentMode = example1 ? SKLabelVerticalAlignmentModeBaseline : SKLabelVerticalAlignmentModeTop;
To understand what is onscreen at any time and where each item is, it helps to examine a scene's underlying node structure. The following methods are part of an SKNode category. They iterate through all the children of a scene and recursively descend their node trees. At each node, the methods print a node description. When working with sprite nodes, it adds the anchor information as well.
Visualization #1: Recursively Descending the Node Tree
@implementation SKNode (Utility) - (void) dumpNode: (SKNode *) node atIndent: (int) indent into: (NSMutableString *) outstring { for (int i = 0; i < indent; i++) [outstring appendString:@"--"]; // Node class and built-in description [outstring appendFormat:@"[%2d] %@", indent, node.description]; // Frame if ([node.description rangeOfString:@"frame:"].location == NSNotFound) [outstring appendFormat:@" Frame: %@" , SKReadableRectString(node.frame)]; // Position if ([node.description rangeOfString:@"position:"].location == NSNotFound) [outstring appendFormat:@" Position: %@" , SKReadablePointString(node.position)]; [outstring appendFormat:@" [ZPos %0.1f at %0.1f°/%0.3f]", node.zPosition, node.zRotation * 180.0 / M_PI, node.zRotation]; // Scale [outstring appendFormat:@" Scale: (%0.1f, %0.1f)" , node.xScale, node.yScale]; // Anchor if applicable if ([node respondsToSelector:@selector(anchorPoint)]) { // Scene, Sprite, or Video SKScene *standin = (SKScene *) node; CGPoint anchorPoint = [standin anchorPoint]; [outstring appendFormat:@" Anchor: %@", SKReadablePointString(anchorPoint)]; } // Accumulated Frame if ([node.description rangeOfString:@"accumulatedFrame:"].location == NSNotFound) [outstring appendFormat:@" Accumulated: %@" , SKReadableRectString(node.calculateAccumulatedFrame)]; // User data if (node.userData.allKeys.count) [outstring appendFormat:@" User Data: %@", SKReadableDictionary(node.userData)]; [outstring appendString:@"\n"]; // Recurse for (SKNode *child in node.children) [self dumpNode:child atIndent:indent + 1 into:outstring]; } - (void) printNodeTree { NSMutableString *string = @"".mutableCopy; if ([self isKindOfClass:[SKScene class]]) { SKScene *scene = (SKScene *) self; [string appendString:@"Scene"]; if (scene.view.ignoresSiblingOrder) [string appendString:@" (SKView ignores sibling order)"]; [string appendString:@"\n"]; } [self dumpNode:self atIndent:0 into:string]; printf("%s", string.UTF8String); } @end
When you print a node tree that includes a label, you discover that each label actually consists of two nodes. There's a parent node, the SKLabelNode instance that sets a frame, and there's a child node, an SKSpriteNode that draws the text. When run on this example, this code produces the following results.
Scene [ 0] <SKLabelNode> name:'hello' text:'Hello World' fontName:'Futura' position:{284, 150} Frame: [88.0, 116.0, 196.0, 34.0] [ZPos 0.0 at 0.0°/0.000] Scale: (1.0, 1.0) Accumulated: [88.0, 116.0, 196.0, 34.0] --[ 1] <SKSpriteNode> name:'(null)' texture:[<SKTexture> '<data>' (196 x 34)] position:{-196, -34} size:{196, 34} rotation:0.00 Frame: [-196.0, -34.0, 196.0, 34.0] [ZPos 0.0 at 0.0°/0.000] Scale: (1.0, 1.0) Anchor: (0.0, 0.0) Accumulated: [-196.0, - 34.0, 196.0, 34.0]
The label's frame rectangle does not start at the node's position of {284, 150}. Instead, the label allows the text to float to a position with respect to the alignment modes you requested. The label's sprite child defines its frame relative to its parent's position.
A node's accumulated frame, available via calculateAccumulatedFrame, calculates an encompassing frame that includes the node and all its descendants. The frame is reported in its parent's coordinate system and includes any cumulative effects of scaling and rotation (specifically xScale, yScale, and zRotation) for all nodes in the subtree.
Drawing Frames
When working with UIView instances, you add overlays to views by adding run-time options to the Xcode scheme or by implementing a UIView category that uses a bordered default layer. With Sprite Kit, it's slightly trickier to visualize frames. Sprite nodes are responders, but they do not descend from the UIView class.
As Apple's Sprite Kit programming guide teaches you, built-in scene properties enable you to display debugging information for various features. You can enable showsDrawCount, showsFPS, showsNodeCount, and showsPhysics. You cannot, however, show node bounds without some kind of hacking workaround. To create the debugging panel in Figure 2, I searched Sprite Kit's binary framework to find the functionality I needed.
Figure 2 This slide-in debugging panel enables me to better visualize performance and position in Sprite Kit projects. Pro tip: If your FPS readout doesn't appear at run-time, toggle the draw-count a couple times
I discovered that SKView implements a showSpriteBounds property. However, this API is not public. To gain access, you must add a class extension, like the simple one shown here. When enabled, Sprite Kit draws a thin blue border around each participating view. If you've enabled showsPhysics (as in Figure 3), the physics body appears in pink.
Visualization #2: Enabling Bounds Display
// NOT FOR APP STORE @interface SKView (Debug) @property (nonatomic) BOOL showsSpriteBounds; @end
This class extension is not suitable for the App Store. Apple's automatic scanner can detect any use of this unpublished API. For debugging, it's a valuable tool. I'm surprised that Sprite Kit did not ship with this option publicly available. (In iOS 8, this property has been renamed to _showsSpriteBounds.)
Figure 3 A sprite node's frame and physics body shape often may not correlate
Visualizing Node Names
The nodeAtPoint: method is fantastic for working with touch-based sprites. In practice, complex node trees can throw off program logic. I usually want to retrieve the name of the top-most object both when debugging and when implementing semantic distinctions.
Here's an example where a touch on the presented child, a textured sprite node, should pass up the chain to a top-level node that can apply the physics body impulse. Although you can jump up to the scene level using a node's scene property, you'll want to find an ancestor node that lives in the scene and not return the scene itself.
- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { // Fetch the touched node UITouch *touch = touches.anyObject; CGPoint point = [touch locationInNode:self]; SKNode *node = [self nodeAtPoint:point]; if (!node) return; CGVector impulse = CGVectorMake(0, isText ? 50 : 10000); CGPoint adjustedPoint = [touch locationInNode:node]; [node.derivedNode.physicsBody applyImpulse:impulse atPoint:adjustedPoint]; }
The following SKNode category creates a derivedNode property that does just that. It walks the node tree until it finds a node that lives directly under the scene and returns that node.
When inspecting the touched node, the name you're interested in usually better corresponds to an ancestor's name than the touched node itself. For example, "high-bounce target" tells you more than "pattered texture node." A second property, derivedName, offers direct access to the semantic name corresponding to a touched node.
Visualization #3: Retrieving Top-Level Node Names
- (SKNode *) derivedNode { SKNode *node = self; while (node.parent && ![node.parent isKindOfClass:[SKScene class]]) node = node.parent; if ([node.parent isKindOfClass:[SKScene class]]) return node; return self; } - (NSString *) derivedName { return self.derivedNode.name; }
Wrap-Up
These few simple Sprite Kit tricks enable you to better delve into your scenes. With them, you can understand how they're composed, why items appear in the positions they're in, and how child nodes relate to their parents within a scene. There are more secrets and tricks that debuted with iOS 8, but those can wait until we're a lot closer to the Gold Master release.