はじめに
どうも、y-tetsuです。
Pythonを使ったコンソール操作の練習として、ライフゲームを作ってみました。コンソール画面制御のため、エスケープコードに少し馴染んだので、メモがてら記事にしたいと思います。また、コマンドライン引数を取得するのにargparseも便利だったので、それについても少しメモしてます。
簡単なロジックを組むだけで、予想もしない複雑な動きが見られるのがライフゲームの面白いところだと思います。そんなライフゲームの魅力も、合わせてお伝えできればいいなと思います。
ライフゲームの仕様については、以下のWikipediaに詳しく書かれていますので、ご存じないよという方はご参照ください。(本記事では実装にしか触れません)
できたもの
今回Pythonを使ってスクリプトを書きました。これを実行すると、以下のようにコンソール(コマンドプロンプト)上で、ライフゲームの世界を堪能できます。
スクリプトは以下のgithubのリポジトリから取得できます。
作ったもののポイント
コンソール制御
コンソール上でライフゲームの画面を実装するにあたり、以下を使っています。
- 画面消去
- カーソルの表示/非表示の切り替え
- カーソル移動
- 文字の色付け
コマンドライン引数
引数の使い方は以下を取り上げています。
- 引数指定
- オプション指定
オリジナル機能
今回のスクリプトはオプション指定により、以下のオリジナル機能を試せるようになっております。
- 「閉じた世界 ⇔ トーラス状の世界」の切り替え
- 「寿命ルールの有効⇔無効」の切り替え
- 「進化ルールの有効⇔無効」の切り替え
などなど。
それでは、追ってご説明したいと思います。
プログラム構成
プログラムの基本構成としては、大きく以下の2つのクラスを用意しました。
- ゲーム部分を司る、
GameOfLife
クラス - コンソールの制御を司る、
Console
クラス
ライフゲームの管理
まず最初に、ライフゲームの実装内容として、GameOfLife
クラスについて説明します。
初期化
GameOfLife
クラスの初期化についてです。
import time
from random import random
class GameOfLife:
def __init__(self, x=30, y=15, max_step=100, wait_time=0.05):
self.x = x
self.y = y
self.world = [[1 if random() < 0.5 else 0 for _ in range(x)]
for _ in range(y)]
self.max_step = max_step
self.wait_time = wait_time
self.dirs = [
(-1, -1), (0, -1), (1, -1),
(-1, 0), (1, 0),
(-1, 1), (0, 1), (1, 1),
]
self.step = 1
self.console = Console(self.x, self.y, 'game_of_life', '■')
引数にて、画面のX方向のサイズをx
、Y方向のサイズをy
で指定可能としています。また、max_step
は何世代まで表示させるかを決めます。画面更新の待ち時間を調整する場合はwait_time
の時間(s)を変更してください。
セルの状態はself.world
にて2次元リストで保持しています。初期配置はrandom()
にて、0(死)または1(生)のいずれかを50%の割合でランダムに設定しています。
self.dirs
はセルの周囲8マスをチェックするための座標の移動量を表しています。
ゲームの開始
GameOfLife
クラスをインスタンス化した後、start()
メソッドにて、ゲームを開始します。
if __name__ == '__main__':
GameOfLife().start()
def start(self):
try:
self.console.setup()
while True:
self.console.display(self.world, self.step)
if self.step == self.max_step:
break
self.update()
self.wait()
except KeyboardInterrupt:
return
finally:
self.console.teardown()
まず、コンソールの準備をself.console.setup()
にて行います。(詳細は後述します)
その後は基本的に、max_step
回以下を繰り返します。
- 画面の表示(
self.console.display()
) - 次の世代への内部状態の更新(
self.update()
) - 次の表示待ち(
self.wait()
)
キーボードからCtrl+C入力で中断できるように、KeyboardInterrupt
をキャッチした場合return
で抜けます。
処理終了時は、self.console.teardown()
にて必ずコンソールの状態を元に戻すようにしています。
状態の更新
元のセルの状態を元に、次のセルの状態を計算する部分です。
def update(self):
new_world = [[self.new_cell(x, y) for x in range(self.x)]
for y in range(self.y)]
self.world = [[x for x in row] for row in new_world]
self.step += 1
def new_cell(self, x, y):
alive = self.count_alive(x, y)
if self.world[y][x]:
return 1 if alive == 2 or alive == 3 else 0
return 1 if alive == 3 else 0
def count_alive(self, x, y):
alive = 0
for dx, dy in self.dirs:
next_x, next_y = x + dx, y + dy
if (0 <= next_x < self.x) and (0 <= next_y < self.y):
if self.world[next_y][next_x]:
alive += 1
return alive
各セルを順に検索して、それぞれ8方向に1(生)のマスが何個含まれているか探します。上記のコードは2次元リストのxyの範囲外の場合は0(死)と扱っています。
次のセルの判定条件は以下としています。
- 対象セルが1(生)の場合、周囲に1のセルが2or3個含まれる場合は1のままとし、それ以外は0
- 対象セルが0(死)の場合、周囲に1のセルが3個含まれる場合は1とし、それ以外は0のまま
待ち時間
timeパッケージのsleep関数を使って待ち時間を作っています。
def wait(self):
time.sleep(self.wait_time)
簡単のため独自に追加した機能は省略します。以上で、ライフゲームのロジック説明は終了です。続いては、コンソール制御の説明に移ります。
コンソール制御について
ここでは、コンソール制御を司るConsole
クラスについて説明します。
描画の方針
ゲーム画面の描画を行うにあたり、当初は以下のcls
コマンドで画面消去後に、printで再描画する方法で実現していました。
import os
os.system('cls')
ですが、この方法ですと見てわかる通り、
結構画面がちらつくんですね。
目がチカチカして見てられませんでした…。そして結構遅い。(皆さんもあまり見過ぎないようにご注意ください)
そこで、今回はANSIエスケープコードを用いて、画面の表示はそのままにし、カーソルを移動させ上書きする方針としました。なお、ANSIエスケープコードは、コンソールの画面を制御するための特殊な文字のことで、以下のサイトを参考に情報収集しました。
それでは、いざ!とエスケープコードを送ってみたのですが、当方の環境では動かず。調べてみると、Windowsのコマンドプロンプトでは通常はエスケープコードが効かないらしい。最終的に、以下のコードを入れて、エスケープコードを有効化するとうまくいきました。
from platform import system
from ctypes import windll
class Console:
def __init__(self, x, y, name, alive):
self.x = x
self.y = y
self.name = name
self.alive = alive
if 'win' in system().lower():
self.enable_win_escape_code()
def enable_win_escape_code(self):
kernel = windll.kernel32
kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7)
上記の enable_win_escape_code()
メソッド実行後に、以下のコードでカーソル位置を移動させられます。
def cursor_up(self, n):
print(f'\033[{n}A', end='')
意味は、n行上にカーソルを移動となります。
なお、クラスの引数の説明は以下です。
引数 | 用途 |
---|---|
x | X方向の表示サイズ |
y | Y方向の表示サイズ |
name | ライフゲームの名前 |
alive | 生のセルの印字文字 |
描画のちらつきはマシになったものの、まだ気になる点がありました。
地味にカーソルがちらついてます…。
ということで、以下でカーソルの表示と非表示を切り替えることにしました。
def cursor_hyde(self):
print('\033[?25l', end='')
def cursor_show(self):
print('\033[?25h', end='')
一応これで、それなりに見られる形には落ち着きました。
セットアップと終了時処理
コンソール制御の開始時は、setup()
メソッドを呼び、画面のクリアとカーソル消去。終了時はtestdown()
メソッドによりカーソル表示をするよう扱います。
def setup(self):
self.clear_screen()
self.cursor_hyde()
def teardown(self):
self.cursor_show()
def clear_screen(self):
print("\033[;H\033[2J")
clear_screen
は、カーソル位置を一番左上に移動(\033[;H) ⇒ 全画面消去(\033[2J) のエスケープシーケンスになっています。
セルの表示
ディスプレイ表示は以下のdisplay()
メソッドにて行っています。XYサイズに合わせて、枠線とセル印字する例です。
def display(self, world, step):
if step > 1:
self.cursor_up(self.y + 4)
self.display_title()
self.display_world(world)
self.display_step(step)
def display_title(self):
print(f'{self.name} ({self.x} x {self.y})')
def display_world(self, world):
print('┌' + '─' * (self.x * 2) + '┐')
line = []
for y in range(self.y):
cells = ''
for x in range(self.x):
cells += self.alive if world[y][x] else ' '
line.append('│' + cells + '│')
print('\n'.join(line))
print('└' + '─' * (self.x * 2) + '┘')
def display_step(self, step):
print(f'step = {step}')
色付け
エスケープコードを使って文字に色を付けることもできます。
今回は以下を使いました。
'\033[38;2;{r};{g};{b}m'
{r}、{g}、{b}には0~255の値を入れて好きな色に設定できました。
また、一度エスケープコードを呼ぶと、以後は同じ色で文字が表示されますので、以下にて色を元に戻しています。
'\x1b[39m'
例えば、以下のprint文を実行すると、
print('\033[38;2;0;153;68m' + '緑')
print('\033[38;2;230;0;18m' + '赤')
print('\033[38;2;235;202;27m' + '黄')
print('\x1b[39m' + '戻す')
のように色が付きます。
コマンドライン引数の取得
ライフゲームをいざ作ってみると、画面のサイズや表示の回数など、条件を変えて色々試してみたくなりました。そこで、今回はargparseで取得した引数により、自分好みのライフゲームを楽しめるようスクリプトを書くことにしました。
公式ドキュメントは以下。
全く使ったことがなかったもので、引数の取得ってこんなに簡単にできたんだ!!と驚きでした。
ここでは、使ったものだけを軽く紹介します。
parserの準備
まず、argparseのインポートとパーサを準備します。
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(
description='A game of life simulator on CLI')
descriptionに説明文に説明文を入れています。これだけで、ヘルプオプションにすでに対応していて、以下の挙動になります。すごい!!
> py game_of_life.py -h
usage: game_of_life.py [-h]
A game of life simulator on CLI
options:
-h, --help show this help message and exit
引数の追加
引数の追加は、先ほどのコードに続いて以下の記述で対応できます。
parser.add_argument('sample', nargs='?')
第一引数の入力をsample
として取得します。nargs='?'
により、指定なしを許可しています。
ここまでの、ヘルプ表示は以下。sampleが追加されていますね。
> py game_of_life.py -h
usage: game_of_life.py [-h] [sample]
A game of life simulator on CLI
positional arguments:
sample
options:
-h, --help show this help message and exit
オプションの追加
今回は、オプション指定のパターンで大きく、「引数を取るもの」と「フラグとして使うもの」の2通りを使いました。
引数を取るもの
以下の記述で対応できます。
parser.add_argument('-n', '--name')
上記で、-n <名前> または --name <名前>で名前を取得することができるようになりました。
引数の型も指定できて、例えば整数値を取りたい場合は、以下の記述で対応できました。
parser.add_argument('-x', type=int)
フラグとして使うもの
action
を指定することで、フラグとして使えます。
parser.add_argument('-l', '--loop', action="store_true")
store_true
とした場合、オプション指定時はTrue、そうでない場合はFalseとなります。また、store_false
とした場合はその逆です。
取得した引数へのアクセス
以下で、引数のパースを実施し結果を格納します。
args = parser.parse_args()
print(args)
print(args.x)
表示結果は以下のようになります。
> py game_of_life.py -n test -x 10 --loop
Namespace(sample=None, name='test', x=10, loop=True)
10
今まで、sys.argv
をコネコネしていたのは何だったんだろうと思います。最後は、今回作ったスクリプトの紹介をさせて下さい。
スクリプト紹介
ダウンロード
下記リンクより、game_of_life.pyとsample.jsonを任意のフォルダにコピーして下さい。
※あらかじめPython 3.9以上をインストールしておいてください。
※Window10での動作のみ確認しております。
実行方法
以下のようにスクリプトを実施してください。[]
は任意。
py game_of_life.py [オプション] [サンプルパターン名]
pyコマンド(Windows用)の代わりにpythonでも動作します。
スクリプトの実行を中断する場合は、Ctrl+Cを押してください。
サンプル
引数にサンプルパターンを指定することで、いくつかのサンプルを眺めることができます。
ランダム
サンプルパターン名なしで実行した場合は、ランダムな初期配置となります。
> py game_of_life.py
グライダー
> py game_of_life.py glider
銀河
> py game_of_life.py galaxy
その他
サンプルパターン名に指定できるものの一覧です。是非お試しください。
名称 | サンプルパターン名 |
---|---|
ランダム | (指定なし) |
八角形 | octagon |
グライダー | glider |
銀河 | galaxy |
ダイハード | die-hard |
グライダー銃 | glider-gun |
各種オプション
無限ループ
-l
オープションを使うと、途中で停止することなく世代交代をいつまでも続けます。
> py game_of_life.py -l
トーラス世界
-t
オープションを使うと、上下と左右がそれぞれつながった、トーラス状の世界でライフゲームを実行できます。
> py game_of_life.py -t
寿命
-m
オプションを使うと、同じマスに居続けているセルが年を取るようになり、やがて寿命を迎えるようになります。固定タイプや振動タイプに新たな動きが加わります。
> py game_of_life.py -m
色進化
-c
オプションを使うと、新しく生まれるセルの色が、周囲の生きたセルの色に応じて、変化するようになります。
> py game_of_life.py -c
自作世界
下記の例のように、JSON形式で初期状態を自作し、それを読み込んで実行することができます。
{ "name": "game_of_life",
"x": 30,
"y": 15,
"world": [ [1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1],
[0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0],
[1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1],
[0, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1],
[0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1],
[0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1],
[0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1],
[0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0],
[1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0],
[0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0]],
"step": 100,
"wait": 0.05,
"delay": 0.0,
"alive": "■",
"ratio": 0.5,
"loop": false,
"torus": false,
"mortal": false,
"color": false}
設定ファイルとコマンドライン引数が重なった場合は、設定ファイルが優先されます。
> py game_of_life.py -j world.json
なお、ランダム実行(サンプルパターン名指定なし)の場合は、スクリプト実行後に初期状態のファイルが、カレントフォルダに自動保存されます。お気に入りのパターンが見つかったら、是非残しておきましょう!
> ls
world20221223205358.json
オプション一覧
使用可能なオプションの一覧を以下に示します。
オプション | 用途 |
---|---|
-n <名前> | 画面に表示する名前を設定します。 |
-x <サイズ> | 画面表示のXサイズを設定します。(デフォルトは30です) |
-y <サイズ> | 画面表示のYサイズを設定します。(デフォルトは15です) |
-j <セルの初期状態.json> | セルの初期状態を設定します。 |
-s <世代数> | 世代交代の表示回数を設定します。(デフォルトは100です) |
-w <時間> | 画面更新の待ち時間を設定します。(デフォルトは0.05です) |
-d <時間> | 初期状態表示から開始までの時間を設定します。(デフォルトは0.0です) |
-a <全角文字> | 生のセルの表示文字を設定します。(デフォルトは■です) |
-r | ランダム実行時の生のセルの生成割合(0~1の実数)を設定します。(デフォルトは0.5です) |
-l | 世代交代を無限に続けます。-stepオプションの設定は無視されます。 |
-t | トーラス状世界を有効にします。 |
-m | セルの寿命を有効にします。 |
-c | セルの色進化を有効にします。 |
-h | ヘルプを表示します。 |
おわりに
もともとは、コンソールの画面制御を試してみたかっただけだったんですが、思った以上にライフゲーム作りは面白かったです。また、コマンドライン引数の取得方法として、argparseにも慣れ親しめて、個人的に得る物が多かったです。たくさんの方が実装されていることからも分かるように、改めてライフゲームの世界はとても奥が深く面白いですね。今回作ったスクリプトを使って、ライフゲームの魅力を少しでも多くの方に味わっていただけたら、大変うれしく思います。
最後に、今の時期にピッタリなクリスマスツリーのライフゲームで締めくくりたいと思います。最後までお読みいただきありがとうございました!!
つづき
その後、続きの記事を書きましたので、良かったら読んでください。
今回作ったものをより"速く"動かすためのに、試行錯誤した内容となっております。