608a5c8c30052cac9be22f21a401f063
Swift 游戏开发之「能否关个灯」(一)

前言

在上一篇文章中,我们已经完成了对《能否关个灯》小游戏的界面和游戏逻辑进行了初步搭建,并且也具备了一定的可玩性。但细心的你会发现,这种「随机过程」的游戏开局,我们几乎一把都不会赢,因为这并不符合这个游戏的初衷——逆序出开灯的顺序去关灯

关卡配置

在现有代码中,每次新开局游戏里各种灯的状态都是之前我们通过「随机化」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 中的代码就变成了:

```swift
import SwiftUI

struct ContentView: View {

top Created with Sketch.