swiftのコマンドラインで動くライフゲームの作り方を解説します。
ライフゲームとは、セルを生命に見立てたシミュレーションゲームです。
ライフゲームがわからない方は、ライフゲームをスマホのアプリで探してみて下さい。プレイできるものが見つかるはずです。
詳しくは、ウィキペディア「ライフゲーム」を、読んでください。
今回は、コマンドラインでゲーム画面が表示される、簡単で分かり易い物を作ります。
作りたくない方は、GitHubのここから落として下さい。
こんな感じです。生存は⬛️、死は⬜️、で表示しています。
|0|1|2|3|4|
⬜️⬜️⬜️⬜️⬜️:0
⬜️⬜️⬜️⬜️⬜️:1
⬜️⬛️⬛️⬜️⬛️:2
⬛️⬜️⬜️⬜️⬜️:3
⬜️⬛️⬜️⬜️⬜️:4
導入する動作は、以下の4つです
1.マップを生成
2.画面を表示
3.次の世代に進める
4.特定のマスを操作
5.コマンドラインで遊べる
##Conway's Game of Lifeのルール
ルールを確認しておきます。
自身のセルに、隣接し生存しているセルの数によって、「生」「死」が決まります
- 誕生 生存セル3つと隣接している -> セルに生命が誕生
- 生存 生存セル2つと隣接しており、自身が生存している -> 次世代でも生存を継続
- 過疎 隣接する生存セルが、1以下 -> 次世代では死滅
- 過密 隣接する生存セルが、4以上 -> 次世代では死滅
##基礎的データ構造について lifeData
今回は、説明を省くため、多重配列
を使用します。
配列の中に配列が入っています。外側の配列をx軸。内側の配列をy軸として扱います
例えば、左上0行目の左から3列目の値を取り出すときは、配列名[0][3]
となります。
内部の型は、真理値
です
trueが生存
falseが死です
//ライフゲームの基礎データ[X軸[Y軸]] 要素0で初期化するなら初期値は不要
var lifeData:[[Bool]]
##マップを生成する mapCreate
マップを生成に、必要なものは、2つです。
- マップの大きさを決める。 今回は、最大でも50*50マス程度にします
- 初期値の基準 ランダムなのか、空白なのか、すべて生存で埋まっているのか。
引数として受け取ります。今回の場合、初期値は、ランダムであった方が楽です。
そのためデフォルト値をクロージャー{Bool.random()}
で、与えています。
これにより、呼び出しの際ランダム生成の場合、マップの大きさを入力するだけになっています。
//マップを生成してくれる 引数 X軸,Y軸,値生成方法(デフォルはランダム)省略可
func mapCreate(Xjiku x:Int,Yjiku y:Int,seisei s:()->Bool = {Bool.random()} ) -> [[Bool]] {
//上書きしてしまうので、初期値を入れたほうが安全
var map = [[Bool]]()
for _ in 0..<x {
//一度に列を入れるために一度変数に入れる。
var yjiku = [Bool]()
for _ in 0..<y {
//値生成部分
yjiku.append(s())
}
map.append(yjiku)
}
return map
}
lifeData = mapCreate(Xjiku: 10, Yjiku:10 )
print(lifeData)
実行結果
[[true, false, true, true, true], [true, true, false, true, false], [true, false, true, true, false], [true, true, false, false, true], [false, false, false, false, false]]
見にくいですよね。これを見やすくしています。
##画面を表示 lifeView
コンソールに表示するには、標準出力print
を使って並べて行きます。
前にも書きましたが、生存は⬛️、死は⬜️を使って表現します。
表番号がついていないと、指示しづらいので、表番号をつけます。
ただし、コンソールで行っているので、多少のズレは発生します。
最後に、現在の生存セル数を表示します。
これをしておかないと、変化があったのか分かりづらいですから必要です。
構造は、一行分のデータを表示したら改行し次の行に行きます。これの繰り返しです。
一行目は、列番号を書き込みます。lifeDataの外側の配列の個数(count)を基準にします
lifeDataを受け取り、1セルづつ生死を確認して行きます。端まできたら、行番号を振って改行します。
死んでいる状態の時でも、セルを設置するのが、四角く表示するコツです。
//ブロック状に表示してくれる。
func lifeView(world w:[[Bool]]) {
print("現在の世界を表示します")
//今回は生存は、黒、絶滅は白の記号で表示していく
let life = "⬛️"
let death = "⬜️"
//生存者集を計算数変数
var ikinokori = 0
print("|", separator: "", terminator: "")
for y in 0..<w[0].count{
//列番号の表示 きれいに表示されるのは,10*10くらいまで
print("\(y%10)|", separator: "", terminator: "")
}
print("")
//ループを回して、マップを読み込む
for y in 0..<w[0].count {
for x in 0..<w.count{
//値を把握して、どちらを表示するか決める
if w[x][y] == true {
ikinokori += 1
print(life, separator: "", terminator: "")
}else{
print(death, separator: "", terminator: "")
}
}
//改行コード 端まできたら改行する
//行番号の表示
print(":\(y)", separator: "", terminator: "\n")
}
print("現在生き残りは、\(ikinokori)です。約\(ikinokori*100/(w.count * w[0].count))%です。")
}
lifeView(world: lifeData)
実行結果
|0|1|2|3|4|
⬛️⬛️⬜️⬛️⬛️:0
⬜️⬛️⬛️⬛️⬜️:1
⬛️⬜️⬜️⬜️⬛️:2
⬛️⬛️⬜️⬜️⬛️:3
⬜️⬜️⬛️⬛️⬜️:4
これで見慣れたゲーム画面になってきました。
次から、世代を導入します。
##次の世代に進める nextLife
次の世代に進めるには、いくつかルールの違いがありますが、今回は、最も基礎ルールで行きます。
つまり、Conway's Game of Lifeのルールで示した4つのルールです。
- 誕生 生存セル3つと隣接している -> セルに生命が誕生
- 生存 生存セル2つと隣接しており、自身が生存している -> 次世代でも生存を継続
- 過疎 隣接する生存セルが、1以下 -> 次世代では死滅
- 過密 隣接する生存セルが、4以上
そのため、端は死のセルであるとして扱われます。
今回は、楽をするために2つだけ変わった組み方をします。
次世代のlifeDataをすべて死んだ状態からにすることで、誕生と生存だけ調べるだけで済むようにします。
セル隣接数マップkamitudo
を導入します。[[Int]]型で、lifeDataよりひと回り大きい配列です。
隣接するセル数を記録します。
どのようにするか、まず三つの同じ大きさのmapを用意します。
- 現在のlifeMap:引数として読み込んだもの
- 未来のlifeMap:返値として利用するもの
- セル隣接数マップ(kamitudo):周辺の生存セル数を表示するもの
セル隣接数マップ(kamitudo)大きさを左右前後に1マスづつ拡大します。
現在のlifeMapを縦横にひとマスずらして
対応させます。現在のlifeMapでは、[2][1]の位置に有ったものを、[3][2]の位置にあるとして扱うと言うことです。
そして、現在のlifeMapを読み込み、生存しているのであれば、セル隣接数マップの8方向セルに、数値を加算します。すべてのセルに対して行います。加算し終わったなら、kamitudoから隣接情報を読み込み、ルールに基づき、次世代セルに書き込みます。
端のデータは、対応する次世代データを作る際に読み込まないので、現在は、対応する必要はありません。
これら方法を取ることでの利益は、計算が早くなることです。
過密度マップが存在しない場合、セル毎に毎回周辺を調査することになります。つまりセル一つにつき8方向から読み込まれます。
過密度マップがある場合、読み込みはセル一つにつき1度だけです。書き込みは、書き込みデータ量によるので、他の方法とほぼ変わりません。
過密度マップを拡大しておくことで、書き込みセルが存在するかを調べる必要がなくなります。各セルごとにnilチェックを入れなくて良くなります。
セルが存在しない場合、アクセスしてしまうとエラーが出ます。そのため、調査は必要ですが、拡大しておけば、マップ端にセルが確実に存在するので不要です。
//1ターン進める 今回は、生存条件を変更不可能にする。
func nextLife(world w:[[Bool]]) -> [[Bool]] {
//毎回読み込ませると時間がかかるので、定数として読み込ませる
let xCount = w.count
let yCount = w[0].count
//周辺の密度を保存する。型がIntのため、mapCreateを使わない。端っこかどうかの計算をなくすために、一マスづつ前後に大きくしています。両側ぶんで2足します
var kamitudo:[[Int]] = Array(repeating:{Array(repeating: 0, count: yCount + 2)}(), count: xCount + 2)
//返値を保存する場所 生命は減っていく傾向にあるのでfalse指定{false}。
var nextWorld = mapCreate(Xjiku: xCount, Yjiku: yCount, seisei: {false})
//引数worldを読み込み過密状況を調査する
for x in 0..<xCount {
for y in 0..<yCount{
//マスに生命が存在したら、周辺の過密度を上昇させる
if w[x][y] == true{
//過密度を書き込むループ 9方向に加算する
// ハードコード(直接書き込む事)したほうが早いが、読みづらいのでforループを使う
for i in 0...2 {
for t in 0...2{
kamitudo[x+i][y+t] += 1
}
}
//自分は隣接する個数に含まれないので、1減らす
kamitudo[x+1][y+1] -= 1
}
}
}
// kamitudo(過密度)に基づき生存判定をしていく
for x in 1...xCount{
for y in 1...yCount {
switch kamitudo[x][y] {
//3なら誕生
case 3 :
nextWorld[x-1][y-1] = true
//2なら、マスに生命がいれば生存させる
case 2 :
if w [x-1][y-1] == true {
nextWorld[x-1][y-1] = true
}
//それ以外は、基礎値でfalseのまま
default:
//xcodeのエラー抑止 *defaultに何も設定しないとエラーが出ます。
{}()
}
}
}
return nextWorld
}
lifeView(world: lifeData)
lifeData = nextLife(world: lifeData)
print("一年進めました")
lifeView(world: lifeData)
実行結果
今回は、変化を見るために二回実行しています。
現在の世界を表示します
|0|1|2|3|4|
⬜️⬜️⬛️⬜️⬛️:0
⬜️⬛️⬛️⬛️⬜️:1
⬛️⬛️⬛️⬜️⬛️:2
⬜️⬜️⬜️⬛️⬜️:3
⬜️⬜️⬛️⬜️⬜️:4
現在生き残りは 11です 約44%です
一年進めました
現在の世界を表示します
|0|1|2|3|4|
⬜️⬛️⬛️⬜️⬜️:0
⬛️⬜️⬜️⬜️⬛️:1
⬛️⬜️⬜️⬜️⬛️:2
⬜️⬜️⬜️⬛️⬜️:3
⬜️⬜️⬜️⬜️⬜️:4
現在生き残りは 7です 約28%です
中央付近が、過密で死亡しています
最も下のマスは、過疎で死亡しています。左上は、生命が誕生しています。
##特定のマスを操作 kamiNoTe
修正地点を受け取り、値を変更して上書きしています。
変更する規則は、クロージャ(無名関数)で受け取ります。デフォルトで反転になっています。
特定の値にしたい場合
正にする {_ in true}
偽にする {_ in false}
で、変えることが可能です。 _ in
は引数を受け取っても使わないと言う指定です。
デフォルトの{!$0}は、!
は反転。$0
は、第一引数の意味です。
クロージャは、返値が{}のなかに1つしか無い場合returnは不要
です。
lifeDataを全面的に作り直すと、効率が良く無いのでinout
を指定して、参照渡し
にしています。
参照渡しとは、データそのもの受け取ると言うことです。
対義語は、値渡し(あたいわたし)です。swiftの場合、ほぼ全ての場合で、値渡しです。
値渡しは、データがコピーされて渡されます。オリジナルに影響を与えないことが、利点です。
値渡しのデータは、変更しても、オリジナルに影響はありませんが、参照渡しの値は、オリジナル(参照元)が変更されます。データの消失に気をつけて下さい。
swiftで参照渡しを行う場合、仮引数の型に、inout
属性を与え、呼び出す際には、実引数に&
をつけて、利用します。
//特定のマスを指示してデータを操作する関数 worldは現在の状態、pointは編集する場所(X軸,Y軸)、sayouは、セルに行う操作 デフォルトは、反転
func kamiNoTe(world w :inout [[Bool]],point p :(Int,Int),sayou s:(Bool)->Bool = {!$0}) {
w[p.0][p.1] = s(w[p.0][p.1])
}
//一番下の行を反転させる
lifeView(world: lifeData)
print("一番下の行を反転させます")
//一番下の行を反転させる
for i in 0..<lifeData[0].count {
//&をつけて、参照渡し。
kamiNoTe(world: &lifeData, point: (i,lifeData.count - 1))
}
実行結果
現在の世界を表示します
|0|1|2|3|4|
⬜️⬜️⬛️⬜️⬛️:0
⬜️⬛️⬛️⬛️⬜️:1
⬛️⬛️⬛️⬜️⬛️:2
⬜️⬜️⬜️⬛️⬜️:3
⬜️⬜️⬛️⬜️⬜️:4
現在生き残りは、11です。約44%です。
一番下の行を反転させます
現在の世界を表示します
|0|1|2|3|4|
⬜️⬜️⬛️⬜️⬛️:0
⬜️⬛️⬛️⬛️⬜️:1
⬛️⬛️⬛️⬜️⬛️:2
⬜️⬜️⬜️⬛️⬜️:3
⬛️⬛️⬜️⬛️⬛️:4
現在生き残りは、14です。約56%です。
一番下の行が反転しています。
これで基本的機能の説明は終わりです。
これを使ってコマンドラインでのゲーム化に入ります。
##コマンドラインで遊べる gameMode
コマンドラインでは、標準入力(readLine)を使って操作します。
今回はXcode
上で実行するので右下のコンソールエリア
に、入出力します。
ココに入力っとなっている場所です。
コンソールエリアに、文字列を入力することでゲームを操作します。
この際、入力を求める内容を忘れないようにします。何が起きているのか分からなくなりますからね。
ゲームの流れを説明します。
まず、数字の入力を求めます。
ゲームマップを大きさを決めます
ライフゲームの操作に入ります。
できる操作は、以下の5つです。
- next:次の時代に進みます
- change:対象のマスを変更します
- changeAll:すべてを変更します
- view:現在の状態を表示します 即時実行されます
- exit:終了します
以上の機能を実装します。
readLineについて、
コンソールに入力された文字列を、入手できる関数です。
使用するとコンソールが入力待機状態になります。そのため、文字入力を求める前に、何を求めるのか説明する必要があります。
次に、文字列がどのようなものかからないため注意する必要があります。何も入力されていないnil
であったりすると、プログラムが止まる原因になります。nil合体演算子 ??を使って安全に利用しています。
オプショナル(nilの可能性のある値) ?? 代替値
で利用します。オプショナルとは、nilになる選択肢(オプション)がある値を示すものです。
readLineの使い方について、ユーザーの行動によって、動作が変わるため、有効な値と無効な値の反応を作る必要があります。今回は、無効な値の場合、再度やり直す構造にします。有効な値を受け取るまで、同じ動作を繰り返すようにします。repeat_while文を使用します。
repeat {プログラム}while 条件
条件が、真の場合に繰り返します。今回は、入力が異常値だった場合、繰り返しています。
ただし、readLineを呼ぶなど、入力待機中になるコードの場合、実行中で止められなくなってしまう場合があるので、注意して利用して下さい。
swiftの場合、main.swiftの最後まで行けば動作を停止しますので、ループを抜けらようにしておきます。
終了した際にはProgram ended with exit code: 0
とコンソールに表示されるので、見てみて下さい。
//世界の大きさ
var ookisa:Int = 0
//ゲームモードのマップ
var gameMap:[[Bool]]
repeat {
print("数字を入力してください1~50まで")
//readLineで入力を受け付ける
let readOokisa = readLine() ?? "0"
ookisa = Int(readOokisa) ?? 0
}while ookisa == 0 || ookisa > 50
print("\(ookisa)を受け取りました。マップを製造します")
gameMap = mapCreate(Xjiku: ookisa, Yjiku: ookisa)
lifeView(world: gameMap)
//操作するループ next change changeAll view exti
//文字入力用文字列
var readString = ""
repeat{
print("操作を英字で入力して下さい。\n next:次の時代に進みます \n change:対象のマスを変更します \n changeAll:すべてを変更します \n view:現在の状態を表示します 即時実行されます \n exit:終了します")
readString = readLine() ?? ""
//switch文で条件分岐
switch readString {
case "next":
var readKaisuu = ""
var nextkaisuu = 0
repeat {
print("どれくらい進めますか?1回以上")
readKaisuu = readLine() ?? "0"
nextkaisuu = Int(readKaisuu) ?? 0
}while nextkaisuu == 0
for _ in 0..<nextkaisuu{
gameMap = nextLife(world: gameMap)
}
case "change":
//x軸
let xMax = gameMap.count
var xjiku:Int = xMax
repeat {
print("x軸を入力して下さい。最大値は\(xMax - 1)です")
let readX = readLine() ?? ""
xjiku = Int(readX) ?? xjiku
}while xjiku >= xMax
//y軸
let yMax = gameMap[0].count
var yjiku:Int = yMax
repeat {
print("y軸を入力して下さい。最大値は\(yMax - 1)です")
let ready = readLine() ?? ""
yjiku = Int(ready) ?? yjiku
}while yjiku >= yMax
//操作部
print("x:\(xjiku) y:\(yjiku)を、反転させます")
kamiNoTe(world: &gameMap, point: (xjiku,yjiku))
case "changeAll":
print("世界を再構成します")
//新たにマップを作って上書きする。
gameMap = mapCreate(Xjiku: ookisa, Yjiku: ookisa)
case "view":
lifeView(world: gameMap)
case "exit":
print("終了します")
default:
print("指示を理解できません")
}
//exitが入力されない限り繰り返す
}while readString != "exit"
実行結果
全ての操作を行ってみます。
ここからコードを配布していますので、自分の環境で実行してみた下さい
数字を入力してください1~50まで
3
3を受け取りました。マップを製造します
現在の世界を表示します
|0|1|2|
⬛️⬜️⬛️:0
⬜️⬜️⬛️:1
⬜️⬛️⬛️:2
現在生き残りは、5です。約55%です。
操作を英字で入力して下さい。
next:次の時代に進みます
change:対象のマスを変更します
changeAll:すべてを変更します
view:現在の状態を表示します 即時実行されます
exit:終了します
next
どれくらい進めますか?1回以上
1
操作を英字で入力して下さい。
/*省略*/
view
現在の世界を表示します
|0|1|2|
⬜️⬛️⬜️:0
⬜️⬜️⬛️:1
⬜️⬛️⬛️:2
現在生き残りは、4です。約44%です。
操作を英字で入力して下さい。
/*省略*/
changeAll
世界を再構成します
操作を英字で入力して下さい。
/*省略*/
view
現在の世界を表示します
|0|1|2|
⬜️⬜️⬜️:0
⬜️⬛️⬜️:1
⬜️⬜️⬜️:2
操作を英字で入力して下さい。
/*省略*/
change
x軸を入力して下さい。最大値は2です
0
y軸を入力して下さい。最大値は2です
3
y軸を入力して下さい。最大値は2です
2
x:0 y:2を、反転させます
操作を英字で入力して下さい。
/*省略*/
view
現在の世界を表示します
|0|1|2|
⬜️⬜️⬜️:0
⬜️⬛️⬜️:1
⬛️⬜️⬜️:2
現在生き残りは、2です。約22%です。
操作を英字で入力して下さい。
/*省略*/
error
指示を理解できません
操作を英字で入力して下さい。
/*省略*/
exit
終了します
Program ended with exit code: 0
##終わりに
一通り動作しました。これでsimpleライフゲームの完成です。説明も終わります。
コンソールで遊ぶ以上テキストベースにはなってしまいますが、今回作ったコードUIViewなどと組み合わせて使えば、リアルタイムで反応するlifeGameになります。リアルタイムに動いているようなゲームでも、根底にあるのは、データの変更です。アクションゲームもパズルゲームもその点では何も変わりません。
今回は非常簡単なデータ構造[[Bool]]
でしたが、それなりにゲームになっていますよね?