A9404de78b499c3ec71ebba7b9526d3a
Swift 游戏开发之「能否关个灯」(二)

前言

在上一篇文章中,我们对游戏主体的逻辑进行了完善,通过一个 GameManager 配置了游戏的关卡,并一同完成了游戏的判赢和判输逻辑。

现在,我们先来完成游戏的计时器。

计时器

计时器的目的是为了记录当前玩家进行游戏时所耗费的时间,给玩家营造出一种「紧张」的氛围,增加游戏乐趣。

在 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 去记录当每次「间隔」到了以后,在回调方法里进行加一操作。

// ...

// 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 使用的是一个字符串变量。

SwiftUI 中修改的代码为:

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 的地方补充计时器重置逻辑:

```swift
// ...

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

top Created with Sketch.