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 を作る)
今回
LifeGame として、進化させていきます。
・初期パターンを指定
・進化機能の実装
・アニメーション
初期パターン
格子模様から始めるのもありかもしれませんが、周期的にパターンを繰り返す有名どころのパターンがあるので、そこから始めてみようと思います。
ビーコンと呼ばれるパターンと、オクタゴンと呼ばれるパターンです。


パターンの Save/Load は、あとから考えるとして、以下のパターンをコードで作ってしまうことにします。
コード的には、setup のための closure を定義しました。
extension LGBoard {
static let setupBlinker: BoardSetupClosure = { index in
let liveCells = [CellIndex(x: 4, y: 2),
CellIndex(x: 4, y: 3),
CellIndex(x: 4, y: 4),
CellIndex(x: 4, y: 5),
CellIndex(x: 4, y: 6),
]
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
}
}
世代を進めるAPI
最初は、ボタンを押すごとに 1世代進むように実装していきます。
例によって、テストから考えます。
世代が進むことによって変化するパターンは、大きく以下の4パターンです。
・.alive -> .alive
・.alive -> .dead
・.dead -> .alive
・.dead -> .dead
LifeGame のルールを考慮して以下のようなテストを作りました。
LGBoard に対して、 doPrep で次世代状態を計算させて、moveToNextGen で実際に遷移させると言うメソッドを想定しています。
初期化時に、3×3 ボード状のセルを .alive/.dead に適切に設定し、(1,1) にある セルの次世代が期待する状態かどうかを確認しています。
func test_proceedGeneration_deadTodead() async throws {
let sut = LGBoard(numX: 3, numY: 3) { _ in return .dead }
guard let current = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
XCTAssertEqual(current, .dead)
await sut.doPrep()
await sut.moveToNextGen()
guard let next = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
XCTAssertEqual(next, .dead)
}
func test_proceedGeneration_aliveTodead() async throws {
let sut = LGBoard(numX: 3, numY: 3) { _ in return .alive }
guard let current = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
XCTAssertEqual(current, .alive)
await sut.doPrep()
await sut.moveToNextGen()
guard let next = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
XCTAssertEqual(next, .dead)
}
func test_proceedGeneration_deadToalive() async throws {
let sut = LGBoard(numX: 3, numY: 3) { cellIndex in
let liveCells = [CellIndex(x: 0, y: 0),
CellIndex(x: 1, y: 0),
CellIndex(x: 2, y: 0),
]
if liveCells.contains(cellIndex) { return .alive }
return .dead
}
guard let current = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
XCTAssertEqual(current, .dead)
await sut.doPrep()
await sut.moveToNextGen()
guard let next = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
XCTAssertEqual(next, .alive)
}
func test_proceedGeneration_aliveToalive() async throws {
let sut = LGBoard(numX: 3, numY: 3) { cellIndex in
let liveCells = [CellIndex(x: 0, y: 0),
CellIndex(x: 1, y: 0),
CellIndex(x: 1, y: 1),
]
if liveCells.contains(cellIndex) { return .alive }
return .dead
}
guard let current = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
XCTAssertEqual(current, .alive)
await sut.doPrep()
await sut.moveToNextGen()
guard let next = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
XCTAssertEqual(next, .alive)
}
Note: 本来であれば、sut.status の返り値の optional チェックについては、XCTUnwrap で行いたいのですが、現時点では XCTUnwrap は、非同期に対応していないので、guard let でチェックしています。
テストはかけたので、このテストが通るように、LGBoard, LGCell を実装していきます。
同時並行的に LGCell の State を変更してしまうと不整合が発生してしまうので、計算は LGBoard 側で行うようにします。
ですので、LGCell への追加は単純なメソッドです。
LGCell の doPrep メソッドは、与えられた LGCellState を次の世代の State として nextState に保持します。
moveToNextGen は、nextState として保持されている状態を state に移すことで、状態を次世代のものに遷移させます。
func doPrep(_ nextState: LGCellState) {
self.nextState = nextState
}
func moveToNextGen() {
guard let nextState = nextState else { fatalError() }
self.state = nextState
self.nextState = nil
}
上記の LGCell のメソッドを使うように、LGBoard を実装します。
doPrep では、すべてのセルの周囲の状態を確認し、次の世代の状態を決定し、セルにセットします。
moveToNextGen では、すべてのセルを次の世代の状態に遷移させます。
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:
if (nbrNumOfAlive == 2)||(nbrNumOfAlive == 3) {
nextState = .alive
} else {
nextState = .dead
}
case .dead:
if nbrNumOfAlive == 3 {
nextState = .alive
} else {
nextState = .dead
}
}
cell.doPrep(nextState)
}
}
func moveToNextGen() {
for key in cells.keys {
cells[key]?.moveToNextGen()
}
}
周囲のセルの数をカウントする countAliveInNbrCells メソッドを作ってあったので、doPrep もシンプルな処理になっています。
上記実装で、テストをパスするようになります。
Refactoring
LGboard の doPrep の実装での if 文は、ルールに沿ってそのまま書いているのですが、少し冗長に見えるので、3項演算子を使って、以下のように書き直しました。
書き直した後もテストはパスするので、書き換えで問題が発生していないことも確認できます。
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)
}
}
3項演算子は、以下の記事で説明してます。
[Swift] 三項演算子の使い方
世代を進めるためのGUI
モデルを次世代へすすめることはできるようになりましたが、そのきっかけを外部から与える必要があります。
将来的には、タイマー等を使って、自動で世代が進むようにもしたいのですが、まずは、ボタンを使って、次世代へ進められるようにします。
ViewModel の修正
View からは、ViewModel 経由で Model にリクエストすることになりますので、ViewModel にも Model である LGBoard の API と同様のメソッドを追加します。
ここでは、実行の終了が確認できるように async なメソッドとして定義しています。
class LGViewModel: ObservableObject {
var board: LGBoard
@Published var cells: [CellIndex:LGCellState] = [:]
// ....omit....
func doPrep() async {
await board.doPrep()
}
func moveToNextGen() async {
await board.moveToNextGen()
}
}
async と sync の使い分け
doPrep / moveToNextGen いずれも sync なメソッドとして定義して、メソッド内部で Task を使用して async なメソッドを呼ぶことも実装としては可能です。
ですが、LifeGame では、doPrep が完了したことを ViewModel 側からも検知できることが必要となります。
検知できないと、doPrep の終了を待たずに、moveToNextGen を呼び出してしまうかもしれません。(結果としては大抵の場合はうまく動作する気がしますが、競合が起こりえる状況になってしまいます)
ということで、doPrep/moveToNextGen のいずれも async なメソッドとして、ViewModel に追加しています。
Viewの修正
Model/ViewModel へは、必要なメソッドを追加しているので、あとは 次世代へ進めるためのボタンを追加すれば OK なハズです。
LGBoardView に Button を追加していきます。
Button を盤面の下側に表示するために、VStack を使いました。
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)
LGCellView(cellState: viewModel.cells[cellIndex] ?? .alive)
}
}
}
}
Button(action: {
Task {
await viewModel.doPrep()
await viewModel.moveToNextGen()
await viewModel.updateCells()
}
}, label: {Text("NextGen")})
}
.onAppear {
Task {
await viewModel.updateCells()
}
}
}
}
以下のような外観になりました。

特に、macOS 限定の機能は使用していないので、(セルの大きさにだけ気をつければ) iOS 上でも同様に動作します。

アニメーション
実際に動作させると、以下のようになります。
せっかく(?)なので、各セルが変更されるときに、カードが裏返るようなアニメーションを入れてみます。
LGCellViewのアニメーション対応
実際には、LGCelLView の修正が必要です。
現在の LGCellView は、与えられた状態に対応するイメージを表示するという実装です。
アニメーションさせるには、”何かが変更される”ということが必要となります。
アニメーションさせたい時は、LGCellView の持っている LGCellState が変更されているハズなので、その変更を契機にアニメーションさせていくことにします。
また、変更されるものを参照して どのようにアニメーションするかの設定も必要となります。
アニメーションについては、以下の記事でも使用している rotation3DEffect を使って、アニメーションさせていきます。
[SwiftUI] サイコロを振ってみる (SwiftUI アニメーションの練習)
cellState の変更を契機に アニメーションするように変更した LGCellView は、以下です。
struct LGCellView: View {
let cellState: LGCellState
var body: some View {
Image(systemName: symbolName)
.resizable().aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50)
.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"
}
}
}
以下のようなアニメーションになります。(LGCellView 1つだけを表示するアプリで動作確認してます)
LGBoard でアニメーション
あとは、実際の CellState の変更を withAnimation で囲うとうまくいくはずなのですが、問題が発生します。
現在の実装で、CellState を変更しているのは、以下の箇所です。具体的には、”await viewModel.updateCells()” の行です。
Button(action: {
Task {
await viewModel.doPrep()
await viewModel.moveToNextGen()
await viewModel.updateCells()
}
}, label: {Text("NextGen")})
この箇所を withAnimation で囲おうとすると、Concurrency はサポートしていないと言うエラーになります・・・
ということで、別の方向からアニメーションを設定することが必要となります。
変数をチェックして View をアニメーションさせる ViewModifier (.animation) を使用します。
以下のように、LGCellView に cellState を引数として設定して付与することで、 LGCellView をアニメーションさせることができます。
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)
}
}
}
}
Button(action: {
Task {
await viewModel.doPrep()
await viewModel.moveToNextGen()
await viewModel.updateCells()
}
}, label: {Text("NextGen")})
}
.onAppear {
Task {
await viewModel.updateCells()
}
}
}
}
.animation に渡すために、viewModel.cells[cellIndex] を、いったん 変数に受けています。
このようにすることで、以下のようにアニメーションしながら、表示が切り替わるようになります。
次回以降
少し遊んでいると、毎回、”NextGen” ボタンを押すのに疲れます。
次回は、指定した秒数ごとに 自動で進化させるような機能を実装してみます。
まとめ
LifeGame のモデルを進化させる機能を実装し、アニメーション表示できるようにしました。
- Concurrency を使っていると、withAnimation 指定できないケースがある
- .animation を使うと、監視する変数を指定してアニメーションできる
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
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