Introduction
When developing games, it's logical to introduce different areas or worlds with various themes that enhance the game's narrative and introduce interesting mechanics. For instance, in a sequel to our game Falling Sky (check it out [https://apps.apple.com/app/id6446787964] if you haven't already!), we could introduce new planets for exploration, each with unique challenges. Once a planet is rescued, Orion, the protagonist, can travel to the next planet. This article demonstrates how to achieve this in Swift for iOS using UIKit. We'll also delve into implementing automated tests. Part 2 will focus on level selection views after a world has been chosen.
World Selector Carousel
There are various UX designs to choose from, but a common approach involves a scrolling view—either rendering worlds as a vertical stack of buttons or a horizontal scroll. We'll showcase the horizontal approach with left and right navigational buttons, and a central button for the world itself. This carousel design is a familiar concept that users will find easy to understand and use.
Locked Worlds
As players progress through the game and complete levels, they unlock new worlds. We'll explore different approaches, such as a star rating system for each level, where worlds unlock upon achieving a set number of stars. Alternatively, in-game currency can be used, and in-app purchases can expedite world unlocking—an effective monetisation strategy for apps.
Automated Tests
Following a test-driven approach, let's define the tests. For a detailed guide on setting up tests for a Swift iOS project, check out [https://www.kingdomarcade.co.uk/post/how-to-write-effective-ui-tests-for-ios-apps]. These tests also serve as a valuable tool for understanding features and required behaviour.
These are the tests we will write for our world selection feature:
testICanNavigateThroughToAllWorlds - This is the happy path; once implemented, the player can navigate to all worlds
testFirstWorldIsSelectableByDefault - This test ensures the first world is selectable by default
testICanSelectWorld2IfAllLevelsInWorld1Completed - This test checks that the next world is playable once unlocked
testWorld2IsLockedIfOnlySomeLevelsInWorld1Completed - This test checks that the next world is locked if the first world is yet to be completed
Once these tests are implemented, we'll have a working world selection screen with the feature of unlocking worlds. If your logic is based on in-app currency or in-app purchases, updating these tests will reflect that behaviour.
Now, let's get to the code!
On the HomeViewController of our app, we're going to add a component called "Worlds." This will contain our carousel for selecting the world to play in. It needs to be passed functions indicating whether the world is locked or unlocked and should handle when a world has been selected.
Worlds.swift:
import UIKit
class Worlds: UIView {
// this scrollview controls and hosts the pagination of the worlds
var scrollView: UIScrollView! = nil
// this is the callback we fire when a world is selected enabling the HomeViewController to load the level selection screen for the world in question - in part 2 this will get refactored to "onLevelSelected"
var onWorldSelected: ((Area) -> ())! = nil
convenience init(appF: AppFunctions, onWorldSelected: @escaping (World) -> ()) {
self.init()
self.onWorldSelected = onWorldSelected
scrollView = UIScrollView()
self.addSubview(scrollView)
scrollView.translatesAutoresizingMaskIntoConstraints = false
//the track view contains the world buttons which we paginate through
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 0
stackView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stackView)
let screenBounds = UIScreen.main.bounds
let buttonWidth = screenBounds.width * 0.6
//here we get the detail on whether a world is locked or unlocked, the gets passed down so can render the "locked" button view, perhaps with a padlock
let levelResults = try! appF.getLevelResults()
let worlds = World.allCases.map {world in
//world button is the button to select the world. this could be a more interesting animated view
WorldButton(width: buttonWidth, world: World, isUnlocked: levelResults.isWorldUnlocked(world), onTouch: onWorldButtonTouch)
}
for item in worlds {
// we put each world button centered into a view.
let itemView = UIView(frame: scrollView.frame)
itemView.addSubview(item)
item.translatesAutoresizingMaskIntoConstraints = false
item.centerXAnchor.constraint(equalTo: itemView.centerXAnchor).isActive = true
item.centerYAnchor.constraint(equalTo: itemView.centerYAnchor).isActive = true
stackView.addArrangedSubview(itemView)
itemView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
itemView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
}
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
scrollView.topAnchor.constraint(equalTo: topAnchor).isActive = true
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
scrollView.contentSize = CGSize(width: CGFloat(areas.count) * frame.size.width, height: frame.size.height)
scrollView.isPagingEnabled = true
//add a left button to scroll left
let leftButton = UIButton()
leftButton.setTitle("Left", for: .normal)
leftButton.addTarget(self, action: #selector(scrollToLeft), for: .touchUpInside)
leftButton.backgroundColor = .black
leftButton.setTitleColor(.white, for: .normal)
leftButton.frame = CGRect(x: 10, y: 10, width: 100, height: 40)
self.addSubview(leftButton)
// add a right button to scroll right
let rightButton = UIButton()
rightButton.setTitle("Right", for: .normal)
rightButton.addTarget(self, action: #selector(scrollToRight), for: .touchUpInside)
rightButton.backgroundColor = .black
rightButton.setTitleColor(.white, for: .normal)
rightButton.frame = CGRect(x: screenBounds.width - 100 - 10, y: 10, width: 100, height: 40)
self.addSubview(rightButton)
leftButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 16).isActive = true
leftButton.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16).isActive = true
rightButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 16).isActive = true
rightButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -16).isActive = true
}
//handle when world button is selected and trigger the callback
func onWorldButtonTouch(world: World) {
onWorldSelected(world)
}
@objc func scrollToLeft() {
let currentOffset = scrollView.contentOffset.x
let itemWidth = scrollView.frame.size.width
let previousPage = max(currentOffset - itemWidth, 0)
scrollView.setContentOffset(CGPoint(x: previousPage, y: 0), animated: true)
}
@objc func scrollToRight() {
let contentWidth = scrollView.contentSize.width
let currentOffset = scrollView.contentOffset.x
let itemWidth = scrollView.frame.size.width
let nextPage = min(currentOffset + itemWidth, contentWidth - scrollView.frame.size.width)
scrollView.setContentOffset(CGPoint(x: nextPage, y: 0), animated: true)
}
}
// the World enum, to add a new world is very simple, we can now just add a new entry this enum and it will get automatically added to the selection screens
enum World: Codable, CaseIterable {
case world1
case world2
case world3
}
// simple extension to get the label to display if need be. This could be refactored further to contain more information.
extension World {
func getLabel() -> String {
return switch self {
case World.world1:
"Lumina Prime"
case World.world2:
"Astra-Veridia"
case World.world3:
"Nebula Nexus"
}
}
}
then it gets used like so:
HomeViewController.swift:
import UIKit
class HomeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .darkGray
addWorlds()
}
func onWorldSelected(world: World) {
let viewController = ...
navigationController!.pushViewController(viewController, animated: true);
}
private func addWorlds() {
let worlds = Worlds(appF: appF(), onWorldSelected: onWorldSelected)
view.addSubview(worlds)
}
}
This looks like:
With the happy path test, the test checks that the worlds are navigable. Here's what it looks like:
If, for whatever reason, we break the code and the worlds can't be reached, this test will fail due to the regression. Here's the full code for the test:
func testICanNavigateThroughToAllWorlds() throws {
app.launch(with: createTestAppF())
assertWorldPresent(expectedWorld: World.world1)
navRight()
assertWorldPresent(expectedWorld: World.world2)
navRight()
assertWorldPresent(expectedWorld: World.world3)
navRight()
assertWorldPresent(expectedWorld: World.world3)
navLeft()
assertWorldPresent(expectedWorld: World.world2)
navLeft()
assertWorldPresent(expectedWorld: World.world1)
navLeft()
assertWorldPresent(expectedWorld: World.world1)
}
fileprivate func assertWorldPresent(expectedWorld: World) {
let worldLabel = expectedWorld.getLabel()
let actualWorld = app.buttons[worldLabel]
XCTAssertTrue(actualWorld.isHittable)
}
fileprivate func navRight() {
let rightButton = app.buttons["Right"]
rightButton.tap()
}
fileprivate func navLeft() {
let rightButton = app.buttons["Left"]
rightButton.tap()
}
In the next post, we'll explore what happens once the world is selected—we navigate into the level selection screen itself with a nice parallax effect when the user scrolls across.
How to support
This content will always remain free, and if you find it valuable, please consider sharing it with others. Additionally, downloading our games and leaving honest reviews greatly supports us. Feel free to reach out with any questions or feedback, and we'll do our best to respond.
Download Falling Sky from the Apple App Store today: https://apps.apple.com/app/id6446787964
Follow us on X: https://x.com/_kingdomarcade