Swift 遊戲開發之「能否關個燈」(〇)

PJHubs發表於2019-09-02

前言

第一個遊戲我們將基於 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

優秀的人遵守規則,頂尖的人創造規則

相關文章