技术人生,  游戏编程,  项目工程

Python使用tkinter制作跑酷游戏

一个基于tkinter的2D跑酷游戏,玩家需要操控角色通过跳跃躲避遇到的任何障碍物,并奔跑尽可能远的距离。

👉 github 项目repo

整个游戏完全基于python标准库实现,没有用到任何第三方库,图形界面基于tkinter接口,画面绘制基于canvas。具体游戏玩法详见README.md文件。下面简单讲解一下主要功能特征的实现。

准备界面
暂停界面
物理引擎:

整体的物理引擎原理十分简单,一般的障碍物以恒定速率始终保持向左平移,随着游戏的推进逐渐加速;人物保持水平不动,响应跳跃事件后在向下的恒定加速度的基础上设置一个向上的初速度即可。

采用以下函数计算人物下一帧所处的位置。

    def to_next(self):
        '''calculate next position of the character'''
        ground = self.cv_h - self.height / 2 - 5
        # change position y according to current y_speed with a ceiling set
        self.y += self.y_speed if self.y + self.y_speed >= -self.height else 0
        # update according to different positions
        if self.y == ground:
            # the character is on the ground
            self.y_speed = 0
            self.isjumping = False
        elif self.y < ground:
            # the character is jumping in the air
            # change y_speed by gravity
            self.y_speed += self.gravity if self.y_speed + \
                self.gravity <= self.maxspeed else 0
        else:
            # the character is under the ground (This should not happen, so it
            # needs to be on the ground)
            self.y = ground  # move to the ground
            self.y_speed = 0
            self.isjumping = False

触发角色跳跃。(默认只有在地面上才能起跳,后期增加了作弊机制可以在空中n段跳)

    def jump(self):
        '''initiate a jump motion'''
        # only allow jump when on the ground
        if self.isCheated or not self.isjumping:
            self.isjumping = True
            # update y_speed to jump
            self.y_speed = -self.maxspeed
主循环:

游戏以每一帧为最小刷新单位,每一帧内需要完成:对象状态刷新,对象间碰撞检测,障碍物刷新,用户事件响应,canvas图形绘制等。每一帧的计算过程构成游戏主循环。详见GameUnit()类的实现。

动画:

人物动画基于sprite sheet的切换实现。在canvas上,每隔固定帧数(8帧)删除上一张sprite sheet并绘制下一张sheet。以此组成跑步动画效果。障碍物动画只需删除上一张sheet再在新的位置绘制,来实现平移效果。

    def draw(self):
        '''delete last image and draw a new image
        according to current pos x, pos y'''
        # delete last image
        self.cv.delete(self.last_img)
        # draw new image
        if self.isjumping:  # jumping
            if self.y_speed <= 5:  # rise
                self.last_img = self.cv.create_image(
                    self.x, self.y, image=self.jump_image, anchor='center')
            else:  # fall
                self.last_img = self.cv.create_image(
                    self.x, self.y, image=self.fall_image, anchor='center')
        else:  # running
            self.last_img = self.cv.create_image(
                self.x, self.y,
                image=self.run_images[self.run_image_index],
                anchor='center')
            if self.frame_count == self.frame_gap:
                self.run_image_index = self.run_image_index + \
                    1 if self.run_image_index + 1 < len(self.run_images) else 0
                self.frame_count = 0
            self.frame_count += 1
帧率控制:

如果按固定时间间隔触发游戏主循环,由于不同硬件计算性能的差异,无法准确控制游戏帧率在不同配置硬件上的表现。因此帧率控制需要以异步的方式实现。我设置了一个全局时钟来控制帧与帧之间的时间间隔,主循环的调用通过after()函数注册固定时间后的下一帧的回调,并异步调用主循环函数。

    def loop_clock(self, func):
        '''This method is used to act as an external clock and
        non-blockingly call game_loop to achieve stable fps performance.
        To stop this clock, use "self.window.after_cancel(self.clock)"'''
        # non-blockingly call game_loop
        self.window.after_idle(func)
        # A timer
        self.clock = self.window.after(
            self.frame_interval, self.loop_clock, func)
得分计算:

游戏得分即玩家跑过的总距离。这个可以按需要设置,每一帧增加一些得分。随着奔跑距离的增加,人物奔跑速度会变快,因此得分的增长速率也应逐渐提升。

        # gradually increasing difficulty gradient
        gradient = self.distance / 100 if self.distance / 100 <= 20 else 20
        for unit in self.gameUnits:
            if unit.unit_loop(gradient):
                # if it is game over, jump to end screen
                self.window.after_cancel(self.clock)
                self.end_screen()
                return
        # increase distance
        self.distance += 0.1 + 0.01 * gradient
碰撞检测(游戏结束判断):

每一帧中需要分别计算场景中的每个障碍物是否和人物发生碰撞,若检测到碰撞则游戏结束。拿最基本的树类障碍物距离,将树的模型简化成一个矩形,将人物的模型简化为圆形。只需判断矩形和圆的碰撞即可。当然如此暴力的模型简化可能并不精确,因此还需要在各个方向上添加一定的阈值来调节碰撞的敏感度。

    def collision_detect(self, character):
        '''detect collision with the character. In this case,
            detect the collision between a circle-like and a rectangle'''
        left = self.x - self.width / 2 + 10
        right = self.x + self.width / 2 - 10
        top = self.y - self.height / 2 + 5
        if character.y >= top:
            if character.x >= left - character.width / \
                    2 and character.x <= right + character.width / 2:
                return True
        elif character.y >= top - character.height / 2 and character.y <= top:
            if character.x >= left - character.width / \
                    2 and character.x <= right + character.width / 2:
                if (character.x >= left and character.x <= right) or \
                        pow(character.x - left, 2) + \
                        pow(character.y - top, 2) <= \
                        pow(character.width / 2, 2) or \
                        pow(character.x - right, 2) + \
                        pow(character.y - top, 2) <= \
                        pow(character.width / 2, 2):
                    return True
        return False
障碍物刷新:

目前一共有三类障碍物,通过随机数的方式随机刷新一个障碍,随机数生成的范围越大,刷新的概率越低。障碍物间需要控制最小刷新间隔,防止出现连续刷新多个障碍使角色无法跳过的情况。随着游戏的推进,障碍物的初始速度逐渐增加,最小间隔逐渐缩小,刷新概率逐渐增大,以此提升游戏难度。

    def add_barrier(self, gradient):
        '''Add barrier on the road, the number and speed will
        increase according to difficulty gradient'''
        # use last_add to limit minimum spacing
        self.last_add += 1
        if self.last_add > 80 - gradient * 3 and \
                random.randint(0, int(100 - gradient * 2)) == 0:
            fence = Fence(self.cv, self.cv_w, self.cv_h)
            fence.x_speed += gradient
            self.objects.append(fence)
            self.last_add = 0
        elif self.last_add > 80 - gradient * 3 and \
                random.randint(0, int(120 - gradient * 2)) == 0:
            tree = Tree(self.cv, self.cv_w, self.cv_h)
            tree.x_speed += gradient
            self.objects.append(tree)
            self.last_add = 0
        elif gradient >= 0.5 and self.last_add > 80 - gradient * 3 and \
                random.randint(0, int(200 - gradient * 2)) == 0:
            # pinball will not occur during first 50 m
            pinball = Pinball(self.cv, self.cv_w, self.cv_h)
            pinball.x_speed += gradient
            pinball.gravity += gradient * 0.06
            self.objects.append(pinball)
            self.last_add = 0
暂停:

游戏过程中允许玩家在任何时候暂停,暂停触发时停止游戏主循环,解绑游戏按键,并显示暂停菜单等。继续游戏时执行以上的相反操作。

            # pause whole game
            # hide/display cursor
            for unit in self.gameUnits:
                # dislay cursor
                unit.cv.configure(cursor=self.default_cursor)
                # disable keybind
                unit.enable_key_bind(False)
            # unbind cheat key
            self.window.unbind(self.keySettings['cheat'], self.bind_cheat)
            # calcel loop clock
            self.window.after_cancel(self.clock)
            # hide fps board when paused
            self.gameUnits[0].cv.itemconfigure(self.fps_board, text='')
            display_pause_menu()
按键设置:

玩家可以自定义游戏键位,设置按键时响应任意键盘事件并获取按键名称,再重新绑定响应键位即可。需要注意处理按键冲突和无效按键的情况。(详见key_setting()函数)

按键设置
作弊:

开启作弊后允许玩家在空中无限跳跃。用一个变量来控制作弊的打开/关闭即可。

    def cheat(self):
        '''turn on / off cheating, which allows infinite jump'''
        self.objects[0].isCheated = not self.objects[0].isCheated
保存/继续游戏:

为了准确还原游戏状态,游戏的保存需要存储场景中所有对象的状态和场景信息。由于需要保存的数据较多且缺乏固定的结构,python的标准库之一pickle可以很好的应对需求。pickle可以很方便的保存python对象的状态,将对象属性序列化存储至本地,并可以在需要时重新加载这些属性来创建对象。拿角色对象的保存来举例,pickle允许我们重写__getstate__()__setstate__()方法来手动设置需要保存和加载的内容。这样就可以准确的选择保存角色的当前状态信息,而去除canvas对象等运行时数据。

    def __getstate__(self):
        '''Export status parameters'''
        status = {'cv_w': self.cv_w,
                  'cv_h': self.cv_h,
                  'width': self.width,
                  'height': self.height,
                  'x': self.x,
                  'y': self.y,
                  'isjumping': self.isjumping,
                  'gravity': self.gravity,
                  'maxspeed': self.maxspeed,
                  'y_speed': self.y_speed,
                  'imageKind': self.imageKind,
                  'run_image_index': self.run_image_index,
                  'frame_gap': self.frame_gap,
                  'frame_count': self.frame_count,
                  'isCheated': self.isCheated
                  }
        return status

    def __setstate__(self, state):
        '''Import status parameters'''
        self.__dict__.update(state)
        self.initialize_images()
老板键:

这是一个很有意思的功能,老板键允许你在游戏的任何时间,瞬间隐藏游戏窗口并使你看上去像是在认真工作,以此在工作中躲避老板的巡视。本项目中当检测到老板键按下后,会暂停游戏、将窗口伪装成一个正经的word文档(包括图标和任务栏标签),并隐藏整个窗口(全透明)。

    def boss_key(self):
        '''pause, hide and camouflage the whole game
        into a word document in the background'''
        self.hide = not self.hide
        if self.hide:
            if self.isPaused is False:
                self.game_pause()
            # do not set cursor for the hideCV
            self.hideCV = Canvas(
                self.window,
                width=self.window_width,
                height=self.window_height,
                highlightthickness=0)
            '''This camouflage picture is a screenshot of the word document'''
            self.hide_bg = PhotoImage(file='images/camouflage.png')
            self.hideCV.create_image(0, 0, image=self.hide_bg, anchor='nw')
            self.hideCV.place(x=0, y=0)
            self.window.title("New Microsoft Word Document.docx")
            if self.isAllowIcon:
                self.window.iconbitmap("images/icons/wordicon.ico")
            # make window completely transparent
            self.window.attributes("-alpha", 0)
        else:
            self.hideCV.destroy()
            self.window.title("Parkour")
            if self.isAllowIcon:
                self.window.iconbitmap("images/icons/parkour.ico")
            self.window.attributes("-alpha", 1)
PEP8规范:

顺带提一句,整个项目的所有代码严格遵循了python-PEP8代码规范,虽然该规范的部分要求有些苛刻(如每行代码不得超过79字符),但这么做可以让你的代码安全严谨,风格一致,易于阅读🙃。附PEP8检查网站。

OK,大功告成。尽管python有强大的第三方库pygame帮助你快速编写一个游戏,但自己用tkinter从零编写一个游戏项目可以加深你对游戏底层运行逻辑的理解,扎实python基本功。编写这个跑酷游戏花费了我三周的时间,总的来说游戏逻辑不算复杂但过程仍然十分有趣。

A WindRunner. VoyagingOne

留言

您的邮箱地址不会被公开。 必填项已用 * 标注