Swift 遊戲開發之「能否關個燈」(一)

PJHubs發表於2019-09-07

前言

在上一篇文章中,我們已經完成了對《能否關個燈》小遊戲的介面和遊戲邏輯進行了初步搭建,並且也具備了一定的可玩性。但細心的你會發現,這種「隨機過程」的遊戲開局,我們幾乎一把都不會贏,因為這並不符合這個遊戲的初衷——逆序出開燈的順序去關燈

關卡配置

在現有程式碼中,每次新開局遊戲裡各種燈的狀態都是之前我們通過「隨機化」Light 模型中的 status 狀態做到的,這種做法之前也說過了幾乎不可能把所有燈都關掉,因此我們需要對資料來源做一些處理,使之能夠通過「配置」去生成遊戲開局。

至此,我們的 ContentView 已經比較龐大了,而且作為一個 View 它所承載的內容已經到了需要被抽離的時間點,我們不能再往 ContentView 裡塞關卡配置的邏輯了。

因此,還是那句話「電腦科學領域的任何問題都可以通過增加一個間接的中間層來解決」,所以我們將引入一個 GameManager 來處理關卡配置。GameManager 中負責的主要內容有:

  • 配置關卡的 size(3x3 or 4x4...
  • 配置關卡的隨機過程;
  • 維護燈狀態;
  • 配置關卡的一些 UI 。

新建一個 GameManager 類,並把之前寫在 ContentView 中的邏輯都遷移進去。經過一番調整後,我們的程式碼就變成了:

import SwiftUI
import Combine

class GameManager {
    var lights = [
        [Light(), Light(status: true), Light()],
        [Light(), Light(), Light()],
        [Light(), Light(), Light()],
    ]

    /// 通過座標索引修改燈狀態
    /// - Parameters:
    ///   - column: 燈-列索引
    ///   - size: 燈-行索引
    func updateLightStatus(column: Int, row: Int) {
        lights[row][column].status.toggle()

        // 上
        let top = row - 1
        if !(top < 0) {
            lights[top][column].status.toggle()
        }
        // 下
        let bottom = row + 1
        if !(bottom > lights.count - 1) {
            lights[bottom][column].status.toggle()
        }
        // 左
        let left = column - 1
        if !(left < 0) {
            lights[row][left].status.toggle()
        }
        // 右
        let right = column + 1
        if !(right > lights.count - 1) {
            lights[row][right].status.toggle()
        }
    }
}

ContentView 中的程式碼被修改為了:

import SwiftUI

struct ContentView: View {    
    var gameManager = GameManager()

    var body: some View {
        ForEach(0..<gameManager.lights.count) { row in
            HStack(spacing: 20) {
                ForEach(0..<self.gameManager.lights[row].count) { column in
                    Circle()
                        .foregroundColor(self.gameManager.lights[row][column].status ? .yellow : .gray)
                        .opacity(self.gameManager.lights[row][column].status ? 0.8 : 0.5)
                        .frame(width: UIScreen.main.bounds.width / 5,
                               height: UIScreen.main.bounds.width / 5)
                        .shadow(color: .yellow, radius: self.gameManager.lights[row][column].status ? 10 : 0)
                        .onTapGesture {
                            self.gameManager.updateLightStatus(column: column, row: row)
                    }
                }
            }
                .padding(EdgeInsets(top: 0, leading: 0, bottom: 20, trailing: 0))
        }
    }
}

執行工程!發現居然點不動了!!!給第 17 行程式碼加上斷點,你會發現實際上是執行了這個方法的。回顧上篇文章中我們所闡述的內容,這是因為 lights 變數的修改未觸發 SwiftUI 的 diff 演算法去檢測需要改變的內容導致的,而之所以 lights 變數未被同步修改是因為Light 模型是值型別,值型別的變數在不同物件間傳遞時,這個變數會遵循值語義而發生複製,也就是說 GameManagerContentView 裡的 lights 是兩個完全不一樣的變數。而以往我們傳遞模型時,模型本身幾乎都是引用型別,所以不會出現這種問題。

把我們遺忘的 @State 補上,通過這個加上這個修飾詞把 lights 變數與遊戲佈局繫結起來:

class GameManager {
    @State var lights = [
        [Light(), Light(status: true), Light()],
        [Light(), Light(), Light()],
        [Light(), Light(), Light()],
    ]

    // ...
}

此時再次執行工程,卻發生了一個 crash:

Thread 1: Fatal error: Accessing State<Array<Array<Light>>> outside View.body

再研究一下我們剛才寫的程式碼,總的來說,我們違反了 SwiftUI 單一資料來源的規範,導致 SwiftUI 在執行 DSL 解析時,跑的資料來源是非自己所有的。因此,我們要把 lights 這個資料來源「轉移」給 ContentView。在解決這個問題之前,我們還需要明確一點,GameManager 是用來解決 ContentView 中邏輯太多導致程式碼臃腫的「中間層」,換句話說,我們要把在 ContentView 中執行的操作都要通過這個「中間層」去解決,因此我們需要用上 Combine 中的 ObservableObject 協議來協助完成單一資料來源的規範,修改後的 GameManager 程式碼如下所示:

class Manager: ObservableObject {
    @Published var lights = [
        [Light(), Light(status: true), Light()],
        [Light(), Light(), Light()],
        [Light(), Light(), Light()],
    ]

    // ...
}

修改後的 ContentView 程式碼如下所示:

struct ContentView: View {    
    @ObservedObject var gameManager = Manager()

    // ...
}

此時執行工程,問題解決啦!接下來我們來看看如何配置關卡。我們需要再明確一點,關卡是遊戲開局時就已經要確定的,所以我們要在遊戲佈局渲染之前就要確定此次遊戲開局的關卡,也就是要對 GameManager 的初始化方法搞事情。

GameManager 中實現一個便捷構造方法,使得我們可以在 ContentView 的初始化方法中重新對 gameManager 變數進行初始化,丟進一些我們真正需要對此次遊戲開局時的初始化引數。

class GameManager: ObservableObject {
    @Published var lights = [[Light]]()
    /// 遊戲尺寸大小
    private(set) var size: Int?

    // MARK: - Init

    init() {}

    /// 便捷構造方法
    /// - Parameters:
    ///   - size: 遊戲佈局尺寸,預設值 5x5
    ///   - lightSequence: 亮燈序列,預設全滅
    convenience init(size: Int = 5,
                     lightSequence: [Int] = [Int]()) {
        self.init()

        var size = size
        // 太大了不好玩
        if size > 8 {
            size = 7
        }
        // 太小了沒意思
        if size < 2 {
            size = 2
        }
        self.size = size
        lights = Array(repeating: Array(repeating: Light(), count: size), count: size)

        updateLightStatus(lightSequence)
    }

    // ...
}

通過 size 引數控制了遊戲佈局尺寸,並考慮了一些 UI 上的規整。新增了一個 updateLightStatus(_ lightSequence: [Int]) 方法,通過這個方法去做遊戲的「隨機過程」。

// ...

/// 通過亮燈序列修改燈狀態
/// - Parameter lightSequence: 亮燈序列
private func updateLightStatus(_ lightSequence: [Int]) {
    guard let size = size else { return }

    for lightIndex in lightSequence {
        var row = lightIndex / size
        let column = lightIndex % size

        // column 不為 0,說明非最後一個
        // row 為 0,說明為第一行
        if column > 0 && row >= 0 {
            row += 1
        }
        updateLightStatus(column: column - 1, row: row - 1)
    }
}

// ...

因為在 GameManager 的便捷構造方法中傳入的 lightSequence 是一個 Int 型別的陣列,而且這個陣列裡元素的實際作用是標記出「亮燈」的順序,所以我們不能使用 Swift 中一些函式式的做法去加快「點亮」速度,只能使用原始方法去做了。我們在 ContentView 中的程式碼就變成了:

import SwiftUI

struct ContentView: View {    
    @ObservedObject var gameManager = GameManager()

    init() {
        gameManager = GameManager(size: 5, lightSequence: [1, 2, 3])
    }

    var body: some View {
        ForEach(0..<gameManager.lights.count) { row in
            HStack(spacing: 20) {
                ForEach(0..<self.gameManager.lights[row].count) { column in
                    Circle()
                        .foregroundColor(self.gameManager.lights[row][column].status ? .yellow : .gray)
                        .opacity(self.gameManager.lights[row][column].status ? 0.8 : 0.5)
                        .frame(width: self.gameManager.circleWidth(),
                               height: self.gameManager.circleWidth())
                        .shadow(color: .yellow, radius: self.gameManager.lights[row][column].status ? 10 : 0)
                        .onTapGesture {
                            self.gameManager.updateLightStatus(column: column, row: row)
                    }
                }
            }
                .padding(EdgeInsets(top: 0, leading: 0, bottom: 20, trailing: 0))
        }
    }
}

此時執行工程,會發現我們已經配置好了關卡啦~

關卡配置完成

判贏判輸

這個遊戲判贏和判輸都非常簡單,如果把燈全都熄滅了就贏得比賽。如果燈全亮了就是輸了。那麼我們可以用一個 lightingCount 變數去記錄下當前遊戲中燈亮的盞數,新增一個方法 updateGameStatus

// ...

/// 判贏
private func updateGameStatus() {
    guard let size = size else { return }

    var lightingCount = 0

    for lightArr in lights {
        for light in lightArr {
            if light.status { lightingCount += 1 }
        }
    }

    if lightingCount == size * size {
        currentStatus = .lose
        return
    }

    if lightingCount == 0 {
        currentStatus = .win
        return
    }
}

// ...

在此,為了連線 SwiftUI 使用 currentStatus 變數記錄了當前遊戲的狀態,經過我們之前的遊戲經驗,《能否關個燈》遊戲的整體狀態就三個:

  • 贏;
  • 輸;
  • 進行中。

因此我們可以建立一個列舉去記錄下當前的遊戲狀態:

extension GameManager {
    enum GameStatus {
        /// 贏
        case win
        /// 輸
        case lose
        /// 進行中
        case during
    }
}

並把 GameManager 做如下修改:

class GameManager: ObservableObject {
    /// 燈狀態
    @Published var lights = [[Light]]()
    @Published var isWin = false
    /// 當前遊戲狀態
    private var currentStatus: GameStatus = .during {
        didSet {
            switch currentStatus {
            case .win: isWin = true
            case .lose: isWin = false
            case .during: break
            }
        }
    }

    // ...
}

我們又新增了一個 @Published 修飾的變數 isWin,用於遊戲狀被修改時通知 SwiftUI 做檢視的更新。

重新開始

接下來我們要考慮,當玩家贏得遊戲時遊戲要重新開始。重新開始遊戲本質上只是對 lights 資料來源的狀態更新,因為此時遊戲佈局已經生成好,不需要重新渲染。對 GameManager 增加一個新方法:

// ...

/// 便捷構造方法
/// - Parameters:
///   - size: 遊戲佈局尺寸,預設值 5x5
///   - lightSequence: 亮燈序列,預設全滅
convenience init(size: Int = 5,
                    lightSequence: [Int] = [Int]()) {

    self.init()

    var size = size
    // 太大了不好玩
    if size > 8 {
        size = 7
    }
    // 太小了沒意思
    if size < 2 {
        size = 2
    }
    self.size = size
    lights = Array(repeating: Array(repeating: Light(), count: size), count: size)

    start(lightSequence)
}

// MARK: Public

/// 遊戲配置
/// - Parameter lightSequence: 亮燈序列
func start(_ lightSequence: [Int]) {
    currentStatus = .during
    updateLightStatus(lightSequence)
}

// ...

ContentView 做如下修改:

import SwiftUI

struct ContentView: View {    
    @ObservedObject var gameManager = GameManager(size: 5, lightSequence: [1, 2, 3])

    var body: some View {
        ForEach(0..<gameManager.lights.count) { row in
            HStack(spacing: 20) {
                ForEach(0..<self.gameManager.lights[row].count) { column in
                    Circle()
                        .foregroundColor(self.gameManager.lights[row][column].status ? .yellow : .gray)
                        .opacity(self.gameManager.lights[row][column].status ? 0.8 : 0.5)
                        .frame(width: self.gameManager.circleWidth(),
                               height: self.gameManager.circleWidth())
                        .shadow(color: .yellow, radius: self.gameManager.lights[row][column].status ? 10 : 0)
                        .onTapGesture {
                            self.gameManager.updateLightStatus(column: column, row: row)
                    }
                }
            }
                .padding(EdgeInsets(top: 0, leading: 0, bottom: 20, trailing: 0))
        }
            .alert(isPresented: $gameManager.isWin) {
                Alert(title: Text("黑燈瞎火,摸魚成功!"),
                      dismissButton: .default(Text("繼續摸魚"),
                                              action: {
                                                self.gameManager.start([3, 2, 1])
                      }
                    )
                )
            }
    }
}

告知使用者贏得比賽,我的做法是在遊戲介面中彈出一個 alert,並通過 GameManager 中的 isWin 變數來控制 alert 出現和隱藏,當 alert 出現時,使用者點選 alert 中的「繼續摸魚」即可開始下一局比賽。執行工程,又可以愉快的玩耍啦!

黑燈瞎火,摸魚成功!

後記

在這篇文章中,我們對遊戲邏輯做了進一步的完善,可以說通過不斷的抽象,把遊戲邏輯和介面進行了分離。通過這種做法可以讓後續實現的需求魯棒性更強!

現在,我們的需求已經完成了:

  • [x] 燈狀態的互斥
  • [x] 燈的隨機過程
  • [x] 遊戲關卡難度配置
  • [ ] 計時器
  • [ ] 歷史記錄
  • [ ] UI 美化

趕快把工程跑起來,配置一個屬於你自己的關卡,拉上小夥伴來體驗一番吧~

GitHub 地址:https://github.com/windstormeye/SwiftGame

來源:我的小專欄《 Swift 遊戲開發》:https://xiaozhuanlan.com/pjhubs-swift-game

優秀的人遵守規則,頂尖的人創造規則

相關文章