[SwiftUI] 15 Puzzle の作り方 (7: 配置ランダム化)

SwiftUI2021

     
⌛️ 2 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つです。

配置ランダム化

現在の実装では、起動時に 初期配置に並べられています。

遊び方としては、バラバラに配置されているパネルを動かして、初期配置に戻すのが一般的だと思います。
ですので、遊ぶためには、初期配置だったものを バラバラにしないといけません。

ランダムボタンを設置して、配置をバラバラにすることができるようにします。

注意

15パズルの配置は、(ルールに沿って移動するだけでは) 初期配置に戻すことのできない配置が存在します。
つまり、乱数を用いてパネルを配置してしまうと、初期配置に戻すことができない配置を作成してしまうかもしれません。
その点について注意しながら配置することが必要です。

例えば、初期配置の “14” と “15” を入れ替えた配置は、どうやっても 初期配置と同じ配置には戻せません。

ランダム化の手法

単純に、初期配置から 複数回 のスライドを実行することで バラバラにするようにします。
こうすることで、解くことができないパターンになってしまうことを防ぎます。

このとき、「数字のパネルを選択してスライドさせる」のではなく、「空パネルを隣接しているパネルのいずれかに動かす」という視点でスライドを実行させるとわかりやすくなります。

補足的なメソッドとして、
ボード中の 空パネル の位置を取得する 「空パネルの Index2D 取得」と
特定の位置からボード内の移動可能位置を取得する「有効な隣接 Index2D 取得」も作ります。

以下のようなフローで 何回か 空きパネルを移動させることとします。

sequenceDiagram title Randomize
  participant Model
  Model-->>Model: 
  Note right of Model: 空パネルIndex2Dを見つける
  loop 指定回数繰り返す
  Model-->>Model: 
  Note right of Model: 空パネルの隣接Index2Dを見つける
  Model-->>Model: 
  Note right of Model: ランダムに1つ選択する(ただし、前回の移動元は除く)
  Model-->>Model: 
  Note right of Model: 選択したパネルと空パネルを入れ替える
  end

空パネルの Index2D 取得

初期配置では自明ですが、それ以外では 空パネルの位置は調べないといけません。
Model に対して問い合わせて Index2D を得るものとします。

ということで、Model Test に追加します。

テスト

テストコードは、以下のようになります。

    func test_emptyIndex() async throws {
        let sut = Puzzle15Model()
        
        let empty1 = await sut.emptyIndex()
        XCTAssertEqual(empty1, Index2D(3, 3))
        
        await sut.swap(Index2D(3,2), Index2D(3,3))
        let empty2 = await sut.emptyIndex()
        XCTAssertEqual(empty2, Index2D(3,2))
    }

1つ目のケースは、初期配置の空パネルの位置が (3,3) として取得できることを確認しています。

その後、”15″ と 空パネルの位置を入れ替える操作をして、空パネルの位置が(3,2) に移動しているハズなので、改めて取得して、(3,2) が取得できることを確認します。

実装

空パネルは、値としては “0” を持っているはずなので、Model 内部に保持している panels から value == 0 となる Index2D を見つけます。

    func emptyIndex() -> Index2D {
        panels.filter({ $0.value == 0 }).keys.first!
    }

確実に1つだけ存在するはずなので、force-unwrap (“!”) を使っています。

上記の実装で、テストコードはパスします。

有効な隣接 Index2D 取得

ボード上の座標から、隣接するボード上の(有効な)座標を取得するメソッドも作ります。

座標に隣接する4方向の Index2D は、dir4 で取得できますが、座標の位置によっては、ボード外の座標になっていることもあるため、そのような無効な座標が除かれた 有効な座標のみを取得するためのメソッドです。

テスト

四隅・外周沿い・内部のパターンでテストすることにします。

    func test_nbrIndices() async throws {
        let sut = Puzzle15Model()

        let nbr00 = await sut.nbrIndices(Index2D(0,0))
        XCTAssertEqual(Set(nbr00), [Index2D(0,1), Index2D(1,0)])

        let nbr22 = await sut.nbrIndices(Index2D(2,2))
        XCTAssertEqual(Set(nbr22), [Index2D(2,1), Index2D(1,2), Index2D(2,3), Index2D(3,2)])
        
        let nbr32 = await sut.nbrIndices(Index2D(3,2))
        XCTAssertEqual(Set(nbr32), [Index2D(3,1), Index2D(3,3), Index2D(2,2)])

    }

実装

今回の例では、ボード外の Index2D に対しては、panels が nil を返すので、dir4 のうち、panels が nil を返さない Index2D を返しています。

    func nbrIndices(_ index: Index2D) -> [Index2D] {
        index.dir4.filter({ panels[$0] != nil })
    }

上記の実装で、テストコードをパスします。

なお、nbrIndixes が返す [Index2D] には 要素が1以上含まれているハズです。

配置ランダム化

補助的なメソッドを作ったので、配置をランダムに変更するメソッドを実装します。

テスト

ランダムに操作する機能のテストは、難しいです。
具体的には、「何をもって ランダムとするか」という定義が難しいです。

今回は、初期配置と異なる配置となっていることを確認して、正常に動作したと判断しています。

    func test_randomize() async throws {
        let sut = Puzzle15Model()
        
        await sut.randomize(11)
        
        let panels = await sut.panels
        XCTAssertNotEqual(panels, Puzzle15Model.initialPanels)
    }

アルゴリズム的には、2回で元の配置に戻ることはあり得ませんが、4回、6回・・・の偶数回の操作では、元の配置に戻ることがあり得ます。
そのようなケースを防ぐために、奇数回の操作でテストしています。

実装

実装は、先の模擬コードをそのまま実装しています。

    func randomize(_ slideNum: Int) {
        var emptyIndex = emptyIndex()
        var prevEmptyIndex: Index2D? = nil
        var movedNum = 0
        while movedNum  slideNum {
            let moveTo = nbrIndices(emptyIndex).filter({ $0 != prevEmptyIndex }).randomElement()!
            swap(emptyIndex, moveTo)
            prevEmptyIndex = emptyIndex
            emptyIndex = moveTo
            movedNum += 1
        }
        return
    }
    

ViewModel/View への実装

長らく 実装追加していなかった “Shuffle” ボタンを実装できる準備が整いました。

まずは、ViewModel に追加します。

class Puzzle15ViewModel: ObservableObject {
    // ... snip ...
    func randomize(_ num: Int) {
        Task {
            await model.randomize(num)
        }
    }
}

最後に、”Shuffle” ボタンを実装します。

//
//  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()
    }
}

#Preview {
    Puzzle15AppView()
}

数字に根拠はありませんが、50回分の移動をしています。

シミュレータでの動作

シミュレータで動かしてみると 以下のようになります。

# 一度に、50回分の移動をしているので、何が動いているのかよくわからなくなってます・・・

まとめ

15Puzzle の配置ランダム化を実装しました。

15Puzzle の配置ランダム化
  • 初期配置に戻すことのできない配置も存在するので注意が必要

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

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版が最新版です。

コメントを残す

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