利用 SwiftUI 的 matchedGeometry 構建一款九宮格遊戲!


本篇原文(標題:Build a Sliding Puzzle Game With SwiftUI)刊登於作者 Medium,由 Mark Lucking 所著,並授權翻譯及轉載。

雖然這樣說有點事後孔明,但我真的覺得如果 Apple 在推出 SwiftUI 1.0 時,就引入 matchedGeomtryEffect primitive,SwiftUI 一定會有更好的開始。

畢竟,我認為對於經驗豐富的 UIKit 程式設計師來說,使用 SwiftUI 時最困難的就是佈局 (layout)。

讓我們來實作一個範例來展示 matchedGeometry 的意義吧!在這篇文章中,我們會一起在 iOS 15 中使用 Swift,配合一些 UnitPoint 對齊方式 (alignment) 等方法,來構建一款九宮格遊戲。

簡介

一張圖片勝過千言萬語,以下就是這篇文章要實作的遊戲,目標是移動方塊,以讓方塊按數字順序排列。下圖的 1 號方塊位置時正確的,但其他方塊的位置全都不對。

swiftui-matchedGeometry-demo

當然,我們也可以就這樣把方塊拖放到正確位置,但這樣就太容易了。所以,我們要利用空的方塊逐格移動。

程式碼簡介

了解完這篇文章的目標後,讓我們來進行實作吧!我們會使用 SwiftUI 的畫布 (canvas),並把數字放在畫布上。我可以使用文本物件 (text object),但我還是想用圖像來做範例,所以我們會使用畫布。這是一個網格 (grid),所以裡面有 LazyVGrid。我計劃使用拖動手勢 (drag gesture) 觸發移動,然後利用 matchedGeometry 進行實際移動。我也希望 App 可以告訴我們完成了移動,並報告所花費的時間,這樣我們才可以逐漸改善和進步。

概念驗證 (Proof of concept)

讓我們從概念驗證開始,我們會看到基本的動態。雖然圖中只有一個數字,而且有些移動路線不是我們想要的效果,但原理就是這樣。我所做的,其實是在變更畫布視圖之間的對齊方式,當中的程式碼是參考我之前的這篇文章。現在,動畫看起來像是一個數字在九個方格之間移動,但實際上,方格的網格並沒有參與其中,我只是在變更數字與中間紅色矩形的對齊方式。我還沒有在這裡實作拖動手勢,而是用了點擊手勢 (tap gesture)。

proof-of-concept

我們使用基於 Combine 框架的 Publisher,來發送手勢和 anchor 數值之間的連接更改。

下一步

接下來,我們需要把其他數字隨機放在網格內。我參考了這篇文章,來在 set 中隨機取得一個整數,然後編寫了一個 SwiftUI 的方法:

var usedRange:Set<Int> = []

func randomF(randomSet:Set<Int>) -> Int {
  if usedRange.isEmpty {
      usedRange = randomSet
    }
    let r = usedRange.first!
    usedRange.remove(at: usedRange.startIndex)
    usedRange = Set(usedRange.shuffled())
    return r
}

func randomF(reset: Bool) {
  if reset {
    usedRange = []
  }
}

簡單來說,以上的程式碼會從 set 中隨機選擇一個數字,並儲存這個決定,讓我們在下一次呼叫這個方法時,可以得到一個不同的數字。我會在 build 的時候,在畫布視圖中呼叫這個方法。不過請注意,我們其實不能這樣隨機創建九宮格,我們會再說說這個問題。

下一階段

我們要進入下一個階段了,這個部分會有一點困難。我暫時不會提供 capture 畫布的方法給大家,不過不用擔心,我已經寫好了程式碼,把 8 個數字儲存為圖像,並放在網格中,留下最後一個為空格。

試過幾次之後,我終於知道如何利用 matched geometryEffect 來得到想要的結果。每張圖片都有佈局 anchor 數值和 freeSpace 變數,當中包含了未使用的方格的對齊方式。以下是範例程式碼:

struct ImageViewsV: View {
  let columns = [
    GridItem(.fixed(64)),
    GridItem(.fixed(64)),
    GridItem(.fixed(64))
  ]
  @State var selected:Int? = nil
  @Binding var images:[UIImage]
  @Namespace private var ns
  @State var anchorValue: [UnitPoint] = changerViews
  @State var freeSpace:UnitPoint = changerViews[8]
  
  let namespaces = ["id1","id2","id3","id4","id5","id6","id7","id8"]
  
  fileprivate func makeMove(_ select: Int, _ nextFree: UnitPoint) {}
  fileprivate func doMove() {}
  
  var body: some View {
    
    ZStack {
      ForEach((0...7), id: \.self) {value in
        Rectangle()
          .fill(Color.clear)
          .matchedGeometryEffect(id: namespaces[value], in: ns, anchor: anchorValue[value])
          .border(Color.clear)
          .frame(width: 144, height: 144, alignment: .center)
          .onReceive(publishChange) { _ in
            doMove()
          }
      }
      LazyVGrid(columns: columns, spacing: 8) {
        ForEach((0...7), id: \.self) {value in
          Image(uiImage: images[value])
            .resizable()
            .matchedGeometryEffect(id: namespaces[value], in: ns, properties: .position, anchor: .center, isSource: false)
            .border(Color.orange)
            .frame(width: 64, height: 64)
            .onTapGesture() {
              selected = value
              publishChange.send()
            }
        }
      }
    }
  }
}


我在兩個 loop 中都提到了 matchedGeometryEffect。我們把圖像佈局成 3×3 正方形後,matchedGeometryEffect tag 就負責控製圖像中間那個矩形的佈局。你可以在這篇文章的 GIF 動畫中看到這個紅色矩形。

我暫時會繼續使用點擊手勢,畢竟這樣比較簡單。我們隨意點擊一個數字方格,它就會移動到空格上。

moving-the-squares

雖然這個範例看起來不錯,但眼尖的大家可能都注意到,這裡用了一種作弊的方法來玩這個遊戲。因為我們沒有定立規則,規定方格只能移動到旁邊的 freespace,現在任何方格都可以移動到空格上。如果你想的話,這可以是遊戲的入門等級。

實作規則

我簡單寫了一點需要的規則。我會把遊戲分為三個等級,在困難等級,使用者只可以垂直或水平移動方格;在中等等級,使用者可以對角 (diagonal) 移動方格;而入門等級,就是任何方格都可以移動。

我將規則編碼到陣列的 dictionary 中,每個陣列都記錄了空格旁邊的有效移動。然後,當使用者要求移動方格時,就可以簡單檢查這是否一個有效的移動。

添加了以上的規則之後,我們還需要一個按鈕,來選擇不同等級。我決定使用之前的文章中的程式碼,來創建一個三段式按鈕。

implementing-rules

完成了!以上是這個遊戲的示範。

在沒有規則 (none) 的時候,我可以把數字 8 的方格對角移動到空格上。

在有些規則 (some) 的時候,我可以把空格附近的數字 3 對角移動到空格上,但就無法跨過其他方格。

最後,在正常規則 (norm) 底下,我們只可以垂直或水平移動方格到空格上。

你可以在 bitbucket 上找到專案的完整程式碼。如有任何問題,歡迎在下面留言,我會盡力解決。

還沒解決的問題

這篇文章到此為止,但這個範例還沒有完成,以下的要求還沒有做到:

  • 我沒有實作拖動手勢,
  • 我沒有實作計時器
  • 我沒有實作排名表
  • 我沒有實作完成遊戲的信息/提示
  • 我沒有嘗試把照片加載到圖像

不過最重要的是,如果我想將這個 App 上傳到 App Store,我們不可以像範例這樣隨機創建一個九宮格,因為這樣創建出來的九宮格,有可能是無法完成的拼圖。因此,在推出之前,一定要解決這個問題。

本篇原文(標題:Build a Sliding Puzzle Game With SwiftUI)刊登於作者 Medium,由 Mark Lucking 所著,並授權翻譯及轉載。

作者簡介:Mark Lucking,編程資歷超過 35 年,熱愛使用及學習 Swift/iOS 開發,定期在 Better ProgrammingThe StartUpMac O’ClockLevel Up Coding、及其它平台上發表文章。

譯者簡介:Kelly Chan-AppCoda 編輯小姐。


此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This