はしくれエンジニアもどきのメモ

情報系技術・哲学・デザインなどの勉強メモ・備忘録です。

Python (NumPy) でLifeGame を作る

Python (NumPy) でLifeGame を作る

NumPyの使い方わかってきたし、せっかくなのでLifeGame を作ってみます。

環境

LifeGameって何?

wikipedia からの引用

ライフゲーム (Conway's Game of Life[1]) は1970年にイギリスの数学者ジョン・ホートン・コンウェイ (John Horton Conway) が考案した生命の誕生、進化、淘汰などのプロセスを簡易的なモデルで再現したシミュレーションゲームである。 単純なルールでその模様の変化を楽しめるため、パズルの要素を持っている。

動画を見たほうが早いすね。

実際に以下のサイトで試せる(jsで書かれている)。

Life Game

パターンや詳細については、こちらがよく書かれている。 

aidiary.hatenablog.com

ライフゲームのルールと実装

ルール解説しながら実装していく。

セル

ライフゲームの1つのマスはセル(細胞)と呼ぶ。 セルには「生」と「死」の2つの状態があります。 「生」の状態は「1(黒)」。「死」の状態は「0(白)」などと決めておく。

なので、 セル配列として、NumPy配列 を使うのがよさそうです。 numpy には零配列を作る関数があるので、それで初期の(どのセルも生きていない)セル配列は作れそうです。

また、今回は簡単化のために行数、列数が同じセル配列にしておきます。 コードに落とすと以下のようになる。


import numpy as np

CELLS_WIDTH = 8
cells = np.zeros((CELLS_WIDTH, CELLS_WIDTH), 'int')
cells

[[0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]]

8×8の0行列ができる。

ルール1:生存

自分(セル)が生きていて、周囲8マス以内のセルが2つか3つ生きていれば、そのセルは「生存」となる。

自分の周りに、ほどよく他のセルがいれば、生き残るという考え方のよう。

今回は状態として、「2」とする。

実際にコードに落としていく。 まず、セルが生きているかをチェックする関数をつくる。 セル配列のindexをもらって, 0 かそれ以外かを見ればいい。


# 条件:セルが生きている
def isAlive(cell_idx, cells):
  if cells[cell_idx] == 0:
    return False
  return True

セルの周りに、どれくらいの数のセルが生きているかをカウントする関数も必要。

  1. 周囲8マスになるようにindex範囲を決定

  2. そのindex範囲でスライス

  3. スライスした配列をnumpy.count_nonzero() でカウントする。

セル配列は、2次元なので、indexは、長さ2のタプルを想定している。 また、はみ出した場合は、indexの始め(0) または最後を使うようにする。


def count_alive_surrounding(cell_idx, cells):
    '''
    note:自分のセル分も含まれる
    '''
    range_idx = [(idx - 1 if idx - 1 >= 0 else 0, idx + 2 if idx + 2 <= CELLS_WIDTH else CELLS_WIDTH) for idx in cell_idx]
    return np.count_nonzero(cells[range_idx[0][0]:range_idx[0][1], range_idx[1][0]:range_idx[1][1]])

次に実際の「生存」の条件のルールをコード化。 自分の周りに生きているセルの数(2か3)を変更できるように、 関数ファクトリにしておく。


def rule_alive_condition_setting(min_cell_num:int, max_cell_num:int):
  def rule_alive_condition(cell_idx, cells_in):
    if isAlive(cell_idx, cells_in) and (min_cell_num <= count_alive_surrounding(cell_idx, cells_in) - 1 <= max_cell_num):
      return True
    return False
  return rule_alive_condition

生存条件が適用されたときの処理を書く。 生存なので、状態「2」に書き換える。


def rule_alive_apply(cell_idx, cells_out):
  # そのcellはそのまま生存 なにもしない
  cells_out[cell_idx] = 2

ルール2:誕生

生きてないセルで、周囲8マス以内にちょうど3セルが生存ならば、 そのセルに「誕生」する。

今回は「生存」と分けたいの状態として、「1」とする。

「誕生」の条件のルールをコード化。 「生存」のとき同様、自分の周りに生きているセルの数を変更できるように、 関数ファクトリにしておく。 自分のセルが生きているか、 周囲8マス以内の生存数については、ルール1で作った関数が使える。


def rule_birth_condition_setting(birth_required_cell_num:int):
  def rule_birth_condition(cell_idx, cells_in):
    # 周囲8マス以内に他のセルがbirth_required_cell_numだけ生きていれば
    if not isAlive(cell_idx, cells_in) and (count_alive_surrounding(cell_idx, cells_in) == birth_required_cell_num):
      return True
    return False

  return rule_birth_condition

「誕生」ルールが適用されたときの処理を書く。 今回は状態「1」に書き換える。


def rule_birth_apply(cell_idx, cells_out):
  ## そのcellに誕生
  cells_out[cell_idx] = 1

ルール3:死亡

ルール1、ルール2以外であれば、そのセルは、「死亡」とする。

以下のように死亡にも、複数の状態があるが、 今回は「生存、誕生」以外は、すべてまとめて「0」 とする。

  • 周りのセルが多すぎた場合は、「過密」による「死亡」

  • 周りのセルが少なすぎた場合は、「過疎」による「死亡」

上記2つの条件が当てはまらなかったら、常に適用するので、 True を返しておく。引数は、ほかの条件と同じようにループで回せるようにダミーにしておく。


def default_condition(cell_idx, cells_in):
  return True

「死亡」ルールが適用された場合は、 セルの状態は「0」 に書き換える。


def rule_death_apply(cell_idx, cells_out):
  cells_out[cell_idx] = 0

ルールをまとめる

今までに作ったルールとその処理、それぞれ3つをタプルでまとめる。


  rules = (
  (rule_alive_condition_setting(2, 3), rule_alive_apply), # alive
  (rule_birth_condition_setting(3), rule_birth_apply), # birth
  (default_condition, rule_death_apply) # death
  )

これで、for文で上から順に条件をチェックしていく。 最後の「死亡」条件はTrueなので必ずどれかが適用される。

処理の実行

実際に処理を実行するを書いてく。

初期のセル配列を入力引数とする。 ジェネレータで実行し、 返り値は、何回目の実行かとルール適用後のcell配列にする。

今回は可視化は行わず、セル配列を直接表示する。


def play_gen(cells):
  def count(i):
    x = [i]
    def count_up():
      x[0] += 1
      return x[0]
    return count_up

    countup = count(0)

    cells_in = np.copy(cells)

    while True:
      # copy先のcell
      cells_out = np.copy(cells_in)
      # index の取得
      for idx in np.ndindex(cells.shape):
        for condition, apply in rules:
              if condition(idx, cells_in):
                      apply(idx, cells_out)
                      break

      yield countup(), cells_out
      cells_in = np.copy(cells_out)

実際にルール適用の処理を書いているのは、 Whileループ後。

  1. セル配列の出力先を作成

  2. セル配列のindex取得

  3. ルールタプルから各ルール条件と適用処理を抽出

  4. 条件に当てはまれば適用 を繰り返す。

  5. すべてのセルに適用したら、カウントとセル配列をyeild で返す。

実行例

実行例示す。 可視化してないので見づらい。。

初期のセル配列をつくる。


CELLS_WIDTH = 8
cells = np.zeros((CELLS_WIDTH, CELLS_WIDTH), 'int')
cells[:3, :3] = 1
print(cells)

[[1 1 1 0 0 0 0 0]
 [1 1 1 0 0 0 0 0]
 [1 1 1 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0]]

あとはgenerater を生成して、nextで回す。


gen = play_gen(cells)
next(gen) #1回目

(1, array([
[2, 0, 2, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0],
[2, 0, 2, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0]]))

gen = play_gen(cells)
next(gen) #1回目

(1, array([
[2, 0, 2, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0],
[2, 0, 2, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0]]))

next(gen) # 2回目

(2, array([
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 2, 0, 0, 0, 0],
[0, 1, 2, 0, 0, 0, 0, 0],
[0, 2, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0]]))

next(gen) # 2回目

(2, array([
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 2, 0, 0, 0, 0],
[0, 1, 2, 0, 0, 0, 0, 0],
[0, 2, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0]]))

next(gen) # 3回目

(3, array([
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 2, 2, 0, 0, 0, 0],
[0, 2, 0, 1, 0, 0, 0, 0],
[0, 2, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0]]))

コード

まとめたコードを載せておく。

LifeGame 処理のみ(classなし、可視化なし)