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

前言

在上一篇文章中,咱們對遊戲主體的邏輯進行了完善,經過一個 GameManager 配置了遊戲的關卡,並一同完成了遊戲的判贏和判輸邏輯。git

如今,咱們先來完成遊戲的計時器。github

計時器

計時器的目的是爲了記錄當前玩家進行遊戲時所耗費的時間,給玩家營造出一種「緊張」的氛圍,增長遊戲樂趣。swift

在 Swift 中實現計時器相對 OC 會簡單一些,主要是相關 API 方法的簡化。在具體實現以前,咱們的須要明確幾個問題:後端

  • 建立好遊戲後,開始計時;
  • 遊戲結束後(贏或輸),結束計時;
  • 點擊「繼續摸魚」後,重置計時器,並重復第一步。

Swift 中的實現計時器有兩種方法,一是直接使用 Timer 但頗有可能會由於當前 RunLoop 中有一些其它操做致使計時不許,另一種是使用 GCD,效果要比 Timer 的好,但使用起來略有不適。考慮到咱們的這個小遊戲總體邏輯並不複雜,並不會在主線程的 RunLoop 中作一些什麼多餘的操做,所以直接使用 Timer 便可。服務器

稍微從總體架構出發思考一下,咱們已經經過了一個 gameManager 去管理了整個遊戲的邏輯,而且準備加入 Timer 作計時器的管理,咱們須要建立一個變量去統一計算出當前遊戲所耗時多少,而不是直接把 Timer 傳遞出去給 SwiftUI架構

class GameManager: ObservableObject {
    /// 對外發布的格式化計時器字符串
    @Published var timeString = "00:00"

    // ...

    /// 遊戲計時器
    private var timer: Timer?
    /// 遊戲持續時間
    private var durations = 0
    
    // ...
}
複製代碼

(若是你已經瞭解了什麼是計時器,這段直接跳過)建立出一個計時器,並非說直接就能夠拿到「計時」的時間值了,而是說給了你一個「間隔」必定時間的回調,至於每次這個「間隔」到了,回調這個方法,這個方法裏作什麼,纔是咱們去定義的。所以,須要使用 durations 去記錄當每次「間隔」到了之後,在回調方法裏進行加一操做。app

// ...

// MARK: - Init

/// 便捷構造方法
/// - Parameters:
/// - size: 遊戲佈局尺寸,默認值 5x5
/// - lightSequence: 亮燈序列,默認全滅
convenience init(size: Int = 5,
                    lightSequence: [Int] = [Int]()) {
    
    // ...
    
    timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in
        self.durations += 1
        
        let min = self.durations >= 60 ? self.durations / 60 : 0
        let seconds = self.durations - min * 60
        
        
        let minString = min >= 10 ? "\(min)" : "0\(min)"
        let secondString = self.durations - min * 60 >= 10 ? "\(seconds)" : "0\(seconds)"
        self.timeString = minString + ":" + secondString
    })
}

// ...
複製代碼

咱們在初始化方法中把 timer 變量給實例化了,並在 block 中補充了「計時」邏輯。對於一個簡單的計時器來講,實際上只須要實現 self.durations += 1 這行代碼就完事了,用 @Publisher 關鍵詞修飾這個變量,在 SwiftUI 中展現出來就穩妥了。可是這樣的計時器是直接從 0 遞增的,與咱們常規看到的計時器不同,須要使用字符串格式化爲「00:04」這樣的方式。因此,咱們最終暴露給 SwiftUI 使用的是一個字符串變量。ide

SwiftUI 中修改的代碼爲:oop

import SwiftUI

struct ContentView: View {    
    @ObservedObject var gameManager = GameManager(size: 5, lightSequence: [1, 2, 3])
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("\(gameManager.timeString)")
                .font(.system(size: 45))
                        
            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])
                          }
                        )
                    )
                }
        }
    }
}
複製代碼

注意,咱們已經給 ContentView 最外層添加上了一個 VStack 用於排布計時器和遊戲主體佈局。運行工程,咱們的計時器已經跑起來啦~佈局

可是遊戲結束後,計時器竟然還在跑!思考一下,咱們確實只開了計時器,並未結束計時。在 GameManager 中新增兩個方法用於控制計時器的銷燬和重置。

// ...

func timerStop() {
    timer?.invalidate()
    timer = nil
}

func timerRestart() {
    self.durations = 0
    self.timeString = "00:00"
    
    timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in
        self.durations += 1
        
        // 格式化字符串
        let min = self.durations >= 60 ? self.durations / 60 : 0
        let seconds = self.durations - min * 60
        
        
        let minString = min >= 10 ? "\(min)" : "0\(min)"
        let secondString = self.durations - min * 60 >= 10 ? "\(seconds)" : "0\(seconds)"
        self.timeString = minString + ":" + secondString
    })
}

// ...
複製代碼

GameManager 中的「判贏」方法補充完相關邏輯:

// ...

/// 判贏
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
        // 新增
        timerStop()
        return
    }
    
    if lightingCount == 0 {
        currentStatus = .win
        // 新增
        timerStop()
        return
    }
}

// ...
複製代碼

再到 ContentView 中彈出 Alert 的地方補充計時器重置邏輯:

// ...

.alert(isPresented: $gameManager.isWin) {
    Alert(title: Text("黑燈瞎火,摸魚成功!"),
            dismissButton: .default(Text("繼續摸魚"), action: {
                self.gameManager.start([3, 2, 1])
                self.gameManager.timerRestart()
            }
        )
    )
}

// ...
複製代碼

運行工程,贏得比賽,從新運行!計時器部分已經完成啦!

計時器完成啦~

操做記錄

只是記錄了這次遊戲的通過時間,貌似還不夠刺激,咱們能夠再給遊戲加上「步數統計」,用於記錄每一個玩家的每盤遊戲都經歷了多少步才完成遊戲。

GameManager 添加上 clickTimes 變量:

class GameManager: ObservableObject {
    // ...

    /// 點擊次數
    @Published var clickTimes = 0

    // ...
}
複製代碼

點擊次數依賴於 ContentViewonTapGesture 事件的觸發,

// ...

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)
                    self.gameManager.clickTimes += 1
            }
        }
    }
        .padding(EdgeInsets(top: 0, leading: 0, bottom: 20, trailing: 0))
}

// ...
複製代碼

修改「步數統計」和「計時統計」在同一個父容器中,修改相關的邏輯:

struct ContentView: View {    
    @ObservedObject var gameManager = GameManager(size: 5, lightSequence: [1, 2, 3])
    
    var body: some View {
        VStack {
            HStack {
                Text("\(gameManager.timeString)")
                    .font(.system(size: 45))
                    
                
                Spacer()
                
                Text("\(gameManager.clickTimes)步")
                    .font(.system(size: 45))
                    
            }
                .padding(20)
            
            // ...
        }
    }
}
複製代碼

運行工程!「步數統計」已經能夠玩啦~

歷史記錄

注意:這部分功能並不會在項目中體現出來,只在文章中作講解。

歷史記錄這個功能有助於玩家回顧本身的遊戲歷程,好比在何年何月何日曆經了多長時間完成了遊戲。換句話來講,這種 case 在實際運營過程當中是有利於用戶留存的,一樣,「排行榜」的做用也是如此。

想要實現歷史記錄的功能,主要有本地和遠端記錄的兩種模式,若是是在實際開發過程當中,主要是經過服務器去作「歷史記錄」的數據保存,但在這個小遊戲中,先經過本地保存一份歷史記錄的數據,再後續的後端開發緩環節中咱們再一塊兒探討。

在 iOS 中實現本地存儲的方法總的來講就是寫文件,只不過這個文件的種類不同而已。由於遊戲的數據比較簡單:

  • 遊戲結束時間;
  • 總耗時;
  • 是否完成;
  • 點擊了幾步。

咱們採起的「序列化」歷史記錄數據,首先須要建立出現須要序列化的模型:

struct History: Codable {
    /// 遊戲建立時間
    let createTime: Date
    /// 遊戲持續時間
    let durations: Int
    /// 遊戲狀態
    let isWin: Bool
    /// 遊戲進行步數
    let clickTimes: Int
}
複製代碼

並在 gameManager 中新增一個保存方法 save()

// ...

private func save() {
    let documentUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last!
    let historyUrl = documentUrl.appendingPathComponent("gameHistory.plist")

    let history = History(createTime: Date(), durations: durations, isWin: isWin, clickTimes: clickTimes)

    var gameHistorys = NSArray(contentsOf: historyUrl)
    if gameHistorys == nil {
        gameHistorys = [History]() as NSArray
    }
    gameHistorys?.adding(history)

    gameHistorys!.write(to: historyUrl, atomically: true)
}

// ...
複製代碼

當每次遊戲結束後,直接調用該方法便可把當前遊戲保存到磁盤中。在這裏咱們把 Array 轉爲 NSArray 是由於 Array 沒有 write(to: URL, atomically: Bool) 這個方法供咱們使用,轉換一下便可。

一樣,咱們能夠在 ContentView 中添加一個 Button,經過 sheet 方法跳轉到「歷史頁面」中:

struct ContentView: View {    
    @ObservedObject var gameManager = GameManager(size: 5, lightSequence: [1, 2, 3])
    
    @State var isShowHistory = false
    
    var body: some View {
        VStack {        
            // ...
            
            HStack {
                Spacer()
                
                Button(action: {
                    self.isShowHistory.toggle()
                }, label: {
                    Image(systemName: "clock")
                        .imageScale(.large)
                        .foregroundColor(.primary)
                })
                    .frame(width: 25, height: 25)
            }
                .padding(20)
        }
            .sheet(isPresented: $isShowHistory, content: {
                HistoryView()
            })

            // ...
        }
    }
}
複製代碼

經過一個 isShowHistory 的變量去控制 HistoryView 的出現,運行工程:

新增的歷史記錄

關於 clock 這個圖標,使用的是 SF Symbols,你能夠在這個網站中進行下載。

至於 HistoryView 中的頁面你們就本身去寫啦~相信通過這幾篇文章的講解,你對 SwiftUI 也有了本身的一些感悟,快動手去嘗試寫一個屬於本身的 SwiftUI 頁面吧~。

後記

在這篇文章中,咱們已經把這個小遊戲的全部邏輯都完成了。可是咱們如今只有單一關卡,若是你想成「闖關」模式,只須要再構建一個二維列表去承載亮燈序列,在每把遊戲結束後經過一個遞增的索引去獲取二維列表中的亮燈序列就能夠啦~

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

  • 燈狀態的互斥
  • 燈的隨機過程
  • 遊戲關卡難度配置
  • 計時器
  • 歷史記錄
  • UI 美化(留給你們按照本身喜歡的樣式去修改吧~)

GitHub 地址:github.com/windstormey…

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

相關文章
相關標籤/搜索