人機對戰初體驗—四子棋游戲
繼去年3月人機大戰引發全球矚目以來,圍棋AI(人工智能)再度引發跨領域的關注:一個叫Master的圍棋AI,幾天時間,面對中日韓頂尖職業圍棋選手,已取得60勝0敗的恐怖戰績,展現出的圍棋技藝已經到了人類理解不了的程度。這可以視為人工智能在圍棋領域的一次“大征服”,而在此之外的意義則是,告訴了我們人工智能在征服一項領域或職業時,究竟速度有多快。理解這一點,對於人類,乃至每一個人,都非常重要。通過本實驗的學習,可以對人機對戰有初步了解。
一、實驗介紹
1.1 實驗內容
實驗利用Python模擬AI和玩家進行四子棋游戲,利用游戲實驗Pygame庫,為游戲提供界面和操作支持。AI算法借用蒙特卡洛搜索樹思想。通過設置AI的難度系數,即AI所能考慮到的未來棋子的可能走向,從而選擇出最佳的方案和玩家對抗。難度系數越大,AI搜索范圍越廣,它所能做出的決定越明智。
游戲最終效果截圖:
1.2 實驗知識點
- Pygame的基礎操作
- 蒙特卡洛搜索樹
1.3 實驗環境
- Python2.7
- gedit
1.4 適合人群
本課程難度一般,屬於初級課程,適合具有Python基礎並對Pygame有所了解的用戶學習。
1.5 代碼獲取
你可以通過下面命令將代碼下載到實驗樓環境中,作為參照對比進行學習。
$ wget http://labfile.oss.aliyuncs.com/courses/746/fourinrow.py
二、四子棋游戲
四子棋游戲是在7*6的格子中。輪流從格子最上方落下棋子。棋子會落在該列格子中最下面的空格子里。先將四個棋子連成一條線(水平直線,豎直直線,或傾斜直線)者獲勝,游戲結束。
三、項目文件結構
四、實驗步驟
4.1 開發准備
在Code目錄下進行創建工程文件Fourinrow,在終端執行命令
cd Code && mkdir Fourinrow
下載本次實驗所需的圖片資源到Fourinrow文件下
$ cd Fourinrow
$ wget http://labfile.oss.aliyuncs.com/courses/746/images.zip
$ unzip images.zip
安裝依賴包
$ sudo apt-get update
$ sudo apt-get install python-pygame
4.2 游戲流程
4.3 初始化變量
用到的變量包括,棋盤的寬度,長度(可以修改,設計不同規格的棋盤),難度系數,棋子大小以及一些設計坐標變量的設定。
在FourinRow.py
文件中輸入如下代碼:
import random, copy, sys, pygame from pygame.locals import * BOARDWIDTH = 7 # 棋子盤的寬度欄數 BOARDHEIGHT = 6 # 棋子盤的高度欄數 assert BOARDWIDTH >= 4 and BOARDHEIGHT >= 4, 'Board must be at least 4x4.' #python assert斷言是聲明其布爾值必須為真的判定,如果發生異常就說明表達示為假。 #可以理解assert斷言語句為raise-if-not,用來測試表示式,其返回值為假,就會觸發異常。 DIFFICULTY = 2 # 難度系數,計算機能夠考慮的移動級別 #這里2表示,考慮對手走棋的7種可能性及如何應對對手的7種走法 SPACESIZE = 50 # 棋子的大小 FPS = 30 # 屏幕的更新頻率,即30/s WINDOWWIDTH = 640 # 游戲屏幕的寬度像素 WINDOWHEIGHT = 480 # 游戲屏幕的高度像素 XMARGIN = int((WINDOWWIDTH - BOARDWIDTH * SPACESIZE) / 2)#X邊緣坐標量,即格子欄的最左邊 YMARGIN = int((WINDOWHEIGHT - BOARDHEIGHT * SPACESIZE) / 2)#Y邊緣坐標量,即格子欄的最上邊 BRIGHTBLUE = (0, 50, 255)#藍色 WHITE = (255, 255, 255)#白色 BGCOLOR = BRIGHTBLUE TEXTCOLOR = WHITE RED = 'red' BLACK = 'black' EMPTY = None HUMAN = 'human' COMPUTER = 'computer'
除此之外我們還需要定義一些pygame的全局變量。這些全局變量在之后的各個模塊中會被多次調用。其中很多是存儲載入圖片的變量,准備工作有點長,請大家耐心一點哦。
#初始化pygame的各個模塊 pygame.init() #初始化了一個Clock對象 FPSCLOCK = pygame.time.Clock() #創建游戲窗口 DISPLAYSURF = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT)) #游戲窗口標題 pygame.display.set_caption(u'four in row') #Rect(left,top,width,height)用來定義位置和寬高 REDPILERECT = pygame.Rect(int(SPACESIZE / 2), WINDOWHEIGHT - int(3 * SPACESIZE / 2), SPACESIZE, SPACESIZE) #這里創建的是窗口中左下角和右下角的棋子 BLACKPILERECT = pygame.Rect(WINDOWWIDTH - int(3 * SPACESIZE / 2), WINDOWHEIGHT - int(3 * SPACESIZE / 2), SPACESIZE, SPACESIZE) #載入紅色棋子圖片 REDTOKENIMG = pygame.image.load('4row_red.png') #將紅色棋子圖片縮放為SPACESIZE REDTOKENIMG = pygame.transform.smoothscale(REDTOKENIMG, (SPACESIZE, SPACESIZE)) #載入黑色棋子圖片 BLACKTOKENIMG = pygame.image.load('4row_black.png') #將黑色棋子圖片縮放為SPACESIZE BLACKTOKENIMG = pygame.transform.smoothscale(BLACKTOKENIMG, (SPACESIZE, SPACESIZE)) #載入棋子面板圖片 BOARDIMG = pygame.image.load('4row_board.png') #將棋子面板圖片縮放為SPACESIZE BOARDIMG = pygame.transform.smoothscale(BOARDIMG, (SPACESIZE, SPACESIZE)) #載入人勝利時圖片 HUMANWINNERIMG = pygame.image.load('4row_humanwinner.png') #載入AI勝時圖片 COMPUTERWINNERIMG = pygame.image.load('4row_computerwinner.png') #載入平局提示圖片 TIEWINNERIMG = pygame.image.load('4row_tie.png') #返回一個Rect實例 WINNERRECT = HUMANWINNERIMG.get_rect() #游戲窗口中間位置坐標 WINNERRECT.center = (int(WINDOWWIDTH / 2), int(WINDOWHEIGHT / 2)) #載入操作提示圖片 ARROWIMG = pygame.image.load('4row_arrow.png') #返回一個Rect實例 ARROWRECT = ARROWIMG.get_rect() #操作提示的左位置 ARROWRECT.left = REDPILERECT.right + 10 #將操作提示與下方紅色棋子實例在縱向對齊 ARROWRECT.centery = REDPILERECT.centery
至此我們完成了前期的准備工作。
4.4 棋盤設計
初始時,將棋盤二維列表清空,然后根據玩家和AI的走法將棋盤相應位置設定顏色。
def drawBoard(board, extraToken=None): #DISPLAYSURF 是我們的界面,在初始化變量模塊中有定義 DISPLAYSURF.fill(BGCOLOR)#將游戲窗口背景色填充為藍色 spaceRect = pygame.Rect(0, 0, SPACESIZE, SPACESIZE)#創建Rect實例 for x in range(BOARDWIDTH): #確定每一列中每一行中的格子的左上角的位置坐標 for y in range(BOARDHEIGHT): spaceRect.topleft = (XMARGIN + (x * SPACESIZE), YMARGIN + (y * SPACESIZE)) #x =0,y =0時,即第一列第一行的格子。 if board[x][y] == RED:#如果格子值為紅色 #則在在游戲窗口的spaceRect中畫紅色棋子 DISPLAYSURF.blit(REDTOKENIMG, spaceRect) elif board[x][y] == BLACK: #否則畫黑色棋子 DISPLAYSURF.blit(BLACKTOKENIMG, spaceRect) # extraToken 是包含了位置信息和顏色信息的變量 # 用來顯示指定的棋子 if extraToken != None: if extraToken['color'] == RED: DISPLAYSURF.blit(REDTOKENIMG,(extraToken['x'], extraToken['y'], SPACESIZE, SPACESIZE)) elif extraToken['color'] == BLACK: DISPLAYSURF.blit(BLACKTOKENIMG, (extraToken['x'], extraToken['y'], SPACESIZE, SPACESIZE)) # 畫棋子面板 for x in range(BOARDWIDTH): for y in range(BOARDHEIGHT): spaceRect.topleft = (XMARGIN + (x * SPACESIZE), YMARGIN + (y * SPACESIZE)) DISPLAYSURF.blit(BOARDIMG, spaceRect) # 畫游戲窗口中左下角和右下角的棋子 DISPLAYSURF.blit(REDTOKENIMG, REDPILERECT) # 左邊的紅色棋子 DISPLAYSURF.blit(BLACKTOKENIMG, BLACKPILERECT) # 右邊的黑色棋子 def getNewBoard(): board = [] for x in range(BOARDWIDTH): board.append([EMPTY] * BOARDHEIGHT) return board #返回board列表,其值為BOARDHEIGHT數量的None
4.5 AI獲取最佳移動算法
簡單介紹一下蒙特卡洛搜索樹的思想:
利用一維中的擲點法完成對圍棋盤面的評估。具體來講,當我們給定某一個棋盤局面時,程序在當前局面的所有可下點中隨機選擇一個點擺上棋子,並不斷重復這個隨機選擇可下點(擲點)的過程,直到雙方都沒有可下點(即對弈結束),再把這個最終狀態的勝負結果反饋回去,作為評估當前局面的依據。
在該實驗當中AI通過不斷選擇不同的欄,然后考慮雙方的獲勝結果進行評估,AI最終會選擇評估較高的策略。
在瀏覽下面圖片和文字之前請先看一下后面的代碼,然后在對應講解內容。
觀察下面圖示中AI和player的對決
實驗中有些變量可以直觀反映了AI棋子操作的過程:
PotentialMoves:返回一個列表,表示AI將棋子移動到列表中任一欄時獲勝的可能性大小,其數值為-7~0的隨機數,數值為負數時表示AI將棋子移動到這一欄時,玩家可能會在接下來兩步取勝,數值越小表示玩家獲勝可能性越大。為0,表示玩家不會獲勝,並且AI也不可能獲勝,為1表示AI可以獲勝。
bestMoveFitness:適應度是選取PotentialMoves中最大的數值
bestMoves:如果PotentialMoves中有多個最大值,則表示AI將棋子移動到這些值所在的欄時,玩家獲勝的幾率都是最小的。所以將這些欄重新添加到列表bestMoves中。
column:當bestMoves為多個值時隨機選擇bestMoves中的一欄作為AI的移動。若是唯一值,則column為這個唯一值。
實驗中通過打印這些 bestMoveFitness ,bestMoves , column ,potentialMoves得出在上圖中AI的每一步參數:
AI moves:
steps | potentialMoves | bestMoveFitness | bestMoves | column |
---|---|---|---|---|
1 | [0, 0, 0, 0, 0, 0, 0] | 0 | [0, 1, 2, 3, 4, 5, 6] | 0 |
2 | [0, 0, 0, 0, 0, 0, 0] | 0 | [0, 1, 2, 3, 4, 5, 6] | 6 |
3 | [-1, -1, -1, 0, -1, -1, -1] | 0 | [3] | 3 |
4 | [-3, -2, 0, -3, -3, -2, -3] | 0 | [2] | 2 |
通過第三步AI的選擇,更加細致地了解算法的原理:
下圖是部分AI走法示意圖,該圖顯示了如果AI將棋子落在第一格中,Player的可能選擇,以及AI接下來的一步對player獲勝產生的影響,正是通過這種搜索,迭代AI可以判定在接下來兩步中對手和自己的獲勝情況,從而做出抉擇。
下圖是計算AI適應度值的流程圖,實驗中難度系數為2,需考慮7 ^ 4=2041次:
通過以上流程圖,不難發現。AI的第一步棋子,若為0,1,2,4,5,6。則Player總有可能將剩下的兩個棋子全部放在3,從而獲勝。以AI=0為例,若player =0,即紅色的第1枚棋子不在3,第2枚紅色棋子不論在哪,都不可能獲勝,為方便表述,用序列表示各種組合,序列第一個表是AI第一步,第二個數表示Player的回應,第三個數表示AI的回應。X表示任意有效移動。 所以[0,0,x]=0,推理得,當序列為[0,x<>3,x],player都不能獲勝。只有當player的第2枚棋子為3時,AI的第二枚在不為3的情況下都能獲勝,所以[0,x=3,x<>3] =-1。共有6種情況。最終的結果為(0+0+…(43個)-1*6)/7 =-1 同理對於其他的四種,結果都為-1。當AI第一步就在3的話,Player就不可能獲勝,並且AI也不能獲勝,所以為0。AI會選擇最高適應度值來走,即會在第3列落下棋子。
同理可以分析接下來AI的選擇。歸納起來便是,如果AI的一步使得Player獲勝的可能性越大,AI的適應度值越低,AI也選擇適應度較高的,即按照阻止Player獲勝的走法進行。當然,如果它自己能夠獲勝,它會優先將自己獲勝的走法設置最高適應度。
def getPotentialMoves(board, tile, lookAhead): if lookAhead == 0 or isBoardFull(board): ''' 如果難度系數為0,或格子已滿 則返回列表值全為0,即此時 適應度值和列的潛在移動值相等。 此時AI將隨機降落棋子,失去智能 ''' return [0] * BOARDWIDTH #確定對手棋子顏色 if tile == RED: enemyTile = BLACK else: enemyTile = RED potentialMoves = [0] * BOARDWIDTH #初始一個潛在的移動列表,其數值全部為0 for firstMove in range(BOARDWIDTH): #對每一欄進行遍歷,將雙方中的任一方的移動稱為firstMove #則另外一方的移動就稱為對手,counterMove。 #這里我們的firstMove為AI,對手為玩家。 dupeBoard = copy.deepcopy(board) #這里用深復制是為了讓board和dupeBoard不互相影響 if not isValidMove(dupeBoard, firstMove): #如果在dupeBoard中黑色棋子移到firstMove欄無效 continue #則繼續下一個firstMove makeMove(dupeBoard, tile, firstMove) #如果是有效移動,則設置相應的格子顏色 if isWinner(dupeBoard, tile): #如果獲勝 potentialMoves[firstMove] = 1 #獲勝的棋子自動獲得一個很高的數值來表示其獲勝的幾率 #數值越大,獲勝可能性越大,對手獲勝可能性越小。 break #不要干擾計算其他的移動 else: if isBoardFull(dupeBoard): #如果dupeBoard中沒有空格 potentialMoves[firstMove] = 0 #無法移動 else: for counterMove in range(BOARDWIDTH): #考慮對手的移動 dupeBoard2 = copy.deepcopy(dupeBoard) if not isValidMove(dupeBoard2, counterMove): continue makeMove(dupeBoard2, enemyTile, counterMove) if isWinner(dupeBoard2, enemyTile): potentialMoves[firstMove] = -1 #如果玩家獲勝,則AI的在此欄值最低 break else: # 遞歸調用getPotentialMoves results = getPotentialMoves(dupeBoard2, tile, lookAhead - 1) potentialMoves[firstMove] += (sum(results) / BOARDWIDTH) / BOARDWIDTH return potentialMoves
4.6 玩家操作
拖拽棋子,判斷棋子所在位置的格子,驗證棋子的有效性,調用棋子下落函數,完成操作。
def getHumanMove(board, isFirstMove): draggingToken = False tokenx, tokeny = None, None while True: # pygame.event.get()來處理所有的事件 for event in pygame.event.get(): if event.type == QUIT:#停止,退出 pygame.quit() sys.exit() elif event.type == MOUSEBUTTONDOWN and not draggingToken and REDPILERECT.collidepoint(event.pos): #如果事件類型為鼠標按下,notdraggingToken為True,鼠標點擊的位置在REDPILERECT里面 draggingToken = True tokenx, tokeny = event.pos elif event.type == MOUSEMOTION and draggingToken:#如果開始拖動了紅色棋子 tokenx, tokeny = event.pos #更新被拖拽的棋子的位置 elif event.type == MOUSEBUTTONUP and draggingToken: #如果鼠標松開,並且棋子被拖拽 #如果棋子被拖拽在board的正上方 if tokeny < YMARGIN and tokenx > XMARGIN and tokenx < WINDOWWIDTH - XMARGIN: column = int((tokenx - XMARGIN) / SPACESIZE)#根據棋子的x坐標確定棋子會落的列(0,1...6) if isValidMove(board, column):#如果棋子移動有效 """ 掉落在相應的空格子中, 這里只是顯示一個掉落的效果 不用這個函數也能通過下面的代碼實現棋子填充空格 """ animateDroppingToken(board, column, RED) #將空格中最下面的格子設為紅色 board[column][getLowestEmptySpace(board, column)] = RED drawBoard(board)#在落入的格子中畫紅色棋子 pygame.display.update()#窗口更新 return tokenx, tokeny = None, None draggingToken = False if tokenx != None and tokeny != None:#如果拖動了棋子,則顯示拖動的棋子 drawBoard(board, {'x':tokenx - int(SPACESIZE / 2), 'y':tokeny - int(SPACESIZE / 2), 'color':RED}) #並且通過調整x,y的坐標使拖動時,鼠標始終位於棋子的中心位置。 else: drawBoard(board)#當為無效移動時,鼠標松開后,因為此時board中所有格子的值均為none #調用drawBoard時,進行的操作是顯示下面的兩個棋子,相當於棋子回到到開始拖動的地方。 if isFirstMove: DISPLAYSURF.blit(ARROWIMG, ARROWRECT)#AI先走,顯示提示操作圖片 pygame.display.update() FPSCLOCK.tick()
4.7 AI操作
實現AI棋子自動移動並降落到相應位置的函數。
def animateComputerMoving(board, column): x = BLACKPILERECT.left#下面黑色棋子的左坐標 y = BLACKPILERECT.top #下面黑色棋子的上坐標 speed = 1.0 while y > (YMARGIN - SPACESIZE):#當y的值較大,即棋子位於窗口下方時 y -= int(speed)#y不斷減小,即棋子不斷上移 speed += 0.5#減小的速度增加 drawBoard(board, {'x':x, 'y':y, 'color':BLACK}) #y不斷變化,不斷繪制紅色棋子,形成不斷上升的效果 pygame.display.update() FPSCLOCK.tick() #當棋子上升到borad頂端時 y = YMARGIN - SPACESIZE#y重新賦值,此時棋子的最下邊和board的最上邊相切 speed = 1.0 while x > (XMARGIN + column * SPACESIZE):#當x值大於需要移到的列的x坐標時 x -= int(speed)#x值不斷減小,即左移 speed += 0.5 drawBoard(board, {'x':x, 'y':y, 'color':BLACK}) #此時y坐標已經不變,即從board上端向左平移到所在列。 pygame.display.update() FPSCLOCK.tick() #黑色棋子降落到計算得到的空格 animateDroppingToken(board, column, BLACK)
通過返回的potentialMoves,選擇其列表中最高的數字作為適應度值,並從這些適應度高欄中隨機選擇作為最后的移動目標。
def getComputerMove(board): potentialMoves = getPotentialMoves(board, BLACK, DIFFICULTY)#潛在的移動,是一個含BOARDWIDTH個值的列表。。 print potentialMoves #列表值與設置的難度系數有關 bestMoves = [] bestMoveFitness =max(potentialMoves) print bestMoveFitness #建立bestMoves空列表 for i in range(len(potentialMoves)): if potentialMoves[i] == bestMoveFitness and isValidMove(board, i): bestMoves.append(i) #列出所有可以移動到的列,該列表可能為空,可能只有一個值,也可能有多個值 print bestMoves return random.choice(bestMoves)#從可以移動到的列中,隨機選擇一個作為移動到的目標
4.8 棋子移動操作
通過不斷改變棋子的相應坐標,實現下落的動畫效果。
def getLowestEmptySpace(board, column): # 返回最一列中最下面的空格 for y in range(BOARDHEIGHT-1, -1, -1): if board[column][y] == EMPTY: return y return -1 def makeMove(board, player, column): lowest = getLowestEmptySpace(board, column)#返回一欄中 if lowest != -1:#如果格子中的有空格 board[column][lowest] = player '''則將player(red/black)賦值給一欄中的最low的一個空格 因為,棋子是落在一欄當中所有空格的最下面一個空格 即認定這個格子中的顏色 ''' def animateDroppingToken(board, column, color): x = XMARGIN + column * SPACESIZE #x坐標 y = YMARGIN - SPACESIZE #y坐標 dropSpeed = 1.0#棋子降落的速度 lowestEmptySpace = getLowestEmptySpace(board, column)#一列的空格當中最下面的一個空格 while True: y += int(dropSpeed)#y的坐標以dropSpeed疊加 dropSpeed += 0.5#dropSpeed也在加速,即棋子下落的加速度為0.5 if int((y - YMARGIN) / SPACESIZE) >= lowestEmptySpace:#判斷到達最下面的空格 return drawBoard(board, {'x':x, 'y':y, 'color':color})#y不斷變化,不斷繪制紅色棋子,形成不斷降落的效果 pygame.display.update() FPSCLOCK.tick()
4.9 一些判斷函數
判斷棋子的移動是否有效,判斷棋盤是否還有空格
def isValidMove(board, column): #判斷棋子移動有效性 if column < 0 or column >= (BOARDWIDTH) or board[column][0] != EMPTY: #如果列<0,或>BOARDWIDTH,或列中沒有空格子 return False #則為無效的移動,否則有效 return True def isBoardFull(board): #如果格子中沒有空余,則返回True for x in range(BOARDWIDTH): for y in range(BOARDHEIGHT): if board[x][y] == EMPTY: return False return True
4.10 獲勝條件判斷
幾張示意圖方便了解,獲勝的四種情況。圖中所示是x,y取極值時所對應的位置。
def isWinner(board, tile): # 檢查水平方向棋子情況 for x in range(BOARDWIDTH - 3):#x的取值為0,1,2,3 for y in range(BOARDHEIGHT):#遍歷所有行 #如果x=0,則看第y行前4個棋子否都是相同的棋子,以此類推可以遍歷所有的水平棋子四子相連情況.只要有一個x,y成立就可以判定獲勝 if board[x][y] == tile and board[x+1][y] == tile and board[x+2][y] == tile and board[x+3][y] == tile: return True # 檢查豎直方向棋子情況,與水平情況類似 for x in range(BOARDWIDTH): for y in range(BOARDHEIGHT - 3): if board[x][y] == tile and board[x][y+1] == tile and board[x][y+2] == tile and board[x][y+3] == tile: return True # 檢查左傾斜方向棋子情況 for x in range(BOARDWIDTH - 3):#x取值0,1,2,3 for y in range(3, BOARDHEIGHT):#因為左傾斜連成四子時,最坐下面的棋子至少為列中距離最上面四個格子,即y>=3 if board[x][y] == tile and board[x+1][y-1] == tile and board[x+2][y-2] == tile and board[x+3][y-3] == tile:#判定左傾斜四子同色 return True # 檢查右傾斜方向棋子情況,與左傾斜類似 for x in range(BOARDWIDTH - 3): for y in range(BOARDHEIGHT - 3): if board[x][y] == tile and board[x+1][y+1] == tile and board[x+2][y+2] == tile and board[x+3][y+3] == tile: return True return False
4.11 程序運行
def main(): isFirstGame = True #初始isFirstGame while True: #使游戲一直能夠運行下去 runGame(isFirstGame) isFirstGame = False def runGame(isFirstGame): if isFirstGame: # 剛剛啟動游戲第一局時 #讓AI先走第一步棋子,以便玩家可以觀察到游戲是怎么玩的 turn = COMPUTER showHelp = True else: # 從第二劇開始,隨機分配 if random.randint(0, 1) == 0: turn = COMPUTER else: turn = HUMAN showHelp = False
五、實驗總結
基於蒙特卡洛搜索樹算法,利用Pygame模塊使用Python代碼實現了,人工自由選擇棋子,AI通過算法智能跳到的人機大戰效果。整個實驗,讓我們熟悉了pygame創建實例和移動的基礎知識,也初步了解了蒙特卡洛算法的具體應用。
六、參考資料
http://www.mamicode.com/info-detail-1261189.html
蒙特卡洛搜索樹介紹:http://jeffbradberry.com/posts/2015/09/intro-to-monte-carlo-tree-search/