前言
在上一篇文章中,我們已經完成了對《能否關個燈》小遊戲的介面和遊戲邏輯進行了初步搭建,並且也具備了一定的可玩性。但細心的你會發現,這種「隨機過程」的遊戲開局,我們幾乎一把都不會贏,因為這並不符合這個遊戲的初衷——逆序出開燈的順序去關燈。
關卡配置
在現有程式碼中,每次新開局遊戲裡各種燈的狀態都是之前我們通過「隨機化」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
模型是值型別,值型別的變數在不同物件間傳遞時,這個變數會遵循值語義而發生複製,也就是說 GameManager
和 ContentView
裡的 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
中的程式碼就變成了:
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
中的「繼續摸魚」即可開始下一局比賽。執行工程,又可以愉快的玩耍啦!
後記
在這篇文章中,我們對遊戲邏輯做了進一步的完善,可以說通過不斷的抽象,把遊戲邏輯和介面進行了分離。通過這種做法可以讓後續實現的需求魯棒性更強!
現在,我們的需求已經完成了:
- [x] 燈狀態的互斥
- [x] 燈的隨機過程
- [x] 遊戲關卡難度配置
- [ ] 計時器
- [ ] 歷史記錄
- [ ] UI 美化
趕快把工程跑起來,配置一個屬於你自己的關卡,拉上小夥伴來體驗一番吧~
GitHub 地址:https://github.com/windstormeye/SwiftGame
來源:我的小專欄《 Swift 遊戲開發》:https://xiaozhuanlan.com/pjhubs-swift-game