🎮

リバーシAIを作るためにリバーシの盤面を作る

に公開

はじめに

「僕AI作ったことがあります!」って響きかっこよくないですか?
最近巷でChatGPTとかGeminiとかの生成AIが流行っていますが、AIはそれ以外にもゲームに特化したAIというのもあります
今回は1からリバーシAIを作成する過程を順に記事にしようかなと思います
では、リバーシAIを作成するときに最初にすることは何かわかりますか?
タイトルにもあるように盤面など人間 vs 人間で普通にリバーシがプレイできる状態を作るのが第一歩になります
なのでまず今回は、普通に遊べるリバーシを作っていきます!
AIの部分は次回以降でやる予定なので気になる方はそちらもぜひ!(AIの部分ができたらこの下にリンクを貼ります)

環境

  • windows10
  • python3.10.4

ファイル構成

  • app.py
  • AI
    • AI1.py
    • AI2.py

こんな感じで今まで一回もファイルを分けるなんてやったことありませんが、AIとゲーム本体を分けた方がやりやすそうだなっていう考えでこのようにしました
この中のapp.pyを今回は作っていきます

実装

リバーシを作るにあたって重要なところを紹介します
まずは一番根底の部分です

app.py
from tkinter import *

class App(Frame):
    def __init__(self, master):
        super().__init__(master)
        self.pack()
        
        self.master.geometry("400x400")
        self.master.title("reversi")
        self.master.resizable(False, False)

if __name__ == "__main__":
    root = Tk()
    app = App(root)
    app.mainloop()

細かい説明は省きますが、これを実行すると基本の画面を作ることができます(さすがtkinter)
classを使うことで関数の管理がしやすくなるので、今回のようにプロジェクトが大きくなる可能性があるときはclassで作りましょう

次にリバーシの画面を作っていきます

app.py
class App(Frame):
    # ウィジェットの配置
    def create_widget(self):
        # キャンバス作成
        self.canvas = Canvas(self.master, height=400, width=400, bg="green")
        self.canvas.pack()
        
        # 線引き
        for line in range(1,8,1):
            self.canvas.create_line(50*line, 0, 50*line, 400)
            self.canvas.create_line(0, 50*line, 400, 50*line)
            
        # 初期石配置
        self.canvas.create_oval(150+3,150+3,200-3,200-3, fill="white", outline="white", tag="stone_3_3")
        self.canvas.create_oval(200+3,200+3,250-3,250-3, fill="white", outline="white", tag="stone_4_4")
        self.canvas.create_oval(200+3,150+3,250-3,200-3, fill="black", tag="stone_3_4")
        self.canvas.create_oval(150+3,200+3,200-3,250-3, fill="black", tag="stone_4_3")
        self.canvas.update()

先ほど作成したclassにcreate_widgetというウィジェットを配置するための関数を作りました

# キャンバス作成
self.canvas = Canvas(self.master, height=400, width=400, bg="green")
self.canvas.pack()

ここでは背景色が緑の400x400の大きさのキャンバスを作成しています
self.を付けたのは他の関数からも参照したいときがあるためです

# 線引き
for line in range(1,8,1):
    self.canvas.create_line(50*line, 0, 50*line, 400)
    self.canvas.create_line(0, 50*line, 400, 50*line)

ここでは、先ほど作成したキャンバスにリバーシのグリッド線を引くための部分になります
線を引くための関数は次のようになっており、x1,y1は始点、x2,y2は終点であり、始点から終点に向かって直線が引かれます
canvas.create_line(x1, y1, x2, y2)

# 初期石配置
self.canvas.create_oval(150+3,150+3,200-3,200-3, fill="white", outline="white", tag="stone_3_3")
self.canvas.create_oval(200+3,200+3,250-3,250-3, fill="white", outline="white", tag="stone_4_4")
self.canvas.create_oval(200+3,150+3,250-3,200-3, fill="black", tag="stone_3_4")
self.canvas.create_oval(150+3,200+3,200-3,250-3, fill="black", tag="stone_4_3")
self.canvas.update()

ここではリバーシを始めた際にすでに置かれている4つの石を配置しています
円を描くための関数は次のようになっており、x1,y1を左上、x2,y2を右下にする長方形にすっぽり入るように円が描かれます
canvas.create_oval(x1, y1, x2, y2)
白石はこの関数の引数にfill="white",outline="white"を追加して表現しています
黒石はこの関数の引数にfill="black"を追加して表現しています
そして石をひっくり返す時には、念のためすでに描かれている石を削除したいので引数に
tag=stone_y座標_x座標として設定しています

これでようやく盤面が完成しました🎉
次に現在の盤面においての合法手を求める関数を作ります

app.py
class App(Frame):
    def __init__(self, master):

    # 盤面
    self.board = [[0]*8 for _ in range(8)]
    self.board[3][4] = 1
    self.board[4][3] = 1
    self.board[3][3] = -1
    self.board[4][4] = -1
    
    self.turn = 1 # 現在の手番

    # 自石が置ける場所を判断する
    def judge(self):
        move = [(1,0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
        return_board = []
        for row_board in range(8):
            for col_board in range(8):
                if self.board[row_board][col_board] != 0:
                    continue
                # 8方向確認
                for vec in move:
                    y = row_board + vec[0]
                    x = col_board + vec[1]
                    # 盤面を外れたらやり直し
                    if not (0 <= y <= 7 and 0 <= x <= 7):
                        continue
                    # 値が0ならやり直し
                    if self.board[y][x] == 0:
                        continue
                    # 値が自分の石ならやり直し
                    if self.board[y][x] == self.turn:
                        continue
                    while True:
                        y += vec[0]
                        x += vec[1]
                        if not (0 <= y <= 7 and 0 <= x <= 7):
                            break
                        elif self.board[y][x] == 0:
                            break
                        # 値が自分の石の時
                        if self.board[y][x] == self.turn:
                            return_board.append([row_board, col_board])
                            break
                    if [row_board, col_board] in return_board:
                        break
        return return_board

合法手を求める関数を作るために__init__に現在の盤面を保持する変数として盤面が0埋めされたself.boardを追加しました
その下のコードでは、最初から配置されている石の設定をしています(黒石が1、白石が-1)
その次のself.turnは現在がどちらの手番なのかを保持する変数です

def judge(self):は合法手を求める関数になります
コードが少し長いので難しそうに見えますが、簡単に説明すると盤面を全探索して現在の手番の人が置けるところを調べて、そこに置くことで一つでもひっくり返すことができるなら、return_boardというリストに座標を追加し、全部調べ終わったらそのリストを返すようにプログラムしています
合法手を求めることができたら、次に石を置く関数の説明に入ります

app.py
class App(Frame):
    # 石を置く
    def put(self, y, x):
        move = [(1,0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
        tag = f"stone_{y}_{x}"
        if self.turn == 1:
            self.canvas.create_oval(x*50+3,y*50+3,(x+1)*50-3,(y+1)*50-3, fill="black", tag=tag)
        elif self.turn == -1:
            self.canvas.create_oval(x*50+3,y*50+3,(x+1)*50-3,(y+1)*50-3, fill="white", outline="white", tag=tag)
        for vec in move:
            put_stone = []
            new_y = y + vec[0]
            new_x = x + vec[1]
            if not (0 <= new_y <= 7 and 0 <= new_x <= 7):
                continue
            if self.board[new_y][new_x] == 0:
                continue
            if self.board[new_y][new_x] == self.turn:
                continue
            put_stone.append([new_y, new_x])
            while True:
                new_y += vec[0]
                new_x += vec[1]
                if not (0 <= new_y <= 7 and 0 <= new_x <= 7):
                    break
                if self.board[new_y][new_x] == 0:
                    break
                if self.board[new_y][new_x] == self.turn:
                    for stone in put_stone:
                        self.board[stone[0]][stone[1]] = self.turn
                        tag = f"stone_{stone[0]}_{stone[1]}"
                        self.canvas.delete(tag)
                        if self.turn == 1:
                            self.canvas.create_oval(stone[1]*50+3,stone[0]*50+3,(stone[1]+1)*50-3,(stone[0]+1)*50-3, fill="black", tag=tag)
                        elif self.turn == -1:
                            self.canvas.create_oval(stone[1]*50+3,stone[0]*50+3,(stone[1]+1)*50-3,(stone[0]+1)*50-3, fill="white", outline="white",tag=tag)
                    break
                put_stone.append([new_y, new_x])

この関数では指定された座標から8方向確認してひっくり返す処理をしています
self.canvas.delete(tag)ではひっくり返す場所にある石を削除しています
そしたら次のif文で現在の手番を確認し、その人の色の石をself.canvas.create_oval()によって描画しています
では次に、この関数の引数の座標を決定する関数の説明になります

app.py
class App(Frame):
    # クリック
    def click(self, event):
        x = event.x//50
        y = event.y//50
        if [y, x] not in self.result:
            return
        self.canvas.unbind("<Button>")
        self.canvas.delete("legal_move")
        self.board[y][x] = self.turn
        self.put(y, x)
        self.turn *= -1
        self.execute_turn()

この関数は盤面をクリックした時に動く関数になります
引数のeventはクリックしたcanvasの情報などが格納されており、event.x,event.yと記述することでクリックした場所のxとyの座標を受け取ることができます
その情報を50で割ることで2次元配列のインデックスと等価として考えることができます
関数内3行目のif文は先ほどの合法手を求める関数の戻り値を格納しているself.resultというリストにクリックした座標が入っていればそこは合法手であると確認ができます
次にself.canvas.unbind("<Button>")でcanvasにbindされていた左クリックをした時に動くように設定されていた関数をunbindしています(外しています)
これに設定されていた関数はまさに今説明している関数でクリックが正常にできたら一旦そのあとクリックしても何も動かないようにしています
self.canvas.delete("legal_move")は細かいことは次の関数で説明しますが、このタグがつけられたcanvas上に描かれたものを削除しています

self.board[y][x] = self.turn
self.put(y, x)
self.turn *= -1

ここでは、現在の盤面を更新した後、先ほどの出てきた石を置く関数をここで実行して、ターンを相手に渡しています
次に出てくるself.execute_turn()という関数はターンを制御するための関数で詳細は最後に説明を入れています

次にself.canvas.delete("legal_move")に関する関数の説明です

app.py
class App(Frame):
    # 置ける手を表示
    def show_move(self):
        for b in self.result:
            if self.turn == 1:
                self.canvas.create_oval(b[1]*50+20,b[0]*50+20,(b[1]+1)*50-20,(b[0]+1)*50-20, fill="black", tag="legal_move")
            else:
                self.canvas.create_oval(b[1]*50+20,b[0]*50+20,(b[1]+1)*50-20,(b[0]+1)*50-20, fill="white", outline="white", tag="legal_move")
        self.canvas.update()

この関数では次に置ける手をユーザー視点で分かりやすいようにcanvas上に表示する関数になっています
合法手が格納されているリストから一つずつ取り出して、現在の手番の色に合わせて小さな円をcanvas上に表示しています
次のターンになったらこの表示されている円は削除したいので、一律でlegal_moveというtagをつけています(先ほどのクリックしたときに削除していたのはこのタグが付いたものになります)

最後にターンを制御するための関数の説明になります

app.py
from tkinter import messagebox
import numpy as np

class App(Frame):
    # ターン制御
    def execute_turn(self):
        self.result = self.judge()
        if not self.result: # パス
            self.turn *= -1
            self.result = self.judge()
            if not self.result: # お互いにパス(ゲーム終了)
                self.board = np.array(self.board)
                white = np.count_nonzero(self.board == -1)
                black = np.count_nonzero(self.board == 1)
                if white < black:
                    text = "先手の勝利"
                elif black < white:
                    text = "後手の勝利"
                else:
                    text = "引き分け"
                messagebox.showinfo(title="ゲーム結果", message=f"{text}\n白石 : {white}個 黒石 : {black}個")
                self.master.destroy()
                return
        self.show_move()
        self.canvas.bind("<Button>", self.click)

一行目で合法手をself.resultに格納しています
もしその中身が空(置ける場所がない)ならFalseが返り、パスをした状態になります
それが2連続続いたら、どちらも置けるところがなく膠着状態となるのでゲーム終了になります
ゲーム終了時には、それぞれの石の数をnp.count_nonzeroで確認し、多い方の勝利とメッセージボックスで表示するようにしています
もしパスにならないなら、合法手をself.show_moveにて表示し、self.canvas.bind("<Button>", self.click)にてcanvasを左クリックした時に動く関数を設定します

この下にコードの全文を載せておくので使ってみたい方はこちらからコピペして使ってみてください
いつかはgithubにあげる予定(未定)

コード全文
app.py
from tkinter import *
from tkinter import messagebox
import numpy as np

class App(Frame):
    def __init__(self, master):
        super().__init__(master)
        self.pack()
        
        self.master.geometry("400x400")
        self.master.title("reversi")
        self.master.resizable(False, False)
        
        # 盤面
        self.board = [[0]*8 for _ in range(8)]
        self.board[3][4] = 1
        self.board[4][3] = 1
        self.board[3][3] = -1
        self.board[4][4] = -1
        
        self.turn = 1 # 現在の手番
        
        self.create_widget()
        self.execute_turn()
    
    # ウィジェットの配置
    def create_widget(self):
        # キャンバス作成
        self.canvas = Canvas(self.master, height=400, width=400, bg="green")
        self.canvas.pack()
        
        # 線引き
        for line in range(1,8,1):
            self.canvas.create_line(50*line, 0, 50*line, 400)
            self.canvas.create_line(0, 50*line, 400, 50*line)
            
        # 初期石配置
        self.canvas.create_oval(150+3,150+3,200-3,200-3, fill="white", outline="white", tag="stone_3_3")
        self.canvas.create_oval(200+3,200+3,250-3,250-3, fill="white", outline="white", tag="stone_4_4")
        self.canvas.create_oval(200+3,150+3,250-3,200-3, fill="black", tag="stone_3_4")
        self.canvas.create_oval(150+3,200+3,200-3,250-3, fill="black", tag="stone_4_3")
        self.canvas.update()
    
    # ターン制御
    def execute_turn(self):
        self.result = self.judge()
        if not self.result: # パス
            self.turn *= -1
            self.result = self.judge()
            if not self.result: # お互いにパス(ゲーム終了)
                self.board = np.array(self.board)
                white = np.count_nonzero(self.board == -1)
                black = np.count_nonzero(self.board == 1)
                if white < black:
                    text = "先手の勝利"
                elif black < white:
                    text = "後手の勝利"
                else:
                    text = "引き分け"
                messagebox.showinfo(title="ゲーム結果", message=f"{text}\n白石 : {white}個 黒石 : {black}個")
                self.master.destroy()
                return
        self.show_move()
        self.canvas.bind("<Button>", self.click)
    
    # クリック
    def click(self, event):
        x = event.x//50
        y = event.y//50
        if [y, x] not in self.result:
            return
        self.canvas.unbind("<Button>")
        self.canvas.delete("legal_move")
        self.board[y][x] = self.turn
        self.put(y, x)
        self.turn *= -1
        self.execute_turn()
    
    # 自石が置ける場所を判断する
    def judge(self):
        move = [(1,0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
        return_board = []
        for row_board in range(8):
            for col_board in range(8):
                if self.board[row_board][col_board] != 0:
                    continue
                # 8方向確認
                for vec in move:
                    y = row_board + vec[0]
                    x = col_board + vec[1]
                    # 盤面を外れたらやり直し
                    if not (0 <= y <= 7 and 0 <= x <= 7):
                        continue
                    # 値が0ならやり直し
                    if self.board[y][x] == 0:
                        continue
                    # 値が自分の石ならやり直し
                    if self.board[y][x] == self.turn:
                        continue
                    while True:
                        y += vec[0]
                        x += vec[1]
                        if not (0 <= y <= 7 and 0 <= x <= 7):
                            break
                        elif self.board[y][x] == 0:
                            break
                        # 値が自分の石の時
                        if self.board[y][x] == self.turn:
                            return_board.append([row_board, col_board])
                            break
                    if [row_board, col_board] in return_board:
                        break
        return return_board
    
    # 石を置く
    def put(self, y, x):
        move = [(1,0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
        tag = f"stone_{y}_{x}"
        if self.turn == 1:
            self.canvas.create_oval(x*50+3,y*50+3,(x+1)*50-3,(y+1)*50-3, fill="black", tag=tag)
        elif self.turn == -1:
            self.canvas.create_oval(x*50+3,y*50+3,(x+1)*50-3,(y+1)*50-3, fill="white", outline="white", tag=tag)
        for vec in move:
            put_stone = []
            new_y = y + vec[0]
            new_x = x + vec[1]
            if not (0 <= new_y <= 7 and 0 <= new_x <= 7):
                continue
            if self.board[new_y][new_x] == 0:
                continue
            if self.board[new_y][new_x] == self.turn:
                continue
            put_stone.append([new_y, new_x])
            while True:
                new_y += vec[0]
                new_x += vec[1]
                if not (0 <= new_y <= 7 and 0 <= new_x <= 7):
                    break
                if self.board[new_y][new_x] == 0:
                    break
                if self.board[new_y][new_x] == self.turn:
                    for stone in put_stone:
                        self.board[stone[0]][stone[1]] = self.turn
                        tag = f"stone_{stone[0]}_{stone[1]}"
                        self.canvas.delete(tag)
                        if self.turn == 1:
                            self.canvas.create_oval(stone[1]*50+3,stone[0]*50+3,(stone[1]+1)*50-3,(stone[0]+1)*50-3, fill="black", tag=tag)
                        elif self.turn == -1:
                            self.canvas.create_oval(stone[1]*50+3,stone[0]*50+3,(stone[1]+1)*50-3,(stone[0]+1)*50-3, fill="white", outline="white",tag=tag)
                    break
                put_stone.append([new_y, new_x])
    
    # 置ける手を表示
    def show_move(self):
        for b in self.result:
            if self.turn == 1:
                self.canvas.create_oval(b[1]*50+20,b[0]*50+20,(b[1]+1)*50-20,(b[0]+1)*50-20, fill="black", tag="legal_move")
            else:
                self.canvas.create_oval(b[1]*50+20,b[0]*50+20,(b[1]+1)*50-20,(b[0]+1)*50-20, fill="white", outline="white", tag="legal_move")
        self.canvas.update()
            
if __name__ == "__main__":
    root = Tk()
    app = App(root)
    app.mainloop()

最後に

今回はリバーシAIを動かすための基盤のリバーシを作成しました!
tkinterだけ使えれば(楽するためにnumpyも使いましたが)昔遊んだリバーシをpythonで作れてしまうなんてなんかすごいですよね
次回からは実際にAIを作って動かしていこうと思います!
完成してその記事をあげたら上の方にリンクを貼っておくのでぜひご確認ください!
大変長くなってしまいましたがここまで読んでくれた皆様ありがとうございます。

Discussion