見出し画像

ライフゲームを作る(3)

ライフゲームを作る(1) はCindyscriptでライフゲームを作る授業をやった話。
ライフゲームを作る(2) はPythonで matplotlib を使って作った話。
matplotlib では,ボタンを作って利用したり,画面をマウスでクリックした位置を取得する方法が,いまひとつわからないか,うまくいかないかで断念。そこで,今回はTkinter を使ったという話。

Tkinter を使う設定

import numpy as np
import tkinter as tk
root = tk.Tk()
root.title("ライフゲーム")
root.geometry("450x500")
canvas = tk.Canvas(root, width=450, height=450, bg="white")

Tkinter では,画面上にボタンやラベルなどの「ウィジェット」をおく。図を描くためのキャンバス:canvas もウィジェットの一つ。サイズと背景色を指定した。背景色指定は必須ではないが,白にしておくとわかりやすい。root.geometry がTkinter の画面サイズで,canvasの方が縦が少ないのは,ボタンを置くスペースをとるため。

リストの定義

マスを9×9にして,最初のパターンを用意しておくのは matplotlib のときと同じだが,現世代と前世代の2つを表示するのはやめて,表示は現世代だけにするので,変数 Generation はない。そのかわり,ひとつ前の世代と初期状態にもボタンで戻れるようにする。

#--- 初期設定  ---------------------
N = 9
matrix = [[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,1,1,0,0,0],
           [0,0,0,1,1,0,0,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]]
#----------------------------------
initLife = np.array(matrix)
Life = np.array(matrix)  
preLife = np.zeros((N,N))  

画面に表示する関数を書き換える

画面に表示する関数 drawboad() をTkinter 仕様で書き換える。Tkinter では,左上が原点になるのと,位置がピクセル単位という ことを考慮して,次のようにする。なお,1 マスのサイズを 50 × 50 とする。
マスを create_rectangle で,生命体を create_oval で描く。fill は塗り色。

def drawboad():
    for y in range(N):
        for x in range(N): # 1マスずつ描画 
            px = x * 50
            py = y * 50
            canvas.create_rectangle(
                   px, py, px + 50, py + 50,
                   fill='white'
                   )
            if Life[y][x] == 1: # x,yの順序に注意 
                canvas.create_oval(
                   px + 10, py + 10, px + 40, py + 40,
                   fill='blue'
                   )

長方形を描くのに,fill='white' としているのは,次世代になったとき,前の生命体の表示を白で上書きして消すためだ。fill なしで枠だけを描くと,再度この関数を実行したとき,前に生命体が描かれていた場合,それが残ってしまう。この関数では,図形を書き換えるのではなく,追加して同じ場所に上書きするので,枠線だけではだめなのだ。

世代を進める,前の世代に戻る,初期状態に戻る関数を作る

世代を進める関数 nextLife は,リストの処理だけなので matplotlib の場合と同じ。

def nextLife():
   work = np.zeros((N, N))     # 作業用配列
   for i in range(1, N-1):
       for j in range(1, N-1):
           s = 0              # 周囲8コマの合計
           for x in range(i-1, i+2):
               for y in range(j-1, j+2):
                   s = s + Life[x][y]
           s = s - Life[i][j]
           if Life[i][j] == 0 and s == 3:             # 誕生
               work[i][j] = 1
           if Life[i][j] == 1 and (s == 2 or s == 3): # 生存
               work[i][j] = 1    
   for x in range(N):
       for y in range(N):
           preLife[x][y] = Life[x][y]   # 現世代を前世代に
           Life[x][y] = work[x][y]      # 次世代を現世代に
   drawboad()

ひとつ前の世代に戻すために,preLife を Life にコピーする prev,初期状態に戻す reset を作る。

def prev():
   for x in range(N):
       for y in range(N):
           Life[x][y] = preLife[x][y]
   drawboad()
   
def reset():
   for x in range(N):
       for y in range(N):
           Life[x][y] = initLife[x][y]
   drawboad()

なお,ここの繰り返し処理は

def prev():
   for i in range(N):
       Life[i] = preLife[i]
   drawboad()

でもよい。Life = preLife では動かない。このあたりのことはいずれ記事にする。

ボタンの作成と実行

最後に,ボタンを作って表示する。command=nextLife で,btn1 をクリックすると関数 nexeLife を呼び出す。引数は渡せない(もともとないが)。関数名だけを書く。
canvas.pack() でキャンバスを配置し,root.mailoop() で処理の実行とイベント待ちになる。イベントとは,ここではボタンをクリックすること。

btn1 = tk.Button(text="次の世代", command=nextLife) 
btn2 = tk.Button(text="前の世代", command=prev) 
btn3 = tk.Button(text="リセット", command=reset) 
btn1.place(x=50, y=460)
btn2.place(x=150, y=460)
btn3.place(x=250, y=460)
drawboad()
canvas.pack()
root.mainloop()

次の画面ができる。

画像1

Tkinter では,Jupyter Notebook とは別のウィンドウができて上のように表示される。プログラムを書き換えるときは,ウィンドウを閉じる。そうでないと,前のウィンドウが残ったままになる。ボタンのついたウィンドウができて,いかにもアプリを作ったという感じになる。

これを実習でやるなら,リストの処理をする next() , prev() , reset() を書かせるくらいのものだろう。それ以外はTkinter のライブラリに依存するので,作り込んでおく方がよい。興味を持って質問する生徒がいれば説明するくらいだ。

何もない画面から始める

 ボタンの作り方の次に,マウスクリックの位置を取得する方法がわかった。エリアを広げ,はじめは何もない画面で,そこにマウスクリックで生命体を置いていくことにする。まず,画面設定を変更。

import numpy as np
import tkinter as tk
root = tk.Tk()
root.title("ライフゲーム")
root.geometry("500x600")
canvas = tk.Canvas(root, width=500, height=500, bg="white")
N = 15   
unitw = 500 // N
initLife = np.zeros((N, N))
Life = np.zeros((N, N))

画面サイズは 500×600 で固定,キャンバスも 500×500で固定。Nの値を変えるとマスの数が変わって,それに応じてマスの幅も変える。それが initw。
生命体が置けたら,それを初期状態としてセットし,ボタンでその初期状態に戻れるようにする。全部クリアすることもできるようにする。ボタンが増えるのと,初めからやり直せるようにするので,前世代に戻るのはやめる。

マウスクリックの検出と座標の取得は,Cindyscriptでは MouseClick というスロットに書くだけでできていたものだが,こちらはそう簡単ではない。
 マウスクリックを検出してその座標を得るには,Tkinter のイベント処理を使う。まず,クリックした位置の生存・消滅を変える関数を定義する。周囲ひとマスずつ(壁ぎわ)には生命体を置かない。

# クリックした位置の生死を変える
def change(ev):
   x = ev.x // unitw
   y = ev.y // unitw
   if 0 < x and x < N-1 and 0 < y and y < N-1:
       Life[x][y] = 1 - Life[x][y]
   drawboad()

壁際かどうかの判定は

if  x > 0 and x < N-1 and y > 0 and y < N-1:

とも書けるが,大小関係を < で統一するようにさせている。数学でも同様。
また,Pythonでは

if 0 < x < N-1 and 0 < y < N-1:

とも書けて,こちらの方が見やすいし,打つ量も少ないのだが,Python依存なので,生徒にはやらせない方が良さそうだ。教員が作り込んでおく部分でやるにはkまわないだろう。
もちろん,生徒がいずれで書いてきてもマルにはするが。

引数 ev は event の略。ちゃんと event と書いてもよい。Tkinter のイベント処理によって,この引 数が渡されてくる。その中に,座標 x, y があるので,ev.x , ev.y でそれを取得する。
マウスクリックによってこの関数を呼ぶために,次の行を書く。

canvas.bind("<Button>", change)

これにより,キャンバスのどこかをクリックすると,そのイベントに関するいろいろなものが change に渡される。それが引数の ev である。

初めはなにもない状態で,マウスクリックにより生命体を置いておく。置いたならば,それを初期状態にセットする。また,すべてをクリアしてやり直すことも考える。そのための関数 setting() と reset() を作る。ここでの reset() は前節で作ったものとは異なるので書き換える。前節で reset() としたものは,init() と名前を変える。

# 初期状態にもどる
def init():
   for x in range(N):
       for y in range(N):
           Life[x][y] = initLife[x][y]
   drawboad()

# 現在の状態を初期状態とする
def setting():
   for x in range(N):
       for y in range(N):
           initLife[x][y] = Life[x][y]
# リセット
def reset():
   for x in range(N):
       for y in range(N):
           initLife[x][y] = 0
           Life[x][y] = 0
   drawboad()

次世代に進む nextLife() は変更しなくてよい。画面表示の drawboad() もそのままだ。念のため再掲しておこう。

# 表示
def drawboad():
   for y in range(N):
       for x in range(N):
           px = x * unitw
           py = y * unitw
           canvas.create_rectangle(
               px, py, px + unitw, py + unitw,
               fill='white'
           )
           if Life[x][y] == 1: 
               canvas.create_oval(
                   px + unitw//5, py + unitw//5,
                   px + unitw*4//5, py + unitw*4//5,
                   fill='blue'
               )
           
# 次世代へ
def nextLife():
   work = np.zeros((N,N))
   for i in range(1,N-1):
       for j in range(1,N-1):
           s = 0              # 周囲8コマの合計
           for x in range(i-1,i+2):
               for y in range(j-1,j+2):
                   s = s + Life[x][y]
           s = s - Life[i][j]
           if Life[i][j] == 0 and s == 3:             # 誕生
               work[i][j] = 1
           if Life[i][j] == 1 and (s == 2 or s == 3): # 生存
               work[i][j] = 1    
   for x in range(N):
       for y in range(N):
           Life[x][y] = work[x][y]      # 次世代を現世代に
   drawboad()

 使用ガイドをつけて,ボタンを増やすため,ボタンなどの設定と配置を次のように変える。

guide = tk.Label(text="クリックして生命体を配置し,世代0にセットして始めましょう。")
btn1 = tk.Button(text="次世代",command = nextLife) 
btn2 = tk.Button(text="世代0に戻る",command = init) 
btn3 = tk.Button(text="世代0にセット",command = setting) 
btn4 = tk.Button(text="リセット",command = reset)
drawboad()
guide.pack()
canvas.place(x=0,y=30)
btn1.place(x=20,y=550)
btn2.place(x=100,y=550)
btn3.place(x=210,y=550)
btn4.place(x=330,y=550)

root.mainloop()

実行した直後と,生命体を置いたあとの図である。

画像2

これで完成。

したのだが・・・・ マスの数Nを N=20  くらいにしてやってみると,次世代のボタンを押し,リセットして別のパターンにして,というのを繰り返すうちに,反応が鈍くなってきた。ボタンを押してもすぐには画面が変わらない。

 なぜ? 上のコードを読んで,あるいは実際に実行して,その原因がすぐにわかる人はTkinter がよくわかっている人だろう。
 筆者は初心者だが,ほどなくわかって,対策もできた。
なぜなのか,どうしてわかったのか,どう対策したのか,は 次回の話題とする。
この note の説明の中にヒントはある。