[Swift][SwiftUI] actor を使って、LifeGame を作る(その6:パターン作成・保存機能)

SwiftUI2021

     
⌛️ 8 min.
LifeGame を作っていく過程で、SwiftUI / Swift を理解していきます。actor と MVVM の組み合わせを確認していきます。

環境&対象

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

  • macOS Ventura 13.2
  • Xcode 14.2

全てを作成してから記事を書いていないので、途中でバージョンアップがあるかもしれません。

LifeGame

LifeGame とは以下のようなルールのもと 世代を進めていき、進化を眺める(?)ゲームです。

・セルは、「生きている」「死んでいる」の2つの状態のいずれかを持つ。
・セルは、時間が進むにつれて、「死んだり」「生きたり」する
・セルは、周囲の8つのセルの状態に応じて、「死んだり」「生きたり」する

・セルの誕生死滅ルール
・生きているセルの周りに、2つ もしくは 3つの生きたセルがあると、そのセルは 次の世代でも生きている
・生きているセルの周りに、1つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代で死滅する
・死んでいるセルの周りにちょうど3つの生きたセルがあると、そのセルに次の世代で誕生する
・死んでいるセルの周りに、2つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代でも死滅したまま

# セルの”周りのセル”とは、周囲8個のセルのことです。
詳細は、Wikipediaで。

前回まで

前回までに行ったことは以下のことです。

・ボード全体を表現する LGBoard を actor で 作成した
・各セルを表現する LGCell を class で作成した
・LGBoard 内での隣接セルの状態を確認する準備をした
[Swift][SwiftUI] actor を使って、LifeGame を作る(その1: actor でモデルを作る)

・ViewModel として LGViewModel を作成した
・LGBoard を表示するための LGBoardView を作成した
・LGCell を表示するための LGCellView を作成した
SwiftUI2021 [Swift][SwiftUI] actor を使って、LifeGame を作る(その 2: ViewModel/View を作る)

・進化ロジックを実装した
・LGViewCell をアニメーション対応にした
SwiftUI2021 [Swift][SwiftUI] actor を使って、LifeGame を作る(その3: 進化とアニメーション)

・自動で進化する機能を追加した
SwiftUI2021 [Swift][SwiftUI] actor を使って、LifeGame を作る(その4: 複数の初期パターンと自動進化)

・自動進化の速度を設定する機能を追加した
SwiftUI2021 [Swift][SwiftUI] actor を使って、LifeGame を作る(その5:自動進化の速度設定)

今回やること

以下の機能を追加してみます。

・UI 上で新しいパターンを設定する
・新しいパターンの Save/Load できるようにする

今回で完成とします。

仕様概要

コードを書く前に、大体の仕様を検討します。

新しく追加する仕様としては、以下としてみます。

・画面上の ○/× を直接 クリック/タップ することで、toggle できるようにする
・サイズ変更は、(今回の)対象としない
・作成したパターンは、”Save” ボタンを押すことで、名前をつけて保存できる
・保存したパターンは、プルダウンから選択できるようになる。
・プルダウンから選択したパターンが表示されているときに、”削除” ボタンをおすと、パターンを削除できる

上記の機能があれば、新しいパターンを作ったり、削除したりできるはずです。
サイズ変更は、今後の課題として 棚上げしておきます。

実装を見据えた仕様詳細

これまでの実装を考えると以下のような責務分割になりそうです。

・UI 上でのタップは、View
・現在のボード/セルの状態は、LGBoard/LGCell で持つ状態をそのまま使う
・別の View を作成して タップできるようにするのではなく、現在の LGBoardView を使って、実装する
・Save/Load 操作は、ViewModel が管理する
・データの Save/Load は、ViewModel が LGBoard 型を使って行う(LGBoard を Codable にする)

実装

新しいパターンの設定

LGBoardView を修正していくことになります。

具体的には、LGBoardView で Grid 中に表示されている LGCellView をタップしたとき、LGBoard 中の該当セルを toggle することになります。

考慮すべきこととしては、LGBoard は actor なので、tapGesture からそのまま変更を行うことはできません。別途 Task を使って変更することが必要となります。
そして、その変更終了を待って、LGViewModel の更新が必要となりそうです。

LGBoardView から直接 LGBoard の変更を行うのは筋が良くなさそうなので、ViewModel/Model にメソッドを準備することにします。

ViewModel/Model 側準備

ViewModel では、LGBoard 内のセルを インデックス指定で toggle し、その後 LGViewModel 内の cells を更新するというメソッドを作ることとします。名前は、toggleCell としました。

また、LGBoard には、特定のセルの状態を操作するメソッドがありませんでしたので、作成しました。こちらも、toggleCell とし、LGCell には、toggle というメソッドを作りました。これで、View -> ViewModel -> Model とセルの状態を反転するメソッド群を作ったことになります。(すこし整備しすぎかもしれません・・・)

class LGViewModel: ObservableObject {
    // ...snip...
    func toggleCell(_ index: CellIndex) async {
        await board.toggleCell(index)
        await updateCells()
    }
}
actor LGBoard: ObservableObject {
    // ...snip...
    func toggleCell(_ index: CellIndex) {
        cells[index]?.toggle()
    }
}
class LGCell {
  // ...snip...
    func toggle() {
        state.toggle()
    }
}

これで、ViewModel/ Model 側の準備はできたので、LGBoardView を変更していきます。

View の実装

素直に(?)、tapGesture を使って実装していきます。
tapGesture に渡す closure で セルを toggle し、ViewModel の 表示用データを更新するようにすればOKのハズです。

struct LGBoardView: View {
    @EnvironmentObject var viewModel: LGViewModel
    @State private var selectedPattern: Int = 0

    var body: some View {
        VStack {
            Grid(horizontalSpacing: 0, verticalSpacing: 0) {
                ForEach(0..<viewModel.numY, id: \.self) { yIndex in
                    GridRow {
                        ForEach(0..<viewModel.numX, id: \.self) { xIndex in
                            let cellIndex = CellIndex(x: xIndex, y: yIndex)
                            let cellState = viewModel.cells[cellIndex] ?? .alive
                            LGCellView(cellState: cellState)
                                .animation(.default, value: cellState)
                                .onTapGesture {
                                    Task {
                                        await viewModel.toggleCell(cellIndex)
                                    }
                                }
                        }
                    }
                }
            }
            // ...snip...
        }
    }
}

toggleCell は、async なメソッドとして定義しているので、 Task を使って呼び出さないといけません。

実装結果

以下のような動作になります。

内部動作は、タップ -> ViewModel 経由で Model を変更 -> Model の状態を ViewModel に反映 -> ViewModel の情報から View が更新 という動作になっています。

パターンの Save/Load

パターンを UI を使って、作れるようになったので、こんどは、作ったパターンを保存することを考えていきます。

・パターンを保存するときは、LGBoard を使って保存する
・Codable の仕組みを使って、保存する

Codable への準拠

まずは、LGBoard/LGCell/LGCellStateそれぞれを Codable に準拠させていくことを考えます。

LGBoard の Codable への準拠

enum, class は、非常に簡単に Codable に準拠することができるのですが、問題となるのは、actor として定義されている LGBoard です。

Codable に準拠するためには、init(from:), encode(to:) の実装が必要となります。
ところが、init(from:), encode(to:) のいずれも sync なアクセスを要求します。
sync なアクセスで、actor 内のプロパティにアクセスすることはできないので、actor 自身のデータを serialize することはできないということになります。

ですので、別の解決策を考えます。actor LGBoard の持っている情報量は、少ないので、Codable に頼らず 自前で serialize/de-serialize することを考えます。

async なメソッドで、”その時点での” LGBoard の情報を取得し、その後、serialize します。

LGBoard の状態を取得/復元

メインの情報である 各セルの情報は、LGBoard の boardStatus メソッドで、[CellIndex:LGCellState] として取得できていますので、これに ボードの X/Y 方向の大きさがわかれば、LGBoard を復元できるはずです。

ということで、以下のような BoardData 型を定義して、そのデータ型を保存し、復元に使うように実装してみます。

struct BoardData {
    let numX: Int
    let numY: Int
    let cells:[CellIndex: LGCellState]
}

LGBoard を BoardData を使って初期化する/LGBoard から BoardData を取得する というような実装を追加して、LGBoard -> BoardData のやりとりができるようにします。

actor LGBoard: ObservableObject {
    // ... omit ...
    init(_ boardData: BoardData ) {
        self.numX = boardData.numX
        self.numY = boardData.numY
        for index in boardData.cells.keys {
            cells[index] = LGCell(state: boardData.cells[index] ?? .alive)
        }
    }
    func boardData() async -> BoardData {
        let cellStatus = await self.boardStatus()
        return BoardData(numX: numX, numY: numY, cells: cellStatus)
    }
}

気をつけるべき点は、LGCell を Value として持つ Dictionary を使ってしまうと、LGCell は class として定義されているので取得元の LGBoard と共有されているという点です。

どちらかが LGCell の中身を変更してしまうと、他方にも影響を与えるということになります。

“変更しないように気をつける” という解決策もありますが、”Value タイプを使ってデータを受け取る” という解決策の方が、間違いが起こりにくいです。

ということで、[CellIndex: LGCellState] という型を使っています。(Swift の Dictionary は、Value タイプで、CellIndex, LGCellState それぞれ struct/ enum なので、どちらも Value タイプ です)

LGViewModel での LGBoard の管理

LGBoard の状態を保存/復元 できるようになりましたので、LGViewModel 側での管理を実装していきます。

複数の LGBoard の状態を LGViewModel 内に持つようにします。

class LGViewModel: ObservableObject {
    var board: LGBoard
    @Published var cells: [CellIndex:LGCellState] = [:]
    // !!! NEW !!!
    @Published var savedBoards: [(String,BoardData)] = []
}

あとから選択しやすくするために、名前とペアにして保存しておきます。
(ユーザーが名前を設定する想定です)

あとは、ユーザーが “Save”ボタンを押した時のための保存メソッドを作っておきます。

class LGViewModel: ObservableObject {
    // ... omit ...
    @MainActor
    func save(_ name: String) async {
        let boardData = await board.boardData()
        self.savedBoards.append((name, boardData))
    }
}

名前を引数に与えられて、その時点での LGBoard の情報とセットで保存しています。

パターンの保存と復元

これまで 事前に定義してあるパターンから選択できていましたが、同じリストの中に、ユーザーが作成したパターンも表示され、選択できるようにします。

パターンの保存

パターンの保存から実装します。

パターンの保存機能自体は、LGBoard/LGViewModel に追加しましたので、UI を作ります。
つまり、その時点でのボードの状態に名前をつけて保存する UI です。

“Save” ボタンを押したら、名前を聞くシートを表示し、そのシートで “OK” が押下されたら 保存する機能とします。

シートは、.sheet(isPresented:) で表示することにし、シート内には、パターンの名前を入力する TextField と 作成するかどうかの “OK” / “Cancel” ボタンを配置することにします。

せっかくなので(?)、iOS16/macOS13 から導入されたハーフモーダルにしてみます。

以下は、シート部分のコードを抜粋したものです。(全体のコードは記事の最後にまとめます)

        .sheet(isPresented: $showSheet) {
            VStack {
                TextField(text: $patternName, label: { Text("Pattern Name: ") })
                    .textFieldStyle(.roundedBorder).padding()
                HStack {
                    Button(action: {
                        guard patternName != "" else { return }
                        Task {
                            await viewModel.save(patternName)
                            patternName = ""
                        }
                        showSheet.toggle()
                    }, label: { Text("OK") }).disabled(patternName=="")
                    Button(action: { showSheet.toggle() }, label: { Text("Cancel") })
                }
            }
            .presentationDetents([.fraction(0.3)]) // half-modal
            .padding()
        }

以下のような動作になります。

これで、LGViewModel には、Save指定したパターンが保持されることになります。

パターンの復元

これまでは、特定のパターンのみ 復元することが可能でした。

新しく保存したパターンも、選択できるようにします。

保存したパターンは、LGViewModel が持っていますので、既知のパターンと合わせて Picker で選択するようにします。

これまで、Picker の Tag は、数値でしたが、文字列に変更します。具体的には、パターンの名前にします。

これまでのパターンも新しいパターンも、パターンの名前で選択するようにします。
以下は、該当箇所の Picker と selection を監視している箇所の抜粋です。

Picker(selection: $selectedPattern, content: {
    // (1)
    Text("Blinker").tag("Blinker")
    Text("Beacon").tag("Beacon")
    Text("Octagon").tag("Octagon")
    // (2)
    ForEach(viewModel.savedBoards, id: \.0) { item in
        // (3)
        Text(item.0)
          // (4)
          .tag(item.0)
    }
}, label: {
    Text("select pattern")
})
.onChange(of: selectedPattern) { name in
    Task {
        // (5)
        switch name {
        // (6)
        case "Blinker":
            await viewModel.modelSetup(LGBoard.setupBlinker)
        case "Beacon":
            await viewModel.modelSetup(LGBoard.setupBeacon)
        case "Octagon":
            await viewModel.modelSetup(LGBoard.setupOctagon)
        default:
            // (7)
            if let data = viewModel.savedBoards.first(where: {$0.0 == name}) {
                await viewModel.modelSetup(data.1)
            }
        }
    }
}

コード解説
  1. これまでのパターンにも、String で tag を付与します
  2. LGViewModel が持っている保存されているパターンも合わせて列挙します
  3. (String,LGBoard) のタプルで持っているので、String を名称として表示します
  4. tag にも String を設定します
  5. 選択を検知して、選択されたパターンをセットします
  6. 事前設定されているパターンは、これまで通り closure で設定します
  7. 別途保存されているケースでは、LGViewModel が持っている情報で設定します

現在の実装では、以下のような制限があります。
・保存するパターンに既知のパターンと同じ名前をつけられると うまく処理できない

補足機能

以下の機能も追加します。
・パターン削除
・LGBoard クリア

パターン削除

現在選択されているパターンを削除する機能を実装します。既知のパターンであれば、削除せずに処理を終えます。

処理の実装としては、LGViewModel からデータを削除することになります。

以下の remove メソッドを実装します。

class LGViewModel: ObservableObject {
    // ... omit ...
    @MainActor
    func remove(_ name: String) {
        if let index = savedBoards.firstIndex(where: {$0.0 == name}) {
            savedBoards.remove(at: index)
        }
    }
}

View 側には、”Save” ボタンの横に、”ゴミ箱”ボタンをつけます。削除すると、選択中のパターンは無くなってしまうので、選択を(デフォルトの) Blinker に戻します。

Button(action: {
    viewModel.remove(selectedPattern)
    selectedPattern = "Blinker"
}, label: { Image(systemName: "trash") })

LGBoardクリア

自分でパターンを作り始めると、まっさらの状態(すべて .dead) から設定したくなりました。
ということで、”Clear” ボタンを作ってみました。

Button(action: {
    Task {
        await viewModel.modelSetup({ _ in
             .dead
        })
    }
 }, label: { Text("Clear") })

LGViewModel に作った board 設定用のメソッドで、すべてを .dead にしています。

アプリ最終版

最終的に以下のようなアプリになりました

・パターンを自分で作る機能(タップで、.alive/.dead 調整、Save/Load)
・進化する機能
・時間経過で自動進化する機能(進化の時間調整機能付き)

まとめ

LifeGame を作りました。以下は、これまでの記事でのまとめのまとめです。

LifeGame の作り方
  • 並行アクセスでデータ競合が発生しそうであれば、actor を使うことを検討する
  • actor 内のプロパティは、actor の context からでないと同期的にアクセスできない
  • データ競合が発生しないとわかっているプロパティは、nonisolated 指定することで、actor context 外からでも同期的にアクセスできるよう指定できる
  • actor へのアクセスは非同期なので、View 向けには 同期アクセスできるデータが必要となる
  • 盤状の要素配置は、 Grid/GridLayout が便利
  • Concurrency を使っていると、withAnimation 指定できないケースがある
  • .animation を使うと、監視する変数を指定してアニメーションできる
  • 時間での実行は、Timer.TimerPublisher を使うと、簡単に実装できる
  • 状態に応じて、disabled すると ユーザーにわかりやすいハズ
  • Slider は線形に値を変化させる
  • Binding を実装することで、線形でない値変化をもつ Slider を作れる

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

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

最終的なコード

パターンを複数から選べるようになり、自動進化の時間も調整できるようになりました。

そろそろ、お決まりの(?) パターンに飽きてきたので、別パターンを試せるように改良していきます。

次回は、GUI 上で、パターンを作成する機能と 作成したパターンの保存機能を作ってみます。

LifeGame2022App.swift

//
//  LifeGame2022App.swift
//
//  Created by : Tomoaki Yagishita on 2022/11/11
//  © 2022  SmallDeskSoftware
//

import SwiftUI

@main
struct LifeGame2022App: App {
    @StateObject var viewModel: LGViewModel = LGViewModel(numX: 8, numY: 8, LGBoard.setupBlinker)
    var body: some Scene {
        WindowGroup {
            MainView()
                .environmentObject(viewModel)
        }
    }
}

LGBoardView.swift

//
//  LGBoardView.swift
//
//  Created by : Tomoaki Yagishita on 2023/01/10
//  © 2023  SmallDeskSoftware
//

import SwiftUI

struct LGBoardView: View {
    @EnvironmentObject var viewModel: LGViewModel
    @State private var selectedPattern: String = "Blinker"
    @State private var showSheet: Bool = false
    @State private var patternName: String = ""

    var body: some View {
        VStack {
            Grid(horizontalSpacing: 0, verticalSpacing: 0) {
                ForEach(0..<viewModel.numY, id: \.self) { yIndex in
                    GridRow {
                        ForEach(0..<viewModel.numX, id: \.self) { xIndex in
                            let cellIndex = CellIndex(x: xIndex, y: yIndex)
                            let cellState = viewModel.cells[cellIndex] ?? .alive
                            LGCellView(cellState: cellState)
                                .animation(.default, value: cellState)
                                .onTapGesture {
                                    Task {
                                        await viewModel.toggleCell(cellIndex)
                                    }
                                }
                        }
                    }
                }
            }
            Button(action: {
                Task {
                    await viewModel.doPrep()
                    await viewModel.moveToNextGen()
                    await viewModel.updateCells()
                }
            }, label: {Text("NextGen")})
            .buttonStyle(.bordered)
            .disabled(viewModel.timerPubliser != nil)
            HStack {
                Button(action: {
                    viewModel.toggleAuto()
                }, label: {Text("NextGen(Auto)")
                        .hidden()
                        .overlay {
                            Text(viewModel.timerPubliser == nil ? "NextGen(Auto)" : "Stop" )
                        }
                })
                .buttonStyle(.bordered)
                HStack {
                    Image(systemName: "hare")
                    Text(viewModel.nextGenIntervalString)
                    Slider(value: Binding(get: {
                        log10(viewModel.nextGenInterval / (0.01) )
                    }, set: { newValue in
                        viewModel.nextGenInterval = 0.01 * pow(10.0, newValue)
                    }),
                           in: 1...2.5,
                           label: { Text("Interval") })
                    Image(systemName: "tortoise")
                }
            }
            HStack {
                Picker(selection: $selectedPattern, content: {
                    Text("Blinker").tag("Blinker")
                    Text("Beacon").tag("Beacon")
                    Text("Octagon").tag("Octagon")
                    ForEach(viewModel.savedBoards, id: \.0) { item in
                        Text(item.0)
                            .tag(item.0)
                    }
                }, label: {
                    Text("select pattern")
                })
                .fixedSize()
                .disabled(viewModel.timerPubliser != nil)
                Button(action: {
                    showSheet.toggle()
                }, label: {Text("Save")})
                Button(action: {
                    viewModel.remove(selectedPattern)
                    selectedPattern = "Blinker"
                }, label: { Image(systemName: "trash") })
                Button(action: {
                    Task {
                        await viewModel.modelSetup({ _ in
                                .dead
                        })
                    }
                }, label: { Text("Clear") })
            }
        }
        .onAppear { Task { await viewModel.updateCells() } }
        .onChange(of: selectedPattern) { name in
            Task {
                switch name {
                case "Blinker":
                    await viewModel.modelSetup(LGBoard.setupBlinker)
                case "Beacon":
                    await viewModel.modelSetup(LGBoard.setupBeacon)
                case "Octagon":
                    await viewModel.modelSetup(LGBoard.setupOctagon)
                default:
                    if let data = viewModel.savedBoards.first(where: {$0.0 == name}) {
                        await viewModel.modelSetup(data.1)
                    }
                }
            }
        }
        .sheet(isPresented: $showSheet) {
            VStack {
                TextField(text: $patternName, label: { Text("Pattern Name: ") })
                    .textFieldStyle(.roundedBorder)
                    .padding()
                HStack {
                    Button(action: {
                        guard patternName != "" else { return }
                        Task {
                            await viewModel.save(patternName)
                        }
                        showSheet.toggle()
                    }, label: { Text("OK") }).disabled(patternName=="")
                    Button(action: { showSheet.toggle() }, label: { Text("Cancel") })
                }
            }
            .presentationDetents([.fraction(0.3)])
            .padding()
        }
    }
}

struct LGBoardView_Previews: PreviewProvider {
    static var previews: some View {
        LGBoardView()
    }
}

LGCellView.swift

//
//  LGCellView.swift
//
//  Created by : Tomoaki Yagishita on 2023/01/10
//  © 2023  SmallDeskSoftware
//

import SwiftUI

struct LGCellView: View {
    let cellState: LGCellState
    var body: some View {
        Image(systemName: symbolName)
            .resizable().aspectRatio(contentMode: .fit)
            .frame(maxWidth: .infinity)
            .rotation3DEffect(.degrees(cellState == .alive ? 0: 180), axis: (x: 0, y: -1, z: 0))
    }

    var symbolName: String {
        switch cellState {
        case .alive:
            return "circle"
        case .dead:
            return "xmark"
        }
    }
}

struct LGCellView_Previews: PreviewProvider {
    static var previews: some View {
        HStack {
            LGCellView(cellState: .alive)
            LGCellView(cellState: .dead)
        }
    }
}

LGViewModel.swift

//
//  LGViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2023/01/10
//  © 2023  SmallDeskSoftware
//

import Combine
import SwiftUI

class LGViewModel: ObservableObject {
    var board: LGBoard
    @Published var cells: [CellIndex:LGCellState] = [:]
    @Published var savedBoards: [(String,BoardData)] = []

    @Published var nextGenInterval: TimeInterval = 2 { // 3 sec. {
        didSet {
            self.restartAuto()
        }
    }
    @Published var timerPubliser: AnyCancellable? = nil

    init(numX: Int, numY: Int,_ closure: @escaping BoardSetupClosure) {
        board = LGBoard(numX: numX, numY: numY, closure)
    }

    func modelSetup(_ closure: BoardSetupClosure ) async {
        await board.setup(closure)
        await updateCells()
    }

    func modelSetup(_ boardData: BoardData) async {
        board = LGBoard(boardData)
        await updateCells()
    }


    @MainActor
    func updateCells() async {
        cells = await board.boardStatus()
    }

    func toggleCell(_ index: CellIndex) async {
        await board.toggleCell(index)
        await updateCells()
    }

    var numX: Int {
        board.numX
    }
    var numY: Int {
        board.numY
    }

    func doPrep() async {
        await board.doPrep()
    }
    func moveToNextGen() async {
        await board.moveToNextGen()
    }

    let numberFormatter: NumberFormatter = {
        let nf = NumberFormatter()
        nf.numberStyle = .decimal
        nf.minimumFractionDigits = 2
        nf.maximumFractionDigits = 2
        return nf
    }()

    var nextGenIntervalString: String {
        numberFormatter.string(from: nextGenInterval as NSNumber)!
    }

    func restartAuto() {
        if timerPubliser == nil { return }
        timerPubliser = Timer.TimerPublisher(interval: nextGenInterval, runLoop: .current, mode: .default)
            .autoconnect()
            .sink(receiveValue: { newDate in
                Task {
                    await self.doPrep()
                    await self.moveToNextGen()
                    await self.updateCells()
                }
            })
    }

    func toggleAuto() {
        if timerPubliser == nil {
            timerPubliser = Timer.TimerPublisher(interval: nextGenInterval, runLoop: .current, mode: .default)
                .autoconnect()
                .sink(receiveValue: { newDate in
                    Task {
                        await self.doPrep()
                        await self.moveToNextGen()
                        await self.updateCells()
                    }
                })
        } else {
            timerPubliser = nil
        }
    }

    @MainActor
    func save(_ name: String) async {
        let boardData = await board.boardData()
        self.savedBoards.append((name, boardData))
    }
    @MainActor
    func remove(_ name: String) {
        if let index = savedBoards.firstIndex(where: {$0.0 == name}) {
            savedBoards.remove(at: index)
        }
    }
}

LGBoard.swift

//
//  LGBoard.swift
//
//  Created by : Tomoaki Yagishita on 2022/11/11
//  © 2022  SmallDeskSoftware
//

import Foundation
import Distributed

enum LGCellState: CustomStringConvertible, Codable {
    case alive, dead
    var description: String {
        switch self {
        case .alive:
            return "o"
        case .dead:
            return "x"
        }
    }
    mutating func toggle() {
        self = (self == .alive) ? .dead : .alive
    }
}

class LGCell: Codable {
    private(set) var state: LGCellState
    private(set) var nextState: LGCellState?

    init(state: LGCellState) {
        self.state = state
        self.nextState = nil
    }

    func toggle() {
        state.toggle()
    }

    func doPrep(_ nextState: LGCellState) {
        self.nextState = nextState
    }

    func moveToNextGen() {
        guard let nextState = nextState else { fatalError() }
        self.state = nextState
        self.nextState = nil
    }
}

struct CellIndex: Hashable, Codable {
    let x: Int
    let y: Int

    var north    : CellIndex { CellIndex(x: x-1, y: y  ) }
    var northWest: CellIndex { CellIndex(x: x-1, y: y-1) }
    var      west: CellIndex { CellIndex(x: x  , y: y-1) }
    var southWest: CellIndex { CellIndex(x: x+1, y: y-1) }
    var south    : CellIndex { CellIndex(x: x+1, y: y  ) }
    var southEast: CellIndex { CellIndex(x: x+1, y: y+1) }
    var      east: CellIndex { CellIndex(x: x  , y: y+1) }
    var northEast: CellIndex { CellIndex(x: x-1, y: y+1) }

    var nbrIndexes:[CellIndex] {
        [north, northWest, west, southWest,
         south, southEast, east, northEast]
    }
}

extension CellIndex: CustomStringConvertible {
    var description: String {
        return String("(\(x),\(y))")
    }
}

typealias BoardSetupClosure = (CellIndex) -> LGCellState

struct BoardData: Codable {
    let numX: Int
    let numY: Int
    let cells:[CellIndex: LGCellState]
}

actor LGBoard: ObservableObject {
    nonisolated let numX: Int
    nonisolated let numY: Int

    var cells:Dictionary = [:]
    init(numX: Int, numY: Int,_ closure: BoardSetupClosure = { index in .alive }) {
        self.numX = numX
        self.numY = numY
        for x in 0.. LGCellState? {
        return cells[index]?.state
    }

    func toggleCell(_ index: CellIndex) {
        cells[index]?.toggle()
    }
    func boardStatus() async -> [CellIndex: LGCellState] {
        var newDic: [CellIndex: LGCellState] = [:]
        for key in cells.keys {
            if let cell = cells[key] {
                newDic[key] = cell.state
            }
        }
        return newDic
    }

    func boardData() async -> BoardData {
        let cellStatus = await self.boardStatus()
        return BoardData(numX: numX, numY: numY, cells: cellStatus)
    }

    func countAliveInNbrCells(center: CellIndex) -> Int {
        var numOfAlive = 0
        for index in center.nbrIndexes {
            guard let cell = cells[index] else { continue }
            if cell.state == .alive {
                numOfAlive += 1
            }
        }
        return numOfAlive
    }

    func doPrep() {
        for index in cells.keys {
            guard let cell = cells[index] else { continue }
            let nbrNumOfAlive = countAliveInNbrCells(center: index)
            let nextState: LGCellState
            switch cell.state {
            case .alive:
                nextState = (nbrNumOfAlive == 2)||(nbrNumOfAlive == 3) ? .alive : .dead
            case .dead:
                nextState = nbrNumOfAlive == 3 ? .alive : .dead
            }
            cell.doPrep(nextState)
        }
    }

    func moveToNextGen() {
        for key in cells.keys {
            cells[key]?.moveToNextGen()
        }
    }

}

extension LGBoard {
    static let setupBlinker: BoardSetupClosure = { index in
        let liveCells = [CellIndex(x: 3, y: 3),
                         CellIndex(x: 3, y: 4),
                         CellIndex(x: 3, y: 5),
        ]
        if liveCells.contains(index) { return .alive }
        return .dead
    }
    static let setupBeacon: BoardSetupClosure = { index in
        let liveCells = [CellIndex(x: 2, y: 2), CellIndex(x: 3, y: 2),
                         CellIndex(x: 2, y: 3), CellIndex(x: 3, y: 3),
                         CellIndex(x: 4, y: 4), CellIndex(x: 5, y: 4),
                         CellIndex(x: 4, y: 5), CellIndex(x: 5, y: 5),
        ]
        if liveCells.contains(index) { return .alive }
        return .dead
    }
    static let setupOctagon: BoardSetupClosure = { index in
        let liveCells = [CellIndex(x: 3, y: 0), CellIndex(x: 4, y: 0),
                         CellIndex(x: 2, y: 1), CellIndex(x: 5, y: 1),
                         CellIndex(x: 1, y: 2), CellIndex(x: 6, y: 2),
                         CellIndex(x: 0, y: 3), CellIndex(x: 7, y: 3),
                         CellIndex(x: 0, y: 4), CellIndex(x: 7, y: 4),
                         CellIndex(x: 1, y: 5), CellIndex(x: 6, y: 5),
                         CellIndex(x: 2, y: 6), CellIndex(x: 5, y: 6),
                         CellIndex(x: 3, y: 7), CellIndex(x: 4, y: 7),

        ]
        if liveCells.contains(index) { return .alive }
        return .dead
    }
}

コメントを残す

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