Sponsor Link
環境&対象
- macOS Ventura 13.2 Beta
- 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 を作成した
[Swift][SwiftUI] actor を使って、LifeGame を作る(その 2: ViewModel/View を作る)
・進化ロジックを実装した
・LGViewCell をアニメーション対応にした
[Swift][SwiftUI] actor を使って、LifeGame を作る(その3: 進化とアニメーション)
今回
以下の機能を追加してみます。
・自動で進化を行う
・いくつかのパターンを選択できる
自動で進化する
NextGen ボタンを繰り返しクリックしなくても、進化を眺めていられるように、自動で進化する機能をつけてみます。
設計概要
特定の時間ごとに、(自動で)進化していくようにしてみます。
ボタンを押すと、自動で進化し始めるとして、必要な情報は 以下です。
・何秒ごとに進化するか
-> とりあえず(?) 2秒固定とします。
・指定時間ごとに進化させるのはだれか?(MVVM のどれ?)
-> ViewModel で管理することとします。
実装
(大したことを決めていませんが、)おおよそ設計できたので進めていきます。
最初に課題となるのが、テストです。(UI以外は、最初にテストを書いて進めていきたいです)
「指定時間ごとに、進化する」をまるごとテストしようとすると難しいです。
「指定時間ごとに、進化する」 = 「指定時間ごとに進化するメソッドをコールする」+「きちんと進化する」
後半の「きちんと進化する」は、すでに書いているので、テストされています。
残りは、「指定時間ごとに進化するメソッドをコールする」です。これは実は、Timer.TimerPublisher がまるごと引き受けてくれる機能です。
アプリのコードが行うことは、正しい引数でメソッドをコールすることです。(厳密にはもう少しあります)
つまり、Apple が提供してくれるライブラリがそのまま引き受けてくれる機能なので、テスト不要です。
Apple のコードをテストする必要はないと言うことです。
アプリの振る舞いをテストするのであれば、アプリのUIを通した振る舞いのテストを行うことになります。
ということで、テストは書かずに実装していきます。
ViewModel
実装する先は、LGViewModel です。
指定時間ように、TimeInterval 型の変数を定義します。(最初は、2秒固定で実装してます)
タイマーの実装は、Timer.TimerPublisher を使います。
[Combine] Combine の Publisher を改めて理解する
外部からのメソッドコールで、自動進化を ON/OFF できるようにします。
TimerPublisher を sink した cancellable を ViewModel に保持し、OFF のときには、nil にすることで、タイマーを停止させるようにします。
class LGViewModel: ObservableObject {
var board: LGBoard
@Published var cells: [CellIndex:LGCellState] = [:]
// NEW !!
let nextGenInterval: TimeInterval = 2 // 2 sec.
@Published var timerPubliser: AnyCancellable? = nil
init(numX: Int, numY: Int,_ closure: @escaping BoardSetupClosure) {
board = LGBoard(numX: numX, numY: numY, closure)
}
@MainActor
func updateCells() async {
cells = await board.boardStatus()
}
var numX: Int {
board.numX
}
var numY: Int {
board.numY
}
func doPrep() async {
await board.doPrep()
}
func moveToNextGen() async {
await board.moveToNextGen()
}
// NEW !!
func toggleAuto() {
if timerPubliser == nil {
// start
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 {
// stop
timerPubliser = nil
}
}
}
View
自動進化させるためのボタンを追加する必要があります。
自動実行中に、”NextGen” ボタンを押されないようにもします。
自動実行中かどうかは、LGViewModel の TimerPublisher からの cancellable を保持している変数が nil であるかどうかで判断できます。
nil 以外であれば、”NextGen” ボタンを disable にするようにします。
自動実行ボタンも、自動実行中には “Stop” ボタンになり、自動実行を停止できるようにします。
struct LGBoardView: View {
@EnvironmentObject var viewModel: LGViewModel
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)
}
}
}
}
HStack {
Button(action: {
Task {
await viewModel.doPrep()
await viewModel.moveToNextGen()
await viewModel.updateCells()
}
}, label: {Text("NextGen")})
.buttonStyle(.bordered)
.disabled(viewModel.timerPubliser != nil)
Button(action: {
viewModel.toggleAuto()
}, label: {Text("NextGen(Auto)")
.hidden()
.overlay {
Text(viewModel.timerPubliser == nil ? "NextGen(Auto)" : "Stop" )
}
})
.buttonStyle(.bordered)
}
}
.onAppear {
Task {
await viewModel.updateCells()
}
}
}
}
ボタンのラベル文字列を変更しても同じサイズで表示する方法は、以下の記事で説明している方法を使っています。
[SwiftUI] 表示要素を同じサイズにする方法
実装結果
以下のような動作をするアプリになりました。
初期パターンを選択する
現在の実装では、初期パターンを変更するにはコードを書き換えて再コンパイルが必要です。
さすがに 不便 なので、いくつかのパターンを記憶させておき、リストから選択できるようにしてみます。
設計概要
設計としては、以下としてみます。
・あらかじめ用意してあるパターンをプルダウンリストから選択できる
用意するパターン
以下の3パターンを用意してみます。
実装
実装自体は、Picker を使って実装します。
Picker の selection の変化に応じて、LGViewModel 経由で LGModel を変更するようにします。
class LGViewModel: ObservableObject {
// .. omit ..
func modelSetup(_ closure: BoardSetupClosure ) async {
await board.setup(closure)
await updateCells()
}
// .. omit ..
}
LGBoard が受け取るのと同様に、セットアップのための closure を受け取ることとしました。
実コードとして closure を渡していますが、名称を渡して、LGModel 内部で変換するようにもできます。
どちらが良いということではなく、今回はこのような方法で実装したと言うことです。
次に、LGBoardView の修正です。
struct LGBoardView: View {
@EnvironmentObject var viewModel: LGViewModel
@State private var selectedPattern: Int = 0
var body: some View {
VStack {
Grid(horizontalSpacing: 0, verticalSpacing: 0) {
// .. snip .. cell 表示
}
HStack {
// .. snip .. ボタン表示
}
// 🆕
Picker(selection: $selectedPattern, content: {
Text("Blinker").tag(0)
Text("Beacon").tag(1)
Text("Octagon").tag(2)
}, label: {
Text("select pattern")
})
.disabled(viewModel.timerPubliser != nil)
}
.onAppear { Task { await viewModel.updateCells() } }
// 🆕
.onChange(of: selectedPattern) { pattern in
Task {
switch selectedPattern {
case 0:
await viewModel.modelSetup(LGBoard.setupBlinker)
case 1:
await viewModel.modelSetup(LGBoard.setupBeacon)
case 2:
await viewModel.modelSetup(LGBoard.setupOctagon)
default:
break
}
}
}
}
}
LGBoard に用意した セットアップ用の closure は、以下のように定義しています。
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
}
}
ここまででできたアプリ
ここまでの実装で以下のようなアプリになっています。
・ボタン押下による LifeGame の進化
・タイマーによる LifeGame の自動進化
・プルダウンからの初期パターン選択
次回以降
パターンを複数から選べるようになり、すこし(?) 遊べるようになりましたが、さまざまなパターンで動かしてみたくなったときに、少し困ります。
次回は、GUI 上で、パターンを作成する機能と 作成したパターンの保存機能を作ってみます。
まとめ
LifeGame のモデルを進化させる機能を実装し、アニメーション表示できるようにしました。
- 時間での実行は、Timer.TimerPublisher を使うと、簡単に実装できる
- 状態に応じて、disabled すると ユーザーにわかりやすいハズ
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
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