文章目录
前言
大家好,又见面了。六一后渐渐复工,工作开始忙碌起来,所以更新的频次不得不慢了下来。而且问哥最近开发的小游戏都是原创,麻雀虽小,五脏俱全,美工创意、窗口布局、代码实现、功能调试,一项都少不了。所以花了不少时间。
24点是我一直想做的小游戏,之前在文本界面就想做这么一期,但是做出来发现文本界面下实在没什么意思,所以就搁置了。现在来到图形界面,终于可以实现小时候的玩法,用扑克牌做道具。因而就会用到洗牌、发牌、计算点数等等小功能。而且还要让程序判断当前的四张牌能不能计算出24点,背后就要实现自动计算24点的方法。(因为问哥采用的是穷举法,肯定有很多重复计算,这里就不敢妄称“算法”了。)
因为内容比较多,还是和之前一样,分为上下篇:
上篇 —— 游戏界面搭建
下篇 —— 功能代码实现
速算24点
1. 玩法简介
相信不少人小时候都玩过,规则也比较简单:找一副扑克牌,去掉大小王,52张牌,每次随机抽取四张牌,运用加减乘除四种计算方法,看看谁能最快计算出24点。小时候由于没有电脑,所以无法判断四张牌到底有没有解,所以只要所有人都同一放弃,就可以跳到下一组。
游戏截图:
2. 游戏流程
问哥感觉这个小程序比之前的都要复杂一些,代码量也达到了两百行。究其原因,就是问哥想要实现的功能太多。洗牌、发牌、判断能否算出24点、计时、提示等等,问哥本来还想做一个记分牌,在游戏结束后,弹出窗口显示正确率。但最后由于精力不济,还是简单地在面板上显示“已测试”、“已通过”作罢。但其实这部分功能比较简单,有兴趣的朋友可以自由添加进来。
速算24点的简易流程图如下:
3. 搭建游戏界面
本篇游戏的界面还是问哥原创,肯定无法符合所有人的审美。大家在了解了实现的逻辑之后,可以自己随意配色、更换图片、调整布局,从而达到令自己满意的效果。
1). 基本界面
游戏背景、标题这种功能的实现,在上篇文章已经介绍过,这里就不啰嗦了。直接上代码:
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()
如此,我们就得到了一个空空荡荡的窗口:
2). 洗牌、发牌
既然是用扑克计算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()
这里面有几个细节:
- 当牌堆最后四张牌被抓完,牌背面的图片应该是被删掉的。然后再下次发牌的时候,调用洗牌的shuffle_card()函数
- Canvas的move方法其实很简单,因为我们在“抓好牌”后,四张牌的图片已经命名好(在列表cv_card里),所以move函数只要传三个参数 move(cv_card[i],1,0),第一个参数表示要移动那个图片,第二个参数1 表示横坐标移动1像素,第三个参数0表示纵坐标不变。
- 每次向右移动1像素,循环150遍(第二、三、四张牌循环次数更多),而在每次循环的时候,必须调用Canvas的update()方法,将每个像素的图片显示出来。
- 之前的文章里,因为是静态界面,问哥建议把canvas的pack方法放在最后。但因为这次我们要动态的实现一些动画效果,所以需要先把canvas“裱”上去,才能在上面播放动画。
最后发牌的效果如下:
3). 计时器
当抽出4张牌并展示以后,我们首先要让程序计算出,这四张牌通过各种排列组合,能否得出24点。这部分计算的方法我们下篇内容再介绍。如果确定有解,能够得出24点,我们就要开始计时了。
问哥在之前的小文章里介绍了计时器的制作,这里就可以直接拿过来用了。当然,还是默认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()即可。
实现效果如下:
4). 玩家输入公式(答案)
想要实现玩家从键盘输入答案的方式有许多办法,问哥这里借这个机会介绍一种“事件绑定"的方法。
首先,创建一个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()
StringVar类
这里首先定义了一个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),然后开始下一局,如果不能,则“擦”掉当前的答案,请求玩家重新输入。
5). 判断玩家输入是否正确
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)表示两个数的差值在一个极小的范围内,则认为两数相同。
最终效果如下图:
4. 知识点回顾
- Canvas的move方法
- 组件的事件绑定
- StringVar类型