前言
第一個遊戲我們將基於 SwiftUI
來完成。主要想驗證的問題有兩點:
SwiftUI/UIKit
這種我們日常接觸到的 UI 框架是否能夠做遊戲?- 如何建立起遊戲開發的思維?
《能否關個燈》是我在大一時去「中國科學技術館」做志願者時發現的一個小遊戲。結合當時「綠色環保」的理念,這個小遊戲火得不行,排了好久的隊才到我,半個多小時後,我幾乎每次都是差一個「燈」就通關了,但每次都不行。
為了避嫌,我把這個遊戲改為了《能否關個燈》。這個小遊戲的規則非常簡單,開始遊戲後,會「隨機」點亮一些燈,接著我們就可以開始玩了,想辦法去關掉這些燈,需要注意的是每一盞燈的開關會連帶其附近的燈進行開關,如下圖所示:
邏輯梳理
從上述內容我們可以把邏輯先寫出來:
- 每一盞燈的開關會影響其 「上下左右」 燈的狀態(取反);
- 燈只有「開」和「關」兩種狀態;
- 勝利的條件是:關掉所有燈;
邏輯梳理完了,看上去不足以稱為一個「遊戲」,我們來把這個邏輯給補充完整,讓它看起來像個遊戲:
- 加入計時器。記錄每把遊戲經歷過的時間;
- 加入關卡難度配置。可以調整為 4x4、5x5 或其它難度;
- 加入燈的隨機過程。讓每次遊戲開局時燈的狀態可控;
- 加入歷史記錄功能。
在這裡解釋一下什麼是「燈的隨機過程」。遊戲的開局已經給定了一些燈的狀態,而且作為一個遊戲,它一定是可以把燈全部滅掉的,但如果我們不是按照開始「亮燈」的順序去逆序的「滅燈」,是一定沒法把所有燈都滅掉的。
因此,這個遊戲的核心邏輯我們也就理解了,是圍繞 「亮燈」的順序去逆序出「滅燈」的順序,比較考驗玩家的想象能力。在這個遊戲中,我們需要做的事情有:
- [ ] 燈狀態的互斥
- [ ] 燈的隨機過程
- [ ] 遊戲關卡難度配置
- [ ] 計時器
- [ ] 歷史記錄
- [ ] UI 美化
遊戲框架搭建
開啟 Xcode11 ( >= beta 7 ),新建一個 iOS 工程,並勾選 SwiftUI。SwiftUI 的語法細節在此不做展開,你可以參考我的這兩篇文章 SwiftUI 如何實現更多選單?與SwiftUI 怎麼和 CoreData 結合?來檢視更多關於 SwiftUI 的基礎內容。
構建燈的模型
對於一個「燈」來說,抽象其模型目前我們只需要一個狀態值 status
即可,用於記錄該燈的開關狀態,且預設值為 false
,也就是「熄滅」狀態。
struct Light {
/// 開關狀態
var status = false
}
遊戲佈局
我們先預設設定遊戲尺寸為 3x3 大小的九宮格,我們可以先快速的搭建出佈局框架:
import SwiftUI
struct ContentView: View {
var lights = [
[Light(), Light(), Light()],
[Light(), Light(), Light()],
[Light(), Light(), Light()],
]
var body: some View {
ForEach(0..<lights.count) { rowindex in
HStack {
ForEach(0..<self.lights[rowindex].count) { columnIndex in
Circle()
.foregroundColor(.gray)
}
}
}
}
}
此時執行工程是下圖這個樣子的。
雖然,我們什麼間距都沒有設定,各個圓形之間間距是 Apple 根據其人機互動指南自動設定一個預設值,並且 SwiftUI
如果我們什麼佈局都不寫的前提下是居中佈局的。我們可以利用 SwiftUI
的優秀佈局能力把遊戲主佈局變為這樣:
import SwiftUI
struct ContentView: View {
var lights = [
[Light(), Light(status: true), Light()],
[Light(), Light(), Light()],
[Light(), Light(), Light()],
]
/// 圓形圖案之間的間距
private let innerSpacing = 30
var body: some View {
ForEach(0..<lights.count) { rowindex in
HStack(spacing: 20) {
ForEach(0..<self.lights[rowindex].count) { columnIndex in
Circle()
.foregroundColor(self.lights[rowindex][columnIndex].status ? .yellow : .gray)
.opacity(self.lights[rowindex][columnIndex].status ? 0.8 : 0.5)
.frame(width: UIScreen.main.bounds.width / 5,
height: UIScreen.main.bounds.width / 5)
.shadow(color: .yellow, radius: self.lights[rowindex][columnIndex].status ? 10 : 0)
}
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 20, trailing: 0))
}
}
}
利用了 Light
模型中的 status
狀態值去控制了每個「燈」(圓形)的顏色和透明度,以顯得我們真的把「燈」給點亮了,調整了一下「燈」和「燈」之間的間距,讓它們顯得不那麼擁擠,同時為了表現出真的「點亮」了燈,使用陰影來表示出燈的「光暈」,並把資料來源 lights
中的一個模型的 status
值設定為了 true
。此時執行工程,你會發現我們遊戲的主佈局完成了:
修改燈的狀態
完成了佈局後,我們需要去修改「燈」的狀態。之前,我們已經通過 lights
這個變數去作為管控佈局中「燈」的模型,我們需要對這些模型進行處理即可。還要給「燈」加上「點亮」操作,相當於需要給每個「燈」新增上觸控手勢,並在觸控手勢的回撥處理事件中,維護與之相關的狀態變化。
import SwiftUI
struct ContentView: View {
var lights = [
[Light(), Light(status: true), Light()],
[Light(), Light(), Light()],
[Light(), Light(), Light()],
]
/// 圓形圖案之間的間距
private let innerSpacing = 30
var body: some View {
ForEach(0..<lights.count) { row in
HStack(spacing: 20) {
ForEach(0..<self.lights[row].count) { column in
Circle()
.foregroundColor(self.lights[row][column].status ? .yellow : .gray)
.opacity(self.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.lights[row][column].status ? 10 : 0)
.onTapGesture {
self.updateLightStatus(column: column, row: row)
}
}
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 20, trailing: 0))
}
}
/// 修改燈狀態
func updateLightStatus(column: Int, row: Int) {
// 對「燈」狀態進行取反
lights[row][column].status.toggle()
}
}
開開心心的寫出上述的狀態修改程式碼,但 Xcode 報了 Cannot assign to property: 'self' is immutable
的錯誤,這是因為 SwiftUI
在執行 DSL 解析還原成檢視節點樹時,不允許有「未知狀態」或者「動態狀態」,SwiftUI
需要明確的知道此時需要渲染的檢視到底是什麼。我們現在直接對這個資料來源進行了修改,想要通過這個資料來源的變化去觸發 SwiftUI
的狀態重新整理,需要借用 @Stata
狀態去修飾 lights
變數,在 SwiftUI 內部 lights
會被自動轉換為相對應的 setter 和 getter 方法,對 lights
進行修改時會觸發 View
的重新整理,body
會被再次呼叫,渲染引擎會找出佈局上與 lights
相關的改變部分,並執行重新整理。修改我們的程式碼:
struct ContentView: View {
// 加上 `@State`
@State var lights = [
[Light(), Light(status: true), Light()],
[Light(), Light(), Light()],
[Light(), Light(), Light()],
]
// ...
}
此時執行工程,會發現我們已經可以完美的把「燈」給點亮啦~
燈狀態的互斥
完成了「燈」的互動後,我們需要對其進行「狀態互斥」的工作。回顧前文所描述的遊戲邏輯,再看這張圖,
我們需要完成的邏輯是,當中間的「燈」被「點選」後,與之相關「上下左右」的四個「燈」和它自己的狀態需要取反。修改之前更新燈狀態的方法 updateLightStatus
為:
// ...
/// 修改燈狀態
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()
}
}
// ...
執行工程,我們可以和這個遊戲開始愉快的玩耍了~
燈的隨機過程
現在遊戲的雛形已經具備,但目前非常死板,每次開局都是第一行中間的燈被點亮,我們需要加上游戲開始時的隨機開局。從我們目前掌握的原始碼帶來看,需要對資料來源 lights
下手。遊戲初始化時的狀態資料來源於 lights
中所記錄的模型狀態,我們需要對這裡邊的模型狀態值在初始化時進行隨機過程。所以可以對 Light
模型進行如下修改:
struct Light {
/// 開關狀態
var status = Bool.random()
}
通過 Bool.random()
讓模型初始化時都生成不一樣的 Bool
值,這樣每次執行工程時,生成的佈局都不一樣,達到了我們的目的!
後記
至此,我們已經完成的需求有:
- [x] 燈狀態的互斥
- [x] 燈的隨機過程
- [ ] 遊戲關卡難度配置
- [ ] 計時器
- [ ] 歷史記錄
- [ ] UI 美化
萬事開頭難,實際上我們已經把這個遊戲的核心部分給完成了,在下一篇文章中,我們將繼續完成剩下的 case,趕快試試看你能不能把所有的燈都熄滅吧~
GitHub 地址:https://github.com/windstormeye/SwiftGame
來源:我的小專欄《 Swift 遊戲開發》:https://xiaozhuanlan.com/pjhubs-swift-game