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

前言

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

關卡配置

在現有代碼中,每次新開局遊戲裏各類燈的狀態都是以前咱們經過「隨機化」Light 模型中的 status 狀態作到的,這種作法以前也說過了幾乎不可能把全部燈都關掉,所以咱們須要對數據源作一些處理,使之可以經過「配置」去生成遊戲開局。github

至此,咱們的 ContentView 已經比較龐大了,並且做爲一個 View 它所承載的內容已經到了須要被抽離的時間點,咱們不能再往 ContentView 裏塞關卡配置的邏輯了。算法

所以,仍是那句話「計算機科學領域的任何問題均可以經過增長一個間接的中間層來解決」,因此咱們將引入一個 GameManager 來處理關卡配置。GameManager 中負責的主要內容有:shell

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

新建一個 GameManager 類,並把以前寫在 ContentView 中的邏輯都遷移進去。通過一番調整後,咱們的代碼就變成了:swift

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 是兩個徹底不同的變量。而以往咱們傳遞模型時,模型自己幾乎都是引用類型,因此不會出現這種問題。ide

把咱們遺忘的 @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 代碼以下所示:ui

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 中的「繼續摸魚」便可開始下一局比賽。運行工程,又能夠愉快的玩耍啦!

黑燈瞎火,摸魚成功!

後記

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

如今,咱們的需求已經完成了:

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

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

GitHub 地址:github.com/windstormey…

來源:個人小專欄《 Swift 遊戲開發》:xiaozhuanlan.com/pjhubs-swif…

相關文章
相關標籤/搜索