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…

相关文章
相关标签/搜索