Sponsor Link
環境&対象
- macOS Sonoma Beta 7
- Xcode 15 Beta 8
- iOS 17 Beta 8
- 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つです。
Model 構想・設計・実装・テスト (1)
前回は、View を作りました。
実際に 15パズルのパネルの位置を管理するには、背後に Model を持つことが必要です。
ということで、Model について詳細を考えます。
Model 構想
15パズルでは、常に 15枚のパネルが(重ならないような位置に)存在しています。
おおよそ、以下のような方向性で考えます。
・パネルの増減は考える必要がないので 可変長の配列を扱った管理は不要となります。
・パネルが重ならないように管理する必要がありますが、そもそも パネル移動が正しく要求される&正しく実装されていれば、重なることはないので、重なっているかをチェックすることはしません。
・ViewModel から Model にリクエストされる 情報は、以下のようなものと想定します。
– 座標値 -> パネルの番号
上記での座標値は、(0,0),(0,1)…,(3,3) であり、パネルの番号は、1~15 + 0 です。
# 現時点では 0 は、空きパネルを意味するとしています。
Model 設計
4×4 の情報を管理するだけなので、どのように実装しても問題は発生しない気がしますが、ここでは、
Dictionary<(Int,Int), Int> を使って、実装してみます。
座標値からパネル番号を取得できるようにしたのは、”パネル -> 位置” という情報取得方法よりも “位置 -> パネル” という情報取得の方が 頻度が高いと予想したためです。
# とくに詳細に検討して比較したわけではありません。
なお、当初の方針に沿って、actor で実装していきます。
Model テスト(1-1)
まずは、初期化のテストを書きます。
1つめは、初期化のテストです。
初期化時には、外部から情報をもらわずに、初期配置を情報として持つように初期化することにします。
テストとしては、初期化後のモデルからデータを取得して、初期配置になっているかをテストします。
2次元配列のインデックスは、struct にしておくと処理しやすいので、Index2D という struct も導入することにします。
//
// Puzzle15Model_Tests.swift
//
// Created by : Tomoaki Yagishita on 2023/09/08
// © 2023 SmallDeskSoftware
//
import XCTest
@testable import Puzzle15
final class Puzzle15Model_Tests: XCTestCase {
func test_init() async throws {
let sut = Puzzle15Model()
XCTAssertNotNil(sut)
}
func test_initialLayout() async throws {
let sut = Puzzle15Model()
let value00 = await sut.panel(at: Index2D(0,0))
let value01 = await sut.panel(at: Index2D(0,1))
let value02 = await sut.panel(at: Index2D(0,2))
let value03 = await sut.panel(at: Index2D(0,3))
let value10 = await sut.panel(at: Index2D(1,0))
let value11 = await sut.panel(at: Index2D(1,1))
let value12 = await sut.panel(at: Index2D(1,2))
let value13 = await sut.panel(at: Index2D(1,3))
let value20 = await sut.panel(at: Index2D(2,0))
let value21 = await sut.panel(at: Index2D(2,1))
let value22 = await sut.panel(at: Index2D(2,2))
let value23 = await sut.panel(at: Index2D(2,3))
let value30 = await sut.panel(at: Index2D(3,0))
let value31 = await sut.panel(at: Index2D(3,1))
let value32 = await sut.panel(at: Index2D(3,2))
let value33 = await sut.panel(at: Index2D(3,3))
XCTAssertEqual(value00, 1)
XCTAssertEqual(value01, 2)
XCTAssertEqual(value02, 3)
XCTAssertEqual(value03, 4)
XCTAssertEqual(value10, 5)
XCTAssertEqual(value11, 6)
XCTAssertEqual(value12, 7)
XCTAssertEqual(value13, 8)
XCTAssertEqual(value20, 9)
XCTAssertEqual(value21, 10)
XCTAssertEqual(value22, 11)
XCTAssertEqual(value23, 12)
XCTAssertEqual(value30, 13)
XCTAssertEqual(value31, 14)
XCTAssertEqual(value32, 15)
XCTAssertEqual(value33, 0)
}
}
# Model のコードを書いていないので、現時点では コンパイルエラーです。
Model 実装(1-1)
テストを書いたので、Model を実装します。
パネル情報を取得する時に、範囲外の Index2D を渡された時には、nil を返すこととしています。
//
// Puzzle15Model.swift
//
// Created by : Tomoaki Yagishita on 2023/09/08
// © 2023 SmallDeskSoftware
//
import Foundation
struct Index2D: Hashable {
var x: Int
var y: Int
init(_ x: Int,_ y: Int) {
self.x = x
self.y = y
}
}
actor Puzzle15Model {
var panels: Dictionary<Index2D, Int>
init() {
panels = Self.initialPanels
}
func panel(at index: Index2D) -> Int? {
return panels[index]
}
}
extension Puzzle15Model {
nonisolated static var initialPanels: Dictionary<Index2D, Int> {
[Index2D(0, 0): 1, Index2D(0, 1): 2, Index2D(0, 2): 3, Index2D(0, 3): 4,
Index2D(1, 0): 5, Index2D(1, 1): 6, Index2D(1, 2): 7, Index2D(1, 3): 8,
Index2D(2, 0): 9, Index2D(2, 1): 10, Index2D(2, 2): 11, Index2D(2, 3): 12,
Index2D(3, 0): 13, Index2D(3, 1): 14, Index2D(3, 2): 15, Index2D(3, 3): 0]
}
}
# 初期配置は、static で参照できるようにしました。
上記のコードで、先ほどのテストはパスするようになります。
Model テスト(1-2)
もう少し Model をテスト・実装していきます。
・指定したパネルは、スライドさせることが可能かどうか
ユーザーがタップやクリックしたときに反応するかどうかは、パネルの配置を知っている Model が判断すべきことです。
パネルの座標が引数で与えられた時に、該当位置のパネルが スライド可能かどうかを返すとします。
以下のようなテストコードを追加しました。
func test_movable_onInitialLayout() async throws {
let sut = Puzzle15Model()
let movable00 = await sut.movable(at: Index2D(0,0))
XCTAssertEqual(movable00, nil)
let movable12 = await sut.movable(at: Index2D(1,2))
XCTAssertEqual(movable12, nil)
let movable22 = await sut.movable(at: Index2D(2,2))
XCTAssertEqual(movable22, nil)
let movable23 = await sut.movable(at: Index2D(2,3))
XCTAssertEqual(movable23, Index2D(3,3))
let movable32 = await sut.movable(at: Index2D(3,2))
XCTAssertEqual(movable32, Index2D(3,3))
let movable33 = await sut.movable(at: Index2D(3,3))
XCTAssertEqual(movable33, nil)
}
初期配置では、右下の座標(3,3) の位置が空きパネルなので、座標(2,3) と 座標(3,2) のパネルのみがスライド可能です。
それ以外のパネルは、スライド不可です。
Bool で返すのではなく、スライド可能であれば、スライド先として選択可能な Index2D を返すようにしました
空きパネルに対しては、スライド不可と返されるとしました。
すべての座標ではなく、特徴的な座標+アルファ である (0,0), (1,2), (2,2),(2,3), (3,2), (3,3) について スライド可能かの判断をテストしています。
# この時点でも movable というメソッドは作っていないのでコンパイルエラーです。
Model 実装(1-2)
テストが通るように実装していきます。
スライドできるかどうかは、そのパネルに隣接する位置に 空きパネルがあるかどうかということです。
なお、隣接関係を表現しやすくするために Index2D に以下のような extension を作りました。
extension Index2D {
var dir4: [Index2D] {
return [self.n, self.e, self.s, self.w]
}
var n: Index2D { return Index2D(self.x , self.y - 1) }
var e: Index2D { return Index2D(self.x + 1, self.y ) }
var s: Index2D { return Index2D(self.x , self.y + 1) }
var w: Index2D { return Index2D(self.x - 1, self.y ) }
}
この extension の表現を使って、movable を実装していきます。併せて、指定位置が 空きパネルかどうかを判断するためのメソッドも追加します。
actor Puzzle15Model {
var panels: Dictionary<Index2D, Int>
// ... snip ...
func isOpenPanel(at index: Index2D) -> Bool {
return (panels[index] == 0)
}
func movable(at index: Index2D) -> Index2D? {
for nbr in index.dir4 {
if isOpenPanel(at: nbr) { return nbr }
}
return nil
}
}
この実装で先ほどのテストも通過するようになります。
Model テスト(1-3)
15パズルのパネルをスライドすることを想定して、指定箇所のパネルを入れ替えるメソッドを定義しておきます。
パネルは座標で指定されるとします。
# 与えられた座標の妥当性は、チェックしないこととします。
func test_swap_one() async throws {
let sut = Puzzle15Model()
let index32 = Index2D(3,2)
let index33 = Index2D(3,3)
var panel32 = await sut.panel(at: index32)
XCTAssertEqual(panel32, 15)
var panel33 = await sut.panel(at: index33)
XCTAssertEqual(panel33, 0)
await sut.swap(index32, index33)
panel32 = await sut.panel(at: index32)
XCTAssertEqual(panel32, 0)
panel33 = await sut.panel(at: index33)
XCTAssertEqual(panel33, 15)
}
上記は、”15″のパネルを(そのパネルの右側にある)空きパネル位置にスライドする操作をテストしています。
Model 実装(1-3)
実装します。
2つの Index2D が与えられる前提なので、Dictionary の Value を交換することになります。
actor Puzzle15Model {
var panels: Dictionary<Index2D, Int>
// ... snip ...
func swap(_ index1: Index2D,_ index2: Index2D) {
let value1 = panels[index1]
panels[index1] = panels[index2]
panels[index2] = value1
}
// ... snip ...
}
Model テスト(1-4)
最後に、テストを容易にするための initializer も追加しておきます。
Model の中身は、Dictionary なので、Dictionary そのものを渡して設定できるようにしておきます。
この init を用意しておくことで、さまざまなケースを直接生成してテストすることが容易になります。
func test_convenientInit() async throws {
let dic = [ Index2D(0,0): 1, Index2D(0,1): 2, Index2D(0,2): 3, Index2D(0,3): 4,
Index2D(1,0): 5, Index2D(1,1): 6, Index2D(1,2): 7, Index2D(1,3): 8,
Index2D(2,0): 9, Index2D(2,1):10, Index2D(2,2):11, Index2D(2,3):12,
Index2D(3,0):13, Index2D(3,1):14, Index2D(3,2):15, Index2D(3,3): 0 ]
let sut = Puzzle15Model(dic: dic)
let value00 = await sut.panel(at: Index2D(0,0))
let value01 = await sut.panel(at: Index2D(0,1))
let value02 = await sut.panel(at: Index2D(0,2))
let value03 = await sut.panel(at: Index2D(0,3))
let value10 = await sut.panel(at: Index2D(1,0))
let value11 = await sut.panel(at: Index2D(1,1))
let value12 = await sut.panel(at: Index2D(1,2))
let value13 = await sut.panel(at: Index2D(1,3))
let value20 = await sut.panel(at: Index2D(2,0))
let value21 = await sut.panel(at: Index2D(2,1))
let value22 = await sut.panel(at: Index2D(2,2))
let value23 = await sut.panel(at: Index2D(2,3))
let value30 = await sut.panel(at: Index2D(3,0))
let value31 = await sut.panel(at: Index2D(3,1))
let value32 = await sut.panel(at: Index2D(3,2))
let value33 = await sut.panel(at: Index2D(3,3))
XCTAssertEqual(value00, 1)
XCTAssertEqual(value01, 2)
XCTAssertEqual(value02, 3)
XCTAssertEqual(value03, 4)
XCTAssertEqual(value10, 5)
XCTAssertEqual(value11, 6)
XCTAssertEqual(value12, 7)
XCTAssertEqual(value13, 8)
XCTAssertEqual(value20, 9)
XCTAssertEqual(value21, 10)
XCTAssertEqual(value22, 11)
XCTAssertEqual(value23, 12)
XCTAssertEqual(value30, 13)
XCTAssertEqual(value31, 14)
XCTAssertEqual(value32, 15)
XCTAssertEqual(value33, 0)
}
Model 実装(1-4)
実装は、与えられた Dictionary をそのままセットするです。
actor Puzzle15Model {
public private(set) var panels: Dictionary<Index2D, Int>
// ... snip ...
init(dic: Dictionary) {
self.panels = dic
}
// ... snip ...
}
Model の持つ panels プロパティを外部から参照できると便利と考えて、public private(set) 指定を追加しました。(参照されても良いのですが、変更はされたくないので、private(set) としています)
なお、actor のプロパティなので、非同期で参照する必要があります。
まとめ
15パズル向けの Model を設定・実装・テストしました。
“テストを書く” -> “テストを通るようにコードを書く” を繰り返して実装しました。
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
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