After the development of my simple puzzle game, one of the things I wanted to do differently was using a generic Node Component. This would be a component that would do all the scaling, rotating, and positioning in such a way that it could apply to any node type (label, sprite, shape, etc) or even things like particle emitters.
In this post, I’ll create a simple SpriteKit scene with a basic render system and a few simple components to see how this might work.
As I write this, I’m walking the Dale’s Way with nothing but my iPad Pro so this will all be done using the Swift Playgrounds app.
I’ll start by creating a new blank project:
Then I’ll name it Node Component Example
and open it:
To start with I’ll import a few libraries and set up the scene:
import SpriteKit import GameplayKit import PlaygroundSupport // 1. class GameScene : SKScene { } // 2. let view = SKView(frame: CGRect(x: 0, y: 0, width: 400, height: 500)) let scene = GameScene(size: view.frame.size) // 3. scene.scaleMode = .aspectFit view.presentScene(scene) // 4. PlaygroundPage.current.liveView = view
- A stub of the subclass of
SKScene
I’ll be using to handle the interaction and manage the entities and systems. - To get something appearing in playgrounds, the
liveView
property of the playground needs to be set to a subclass ofUIView
. Initialise a standardSKView
with a hardcoded width and height that happens to work well on my iPad. Use the same size to initialise the scene. - Set the
scaleMode
toaspectFit
so that the scene will be squeezed down to fit in the space available in order to maintain its aspect ratio. - Set the
liveView
property of the current playground page in order to have the SpriteKit scene appear in the playground.
The scene will keep track of a couple of things: the previous time (in order to calculate the delta time between frame updates) and the entities that are created:
class GameScene : SKScene { var entities : [GKEntity] = [] var previousTime : TimeInterval = 0 }
I’ll then override the update(_:)
method to calculate the delta time. At the bottom of the GameScene
class, before the closing brace, I’ll add:
override func update(_ currentTime: TimeInterval) { if self.previousTime == 0 { self.previousTime = currentTime } let delta = currentTime - self.previousTime }
Now I can add the node component, initially a very simple class with just an SKNode
instance as it’s only property:
class NodeComponent : GKComponent { let node = SKNode() }
Following my preferred way of managing ECS in SpriteKit, an extension on the node component is going to function as my render system. In order to make sure that this gets called every frame, I need to add a GKComponentSystem
to the main game scene and then pass the delta time to this system.
At the top of the GameScene
declaration, under the previous properties, I’ll add:
lazy var systems : [GKComponentSystem] = { let render = GKComponentSystem(componentClass: NodeComponent.self) return [render] }()
Then, at the bottom of the update(:)
method, I’ll add:
for system in systems { system.update(deltaTime: delta) }
This ensures that every component that is registered with a system receives the update.
Adding a Shape Component
I’ll define a protocol with a single method that returns the more specific SKNode
subclass (e.g. SKLabelNode
, SKSpriteNode
) as a generic SKNode
.
Any components that want to be included in the Node Component’s render system will have to conform to this protocol.
protocol ChildNode { func asNode() -> SKNode }
This will make it easier in future for the Node Component to accept these more specific components without knowing anything about how these SKNode
subclasses are stored within the component.
Below is a ShapeComponent
that has a property called shape
which is an instance of the SKSpriteNode
. The Node Component doesn’t need to know about this property, it will only have to check that this sprite component conforms to the ChildNode
protocol and then use its method to add the Shape Component’s node as a child:
// 1. enum Shape { case circle, square } class ShapeComponent : GKComponent, ChildNode { let shape : SKShapeNode // 2. init( shape : Shape) { switch shape { case .circle: self.shape = SKShapeNode(circleOfRadius: 20) case .square: self.shape = SKShapeNode(rect: CGRect(x: 0, y: 0, width: 20, height: 20)) } // 3. self.shape.fillColor = SKColor.purple super.init() } required init?(coder aDecoder: NSCoder) { fatalError("Not implemented") } // 4. func asNode() -> SKNode { return self.shape } }
- This enum will define some preset shapes that the component can create in order to simplify creating shapes for this example.
- The initialiser will accept an enum member and then create a
SKShapeNode
of that enum member, assigning it to the component’s property. - Set all the shapes to the same colour as a visual check that everything is working as it should.
- This protocol conformance method returns the
SKShapeNode
as it’s generic superclass as a convenience for the parent node component.
Adding a Position Component
I now have a generic node to act as a parent and a more specific node type that I might want to display in a scene. I can now add some attribute components to define how that node is displayed. I’ll start with positioning:
class PositionComponent : GKComponent { // 1. var currentPosition : CGPoint // 2. init(pos : CGPoint){ self.currentPosition = pos super.init() } required init?(coder aDecoder: NSCoder) { fatalError("Not implemented") } }
- This will define the position of the entity for any given frame and is a variable as it other components and systems might need to change it.
- The initialiser accepts a
CGPoint
, which defines the initial position of the entity.
For this example playground, I’ll use the tap location received from the touchesEnded(with:)
method in the GameScene
to set the initial location of an entity. I’ll start by creating a helper method:
func addNode(at point:CGPoint){ // 1. let entity = GKEntity() // 2. let nodeComponent = NodeComponent() let shapeComponent = ShapeComponent(shape: .circle) // 3. nodeComponent.node.addChild(shapeComponent.asNode()) // 4. let positionComponent = PositionComponent(pos: point) // 5. entity.addComponent(nodeComponent) entity.addComponent(typeComponent) entity.addComponent(positionComponent) // 6. self.entities.append(entity) // 7. self.addChild(nodeComponent.node) // 8. for system in systems { system.addComponent(foundIn: entity) } }
- Create a generic entity.
- Initialise the node component and the shape component.
- Add the shape component’s
shape
property as a child of the node component’snode
property using the protocol method I defined earlier. - Initialise a position property passing through the
CGPoint
parameter of the method. - Add all of these components to the entity.
- Add this new entity to the array of entities.
- Add the node component’s
node
property as a child to the scene (which will also add the node’s children—in this case the shape node—to the scene) - Make sure that all of the components that belong to the entity are registered with the systems so that their
update(deltaTime:)
methods are called.
Then I’ll override the touchesEnded(with:)
method in the GameScene
class:
override func touchesEnded(_ touches: Set, with event: UIEvent?) { // 1. guard let touch = touches.first else { return } // 2. let loc = touch.location(in: self) // 3. self.addNode(at:loc) }
- Make sure there’s at least one touch and grab a reference to the first one.
- Get the location of the touch in the current scene.
- Call the helper method to add a new shape node to the scene.
Creating the Render System
If I run this Playground now, all the shapes will be created at 0,0. The position component is storing the correct position but the position is not currently being applied.
For this, I’ll need a render system:
// 1. extension NodeComponent { // 2. override func update(deltaTime seconds: TimeInterval) { // 3. if let hasPos = self.entity?.component(ofType: PositionComponent.self)?.currentPosition { self.node.position = hasPos } } }
- The render system is an extension of the node component (see my post about ECS).
- As I added this component to the systems array earlier, this update method will get called every frame with the time difference between this frame and the last
- If the entity that this component belongs to also has a position component, apply the current position to the node.
This will now apply the position component’s currentPosition
property to the node which itself has the shape node as a child.
Expanding the Node Types
This was a lot of work to just position a shape node on screen, so let’s add a couple of different node types:
class LabelComponent : GKComponent, ChildNode { let label : SKLabelNode init(text: String) { self.label = SKLabelNode(text: text) super.init() } required init?(coder aDecoder: NSCoder) { fatalError("Not implemented") } func asNode() -> SKNode { return self.sprite } }
Like the shape node, this one has a SKLabelNode
property which is a constant. This property is created with the passed string on initialisation.
I also can’t forget the all-important sprite component:
class SpriteComponent : GKComponent, ChildNode { let sprite : SKSpriteNode override init() { sprite = SKSpriteNode(color: #colorLiteral(red: 0.854901969432831, green: 0.250980406999588, blue: 0.47843137383461, alpha: 1.0), size: CGSize(width: 100, height: 100)) super.init() } required init?(coder aDecoder: NSCoder) { fatalError("Not implemented") } func asNode() -> SKNode { return self.sprite } }
As this is being developed on an iPad, I’m going to skip using an actual sprite and just create a 100×100 pinkish square just to show it works.
Now that I have multiple node types, to demonstrate I’ll need a way of choosing between them.
I’ll define an enum that defines the available node types and has a convenience method that will return an array of all of the available types.
enum NodeType { case shape, label, sprite static func allTypes() -> [NodeType] { return [.shape, .label, .sprite] } }
Then, in the GameScene
, I’ll add a new property that starts as an empty array of this NodeType
:
var typeArray = [NodeType]()
Finally, in the addNode(at:)
method, I’ll replace:
let shapeComponent = ShapeComponent(shape: .circle) nodeComponent.node.addChild(shapeComponent.shape)
With:
// 1. if self.typeArray.isEmpty { self.typeArray = NodeType.allTypes() } // 2. let next = self.typeArray.popLast()! // 3. let typeComponent : GKComponent // 4. switch next { case .shape: // 5. typeComponent = ShapeComponent(shape: .circle) case .label: typeComponent = LabelComponent(text: "Hello!") case .sprite: typeComponent = SpriteComponent() } // 6. if let hasChild = typeComponent as? ChildNode { nodeComponent.node.addChild(hasChild.getNode()) }
- If
typeArray
is empty, repopulate it with the available types from theNodeType
convenience method. - At this point, I can be sure that the array is not empty so I can pop the last item and force unwrap it to get the next type in the array.
- Define a generic
GKComponent
object to hold the component I’m about to create. - Switch on the type…
- And create a concrete subclass of the given type.
- Finally, I make sure that, whatever specific type was used to populate
typeComponent
(which was declared as a genericGKComponent
), it conforms to theChildNode
protocol. If it does, the node component can add it as a child without knowing anything more about its implementation (i.e. it doesn’t care if it’s a label, a shape, or a sprite).
Specifying all cases of the enum will ensure that any new types I add will require me to initialise their related components here, but that’s all I’ll need to do. Conformance to the protocol will ensure that these new components will automatically have their specific SKNode subclass property added to NodeComponent
parent node.
Adding a Scale Component
One of the other issues I mentioned in my jigsaw puzzle post-mortem was that the way I implemented the scale component was overkill. I had basically re-implemented the SKAction manually, so this time I’m going to create a simpler ScaleComponent
that uses an SKAction
:
class ScaleComponent : GKComponent { // 1. var scaleAmountPerSecond : CGFloat = 1.5 // 2. var targetScale : CGFloat = 1 { didSet { // 3. guard let hasNode = self.entity?.component(ofType: NodeComponent.self)?.node else { return } // 4. let distance = abs(targetScale - hasNode.xScale) let duration = TimeInterval(distance / scaleAmountPerSecond) // 5. hasNode.removeAction(forKey: "scaleAction") let action = SKAction.scale(to: targetScale, duration: duration) hasNode.run(action, withKey: "scaleAction") } } }
- This adjustable property defines how quickly an entity scales. In this case, it gets 1.5x bigger every second.
- This is the property that other systems will set to begin scaling a node.
- If the entity doesn’t also have a node component, then it can’t be scaled.
- Using the scale
SKAction
requires a duration. In order to calculate this, I get the absolute difference between the node’s current scale and target scale and then I divide this distance by the scale speed to get the time it should take. - If the node is already running a scale action, stop it immediately. Set up a new action passing the
targetScale
andduration
, then run it on the node component’s node (giving it a custom name so I only remove that action).
In the GameScene
class, under let positionComponent = PositionComponent(pos: point)
, I’ll add:
let scaleComponent = ScaleComponent() entity.addComponent(scaleComponent)
Then, just before self.entities.append(entity)
, I’ll add:
scaleComp.targetScale = 1.5
This will cause the shape to start growing bigger as soon as it’s added to the scene.
Finally, it would be nice if I could have some visual proof that the scale system is working correctly (i.e. that the scale animation stops at the moment of interruption and begins a new one, and that the speed remains consistent whatever scale it is animating to) . every time a new entity is added I want the last entity to shrink back down to a 1x scale.
At the start of touchesEnded(with:)
, I can add:
self.entities.last?.component(ofType: ScaleComponent.self)?.targetScale = 1
This will get the scale component (if there is one) of the last entity in the array (if there is one), and set its target scale to 1.
The finished Playground is available on GitHub.
Why Create a Node Component Like This?
Clearly, this is a lot of work for an effect that could be achieved more directly with many fewer lines of code. While the benefits of using ECS really become apparent when dealing with much larger projects, here are some immediate advantages as I see them:
- Any SKNode subclass can now be positioned, rotated, or scaled using the same code.
- All of the code that defines the
SKNode
subclasses (e.g. theShapeComponent
,LabelComponent
, etc.) as well as all the code that handles positioning, rotating, and scaling can be moved out of the main game scene and into smaller, more manageable, and easier to find files. - Whether an entity can be positioned, rotated, or scaled becomes dependent on whether it has the relevant component. Neither the main game scene or the systems need any knowledge about the entity as the entity itself is defining its own attributes and abilities. Using optional chaining, systems can attempt to change a property on any component on any entity and if that entity doesn’t have that component, nothing happens.
Having said that, I am still exploring the practical implications of ECS and how it will work managing large game scenes. I am especially interested if I can get it to work with things like physics engines—there are some tutorials that I’m interested in re-implementing using ECS to see if the advantages really hold up in more practical situations—and will be exploring those next.