文章目录
前言
大家好,最近实在是有点忙,下篇迟迟没有动笔。上一篇文章也结束得很匆忙,实在抱歉。代码部分其实早已写好,但是问哥还是想尽力将其拆解、讲解清楚,所以并不是直接分享代码。当然,如果想跳过问哥啰嗦的废话,直接参考代码,也可以跳到文章末尾查阅。
废话不多说,马上进入我们剩下的部分:
上篇 —— 游戏界面搭建
下篇 —— 功能代码实现
速算24点
1. 玩法简介
游戏规则比较简单:找一副扑克牌,去掉大小王,52张牌,每次随机抽取四张牌,运用加减乘除四种计算方法,看看谁能最快计算出24点。虽说是单机游戏,没有比赛的压力,但还是增加了计时功能:如果90秒内未能找出答案,则视为当前测试失败,自动进入下一关。同时,为了降低难度,提供了提示功能:闯关过程中,共有三次机会获得提示参考答案。
游戏截图:
2. 游戏流程
速算24点的简易流程图如下:
3. 剩下的部分
上篇内容里讲解了大部分界面的搭建,还有两个小地方,一个是关卡信息,一个是提示按钮。
1). 关卡 / 分数信息
IntVar类
上篇我们引入了StringVar类,实例了变量后,用来和标签绑定,可以直接将变量的内容显示在标签上,从而省去了不少麻烦。而StringVar是针对字符串,对于纯粹的数字,tkinter还提供了类似功能的IntVar类。同样地,将其和标签或其他组件绑定,即可直接将IntVar类实例的值显示出来。与StringVar相区别的是,IntVar是整数(int),可以直接参与计算,所以,用来记录玩家已测试的关卡数,以及通过的关卡(分数),也是十分方便的。
于是,我们先定义两个变量,实例化IntVar类:
level=IntVar()
score=IntVar()
再创建4个标签,两个用来显示固定字符“已测试”、“已通过”,另外两个用来和变量level、score绑定,以动态显示当前的关卡和分数。
cv.create_text(600,350,text='已测试:',font =('方正楷体简体',16,'bold'))
cv.create_text(600,400,text='已通过:',font =('方正楷体简体',16,'bold'))
level_lable = Label(root,text='',font=('微软雅黑',15),textvariable=level,bg='lightyellow')
cv_level = cv.create_window(670,350,window=level_lable)
score_lable = Label(root,text='',font=('微软雅黑',15),textvariable=score,bg='lightyellow')
cv_score = cv.create_window(670,400,window=score_lable)
实现效果如下图:
2). 提示按钮
提示按钮区域还分了两部分,一部分是按钮,另一部分是小灯泡的图片,用来表示玩家还剩多少次机会。
图片
问哥从网上找到的小灯泡的图片,将它改成png格式,然后命名为idea。
先定义一个常量HINT,用来表示玩家可以有多少次提示机会。默认是3次,当然你也可以改成5次,更多或更少。然后再通过for循环,在指定的位置绘制3个灯泡图片,并将这些绘制的组件放在一个列表ideas里。
HINT = 3
idea = PhotoImage(file=r"image\poker\idea.png")
ideas = []
for i in range(HINT):
ideas.append(cv.create_image(450+i*25,450,image = idea))
为什么放进列表呢?因为可以方便我们在按下提示按钮后,自动减少图片并在画布上刷新。
按钮
按钮的创建很简单,我们之前也介绍过。问哥为了加速完成这个小项目,就不使用特效了,于是默认使用tkinter的Button组件。
btn = Button(root,text='提示',width=5,command=hint)
cv_btn = cv.create_window(400,450,window=btn)
关键是我们需要为这个按钮绑定一个回调函数,起名叫hint,在该函数里,我们要完成以下三个功能:
- 获取正确答案right_answer,并显示在标签上
- 将按钮禁用,以防止玩家不小心点击了多次,从而浪费了提示次数
- 减少一个“小灯泡”
实现代码如下:
def hint():
answer.set(right_answer)
btn['state']=DISABLED
idea = ideas.pop()
cv.delete(idea)
同样的道理,为了防止玩家误操作(你永远无法完全预见玩家或用户大开脑洞的操作),也为了减少程序的可能,我们规定该按钮只允许在被点击的时候启用,换句话说,除了倒计时开始,玩家开始答题时,而且提示次数没有用完(ideas>0),按钮的状态是NORMAL,其他时间该按钮都应该处于DISABLED状态。
于是,我们分别在其他位置做以下更新:
def initialize():
# 省略代码
btn['state']=DISABLED
def draw_card():
# 省略代码
if len(ideas)>0: btn['state']=NORMAL
测试效果基本OK:
3). 重新发牌
代码写到这里,除了还没有让电脑替我们找出正确答案,已经实现基本的输入、判断等功能了。试着运行一下:
发现回答正确、在选择继续下一局后,桌面上的发牌混乱了,不知道新的四张牌跑到哪里去了,这是怎么回事?
原来,我们在上篇用了一个数组cv_card来储存抽出来的四张牌,并使用for循环将这四张牌显示在桌面上,但是没有考虑到当一局游戏结束,需要重新抽四张牌的情况。在这种情况下,我们需要把桌面上的4张牌从画布上擦去,用代码的话主要是两步:
- 删除图片
- 清空列表
为了避免重复操作,我们再定义一个子程序来完成清除clear的操作:
def clear(cv_card):
for i in cv_card:
cv.delete(i)
cv_card.clear()
然后,我们再把该函数放在抓牌函数的开始,这样,每次开始抓牌前,我们都先清除(如果有的话)上一轮的四张牌,从而保证代码对新的四张牌依然适用。
def draw_card():
clear(cv_card)
同样的理由,我们在重新开始下一局后,答案标签的内容也需要清空,所以我们需要在initialize函数以及myanswer函数里添加以下代码:
def initialize():
answer.set('')
# 省略代码
def myanswer(event):
# 省略代码
if s=='BackSpace':
txt=txt[:-1]
elif s=='Return':
if is_right(txt):
root.after_cancel(cd)
c = tm.askyesno(message='继续下一局吗?')
if c:
# 省略代码
return # 添加 return,表示进入下一局后就不继续显示标签
经测试,在没有电脑帮助的情况下,已经可以成功地“自食其力”进行闯关了。可问题是,常常会出现这种情况:抽出四张牌,却很难写出答案,因为我们也无法确定是我们没有想出答案,还是这四张牌根本没有答案。所以,我们需要设计一个方法,让电脑替我们先算好答案,保存在变量right_answer里,如果没有答案的话,就自动更换下一组。有了right_answer,提示按钮也就可以发挥作用了。
4. 让电脑计算24点
算起来,这部分其实是相对独立的代码,因为我们可以把要解决的问题从这个游戏里抽离出来,转换为“给定4个数,计算出能否通过排列组合,使得这四个数组成的算式运算结果等于24。”
1). 表达式的特征
为了解决这个问题,我们观察一下四个数字组成的算术表达式的特征:
- 不考虑小括号的情况(也相当于一对小括号把四个数字包起来)。算式的形式为:(a+b+c+d),其中有4个数字,3个运算符(这里加号+只是代表运算符,可以是加减乘除的任意一种)。
- 考虑小括号的情况又分为两种:
第一种情况:一对小括号 | 第二种情况:两对小括号 |
---|---|
(a+b)+c+d | (a+b)+(c+d) |
a+(b+c)+d | ((a+b)+c)+d |
a+b+(c+d) | (a+(b+c))+d |
(a+b+c)+d | a+((b+c)+d) |
a+(b+c+d) | a+(b+(c+d)) |
- 再进一步观察可以发现,就像(a+b+c+d)的括号可以省略一样,(a+b+c)+d,a+(b+c+d)也一定与两对括号的情况重复:(a+b+c)+d 必定等价于 ((a+b)+c)+d 或 (a+(b+c))+d;a+(b+c+d) 必定等价于 a+((b+c)+d) 或 a+(b+(c+d))。所以最终,我们只需要考虑8种情况:
(a+b)+c+d
a+(b+c)+d
a+b+(c+d)
(a+b)+(c+d)
((a+b)+c)+d
(a+(b+c))+d
a+((b+c)+d)
a+(b+(c+d))
当然,这里仍然存在较大重复计算的可能性,假如三个运算符都是加法或乘法的话,上面八个表达式都是等价的,所以这并不是一个最优的算法。
2). 代码的实现
虽然存在很多重复计算,但在不考虑时间复杂度,以及运算量并不算大的情况下,完全可以让电脑进行穷举运算,把所有的可能性都检查一遍。于是我们可以把代码的实现过程分成三步:
- 找出4个数字所有不重复的排列组合,最多有24种可能(4!)
- 找出3个运算符(加减乘除)的排列组合,因为运算符可重复使用,所以是4^3=64种可能。(这里面有一些组合是不可能计算出24点的,比如连续三个减号或除号,但是如果添加小括号使得计算顺序发生改变的话,结果将有所不同。为了省事,这里就把所有排列组合都考虑了)
- 在算式的不同位置添加小括号。根据前面列举的,总共有8种可能。
于是,不考虑存在重复的情况下,最多总共有 24*64*8 = 12288 种可能。这点计算量对人类来说可能望而却步,但是对电脑来说简直不值一提。更何况,我们并不用找出所有正确答案,而是只要找到一个即可。
下面我们开始写代码:
将扑克牌转换成数字
我们首先要做的,就是把抽取的4张牌转化为数字。因为扑克牌的数字是从0到51,但是所代表的用于计算的数字却是从1到13,这其实可以通过简单的求余运算来实现。
于是,我们定义一个函数:
def calnum(n):
global nums
nums=[i%13+1 for i in n]
formula=form(nums)
这个函数接收一个变量n,代表含有4张牌的列表,然后通过列表生成式(或称为推导式)将其转换成一个实际用于计算的数字的列表nums。然后再调用另一个自定义函数form()将这个列表通过排列组合转化成含有最多12288个表达式的列表,保存为formula。
需要注意的是,我们还要将nums声明成全局变量。这样做的唯一目的,是为了在判断玩家输入的时候,是否只使用了给定的4个数字。于是,我们顺便将判断玩家输入的函数 is_right() 也更新如下:
def is_right(txt):
# 省略代码
if sorted(txt)!=sorted(nums):
tm.showinfo(message='请使用给定的数字!')
return False
# 省略代码
下面我们接着写form()函数。
数字的排列组合
为了不重复制造轮子,我们可以直接使用Python提供的内置模块来计算排列组合。
from itertools import permutations
def form(nums):
numlist=set(permutations(nums))
从itertools模块中导入permutations函数以后,就可以使用它来计算列表的排列组合了。这个函数接收两个参数,第一个是列表等可迭代对象,第二个是数字,表示从前面的列表里取出几个元素进行排列组合,可以省略,如果省略的话,则表示默认对所有元素进行排列组合。于是在这里,我们可以省略第二个参数,直接将含有4个数字的nums列表交给permutations函数,返回一个可迭代对象。同时,为了去重,比如四张牌里有重复的数字,3,3,4,4这种,我们可以将这个结果转换为集合set,最终将结果保存在numlist里。
在控制台测试结果:
>>> from itertools import permutations
>>> nums = [1,2,3,4]
>>> numlist = permutations(nums)
>>> type(numlist)
<class 'itertools.permutations'>
>>> for i in numlist: print(i,end=' ')
(1, 2, 3, 4) (1, 2, 4, 3) (1, 3, 2, 4) (1, 3, 4, 2) (1, 4, 2, 3) (1, 4, 3, 2) (2, 1, 3, 4) (2, 1, 4, 3) (2, 3, 1, 4) (2, 3, 4, 1) (2, 4, 1, 3) (2, 4, 3, 1) (3, 1, 2, 4) (3, 1, 4, 2) (3, 2, 1, 4) (3, 2, 4, 1) (3, 4, 1, 2) (3, 4, 2, 1) (4, 1, 2, 3) (4, 1, 3, 2) (4, 2, 1, 3) (4, 2, 3, 1) (4, 3, 1, 2) (4, 3, 2, 1)
>>>
可以看到,如我们所预想的,4个不重复的数字可以组成最多24个互不重复的组合。
添加运算符
接下来,我们需要在这四个数字中间插入三个运算符,当然,我们首先要找到运算符组成的64种组合,使用三层循环即可实现,并将结果保存在列表operations里。
operations=[]
for i in '+-*/':
for j in '+-*/':
for k in '+-*/':
operation = i+j+k
operations.append(operation)
因为这个列表会被频繁调用(每发四张牌,就要插入运算符),所以我们可以将它放在主程序里,这样只要在游戏开始时运算一次,在函数里就可以一直调用(而不修改)。
接着,在函数form里,我们同样使用for循环来将3个运算符插入到4个数字之间:
def form(nums):
numlist=set(permutations(nums))
combo=[]
for num in numlist:
for i in operations:
temp=[]
for j in range(3):
temp+=[str(num[j]),i[j]]
temp.append(str(num[j+1]))
combo.append(temp)
因为最终我们需要将表达式转换成字符串,所以在这里我们就可以使用str函数将数字转换成字符串,保存在列表combo里。根据之前计算的,combo里最多应该存在 24*64 = 1536 个元素,代表了1536个表达式。但是现在它们任然是单个的字符,因为我们还需要插入小括号。
插入小括号
小括号用于提升运算等级、改变运算顺序。根据前面所分析的,由于默认计算顺序为先乘除、后加减、从左到右依次运算,所以不包含小括号的情况(a+b+c+d)一定等价于某种使用小括号的情况。所以最终我们只需要考虑8种小括号的情况:三种一对小括号的情况,和五种两对小括号的情况。但是该怎样插入呢?
通过之前的计算,我们得到的combo二维列表里的表达式列表应该类似这个样子:
[‘1’, ‘+’, ‘2’, ‘+’, ‘3’, ‘+’, ‘4’]
可见,每个表达式都是包含7个字符串元素(4个数字、3个运算符)的列表。于是,我们只要事先找到需要添加小括号的位置(索引),就可以通过循环添加了。
通过比较,我们将两种情况(一对和两对小括号)的左右小括号的位置索引找到,并创建列表如下:
one=[(3,0),(5,2),(7,4)]
two=[(7,3,5,0),(5,3,0,0),(5,5,2,0),(7,5,2,2),(7,7,4,2)]
需要注意的是,我们要先插入右边的小括号,再插入左边小括号。因为插入元素以后,列表的长度改变,为了方便计算,先插入右边小括号可以最大程度保持相对位置。在两对小括号的时候也是如此(先插入右侧的括号),只需要注意其中一种情况:(a+b)+(c+d)。在这种情况下,插入两个右括号之后,夹在两个右括号中间的左括号位置发生了变化,只要记录新的位置即可。
于是,通过索引列表,我们可以将form函数补全:
def form(nums):
numlist=set(permutations(nums))
combo=[]
for num in numlist:
for i in operations:
temp=[]
for j in range(3):
temp+=[str(num[j]),i[j]]
temp.append(str(num[j+1]))
combo.append(temp)
one=[(3,0),(5,2),(7,4)]
two=[(7,3,5,0),(5,3,0,0),(5,5,2,0),(7,5,2,2),(7,7,4,2)]
formula=[]
for i in combo:
for j in one:
temp=i[:]
temp.insert(j[0],')')
temp.insert(j[1],'(')
formula.append(''.join(temp)) # 将列表转化为字符串
for j in two:
temp=i[:]
temp.insert(j[0],')')
temp.insert(j[1],')')
temp.insert(j[2],'(')
temp.insert(j[3],'(')
formula.append(''.join(temp)) # 将列表转化为字符串
return formula
返回运算结果
最后,我们得到包含最多12288个表达式的列表formula,并返回到函数calnum里使用eval函数进行计算。
def calnum(n):
# 省略代码
formula=form(nums)
for i in formula:
try:
result = eval(i)
except:
continue
if math.isclose(result,24):
return i
return 'None'
通过for循环遍历formula列表,依次计算结果是否等于24。如果正确,则把正确答案(表达式)返回,如果遍历所有12288种可能都没有结果,则返回字符串None。
3). 调用计算函数
按照我们之前的逻辑,在每次抓取4张牌之后,我们都需要电脑帮忙计算,看看当前4张牌能否计算出24点,如果不能,则自动进入下一局(重新抓牌),如果可以,则将答案保存在变量right_answer里,方便提示(hint)按钮调用。于是,我们更新相应部位的代码即可:
def draw_card():
global cv_card, right_answer
invalid=True
while invalid:
clear(cv_card)
draw=[]
if len(cardnum)==0:
tm.showinfo(message='牌堆已用完,为您重新洗牌')
shuffle_card()
for i in range(4):
draw.append(cardnum.pop())
cv_card.append(cv.create_image(100,200,image=card[draw[i]]))
if len(cardnum)==0:cv.delete(cv_back)
for _ in range(150*(i+1)):
cv.move(cv_card[i],1,0)
cv.update()
right_answer = calnum(draw) # 调用函数计算12288种可能
if right_answer=='None':
tm.showinfo(message='本组数字无解,为您自动更换下一组')
else:
countdown()
if len(ideas)>0: btn['state']=NORMAL
invalid=False
如果4张牌无法计算24点,就需要一直重新抽牌,直到可以计算为止。于是这里使用一个while循环,并给定一个标记 invalid 假定当前组合无法计算24点。只有当得到答案时(right_answer的值不是None),invalid变为False,结束while循环,开始计时等后续程序。
5. 知识点回顾
- Canvas的delete方法
- IntVar类型
- permutations函数
到这里,我们这个“速算24点”的小游戏就做好了。大家可以继续在里面添加其他想要的功能,改变布局、颜色等等。最终运行效果如下:
完整代码
from tkinter import *
import tkinter.messagebox as tm
import random
import math
from itertools import permutations
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)
def clear(cv_card):
for i in cv_card:
cv.delete(i)
cv_card.clear()
def draw_card():
global cv_card, right_answer
invalid=True
while invalid:
clear(cv_card)
draw=[]
if len(cardnum)==0:
tm.showinfo(message='牌堆已用完,为您重新洗牌')
shuffle_card()
for i in range(4):
draw.append(cardnum.pop())
cv_card.append(cv.create_image(100,200,image=card[draw[i]]))
if len(cardnum)==0:cv.delete(cv_back)
for _ in range(150*(i+1)):
cv.move(cv_card[i],1,0)
cv.update()
right_answer = calnum(draw)
if right_answer=='None':
tm.showinfo(message='本组数字无解,为您自动更换下一组')
else:
countdown()
if len(ideas)>0: btn['state']=NORMAL
invalid=False
def initialize():
global angle,count,cv_arc,cv_inner,cv_text
count=90
angle=360
btn['state']=DISABLED
answer.set('')
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='倒计时结束!自动进入下一局')
level.set(int(level.get())+1)
cv.delete(cv_arc)
cv.delete(cv_inner)
cv.delete(cv_text)
initialize()
else:
cd = root.after(250,countdown)
def myanswer(event):
s=event.keysym
txt=answer.get()
if s=='BackSpace':
txt=txt[:-1]
elif s=='Return':
if is_right(txt):
level.set(int(level.get())+1)
score.set(int(score.get())+1)
root.after_cancel(cd)
c = tm.askyesno(message='继续下一局吗?')
if c:
cv.delete(cv_arc)
cv.delete(cv_inner)
cv.delete(cv_text)
initialize()
return
else:root.destroy()
else:
txt=''
elif s.isnumeric():
txt+=s
elif s in trans:
txt+=trans[s]
answer.set(txt)
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
def hint():
answer.set(right_answer)
btn['state']=DISABLED
idea = ideas.pop()
cv.delete(idea)
def calnum(n):
global nums
nums=[i%13+1 for i in n]
formula=form(nums)
for i in formula:
try:
result = eval(i)
except:
continue
if math.isclose(result,24):
return i
return 'None'
def form(nums):
numlist=set(permutations(nums))
combo=[]
for num in numlist:
for i in operations:
temp=[]
for j in range(3):
temp+=[str(num[j]),i[j]]
temp.append(str(num[j+1]))
combo.append(temp)
one=[(3,0),(5,2),(7,4)]
two=[(7,3,5,0),(5,3,0,0),(5,5,2,0),(7,5,2,2),(7,7,4,2)]
formula=[]
for i in combo:
for j in one:
temp=i[:]
temp.insert(j[0],')')
temp.insert(j[1],'(')
formula.append(''.join(temp))
for j in two:
temp=i[:]
temp.insert(j[0],')')
temp.insert(j[1],')')
temp.insert(j[2],'(')
temp.insert(j[3],'(')
formula.append(''.join(temp))
return formula
# 游戏从这里开始
HINT = 3
operations=[]
for i in '+-*/':
for j in '+-*/':
for k in '+-*/':
operation = i+j+k
operations.append(operation)
trans={'plus':'+','minus':'-','asterisk':'*','slash':'/','parenleft':'(','parenright':')'}
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)
# 显示答案及关卡分数等信息
answer=StringVar()
level=IntVar()
score=IntVar()
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)
cv.create_text(600,350,text='已测试:',font =('方正楷体简体',16,'bold'))
cv.create_text(600,400,text='已通过:',font =('方正楷体简体',16,'bold'))
level_lable = Label(root,text='',font=('微软雅黑',15),textvariable=level,bg='lightyellow')
cv_level = cv.create_window(670,350,window=level_lable)
score_lable = Label(root,text='',font=('微软雅黑',15),textvariable=score,bg='lightyellow')
cv_score = cv.create_window(670,400,window=score_lable)
# 提示图片及按钮
idea = PhotoImage(file=r"image\poker\idea.png")
ideas = []
for i in range(HINT):
ideas.append(cv.create_image(450+i*25,450,image = idea))
btn = Button(root,text='提示',width=5,command=hint)
cv_btn = cv.create_window(400,450,window=btn)
# 绑定从键盘获取输入<Key>,并传给自定义函数myanswer
lb.bind('<Key>',myanswer)
# 使标签组件获得焦点,不然无法从键盘输入
lb.focus_set()
card = [PhotoImage(file=f'image/poker/{i:0>2}.png') for i in range(1,53)]
cv_card=[]
cv.pack()
shuffle_card()
initialize()
root.mainloop()