tkinterを使ってライフゲームっぽいものを作成する

## 概要

ライフゲームといえば、生命の誕生と死亡をシンプルにシミュレーションするものです。
生死はマスの白黒によって表現され、一定の条件によってそれらが変化します。
最もメジャーなものとして、以下の条件が用いられます。
- 誕生
  - 死んでいるマスは、隣接している生きているマスが3つある場合、
  次のステップで生きているマスになる
- 生存
  - 生きているマスは、隣接している生きているマスが2つか3つの場合
  次のステップでも生きているマスのまま
- 過疎
  - 生きているマスは、隣接している生きているマスが1つ以下の場合
  次のステップで死んでいるマスになる
- 過密
  - 生きているマスは、隣接している生きているマスが4つ以上の場合
  次のステップで死んでいるマスになる

今回はその派生形として、1マスに3種の生命をもつライフゲームの亜種を実装します。
GUIの実装のため、`tkinter`というツールキットを使用しました。

## コンセプト

以下のコンセプトを元にコードを実装しました。
- 1マスには3種類の生命を持つ
  - 各生命の量をマスの色のRGBの値で表現する
  - 各生命は捕食関係にある
- RGBに対応する各生命を以下のように定義する
  - B：物質
    - 自然界に存在する物質を表現する
    - 物質は時間経過で一定量ずつ増加する
    - 増加量はマスによってランダムとする
  - G：分解者
    - 物質を分解してエネルギーとする分解者を表現する
    - 分解者は物質の量によって確率で発生する
    - 物質を糧として消費しながら増えていく
    - 物質が少なくなると餌を求めて周囲に拡散する
  - R：捕食者
    - 分解者を捕食して発展する捕食者を表現する
    - 捕食者は分解者の量によって確率で発生する
    - 分解者を糧として消費しながら増えていく
    - 分解者が少なくなると餌を求めて周囲に拡散する

物質は単純増加し、分解者は物質を、捕食者は分解者を消費して生きています。
各色が食物連鎖の関係にあり、消費と拡散をしながら増減するということです。

## コード解説

コードはメインウィンドウとマスであるCellクラスに分かれています。
まずはCellクラスから解説します。

### Cellクラス

Cellクラスではマスの状態を表現します。
各生命の量やサイクル(1ステップごとの処理)、拡散の処理を行っています。

まずは各生命の最大値や増加速度(1ステップごとの増加率)について定義します。
コード上では、物質=material、分解者=decomposer、捕食者=eaterです。
量はRGBとして利用するため、最大値を255としました。
(増加速度や発生時の量によって盤面の動きが変わります。)

```Python
# 物質系
## 量の最大値
MAX_MATERIAL : Final[int] = 255
## 増加速度(ランダム)の最大値
MAX_MATSPEED : Final[int] = 30
## 増加速度(ランダム)の最小値
MIN_MATSPEED : Final[int] = 5
# 分解者系
## 量の最大値
MAX_DECOMP : Final[int] = 255
## 発生時の量
INIT_DECOMP : Final[int] = 30
## 増加速度
SPEED_DECOMP : Final[int] = 1.7
# 捕食者系
## 量の最大値
MAX_EATER : Final[int] = 255
## 発生時の量
INIT_EATER : Final[int] = 10
## 増加速度
SPEED_EATER : Final[int] = 1.2
## 捕食量の比(何倍量を消費するか)
RATE_EATER : Final[int] = 3
```

また、コンストラクタで物質の増加速度を決定しておきます。
ランダムにしたのはマスごとに差をつけるためです。

```Python
class Cell:
    # 各生物の量
    material = 0
    decomposer = 0
    eater = 0
    # 物質の増加速度(ランダム)
    mat_speed = 0
    # セルの座標
    x = -1
    y = -1

    def __init__(self, x, y):
        self.x = x
        self.y = y
        # マスごとの環境のばらつきを生むためにランダムにしている
        self.mat_speed = random.randint(MIN_MATSPEED, MAX_MATSPEED)
```

次に、各生命のサイクルを設定します。
前述の通り、物質以外は捕食対象によって挙動が変わります。
捕食者はより多くの餌を必要とするため、消費量が倍数になっています。
発生の確率は捕食対象の量によって変化させていますが、盤面を見るに高すぎたかもしれません。

```Python
# 拡散が発生したか
is_explain_decomp = False
is_explain_eater = False

def _next_mat(self) -> int:
        """物質のサイクル"""
        mat = self.material - self.decomposer
        if mat < 0 :
            mat = 0
        mat = mat + self.mat_speed
        if mat > MAX_MATERIAL:
            mat = MAX_MATERIAL
        return mat

def _next_decomp(self) -> int:
    """分解者のサイクル"""
    dec = self.decomposer - self.eater
    if dec < 0:
        dec = 0
    if dec==0:
        # 確率で発生
        rnd = random.randint(0,MAX_MATERIAL)
        if rnd <= self.material:
            dec = self._born_decomp()
    else:
        if dec < self.material:
            # 物質が十分にある時は一定倍率で増加
            dec = int(self.decomposer * SPEED_DECOMP)
            if dec > MAX_DECOMP:
                dec = MAX_DECOMP
        else:
            # 拡散による減少
            dec = self.material
            # フラグを立てておいて、後から別のマスに増加させる
            self.is_explain_decomp = True
    return dec

def _next_eater(self) -> int:
    """捕食者のサイクル"""
    eat = self.eater
    if self.eater==0:
        # 確率で発生
        rnd = random.randint(0,MAX_DECOMP)
        if rnd <= self.decomposer:
            eat = self._born_eater()
    else:
        if self.eater < (self.decomposer * RATE_EATER):
            # 分解者が十分ある時は一定倍率で増加
            eat = int(self.decomposer * SPEED_DECOMP)
            if eat > MAX_EATER:
                eat = MAX_EATER
        else:
            # 拡散による現象
            eat = int(self.decomposer / RATE_EATER)
            # フラグを立てておいて、後から別のマスに増加させる
            self.is_explain_eater = True
    return eat

    def _born_decomp(self) -> int:
    """分解者の発生"""
    return INIT_DECOMP

    def _born_eater(self) -> int:
        """捕食者の発生"""
        return INIT_EATER
```

拡散の処理はマスを跨いだ処理が必要になるので、セル側ではフラグ管理と処理の実装だけして呼び出しは盤面側の処理で行います。
発生時と同じ量の生命が移動してきますが、最大値を超えた場合は切りそろえています。
```Python
    def explain_decomp(self) -> None:
        """分解者が拡散してくる処理"""
        self.decomposer = self.decomposer + INIT_DECOMP
        if self.decomposer > MAX_DECOMP:
            self.decomposer = MAX_DECOMP

    def explain_eater(self) -> None:
        """捕食者が拡散してくる処理"""
        self.eater = self.eater + INIT_EATER
        if self.eater > MAX_EATER:
            self.eater = MAX_EATER
```

最後に、これらを呼び出す処理をまとめてupdate()としました。
このupdate()を盤面側から呼び出します。

```Python
def update(self) -> None:
    """セル内の状態を更新する"""
    # フラグリセット
    self.is_explain_decomp = False
    self.is_explain_eater = False
    # 次の値を計算
    next_mat = self._next_mat()
    next_decomp = self._next_decomp()
    next_eater = self._next_eater()

    # 値を更新
    self.material = next_mat
    self.decomposer = next_decomp
    self.eater = next_eater
```


### メインウィンドウ

メインウィンドウでは盤面の管理とGUIの処理を行っています。
まずはマス関係の処理からです。

マスごとの更新処理は先ほどのupdate()を呼び出すだけでOKです。
```Python
# サイズ設定
## 盤面のサイズ(マス数)
NUM_CELL : Final[int] = 40

def update_cells() -> None:
    """
    マス単位での更新処理
    (相互作用無し)
    """
    for i in range(0,NUM_CELL):
        for j in range(0, NUM_CELL):
            # 単に各マスのupdate()を呼び出す
            board[i][j].update()
```

マス間で相互作用する拡散の処理については、順番にマスを見ていきます。
周囲のマスのフラグを確認して分解者・捕食者の処理を呼び出します。
```Python
def update_area() -> None:
    """
    マス間の相互作用を含む更新処理
    """
    global board,NUM_CELL
    # 盤面は[i]行目、[j]列で管理(i=y,j=x)
    for i in range(0,NUM_CELL):
        for j in range(0, NUM_CELL):
            # 周囲から拡散するかを確認
            is_explain_decomp = False
            is_explain_eater = False
            # 盤外と自身は除く
            for di in range(-1, 2):
                if (i+di < 0) | (i+di >= NUM_CELL):
                    continue
                for dj in range(-1, 2):
                    if (j+dj < 0) or (j+dj >= NUM_CELL) or (di==0 and dj==0):
                        continue
                    if board[i+di][j+dj].is_explain_decomp:
                        is_explain_decomp = True
                    if board[i+di][j+dj].is_explain_eater:
                        is_explain_eater = True
            # 拡散の判定
            if is_explain_decomp:
                board[i][j].explain_decomp()
            if is_explain_eater:
                board[i][j].explain_eater()
```

GUIに関しては冒頭で触れたようにtkinterを使います。
画面はキャンバス1枚で、その上にマスを描画します。
描画の際にRGBに対応する値(material、decomposer、eater)を取ってきて反映させます。

canvas.afterで次の描画処理を仕込めるので、それを使って毎フレームの描画を行います。

```Python
# サイズ設定
## 1マスのサイズ(pixel)
CELL_SIZE : Final[int] = 15
## マス間の距離
CELL_MARGIN : Final[int] = 1
## 盤面全体のサイズ(1辺)
BOARD_WIDTH : Final[int] = NUM_CELL*CELL_SIZE+CELL_MARGIN
## サイクル間隔(ms)
DRAW_RATE : Final[int] = 50

def draw() -> None:
    """
    盤面を描画する。
    """
    # 描画をクリア
    canvas.delete("all")
    # 盤面を更新
    update_cells()
    update_area()
    # 盤面を描画
    global CELL_SIZE, CELL_MARGIN, board
    for i in range(0,NUM_CELL):
        for j in range(0, NUM_CELL):
            # 盤面は[i]行目、[j]列で管理(i=y,j=x)
            posx = CELL_SIZE * j
            posy = CELL_SIZE * i
            canvas.create_rectangle(posx, posy, posx+CELL_SIZE-CELL_MARGIN, posy+CELL_SIZE-CELL_MARGIN,  # マージン込みで描画
                                    fill="#{:02x}{:02x}{:02x}".format(board[i][j].get_eater(),
                                                                      board[i][j].get_decomposer(),
                                                                      board[i][j].get_material()),  # 各マスの内容を参照
                                    width=0)
    canvas.after(DRAW_RATE,draw)


# 盤面を初期化
board = [[Cell(j, i) for i in range(0,NUM_CELL)] for j in range(0, NUM_CELL)]
# GUI設定
root = tk.Tk()
root.title(u"RGBLife")
root.geometry(str(BOARD_WIDTH)+"x"+str(BOARD_WIDTH))
root.resizable(False, False)

#キャンバスエリア
canvas = tk.Canvas(root, width = NUM_CELL*CELL_SIZE+1, height = NUM_CELL*CELL_SIZE+1, bg="white")
# 描画の呼び出し設定
canvas.after(DRAW_RATE,draw)
#キャンバスバインド
canvas.place(x=1, y=1)

# メインループ
root.mainloop()
```

## 動作結果

こんな感じで動きます。
設定値によって色の移り変わりが変化するので試してみてください。

![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268814/3694915a-6889-0a95-f984-3b3c0cf8b1cd.gif)


## コード全文
<details><summary>Cellクラス</summary>

```Python
import random
from typing import Final

# 物質系
## 量の最大値
MAX_MATERIAL : Final[int] = 255
## 増加速度(ランダム)の最大値
MAX_MATSPEED : Final[int] = 30
## 増加速度(ランダム)の最小値
MIN_MATSPEED : Final[int] = 5
# 分解者系
## 量の最大値
MAX_DECOMP : Final[int] = 255
## 発生時の量
INIT_DECOMP : Final[int] = 30
## 増加速度
SPEED_DECOMP : Final[int] = 1.7
# 捕食者系
## 量の最大値
MAX_EATER : Final[int] = 255
## 発生時の量
INIT_EATER : Final[int] = 10
## 増加速度
SPEED_EATER : Final[int] = 1.2
## 捕食量の比(何倍量を消費するか)
RATE_EATER : Final[int] = 3

class Cell:
    """
    マスの状態を表現する。
    material、decomposer、eaterを持ち、それらが増減する。
    """

    # 各生物の量
    material = 0
    decomposer = 0
    eater = 0
    # 物質の増加速度(ランダム)
    mat_speed = 0
    # セルの座標
    x = -1
    y = -1
    # 拡散が発生したか
    is_explain_decomp = False
    is_explain_eater = False

    def __init__(self, x, y):
        self.x = x
        self.y = y
        # マスごとの環境のばらつきを生むためにランダムにしている
        self.mat_speed = random.randint(MIN_MATSPEED, MAX_MATSPEED)

    def _next_mat(self) -> int:
        """物質のサイクル"""
        mat = self.material - self.decomposer
        if mat < 0 :
            mat = 0
        mat = mat + self.mat_speed
        if mat > MAX_MATERIAL:
            mat = MAX_MATERIAL
        return mat

    def _next_decomp(self) -> int:
        """分解者のサイクル"""
        dec = self.decomposer - self.eater
        if dec < 0:
            dec = 0
        if dec==0:
            # 確率で発生
            rnd = random.randint(0,MAX_MATERIAL)
            if rnd <= self.material:
                dec = self._born_decomp()
        else:
            if dec < self.material:
                # 物質が十分にある時は一定倍率で増加
                dec = int(self.decomposer * SPEED_DECOMP)
                if dec > MAX_DECOMP:
                    dec = MAX_DECOMP
            else:
                # 拡散による減少
                dec = self.material
                # フラグを立てておいて、後から別のマスに増加させる
                self.is_explain_decomp = True
        return dec

    def _next_eater(self) -> int:
        """捕食者のサイクル"""
        eat = self.eater
        if self.eater==0:
            # 確率で発生
            rnd = random.randint(0,MAX_DECOMP)
            if rnd <= self.decomposer:
                eat = self._born_eater()
        else:
            if self.eater < (self.decomposer * RATE_EATER):
                # 分解者が十分ある時は一定倍率で増加
                eat = int(self.decomposer * SPEED_DECOMP)
                if eat > MAX_EATER:
                    eat = MAX_EATER
            else:
                # 拡散による現象
                eat = int(self.decomposer / RATE_EATER)
                # フラグを立てておいて、後から別のマスに増加させる
                self.is_explain_eater = True
        return eat

    def _born_decomp(self) -> int:
        """分解者の発生"""
        return INIT_DECOMP

    def _born_eater(self) -> int:
        """捕食者の発生"""
        return INIT_EATER

    def explain_decomp(self) -> None:
        """分解者が拡散してくる処理"""
        self.decomposer = self.decomposer + INIT_DECOMP
        if self.decomposer > MAX_DECOMP:
            self.decomposer = MAX_DECOMP

    def explain_eater(self) -> None:
        """捕食者が拡散してくる処理"""
        self.eater = self.eater + INIT_EATER
        if self.eater > MAX_EATER:
            self.eater = MAX_EATER

    def get_material(self) -> int:
        """物質の量"""
        return self.material

    def get_decomposer(self) -> int:
        """分解者の量"""
        return self.decomposer

    def get_eater(self) -> int:
        """捕食者の量"""
        return self.eater

    def update(self) -> None:
        """セル内の状態を更新する"""
        # フラグリセット
        self.is_explain_decomp = False
        self.is_explain_eater = False
        # 次の値を計算
        next_mat = self._next_mat()
        next_decomp = self._next_decomp()
        next_eater = self._next_eater()

        # 値を更新
        self.material = next_mat
        self.decomposer = next_decomp
        self.eater = next_eater
```
</details>

<details><summary>メインウィンドウ</summary>

```Python

import tkinter as tk
from cell import Cell
from typing import Final

# サイズ設定
## 盤面のサイズ(マス数)
NUM_CELL : Final[int] = 40
## 1マスのサイズ(pixel)
CELL_SIZE : Final[int] = 15
## マス間の距離
CELL_MARGIN : Final[int] = 1
## 盤面全体のサイズ(1辺)
BOARD_WIDTH : Final[int] = NUM_CELL*CELL_SIZE+CELL_MARGIN
## サイクル間隔(ms)
DRAW_RATE : Final[int] = 50

def update_area() -> None:
    """
    マス間の相互作用を含む更新処理
    """
    global board,NUM_CELL
    # 盤面は[i]行目、[j]列で管理(i=y,j=x)
    for i in range(0,NUM_CELL):
        for j in range(0, NUM_CELL):
            # 周囲から拡散するかを確認
            is_explain_decomp = False
            is_explain_eater = False
            # 盤外と自身は除く
            for di in range(-1, 2):
                if (i+di < 0) | (i+di >= NUM_CELL):
                    continue
                for dj in range(-1, 2):
                    if (j+dj < 0) or (j+dj >= NUM_CELL) or (di==0 and dj==0):
                        continue
                    if board[i+di][j+dj].is_explain_decomp:
                        is_explain_decomp = True
                    if board[i+di][j+dj].is_explain_eater:
                        is_explain_eater = True
            # 拡散の判定
            if is_explain_decomp:
                board[i][j].explain_decomp()
            if is_explain_eater:
                board[i][j].explain_eater()

def update_cells() -> None:
    """
    マス単位での更新処理
    (相互作用無し)
    """
    for i in range(0,NUM_CELL):
        for j in range(0, NUM_CELL):
            # 単に各マスのupdate()を呼び出す
            board[i][j].update()

def draw() -> None:
    """
    盤面を描画する。
    """
    # 描画をクリア
    canvas.delete("all")
    # 盤面を更新
    update_cells()
    update_area()
    # 盤面を描画
    global CELL_SIZE, CELL_MARGIN, board
    for i in range(0,NUM_CELL):
        for j in range(0, NUM_CELL):
            # 盤面は[i]行目、[j]列で管理(i=y,j=x)
            posx = CELL_SIZE * j
            posy = CELL_SIZE * i
            canvas.create_rectangle(posx, posy, posx+CELL_SIZE-CELL_MARGIN, posy+CELL_SIZE-CELL_MARGIN,  # マージン込みで描画
                                    fill="#{:02x}{:02x}{:02x}".format(board[i][j].get_eater(),
                                                                      board[i][j].get_decomposer(),
                                                                      board[i][j].get_material()),  # 各マスの内容を参照
                                    width=0)
    canvas.after(DRAW_RATE,draw)


# 盤面を初期化
board = [[Cell(j, i) for i in range(0,NUM_CELL)] for j in range(0, NUM_CELL)]
# GUI設定
root = tk.Tk()
root.title(u"RGBLife")
root.geometry(str(BOARD_WIDTH)+"x"+str(BOARD_WIDTH))
root.resizable(False, False)

#キャンバスエリア
canvas = tk.Canvas(root, width = NUM_CELL*CELL_SIZE+1, height = NUM_CELL*CELL_SIZE+1, bg="white")
# 描画の呼び出し設定
canvas.after(DRAW_RATE,draw)
#キャンバスバインド
canvas.place(x=1, y=1)

# メインループ
root.mainloop()

```
</details>

## 参考資料

- [ライフゲーム(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/Tkinter 入門](http://www.nct9.ne.jp/m_hiroi/light/pytk01.html)