Day 85 - 100 Days of Swift

5 minute read

Project 26 (part 1)

Day 85 is the first part of the twenty-sixth project. It will be a marble maze game where you tilt the iPad in different directions to get the marble to roll through the maze. Today you pretty much just get the project set up and add the code for loading a level from a text file that visually represents the layout of the level.

First, you make a new game project, do all the normal clean up stuff, and lock the orientation into just landscape right. You do that because the game will involve tilting the iPad around and it would be frustrating to have the screen spinning around on you if you accidentally tilted too far. You also pull in the artwork that he provides.

Then you add a function called loadLevel that will load up a level from a text file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
guard let levelURL = Bundle.main.url(forResource: "level1",
                                     withExtension: "txt") else {
    fatalError("Could not find level1.txt in the app bundle.")
}
guard let levelString = try? String(contentsOf: levelURL) else {
    fatalError("Could not load level1.txt from the app bundle.")
}

let lines = levelString.components(separatedBy: "\n")

for (row, line) in lines.reversed().enumerated() {
    for (column, letter) in line.enumerated() {
        let x = (64 * column) + 32
        let y = (64 * row) + 32
        let position = CGPoint(x: x, y: y)

        if letter == "x" {
            loadWall(at: position)
        } else if letter == "v"  {
            loadVortex(at: position)
        } else if letter == "s"  {
            loadStar(at: position)
        } else if letter == "f"  {
            loadFinish(at: position)
        } else if letter == " " {
            // this is an empty space – do nothing!
        } else {
            fatalError("Unknown level letter: \(letter)")
        }
    }
}

The first part gets the text out of the file and splits it into lines that we can work with. Then it loops through the lines in reversed order because SpriteKit’s coordinate system goes from the bottom up so we need to start with the bottom lines. It also enumerates the lines so that we have something to calculate the position off of. Inside of that, there is a second loop that loops through each letter in the line, also enumerated. And inside the second loop we now have enough information to calculate the position, and we use the value of the letter to decide what sort of node to make at that position.

loadWall(at:) looks like this:

1
2
3
4
5
6
7
8
9
func loadWall(at position: CGPoint) {
    let node = SKSpriteNode(imageNamed: "block")
    node.position = position

    node.physicsBody = SKPhysicsBody(rectangleOf: node.size)
    node.physicsBody?.categoryBitMask = CollisionTypes.wall.rawValue
    node.physicsBody?.isDynamic = false
    addChild(node)
}

You get the right node, set its position, give it a physics body of its own size, give it a category for collisions, set it to not be dynamic (meaning it won’t move) and then add it as a child.

The rest of the “load” functions follow pretty much the same pattern with a few extras. Here’s loadVortex(at:):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func loadVortex(at position: CGPoint) {
    let node = SKSpriteNode(imageNamed: "vortex")
    node.name = "vortex"
    node.position = position
    node.run(SKAction.repeatForever(SKAction.rotate(byAngle: .pi,
                                                    duration: 1)))
    node.physicsBody = SKPhysicsBody(circleOfRadius: node.size.width / 2)
    node.physicsBody?.isDynamic = false

    node.physicsBody?.categoryBitMask = CollisionTypes.vortex.rawValue
    node.physicsBody?.contactTestBitMask = CollisionTypes.player.rawValue
    node.physicsBody?.collisionBitMask = 0
    addChild(node)
}

In addition to the stuff that loadWall does, this just gives it a name, adds an action to rotate the node and sets the contactTestBitMask to notify us when it collides with a player.

The loadStar(at:) and loadFinish(at:) methods do largely the same things:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func loadStar(at position: CGPoint) {
    let node = SKSpriteNode(imageNamed: "star")
    node.name = "star"
    node.physicsBody = SKPhysicsBody(circleOfRadius: node.size.width / 2)
    node.physicsBody?.isDynamic = false

    node.physicsBody?.categoryBitMask = CollisionTypes.star.rawValue
    node.physicsBody?.contactTestBitMask = CollisionTypes.player.rawValue
    node.physicsBody?.collisionBitMask = 0
    node.position = position
    addChild(node)
}

func loadFinish(at position: CGPoint) {
    let node = SKSpriteNode(imageNamed: "finish")
    node.name = "finish"
    node.physicsBody = SKPhysicsBody(circleOfRadius: node.size.width / 2)
    node.physicsBody?.isDynamic = false

    node.physicsBody?.categoryBitMask = CollisionTypes.finish.rawValue
    node.physicsBody?.contactTestBitMask = CollisionTypes.player.rawValue
    node.physicsBody?.collisionBitMask = 0
    node.position = position
    addChild(node)
}

Finally, you just add a background node to fill out the scene:

1
2
3
4
5
6
// In didMove(to:)
let background = SKSpriteNode(imageNamed: "background.jpg")
background.position = CGPoint(x: 512, y: 384)
background.blendMode = .replace
background.zPosition = -1
addChild(background)

With that, we have code that will lay out a level based on a text file and it looks like this:

Screenshot of level laid out

You can find my version of this project at the end of day 85 on Github here.