大家好,又見面了。六一後漸漸復工,工作開始忙碌起來,所以更新的頻次不得不慢了下來。而且問哥最近開發的小游戲都是原創,麻雀雖小,五髒俱全,美工創意、窗口布局、代碼實現、功能調試,一項都少不了。所以花了不少時間。
24點是我一直想做的小游戲,之前在文本界面就想做這麼一期,但是做出來發現文本界面下實在沒什麼意思,所以就擱置了。現在來到圖形界面,終於可以實現小時候的玩法,用撲克牌做道具。因而就會用到洗牌、發牌、計算點數等等小功能。而且還要讓程序判斷當前的四張牌能不能計算出24點,背後就要實現自動計算24點的方法。(因為問哥采用的是窮舉法,肯定有很多重復計算,這裡就不敢妄稱“算法”了。)
因為內容比較多,還是和之前一樣,分為上下篇:
上篇 —— 游戲界面搭建
下篇 —— 功能代碼實現
相信不少人小時候都玩過,規則也比較簡單:找一副撲克牌,去掉大小王,52張牌,每次隨機抽取四張牌,運用加減乘除四種計算方法,看看誰能最快計算出24點。小時候由於沒有電腦,所以無法判斷四張牌到底有沒有解,所以只要所有人都同一放棄,就可以跳到下一組。
游戲截圖:
問哥感覺這個小程序比之前的都要復雜一些,代碼量也達到了兩百行。究其原因,就是問哥想要實現的功能太多。洗牌、發牌、判斷能否算出24點、計時、提示等等,問哥本來還想做一個記分牌,在游戲結束後,彈出窗口顯示正確率。但最後由於精力不濟,還是簡單地在面板上顯示“已測試”、“已通過”作罷。但其實這部分功能比較簡單,有興趣的朋友可以自由添加進來。
速算24點的簡易流程圖如下:
本篇游戲的界面還是問哥原創,肯定無法符合所有人的審美。大家在了解了實現的邏輯之後,可以自己隨意配色、更換圖片、調整布局,從而達到令自己滿意的效果。
游戲背景、標題這種功能的實現,在上篇文章已經介紹過,這裡就不啰嗦了。直接上代碼:
from tkinter import *
# 初始化主窗口
root = Tk()
root.geometry('800x500+400+200')
root.resizable(0,0)
root.title('速算24點')
# 畫布大小和主窗口大小一致
cv = Canvas(root,width=800,height=500)
# 背景圖片
bg = PhotoImage(file=r"image\poker\bg.png")
cv_bg = cv.create_image(400,250,image = bg)
# 標題圖片
title = PhotoImage(file=r"image\poker\title.png")
cv_title = cv.create_image(400,60,image = title)
# 畫布裱在窗口裡
cv.pack()
root.mainloop()
如此,我們就得到了一個空空蕩蕩的窗口:
既然是用撲克計算24點,那洗牌、發牌的操作必不可少。問哥之所以創建這麼大的一個窗口,也是為了能夠給發牌留下足夠的空間。
當游戲開始時,或者牌堆用完了,就要開始洗牌。所以為了方便調用,我們創建一個自定義函數。
import random
def shuffle_card():
global cardnum, back, cv_back
cardnum = list(range(52))
random.shuffle(cardnum)
# 洗完牌的牌堆,放在窗口左側
back = PhotoImage(file=r"image\poker\back1.png")
cv_back = cv.create_image(100,200,image = back)
random模塊是我們的老朋友了,為了實現隨機的效果,我們在洗牌的時候調用random.shuffle方法,將52個數字(0到51)的順序隨機打散,cardnum這個列表將變成一個不規則排列的列表。正好shuffle的意思就是洗牌的意思,所以,在這裡使用這個方法,真是在合適不過了。cardnum方法需要被主程序及其他函數調用,所以使用global關鍵字將其聲明為全局變量。
洗完牌以後,我希望在窗口的左側顯示一張撲克牌背面的圖片,代表牌堆有牌。由於精力有限,牌堆的厚度就沒有實現了(當然想實現也是可以的)。而要想撲克牌背面的圖片能夠持續顯示,也需要將其聲明成全局變量。
代碼運行的效果如下:
我們可以模擬現實中發牌的動作,將發牌分為兩個步驟:1)將牌在牌堆頂顯示,2)將牌移動到指定位置。
前面洗牌的時候,我們已經創建了一個0到51的數字亂序列表cardnum,現在我們只需要將這52個數字和圖片對應上,就可以在抓牌的時候,自動顯示撲克牌的圖片了。
首先,創建一個撲克牌圖片的列表。
card = [PhotoImage(file=f'image/poker/{
i:0>2}.png') for i in range(1,53)]
問哥准備的撲克牌圖片是從01開始命名的,所以需要使用格式化方法,將01到52的數字命名的圖片讀入到列表card裡。
接著,我們自定義一個抓牌、發牌的函數:
import tkinter.messagebox as tm
def draw_card():
draw=[]
if len(cardnum)==0:
tm.showinfo(message='牌堆已用完,為您重新洗牌')
shuffle_card()
for i in range(4):
# 模擬抓牌:牌堆cardnum尾部彈出一張牌,放進要展示的牌列表draw裡
draw.append(cardnum.pop())
# 將牌在牌堆頂顯示
cv_card.append(cv.create_image(100,200,image=card[draw[i]]))
# 如果抓完最後四張牌,刪除牌背面的圖片(細節控)
if len(cardnum)==0:cv.delete(cv_back)
# 調用canvas的move方法,將牌移動到指定位置,實現發牌效果
for _ in range(150*(i+1)):
cv.move(cv_card[i],1,0)
cv.update()
這裡面有幾個細節:
最後發牌的效果如下:
當抽出4張牌並展示以後,我們首先要讓程序計算出,這四張牌通過各種排列組合,能否得出24點。這部分計算的方法我們下篇內容再介紹。如果確定有解,能夠得出24點,我們就要開始計時了。
問哥在之前的小文章裡介紹了計時器的制作,這裡就可以直接拿過來用了。當然,還是默認90秒倒計時。
Python動畫制作:90秒倒計時圓形進度條效果
具體代碼在這篇小文章裡已經有解釋,所以這裡問哥就不多啰嗦了。不過這裡的代碼也解答了那篇小文章裡最後的思考題:如果實現平滑的進度條。
代碼如下:
def initialize():
global angle,count,cv_arc,cv_inner,cv_text
count=90
angle=360
cv_arc=cv.create_oval(100,330,200,430,fill='red',outline='yellow')
cv_inner=cv.create_oval(120,350,180,410,fill='yellow',outline='yellow')
cv_text=cv.create_text(150,380,text=count,font =('微軟雅黑',20,'bold'),fill='red')
draw_card()
def countdown():
global angle,count,cv_arc,cv_inner,cv_text,cd
if angle == 360:
angle -= 1
else:
cv.delete(cv_arc)
cv.delete(cv_inner)
cv.delete(cv_text)
cv_arc=cv.create_arc(100,330,200,430,start=90,extent=angle,fill="red",outline='yellow')
angle -= 1
if angle%4 == 0: count-=1
cv_inner=cv.create_oval(120,350,180,410,fill='yellow',outline='yellow')
cv_text=cv.create_text(150,380,text=count,font =('微軟雅黑',20,'bold'),fill='red')
if count==0:
tm.showinfo(message='倒計時結束!自動進入下一局')
cv.delete(cv_arc)
cv.delete(cv_inner)
cv.delete(cv_text)
initialize()
else:
cd = root.after(250,countdown)
另外,問哥這裡又定義了一個名叫initialize()的函數,翻譯過來就是初始化,目的是為了將一些重復性的工作放進去,比如遮擋進度條的圓形等等。於是,我們可以將抓牌的函數draw_card()在放在裡面,而在主程序裡只需調用initialize()即可。
實現效果如下:
想要實現玩家從鍵盤輸入答案的方式有許多辦法,問哥這裡借這個機會介紹一種“事件綁定"的方法。
首先,創建一個Label標簽組件,同Button按鈕組件、Canvas畫布組件一樣,標簽組件也是tkinter下的組件,可以簡單理解為何Canvas同樣級別。要在Canvas上顯示同樣級別的組件,需要使用我們上篇文章裡介紹過的create_window方法。
answer=StringVar()
cv.create_text(400,350,text='請輸入您的答案',font =('方正楷體簡體',18,'bold'))
lb = Label(root,text='',font=('微軟雅黑',15),textvariable=answer,bg='lightyellow')
cv_lb = cv.create_window(400,400,window=lb)
# 綁定從鍵盤獲取輸入<Key>,並傳給自定義函數myanswer
lb.bind('<Key>',myanswer)
# 使標簽組件獲得焦點,不然無法從鍵盤輸入
lb.focus_set()
這裡首先定義了一個tkinter下的StringVar類的實例,其實它就表示一個字符串,但是比普通字符串變量更“智能”。這樣,當我們在Label組件裡使用textvariable參數,指定這個實例後,就可以動態的綁定在一起了。也就是說StringVar變成什麼內容,Label組件會自動顯示出來,比賦值操作要簡單方便許多。
創建好Label標簽組件以後,就可以使用bind()方法為其綁定一個Key事件,代表鍵盤的輸入內容,然後將輸入的內容隱式傳參給myanswer這個自定義回調函數,通過這個函數來將鍵盤輸入的內容賦值給StringVar,然後再在Label上顯示出來。
函數定義如下:
trans={
'plus':'+','minus':'-','asterisk':'*','slash':'/','parenleft':'(','parenright':')'}
def myanswer(event):
s=event.keysym
txt=answer.get()
if s=='BackSpace':
txt=txt[:-1]
elif s=='Return':
if is_right(txt):
root.after_cancel(cd)
c = tm.askyesno(message='繼續下一局嗎?')
if c:
cv.delete(cv_arc)
cv.delete(cv_inner)
cv.delete(cv_text)
initialize()
else:root.destroy()
else:
txt=''
elif s.isnumeric():
txt+=s
elif s in trans:
txt+=trans[s]
answer.set(txt)
event是事件綁定函數隱式傳參進來的變量,而event.keysym就代表了玩家當前(觸發)這個函數得到的字符,也就是從鍵盤按了哪個鍵。注意,這裡只要按下一個鍵,就會觸發myanswer回調函數,所以event.keysym的值每次只表示一個字符。
但是這個字符的顯示形式和我們平時認為的不太一樣。除了數字和字母這種單字符的鍵之外,方向鍵、符號等等都是使用一個單詞表示,而event.keysym得到的也是這個單詞,而不是方向鍵、符號等特殊字符。所以,我們這裡創建一個字典trans,來把event.keysym得到的符號,比如加減乘除、小括號等,轉化成正確的字符 + - * / ( ) 顯示出來。
而我們還需要對玩家輸入的字符做出限制,除了數字和算術運算符號 + 0 * / ( ),還有回車與退格(刪除),玩家按下其他任何鍵都不應該有反應。
每次玩家輸入一個字符後,都使用answer.get()方法取得當前Label上的字符,再進行相應的操作(刪除、增加字符),最後通過answer.set()方法將新的字符串顯示在Label上。
除此之外,在這個函數中還包含了一個函數,is_right(),當玩家按下回車鍵(Return)的時候,該函數用來判斷當前Label上的字符是否能夠計算出24,從而判斷勝負。如果能,則取消計時器(cd),然後開始下一局,如果不能,則“擦”掉當前的答案,請求玩家重新輸入。
is_right()函數屬於代碼實現的部分,但問哥覺得這個函數和上面的回調函數有關聯,還是決定放在上篇裡介紹一下。
該函數除了判斷是否能夠計算出24之外,還要確保玩家只使用,且用完了給定的4個數字。於是將得到的等式中的算術符號去除掉,再分割成數字,只要和當前的牌組數字nums對比,如果不同,則表示玩家沒有使用正確的數字。
為了防止玩家輸入了分母為0等錯誤的表達式,這裡使用try…except方法進行判斷。eval()函數用來將算術表達式的字符串轉化成真正的表達式進行計算,而如果一旦遇到了分母為0,多或少了小括號,等等表達式錯誤,則直接拋出一個錯誤提示。
import math
def is_right(txt):
try:
result = eval(txt)
except:
tm.showinfo(message='算式不正確,請重新輸入!')
return False
for i in '+-*/()':
txt=txt.replace(i,' ')
txt=[int(i) for i in txt.split()]
if sorted(txt)!=sorted(nums):
tm.showinfo(message='請使用給定的數字!')
return False
if math.isclose(result,24):
tm.showinfo(message='恭喜您!回答正確!')
return True
最後要注意的是,因為我們在計算24點的過程中,很有可能會使用除法,而一旦使用了除法,結果就必然變成一個浮點數。由於計算機使用二進制表示浮點數,所以一旦出現某些小數不能用二進制完全表達的情況,就會出現無限循環小數,產生誤差。所以這裡如果直接使用 if result==24來進行判斷,在某些情況下將會得不到正確結果,比如23.999999 和24就並不相等,但23.999999可能是計算過程中的除法產生的。
於是我們使用math模塊的isclose方法。math.isclose(result, 24)表示兩個數的差值在一個極小的范圍內,則認為兩數相同。
最終效果如下圖: