[SwiftUI] 15 Puzzle の作り方 (3: Model 設計・実装・テスト)

SwiftUI2021

     
⌛️ 4 min.
SwiftUI を使って、15パズルを作っていきます。

環境&対象

以下の環境で動作確認を行なっています。

  • 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 を考慮しつつ、最大限コードを共通化します

全体のステップは、以下です。

15Puzzle とは

これから作っていくのは 15パズル です。スライドパズルと呼ばれることもあるようです。

Wikipedia での説明は、こちら

15puzzle

# 上記の画像も、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 もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です