top of page
Matt

iOS에서 모바일 게임을 위한 레벨 선택 화면 만들기 (자동 테스트 포함) - 파트 1

소개

게임을 개발할 때, 게임의 내러티브를 강화하고 흥미로운 메카닉을 소개하기 위해 다양한 테마의 다른 지역이나 세계를 소개하는 것은 논리적입니다. 예를 들어, Falling Sky라는 게임의 후속작에서 (아직 체험하지 않았다면 여기 [https://apps.apple.com/app/id6446787964]에서 확인하세요!), 새로운 행성을 소개하여 각각 독특한 도전을 제공할 수 있습니다. 한 번 행성이 구출되면 주인공 오리온은 다음 행성으로 여행할 수 있습니다. 이 기사에서는 UIKit을 사용하여 iOS에서 Swift로 이를 어떻게 구현하는지를 보여줍니다. 또한 자동화된 테스트를 구현하는 방법에 대해서도 살펴보겠습니다. 파트 2에서는 세계가 선택된 후의 레벨 선택 화면에 중점을 둘 것입니다.


월드 선택 캐러셀

여러 UX 디자인 중에서 선택할 수 있지만, 일반적인 접근 방식은 스크롤 뷰를 사용하는 것입니다. 이는 세계를 수직으로 쌓은 버튼 스택으로 렌더링하거나 수평으로 스크롤하는 방식 중 하나일 수 있습니다. 우리는 좌우 탐색 버튼과 세계 자체를 위한 중앙 버튼이 있는 수평 접근 방식을 소개합니다. 이 캐러셀 디자인은 사용자가 쉽게 이해하고 사용할 수 있는 익숙한 개념입니다.


잠긴 세계

플레이어가 게임을 진행하고 레벨을 완료함에 따라 새로운 세계가 잠금 해제됩니다. 각 레벨에 대한 별 등급 시스템과 같은 다양한 방법을 탐색할 것입니다. 세계가 일정한 수의 별을 획득하면 잠금이 해제되는 시스템이나 게임 내 통화를 사용하고 인앱 구매로 세계를 빠르게 해제할 수도 있습니다. 이는 앱에 대한 효과적인 수익화 전략입니다.


자동화된 테스트

테스트 주도 개발 접근 방식을 따라 테스트를 정의해 보겠습니다. Swift iOS 프로젝트에 대한 효과적인 UI 테스트를 작성하는 자세한 가이드는 여기 [https://www.kingdomarcade.co.uk/ko/post/ios-앱에-대한-효과적인-ui-테스트-작성-방법]를 확인하세요. 이러한 테스트는 또한 기능과 필요한 동작을 이해하는 데 유용한 도구로 사용됩니다.


다음은 세계 선택 기능에 대해 작성할 테스트입니다:

  • testICanNavigateThroughToAllWorlds - 이는 행복한 경로입니다. 한 번 구현되면 플레이어는 모든 세계로 이동할 수 있습니다.

  • testFirstWorldIsSelectableByDefault - 이 테스트는 첫 번째 세계가 기본적으로 선택 가능한지 확인합니다.

  • testICanSelectWorld2IfAllLevelsInWorld1Completed - 이 테스트는 첫 번째 세계의 모든 레벨이 완료된 경우 다음 세계가 플레이 가능한지 확인합니다.

  • testWorld2IsLockedIfOnlySomeLevelsInWorld1Completed - 이 테스트는 첫 번째 세계가 아직 완료되지 않은 경우 다음 세계가 잠겨 있는지 확인합니다.

이러한 테스트를 구현하면 세계 선택 화면이 잠금 해제 기능을 갖춘 상태로 작동합니다. 여러분의 논리가 인앱 통화 또는 인앱 구매에 기반한 경우, 이러한 테스트를 업데이트하면 해당 동작이 반영됩니다.


이제 코드로 넘어가겠습니다!

우리 앱의 HomeViewController에서 "Worlds"라는 컴포넌트를 추가할 것입니다. 이는 세계를 선택하는 데 사용될 캐러셀을 포함할 것입니다. 세계가 잠겨 있거나 잠금 해제되었는지를 나타내는 함수가 전달되어야 하며 세계가 선택되면 처리해야 합니다.


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"
        }
    }
}

다음과 같이 사용됩니다:


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

이렇게 보입니다:



행복한 경로 테스트에서는 세계가 탐색 가능한지 확인합니다. 다음은 그 모습입니다:

어떤 이유로든 코드가 깨져서 세계에 도달할 수 없게 되면 이 테스트는 회귀로 인해 실패할 것입니다. 여기는 테스트의 전체 코드입니다:

    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()
    }

다음 글에서는 세계가 선택된 후에 무엇이 발생하는지 알아보겠습니다. 사용자가 스크롤할 때 좋은 패럴랙스 효과와 함께 레벨 선택 화면 자체로 이동합니다.


지원하는 방법


이 콘텐츠는 항상 무료로 유지될 것이며, 만약 가치 있다고 생각된다면 다른 사람과 공유하는 것을 고려해 주세요. 또한 게임을 다운로드하고 솔직한 리뷰를 남겨 주시면 저희에게 큰 지원이 됩니다. 어떠한 질문이나 피드백이 있으면 언제든지 문의해 주시면 최선을 다해 답해 드리겠습니다.


오늘 Apple App Store에서 Falling Sky를 다운로드하세요: https://apps.apple.com/app/id6446787964


X에서 저희를 팔로우하세요: https://x.com/_kingdomarcade

조회수 0회
bottom of page