Shuffle ボタンで、パズルを遊ぶことができるようになったので、揃ったかどうかの 完成判定を追加します。
Sponsor Link
環境&対象
- macOS Sonoma RC
- Xcode 15 RC
- iOS 17 RC
- Swift 5.9
全体の方針
以下のような方針で、作っていきます。
– 全体のアーキテクチャは、MVVM(Model-View-ViewModel) です
– TDD で作っていきます。
– Model は、actor で作ります
– スライドパズルは、アニメーションして動作させます
– macOS/iOS それぞれの UI を考慮しつつ、最大限コードを共通化します
- Step1: 全体構想 / MVVM 責務分割
- Step2: View 設計・実装・テスト
- Step3: Model 設計・実装・テスト
- Step4: ViewModel 設計・実装・テスト
- Step5: View-ViewModel 接続
- Step6: アニメーション追加
- Step7: 配置ランダム化
- Step8: 完成判定
- Step9: Animation 手直し
15Puzzle とは
これから作っていくのは 15パズル です。スライドパズルと呼ばれることもあるようです。
Wikipedia での説明は、こちら
# 上記の画像も、Wikipedia から引用しています。
いわゆる ソリティア と呼ばれる 一人で遊ぶタイプのゲームの1つです。
完成判定
完成したかどうかの判定として以下を使用します。
・(過去に)一度でも パネルをスライドしている
・初期配置相当に並んでいる
つまり、起動時の初期配置では 完成と判定されず、その後 パネルをスライド or Shuffle ボタン使用した 以降で、配置が初期配置相当になった時に、完成と判断したいということです。
Puzzle15Model への追加
テスト
最初にテストを書きます。
判定したいケースをそのままテストにしています。
・起動直後の初期配置は、isComplete と判定しないこと
・パネルを一枚スライドした状態は、(揃っていないので) isComplete と判定しないこと
・パネルを操作して初期配置相当になった時には、isCompete と判定すること
func test_isComplete() async throws {
let sut = Puzzle15Model()
var state = await sut.isComplete()
XCTAssertEqual(state, false)
await sut.swap(Index2D(3,2), Index2D(3,3))
state = await sut.isComplete()
XCTAssertEqual(state, false)
await sut.swap(Index2D(3,2), Index2D(3,3))
state = await sut.isComplete()
XCTAssertEqual(state, true)
}
実装
テストが書けたので実装します。
Puzzle15Model に 初期化直後かどうかのフラグを導入します。
actor Puzzle15Model {
private(set) var panels: Dictionary<Index2D, Int>
private var justInitialized: Bool
...
}
このフラグは、init 直後には true で、その後 操作されると false になるフラグです。
そして、swap で そのフラグを操作するように修正します。
init() {
panels = Self.initialPanels
justInitialized = true // ← NEW
}
func swap(_ index1: Index2D,_ index2: Index2D) {
let value1 = panels[index1]
panels[index1] = panels[index2]
panels[index2] = value1
justInitialized = false // ← NEW
publishChange()
}
そして 完成判定メソッドの実装です。
func isComplete() -> Bool {
return !justInitialized && (panels == Self.initialPanels)
}
いろいろと準備してきたので、実装はシンプルです。
ViewModel/View への追加
Model が完成判定できるようになったので、ViewModel/View へ追加していきます。
操作して、完成状態になった時には、”Well Donw!”という シートを表示するようにしてみます。
ViewModel への追加
シート表示を制御する変数を ViewModel に追加しようと思います。
showComplete という Bool 型の変数の予定です。
テスト
テストとしては、スライド操作で パズルが完成したタイミングで、この showComplete が true になっていることを確認します。
func test_showComplete() async throws {
let sut = Puzzle15ViewModel()
let expectation = expectation(description: "ViewModel is updated")
let cancellable = sut.$layout
.dropFirst(2)
.sink(receiveValue: { newModel in
expectation.fulfill()
})
XCTAssertEqual(sut.showComplete, false)
sut.swap(Index2D(3,2), Index2D(3,3))
sut.swap(Index2D(3,2), Index2D(3,3))
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(sut.showComplete, true)
}
showComplete が true になったときに 適切なシートを表示するのは、View の責務とします。
実装
予定通り変数を追加するとともに、swap メソッドを修正します。
class Puzzle15ViewModel: ObservableObject {
var model: Puzzle15Model
@Published var layout: Puzzle15Model.PanelLayout
var cancellable: AnyCancellable? = nil
@Published var showComplete = false // ← NEW
// ... omit ...
func swap(_ index1: Index2D,_ index2: Index2D) {
Task {
await model.swap(index1, index2)
let state = await model.isComplete()
if state {
Task { @MainActor in
self.showComplete = state // ← NEW
}
}
}
}
// ... omit ...
}
View への追加
View 側では、ViewModel の showComplete フラグが true になった時に 適切なシートを表示します。
//
// ContentView.swift
//
// Created by : Tomoaki Yagishita on 2023/09/06
// © 2023 SmallDeskSoftware
//
import SwiftUI
struct Puzzle15AppView: View {
@StateObject var viewModel = Puzzle15ViewModel()
var body: some View {
VStack {
Puzzle15BoardView()
.environmentObject(viewModel)
Button(action: {
viewModel.randomize(50)
}, label: {
Text("Shuffle")
})
.accessibilityIdentifier("ShuffleButton")
}
.padding()
.popover(isPresented: $viewModel.showComplete, content: {
wellDoneView
.presentationDetents([.height(150)])
})
}
var wellDoneView: some View {
HStack {
VStack {
Text("Well Done !!").font(.largeTitle)
Spacer()
Button(action: {
viewModel.randomize(50)
viewModel.showComplete = false
}, label: { Text("Shuffle again") })
Button(action: {
viewModel.showComplete = false
}, label: { Text("Close") })
}
.padding(20)
.padding(.leading, 10)
Image("Cracker").resizable().scaledToFit().padding(.bottom, 10)
}
.background {
RoundedRectangle(cornerRadius: 5).fill(.white.opacity(0.95))
}
}
}
#Preview {
Puzzle15AppView()
}
ここでは、”Well Done!” というテキストとともにクラッカーのイラストを表示しています。
Shuffule ボタンと Close ボタンの2つも合わせて表示しています。
以下のような表示です。
タイミング調整
現状でも良いのですが、スライドのアニメーションにかかる時間があるため、パネルのスライド アニメーションが実行中に wellDone シートが表示されてしまいます。
機能的な問題ではありませんが、ViewModel に sleep を入れることで調整しました。
func swap(_ index1: Index2D,_ index2: Index2D) {
Task {
await model.swap(index1, index2)
let state = await model.isComplete()
if state {
Task { @MainActor in
try? await Task.sleep(for: .seconds(1))
self.showComplete = state
}
}
}
}
以下のようなタイミングになりました。
まとめ
Puzzle15 に完成判定を追加し、完成した時には、おめでとうのビューを表示するようにしました
- 完成したかどうかの判定は、Model で行う
- アニメーションやビューの表示タイミング調整したかったので、ViewModel で調整した
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
SwiftUI おすすめ本
SwiftUI を理解するには、以下の本がおすすめです。
SwiftUI ViewMatery
SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。
英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。
View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。
超便利です
販売元のページは、こちらです。
SwiftUI 徹底入門
# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。
Swift学習におすすめの本
詳解Swift
Swift の学習には、詳解 Swift という書籍が、おすすめです。
著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。
最新版を購入するのがおすすめです。
現時点では、上記の Swift 5 に対応した第5版が最新版です。
Sponsor Link