Python写个小游戏:速算24点(上)



前言

大家好,又见面了。六一后渐渐复工,工作开始忙碌起来,所以更新的频次不得不慢了下来。而且问哥最近开发的小游戏都是原创,麻雀虽小,五脏俱全,美工创意、窗口布局、代码实现、功能调试,一项都少不了。所以花了不少时间。

24点是我一直想做的小游戏,之前在文本界面就想做这么一期,但是做出来发现文本界面下实在没什么意思,所以就搁置了。现在来到图形界面,终于可以实现小时候的玩法,用扑克牌做道具。因而就会用到洗牌、发牌、计算点数等等小功能。而且还要让程序判断当前的四张牌能不能计算出24点,背后就要实现自动计算24点的方法。(因为问哥采用的是穷举法,肯定有很多重复计算,这里就不敢妄称“算法”了。)

因为内容比较多,还是和之前一样,分为上下篇:

上篇 —— 游戏界面搭建
下篇 —— 功能代码实现


速算24点

1. 玩法简介

相信不少人小时候都玩过,规则也比较简单:找一副扑克牌,去掉大小王,52张牌,每次随机抽取四张牌,运用加减乘除四种计算方法,看看谁能最快计算出24点。小时候由于没有电脑,所以无法判断四张牌到底有没有解,所以只要所有人都同一放弃,就可以跳到下一组。

游戏截图:
在这里插入图片描述

2. 游戏流程

问哥感觉这个小程序比之前的都要复杂一些,代码量也达到了两百行。究其原因,就是问哥想要实现的功能太多。洗牌、发牌、判断能否算出24点、计时、提示等等,问哥本来还想做一个记分牌,在游戏结束后,弹出窗口显示正确率。但最后由于精力不济,还是简单地在面板上显示“已测试”、“已通过”作罢。但其实这部分功能比较简单,有兴趣的朋友可以自由添加进来。

速算24点的简易流程图如下:

No
Yes
Yes
No
Yes
No 更换下一组
No
Yes
游戏开始
牌堆是否为0
抽取4张牌
计算该组合是否有解
倒计时开始
玩家输入答案
判断答案是否正确
停止倒计时
是否进入下一局
游戏结束
洗牌
有三次机会使用提示
时间用完,自动进入下一局

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()

这里面有几个细节:

  1. 当牌堆最后四张牌被抓完,牌背面的图片应该是被删掉的。然后再下次发牌的时候,调用洗牌的shuffle_card()函数
  2. Canvas的move方法其实很简单,因为我们在“抓好牌”后,四张牌的图片已经命名好(在列表cv_card里),所以move函数只要传三个参数 move(cv_card[i],1,0),第一个参数表示要移动那个图片,第二个参数1 表示横坐标移动1像素,第三个参数0表示纵坐标不变。
  3. 每次向右移动1像素,循环150遍(第二、三、四张牌循环次数更多),而在每次循环的时候,必须调用Canvas的update()方法,将每个像素的图片显示出来。
  4. 之前的文章里,因为是静态界面,问哥建议把canvas的pack方法放在最后。但因为这次我们要动态的实现一些动画效果,所以需要先把canvas“裱”上去,才能在上面播放动画。

最后发牌的效果如下:
在这里插入图片描述

3). 计时器

当抽出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()即可。

实现效果如下:
在这里插入图片描述

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. 知识点回顾

  1. Canvas的move方法
  2. 组件的事件绑定
  3. StringVar类型

  • 6
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

请叫我问哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值