文章目录
前言
大家好,好久没更新游戏主题的文章了。复工以来,因忙于工作,构思游戏的步伐慢了下来,请大家谅解。
今天给大家带来的游戏叫《蛇棋》,不一定很多人玩过,但规则比较简单,问哥宁可称其为简化版的强手棋。选择这个游戏的一方面是因为同类教程比较少(问哥总想着推陈出新 😃 ),另一个方面可以通过这个游戏学习到基本的角色动作的实现以及面向对象编程。
因为内容比较多,还是和之前一样,分为上下篇:
上篇 —— 游戏界面搭建与基本逻辑
下篇 —— 移动棋子的动画实现与算法
蛇棋
1. 玩法简介
一般是2到4个玩家,在由一系列格子组成的地图上移动,玩家通过掷骰子来决定前进的点数,如果遇到梯子的尾部,则前进至梯子顶部的位置,如果遇到蛇头,称为“蛇吻”,则滑落至蛇尾的位置。最先到达终点的角色获胜,但是骰子的点数必须等于最终到达终点的步数,如果大于步数,则棋子倒退移动。
本程序做了一定的简化,仅提供两名玩家(人机大战),而且地图由10 x 10的正方形格子组成,方便计算。
游戏截图:
2. 游戏流程
出于惯例,先画出游戏流程图。棋类、回合类游戏的流程比较简单,无非是玩家交替执行相同的程序,然后每次移动后判断是否达成胜利条件,最后再由玩家决定是否开始新的对战。
蛇棋的简易流程图如下:
3. 搭建游戏界面
问哥从网上下载了蛇棋的棋盘图片,色彩比较鲜艳、分辨率还可以。但因为不是自己绘制的棋盘,所以无法精确到每个格子的大小,但这个数值却对我们在后面对移动的棋子进行定位至关重要。问哥经过反复测量与实验,得到图片中棋盘每一格的宽度为58像素,高度为63像素。记住这两个数字,后面会用到。
1). 绘制棋盘
游戏背景、标题,相信大家已经很熟悉了。问哥还是使用Canvas进行游戏界面的绘制,而窗口的尺寸就是背景棋盘图片的大小。
直接上代码:
from tkinter import *
root = Tk()
root.geometry('1000x750+300+100')
root.title('蛇棋')
root.resizable(0,0)
cv = Canvas(root, width=1000,height=750,bg='lightyellow')
bg = PhotoImage(file=r"image\snake\bg.png")
cv_bg = cv.create_image(500,375,image = bg)
cv.pack()
root.mainloop()
2). 放置信息框
图片原本自带的规则说明的部分在这里有点多余,而且我们也需要在右边放置一个滚动显示当前游戏信息的窗口,于是在其位置插入一个带滚动条的文本框 ScrolledText组件。注意,该组件需要从tkinter的子模块scrolledtext中,所以需要先导入该模块。
在Cavans画布上插入此文本框,以挡住规则说明的部分(横纵坐标为反复测试获得)。
import tkinter.scrolledtext as ts
info=ts.ScrolledText(root,width=25, height=17)
cv.create_window(890,390,window=info)
3). 掷骰子的动画
在文本框的下面,我们放置一个投掷骰子的区域,并添加一个按钮,这样当玩家按下按钮,骰子将会转动,然后随机得到一个点数,即为玩家或电脑棋子要前进的步数。
关于掷骰子的动画,我在之前的文章介绍过,大家可以参考 Python动画制作:用tkinter模拟掷骰子
可以直接把代码复制过来,不过问哥在之前那篇文章里使用了一个全局变量i,用来决定骰子转动的起始图片位置。这里,我们可以使用另一种写法,将按钮的回调函数写成lambda匿名函数,然后向函数里传参i。最后调整一下横纵坐标,放置在合适的位置。
import random
def roll(i):
btn['state']=DISABLED
if i<13:
cv.itemconfig(image1,image=dice_rotate[i])
root.after(50,lambda :roll(i+1))
else:
res = random.randint(1,6)
cv.itemconfig(image1,image=dice[res-1])
dice_rotate = [PhotoImage(file=r'image\snake\roll.gif', format=f'gif -index {i}') for i in range(13)]
dice = [PhotoImage(file=f'image\snake\{i+1}.png') for i in range(6)]
image1 = cv.create_image(880,580,image=dice[0])
btn = Button(root, text='掷骰子',command=lambda :roll(0))
cv.create_window(880,680,window=btn)
其中lambda匿名函数 lambda :roll(0) 以及 lambda :roll(i+1) 相当于下面这种写法,只不过省去了fun这个名字(所以才叫匿名函数啊):
def fun():
roll(i+1)
为了得到随机的掷骰子结果,别忘记导入random模块,生成1到6之间的随机整数。
当然,我们不希望玩家不停地按按钮,于是在按下按钮后,有必要将其设置为DISABLED,禁用状态,在后面轮到玩家掷骰子的时候再将其重新启用。实现效果如下:
4. 子窗口
根据我们之前画出的流程图,当游戏开始的时候(包括游戏结束玩家选择重新开始游戏的时候),我们需要让玩家选择代表自己的棋子,于是需要弹出一个窗口,并提供棋子让玩家选择。
其实这也可以在同一个Canvas画布上实现,在画布上创建一个Label组件,然后当玩家点击后,删除Label。但是使用子窗口会更加灵活一点,玩家可以移动它的位置,也可以在子窗口上自由布局,不用担心影响主窗口。
创建子窗口的组件是Toplevel(),可以将其放在游戏开始的自定义函数里,当我们开始游戏的时候就调用它。为此,我们需要创建以下几个自定义函数:
1). 游戏开始函数
为了方便反复调用,我们自定义一个游戏开始函数,这样,当游戏结束的时候,如果玩家选择继续游戏,可以再次调用此函数。
本游戏需要在游戏开始函数里进行初始化的环境变量并不多。把掷骰子的按钮禁掉(防止玩家不选棋子直接去掷骰子)、把滚动文本框的输入功能禁掉(该文本框用于显示游戏信息,并不允许玩家进行输入),然后就要通过Toplevel()创建一个子窗口。
子窗口的尺寸、标题等参数和主窗口一样,这里不再赘述。该子窗口要实现的功能也非常简单,只有三个组件,一个标签,用来提示玩家,在这里选择棋子,两个按钮(因为人机大战,只有两个棋子),按钮上分别显示两个棋子的图片,如果玩家按下了相应棋子的案件,则通过回调函数将棋子初始化。
def start_game():
global tl
btn['state']=DISABLED
info['state']=DISABLED
tl = Toplevel(root)
tl.geometry('220x150+690+400')
tl.title('游戏开始')
tl.resizable(0,0)
tl.wm_transient(root)
Label(tl,text='请选择棋子').place(x=80,y=10)
Button(tl,image=player_img[0],command=lambda :choose_piece(0)).place(x=50,y=50)
Button(tl,image=player_img[1],command=lambda :choose_piece(1)).place(x=120,y=50)
player_img = [PhotoImage(file=f'image\snake\p{i+1}.png') for i in range(2)]
start_game()
在按钮上展示图片只需要把按钮的image参数设置为图片即可。因为只有电脑和玩家两个棋子,所以只需要准备两张图片即可。问哥这里使用了国际象棋的棋子图片作为例子,大家可以选择自己喜欢的图片。
2). 棋子初始化函数
当玩家按下棋子的按钮后,自动调用另一个自定义函数 choose_piece() 用来将玩家的棋子初始化(绘制在棋牌左下角开始位置),并创建一个players的列表,用来表示棋子的顺序。关于棋子初始化的部分,我们用到了面向对象的功能,后面会介绍,这里可以先将其注释掉。
def choose_piece(img):
# global players
# P1 = Piece("Player", cv, (30,650),player_img[img])
# P2 = Piece("電腦", cv, (50,655),player_img[1-img])
# players=[P1,P2]
info_update("游戏开始")
info_update("请玩家先掷骰子")
btn['state']=NORMAL
tl.destroy()
因为我们的规则默认玩家先行动,于是紧接着我们就可以在信息框里显示“请玩家先掷骰子”这样的信息。(可以增加一个随机变量用来决定谁先移动,大家可以思考一下如何实现)
最后再把掷骰子的按钮状态恢复,然后使用子窗口的destroy()方法将其销毁。
3). 更新文本框函数
由于文本框的内容会被频繁更新,所以我们需要创建一个更新文本框的函数 info_update(),方便调用,实现的效果是只需要把需要显示的文本传递进去,就会自动显示在文本框的最后一行,同时将滚动条移动到最后一行。
代码如下:
def info_update(content):
info['state']=NORMAL
info.insert(END, content+'\n')
info.see('end')
info['state']=DISABLED
文本框的 insert() 方法使用的关键字“END”表示在文本框现有内容的末尾开始插入,然后转义符号\n表示在末尾插入一个换行符,以方便下次调用该函数时继续在末尾插入文字。
文本框的 see(‘end’) 方法会自动将滚动条向下滚动,把最后一行显示出来。如果不调用这个函数,虽然文本同样会正常更新进去,但如果内容太多的话,我们需要手动将滚动条拉到最下面才能看到。
函数的最后,还要记得把文本框的状态设置为禁用,因为我们不希望玩家在文本框内手工修改信息。
最后,测试效果,没有问题:
5. 面向对象——定义棋子类
1). 把棋子看做“有生命的对象”
面向对象编程其实就是这么一个概念,把无生命的东西、概念实体化,变成有“生命”的物体,就像“成精”了。这些物体有他们自己的属性(名字、年龄等),还有自己的方法(会跑、跳、攻击、死亡等等)。游戏编程里有个概念叫Sprite,表示在屏幕上出现的各种物体:主角、敌人、道具等等。——很多中文翻译都太文雅了,称其“精灵”,其实翻译成“妖精”也无不可。特指的就是这些原本无生命,却被实体化出现在游戏屏幕上“有生命的对象”们。
这些对象自创建以后,他们的“属性”便一直跟随,可以随时查看调用。比如这个例子里,我们把棋子自定义成对象后,就可以把其在棋盘上的位置定义成棋子的属性,这样,如果我们想知道棋子现在在哪里,只要调用它的属性一看便知。这便是自定义棋子类的一个好处。
而对象的方法是针对这个对象里的所有实例。比如这个例子里,我们有玩家和电脑两个棋子,都属于棋子类,那么当我们赋予这个类“移动”的方法时,两个棋子就都“会”移动了。只不过,每个棋子移动的方向、距离、方式等都是根据棋子当前在棋盘的位置决定的。换句话说,当棋子执行自己的方法时,需要调用自己的属性,那我们就没有必要指定参数了,只要告诉棋子去移动到哪里就好。这便是另一个好处。
棋子类的属性
自定义类的方法使用 “class 类名” 就可以。对于类名的要求,和变量的规则一样,但是为了便于和普通变量区分,一般约定首字母大写。
为了给我们的棋子“妖精”们赋上属性,我们需要定义一个初始化函数,然后在函数里定义这些变量。下面我们来整理一下,看看棋子们需要哪些属性:
- 名字:用来标记这个棋子是“电脑”还是“玩家”,纯文本,仅在输出提示信息的时候被用到。
- Canvas:因为我们需要借助Canvas的move方法来移动棋子,而move方法属于Canvas类,所以我们可以把Canvas对象也传进来,使它成为棋子的属性,方便调用。
- 棋子在Canvas上的唯一标识符:用来表示Canvas绘制该棋子图片时的id。这样后面使用move方法的时候,才能指定移动哪个棋子。
- 位置:最关键的信息,表示棋子目前处于哪个格子。因为我们有100个格子,于是可以定义一个整数,范围在0到100即可。
结合这些信息,初始化棋子类的代码如下:
class Piece:
def __init__(self,name,cv,co,bg):
self.name = name
self.cv = cv
self.id = cv.create_image(co,image = bg)
self.pos = 0
注意到我们刚刚在主程序初始化棋子的函数里,根据用户的选择,来定义棋子的图片和绘制该图片的坐标。实际上,这就是类的实例化过程,只需要调用类名,然后在参数里传入类的属性即可。于是我们根据刚才定义的棋子类的属性,按顺序传入以下几个参数:
- 名字:用于给棋子类的name属性赋值
- Canvas对象:表示当前操作的Canvas,也是棋子类的属性
- 坐标:用于Canvas绘制在什么位置棋子
- 棋子图片:用于Canvas绘制棋子
def choose_piece(img):
global players
P1 = Piece("Player", cv, (30,650),player_img[img])
P2 = Piece("電腦", cv, (50,655),player_img[1-img])
players=[P1,P2]
这里的坐标参数是我试了很多次,结合棋盘上的宽度和后面要移动的距离,还要使两个棋子相互错开,于是得到一个比较适合的坐标对。大家也可以改成其他数字作为棋子在棋盘上的初始位置。
棋子类的方法
显而易见,棋子第一个方法就是移动。我们想要展现棋子跳跃前进的一个效果,如下图所示,就势必需要定义一个棋子跳动的方法。
当棋子移动到两边,需要向上移动时,或者遇到梯子和蛇头,需要向上、向下移动时,就要定义另一种移动的方法:
关于这两种移动方法的定义,我们放在下篇再详细介绍。
6. 知识点回顾
- Lambda 匿名函数
- 滚动文本框 ScrolledText 组件
- 子窗口 Toplevel()
- 面向对象与自定义类