Pythonで作ったコンソール上で動くライフゲーム

# はじめに
どうも、y-tetsuです。

Pythonを使ったコンソール操作の練習として、ライフゲームを作ってみました。コンソール画面制御のため、エスケープコードに少し馴染んだので、メモがてら記事にしたいと思います。また、コマンドライン引数を取得するのにargparseも便利だったので、それについても少しメモしてます。

簡単なロジックを組むだけで、予想もしない複雑な動きが見られるのがライフゲームの面白いところだと思います。そんなライフゲームの魅力も、合わせてお伝えできればいいなと思います。

ライフゲームの仕様については、以下のWikipediaに詳しく書かれていますので、ご存じないよという方はご参照ください。(本記事では実装にしか触れません)

https://ja.wikipedia.org/wiki/%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B2%E3%83%BC%E3%83%A0



# できたもの
今回Pythonを使ってスクリプトを書きました。これを実行すると、以下のようにコンソール(コマンドプロンプト)上で、ライフゲームの世界を堪能できます。
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/192e0112-3e89-c2c3-798f-bcc21da543ff.gif)

スクリプトは以下のgithubのリポジトリから取得できます。

https://github.com/y-tetsu/game_of_life


## 作ったもののポイント

### コンソール制御
コンソール上でライフゲームの画面を実装するにあたり、以下を使っています。

- 画面消去
- カーソルの表示/非表示の切り替え
- カーソル移動
- 文字の色付け

### コマンドライン引数
引数の使い方は以下を取り上げています。

- 引数指定
- オプション指定

### オリジナル機能
今回のスクリプトはオプション指定により、以下のオリジナル機能を試せるようになっております。

- 「閉じた世界 ⇔ トーラス状の世界」の切り替え
- 「寿命ルールの有効⇔無効」の切り替え
- 「進化ルールの有効⇔無効」の切り替え

などなど。

それでは、追ってご説明したいと思います。


# プログラム構成
プログラムの基本構成としては、大きく以下の2つのクラスを用意しました。

- ゲーム部分を司る、`GameOfLife`クラス
- コンソールの制御を司る、`Console`クラス

# ライフゲームの管理
まず最初に、ライフゲームの実装内容として、`GameOfLife`クラスについて説明します。

## 初期化
`GameOfLife`クラスの初期化についてです。

```python
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マスをチェックするための座標の移動量を表しています。

![dir.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/eec0b082-c7d7-1a8f-0bc0-8ee85d179fa4.png)


## ゲームの開始
`GameOfLife`クラスをインスタンス化した後、`start()`メソッドにて、ゲームを開始します。

```python
if __name__ == '__main__':
    GameOfLife().start()
```

```python
    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()`にて必ずコンソールの状態を元に戻すようにしています。



## 状態の更新
元のセルの状態を元に、次のセルの状態を計算する部分です。

```python
    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関数を使って待ち時間を作っています。

```python
    def wait(self):
        time.sleep(self.wait_time)
```

簡単のため独自に追加した機能は省略します。以上で、ライフゲームのロジック説明は終了です。続いては、コンソール制御の説明に移ります。



# コンソール制御について
ここでは、コンソール制御を司る`Console`クラスについて説明します。

## 描画の方針
ゲーム画面の描画を行うにあたり、当初は以下の`cls`コマンドで画面消去後に、printで再描画する方法で実現していました。

```python
import os
os.system('cls')
```

ですが、この方法ですと見てわかる通り、
![flash.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/70c390d3-52c5-db62-dba0-92725512fee2.gif)
**結構画面がちらつくんですね。**

目がチカチカして見てられませんでした…。そして結構遅い。(皆さんもあまり見過ぎないようにご注意ください)

そこで、今回はANSIエスケープコードを用いて、画面の表示はそのままにし、カーソルを移動させ上書きする方針としました。なお、ANSIエスケープコードは、コンソールの画面を制御するための特殊な文字のことで、以下のサイトを参考に情報収集しました。

https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit

https://www.mm2d.net/main/prog/c/console-02.html

それでは、いざ！とエスケープコードを送ってみたのですが、当方の環境では動かず。調べてみると、Windowsのコマンドプロンプトでは通常はエスケープコードが効かないらしい。最終的に、以下のコードを入れて、エスケープコードを有効化するとうまくいきました。

```python
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()`メソッド実行後に、以下のコードでカーソル位置を移動させられます。

```python
    def cursor_up(self, n):
        print(f'\033[{n}A', end='')
```

意味は、n行上にカーソルを移動となります。

なお、クラスの引数の説明は以下です。
|引数|用途|
|---|---|
|x|X方向の表示サイズ|
|y|Y方向の表示サイズ|
|name|ライフゲームの名前|
|alive|生のセルの印字文字||

描画のちらつきはマシになったものの、まだ気になる点がありました。
![cursor_flash.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/ae4506d9-ff6b-01a8-dfe1-ec24d2d88494.gif)
**地味にカーソルがちらついてます…。**

ということで、以下でカーソルの表示と非表示を切り替えることにしました。

```python
    def cursor_hyde(self):
        print('\033[?25l', end='')

    def cursor_show(self):
        print('\033[?25h', end='')
```

一応これで、それなりに見られる形には落ち着きました。


## セットアップと終了時処理
コンソール制御の開始時は、`setup()`メソッドを呼び、画面のクリアとカーソル消去。終了時は`testdown()`メソッドによりカーソル表示をするよう扱います。

```python
    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サイズに合わせて、枠線とセル印字する例です。

```python
    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}')
```


## 色付け
エスケープコードを使って文字に色を付けることもできます。

今回は以下を使いました。

```python
'\033[38;2;{r};{g};{b}m'
```
{r}、{g}、{b}には0～255の値を入れて好きな色に設定できました。

また、一度エスケープコードを呼ぶと、以後は同じ色で文字が表示されますので、以下にて色を元に戻しています。

```python
'\x1b[39m'
```

例えば、以下のprint文を実行すると、

```python
print('\033[38;2;0;153;68m' + '緑')
print('\033[38;2;230;0;18m' + '赤')
print('\033[38;2;235;202;27m' + '黄')
print('\x1b[39m' + '戻す')
```

![coloring.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/cf30e738-345f-7042-2d02-870679fea2ee.png)

のように色が付きます。



# コマンドライン引数の取得
ライフゲームをいざ作ってみると、画面のサイズや表示の回数など、条件を変えて色々試してみたくなりました。そこで、今回はargparseで取得した引数により、自分好みのライフゲームを楽しめるようスクリプトを書くことにしました。

公式ドキュメントは以下。

https://docs.python.org/ja/3.6/library/argparse.html

全く使ったことがなかったもので、引数の取得ってこんなに簡単にできたんだ！！と驚きでした。

ここでは、使ったものだけを軽く紹介します。

## parserの準備
まず、argparseのインポートとパーサを準備します。

```python
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

```

## 引数の追加
引数の追加は、先ほどのコードに続いて以下の記述で対応できます。

```python
    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通りを使いました。

### 引数を取るもの
以下の記述で対応できます。

```python
    parser.add_argument('-n', '--name')
```

上記で、-n <名前> または --name <名前>で名前を取得することができるようになりました。

引数の型も指定できて、例えば整数値を取りたい場合は、以下の記述で対応できました。

```python
    parser.add_argument('-x', type=int)
```

### フラグとして使うもの
`action`を指定することで、フラグとして使えます。

```python
    parser.add_argument('-l', '--loop', action="store_true")
```
`store_true`とした場合、オプション指定時はTrue、そうでない場合はFalseとなります。また、`store_false`とした場合はその逆です。


### 取得した引数へのアクセス
以下で、引数のパースを実施し結果を格納します。

```python
    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を任意のフォルダにコピーして下さい。

https://github.com/y-tetsu/game_of_life

※あらかじめ[Python 3.9以上](https://www.python.org/downloads/)をインストールしておいてください。
※Window10での動作のみ確認しております。

## 実行方法
以下のようにスクリプトを実施してください。`[]`は任意。
```
py game_of_life.py [オプション] [サンプルパターン名] 
```
:::note
pyコマンド(Windows用)の代わりにpythonでも動作します。
:::

:::note warn
スクリプトの実行を中断する場合は、Ctrl+Cを押してください。
:::


## サンプル
引数にサンプルパターンを指定することで、いくつかのサンプルを眺めることができます。

### ランダム
サンプルパターン名なしで実行した場合は、ランダムな初期配置となります。
![random.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/c423073e-6e3c-237b-6263-bec52f583448.gif)


```
> py game_of_life.py
```

### グライダー
![glider.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/34494805-c0bf-c507-fd1f-c72076f1b106.gif)

```
> py game_of_life.py glider
```

### 銀河
![galaxy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/e33f7bd8-15f4-d85b-963b-4fd9fbefd1e2.gif)

```
> py game_of_life.py galaxy
```

### その他
サンプルパターン名に指定できるものの一覧です。是非お試しください。
|名称|サンプルパターン名|
|---|---|
|ランダム|(指定なし)|
|八角形|octagon|
|グライダー|glider|
|銀河|galaxy|
|ダイハード|die-hard|
|グライダー銃|glider-gun|



## 各種オプション

### 無限ループ
`-l`オープションを使うと、途中で停止することなく世代交代をいつまでも続けます。

```
> py game_of_life.py -l
```

### トーラス世界
`-t`オープションを使うと、上下と左右がそれぞれつながった、トーラス状の世界でライフゲームを実行できます。
![torus.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/9eeec9c7-152c-4f13-fdf8-c2afcd796a9f.png)

![glider_t.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/508ea0ee-1d48-2bef-ab92-a107806812f9.gif)
これにより、グライダーがループするようになるんですね。

```
> py game_of_life.py -t
```

### 寿命
`-m`オプションを使うと、同じマスに居続けているセルが年を取るようになり、やがて寿命を迎えるようになります。固定タイプや振動タイプに新たな動きが加わります。

![mortal.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/7a65b84f-04ea-c451-0fcd-ab155b0f9031.gif)

```
> py game_of_life.py -m
```


### 色進化
`-c`オプションを使うと、新しく生まれるセルの色が、周囲の生きたセルの色に応じて、変化するようになります。

![color.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/14cf270e-9145-383f-b1bc-f4a8dfea75d6.gif)

```
> py game_of_life.py -c
```


### 自作世界
下記の例のように、JSON形式で初期状態を自作し、それを読み込んで実行することができます。

```world.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}
```

:::note
設定ファイルとコマンドライン引数が重なった場合は、設定ファイルが優先されます。
:::

```
> 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にも慣れ親しめて、個人的に得る物が多かったです。たくさんの方が実装されていることからも分かるように、改めてライフゲームの世界はとても奥が深く面白いですね。今回作ったスクリプトを使って、ライフゲームの魅力を少しでも多くの方に味わっていただけたら、大変うれしく思います。

最後に、今の時期にピッタリなクリスマスツリーのライフゲームで締めくくりたいと思います。最後までお読みいただきありがとうございました！！
![tree.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/ad51171a-46a8-73c6-c841-e2422a53131d.gif)

# つづき
その後、続きの記事を書きましたので、良かったら読んでください。

https://qiita.com/y-tetsu/items/2db7ce8dd2f8884c2028

今回作ったものをより"速く"動かすためのに、試行錯誤した内容となっております。