ライフゲームを作る(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()
次の画面ができる。
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()
実行した直後と,生命体を置いたあとの図である。
これで完成。
したのだが・・・・ マスの数Nを N=20 くらいにしてやってみると,次世代のボタンを押し,リセットして別のパターンにして,というのを繰り返すうちに,反応が鈍くなってきた。ボタンを押してもすぐには画面が変わらない。
なぜ? 上のコードを読んで,あるいは実際に実行して,その原因がすぐにわかる人はTkinter がよくわかっている人だろう。
筆者は初心者だが,ほどなくわかって,対策もできた。
なぜなのか,どうしてわかったのか,どう対策したのか,は 次回の話題とする。
この note の説明の中にヒントはある。