12
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonAdvent Calendar 2022

Day 19

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

Last updated at Posted at 2022-12-24

はじめに

どうも、y-tetsuです。

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

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

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

できたもの

今回Pythonを使ってスクリプトを書きました。これを実行すると、以下のようにコンソール(コマンドプロンプト)上で、ライフゲームの世界を堪能できます。
demo.gif

スクリプトは以下の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マスをチェックするための座標の移動量を表しています。

dir.png

ゲームの開始

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')

ですが、この方法ですと見てわかる通り、
flash.gif
結構画面がちらつくんですね。

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

そこで、今回は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 生のセルの印字文字

描画のちらつきはマシになったものの、まだ気になる点がありました。
cursor_flash.gif
地味にカーソルがちらついてます…。

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

    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' + '戻す')

coloring.png

のように色が付きます。

コマンドライン引数の取得

ライフゲームをいざ作ってみると、画面のサイズや表示の回数など、条件を変えて色々試してみたくなりました。そこで、今回は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を押してください。

サンプル

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

ランダム

サンプルパターン名なしで実行した場合は、ランダムな初期配置となります。
random.gif

> py game_of_life.py

グライダー

glider.gif

> py game_of_life.py glider

銀河

galaxy.gif

> py game_of_life.py galaxy

その他

サンプルパターン名に指定できるものの一覧です。是非お試しください。

名称 サンプルパターン名
ランダム (指定なし)
八角形 octagon
グライダー glider
銀河 galaxy
ダイハード die-hard
グライダー銃 glider-gun

各種オプション

無限ループ

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

> py game_of_life.py -l

トーラス世界

-tオープションを使うと、上下と左右がそれぞれつながった、トーラス状の世界でライフゲームを実行できます。
torus.png

glider_t.gif
これにより、グライダーがループするようになるんですね。

> py game_of_life.py -t

寿命

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

mortal.gif

> py game_of_life.py -m

色進化

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

color.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}

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

> 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

つづき

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

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

12
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?