跳转至

第四章:贪吃蛇游戏编程

第三章我们已经学习了PyGame的基本知识,在后续几章我们会基于PyGame编写一些好玩的小游戏。本章将尝试编写贪吃蛇游戏。首先介绍贪吃蛇游戏的规则,然后分析其功能需求,以及对应的游戏程序设计思路,最后介绍代码如何编写实现。

一、贪吃蛇游戏介绍

游戏规则

作为一个经典游戏,贪吃蛇的游戏规则并不复杂。游戏需要玩家控制一条蛇,在一个二维的平面场景里游走。场景的四周都是墙壁,场景中会随机出现一个果实。如果蛇吃到果实,会得到一分的奖励,如果蛇的头部撞到墙壁或撞到自己的身体,就判定为失败,结束游戏。每吃到一个果实,蛇的身体会增加一节。所以随着果实吃的越来越多,身体也会越来越长,游戏难度就越来越高。需要玩家仔细控制自己行走的方向,在吃到果实时,避免撞到障碍物自己的身体。

贪吃蛇的游戏有一个很重要的特点。它是一种网格类的移动,也就是说角色的移动并不是以像素为单位的,而是每次移动一个固定的格子单位。如果游戏窗口大小是640*480个像素,每个方格如果设置为16*16个像素宽度,那么游戏场景将由40*30个格子组成。贪吃蛇的身体也是由方格构成,每次移动时,身体的格子移动占据下一个格子的位置。大家在看代码实现之前,可以自行思考一下,如果你自己来写代码需要如何来实现呢?

游戏场景如下图所示。图中黄色的小方块代码了蛇的身体,最右侧是蛇的头部。红色草莓是要去吃掉的果实,周围绿色的小方块是需要规避的墙壁。

ch04-01

游戏资源

本游戏依赖几种游戏资源,一种是图片资源,包括了贪吃蛇的图片文件,墙壁的图片文件,果实的图片文件。还有一种是声音资源,我们可以直接借用上一章的声音文件。最后还会看到一种资源是一个文本文件,表示场景地图。这些资源文件我们都可以从本书配套的github地址下载得到。大家下载这些文件后,可以打开观察下其中的内容。

墙壁和果实的图片比较简单,贪吃蛇的图片资源比较特别,需要多介绍一下,其文件内容如下图所示。它是由9个小图构成的,从左往右看,第一个小图画了一只眼睛,它代表了嘴巴没有张开时的蛇头,第二张小图多画了一张嘴巴,所以代表的是嘴巴方向向右的蛇头。以此类推,第三到八张小图是代表了方向向左、向上和向下的蛇头。从位置规律上看,奇数位置的蛇头是嘴巴关闭的,偶数位置的蛇头是嘴巴打开的,四个方向的蛇头一共需要八个小图。最后第九个位置上的小图是一个黄色小方块,用于代表蛇的身体。这些图片是为了我们在程序中表现贪吃蛇不同方向游走的动画效果。之所以要把这些不同状态的小图片整合在一起,是为了加载资源和调用资源的方便起见。我们加载这一张图片之后,就可以用代码来切割出不同的状态的子图。具体使用会在后续代码中讲解。

snake

另外还要介绍一下地图文件,这是一个普通的文本文件。通过编程器开找后,可以看到这个文本文件有30行字符串,每一行的字符串是由40个字符构成,字符要么是1要么是0,而且位于中部的都是“0”,四周都是“1”。这些字符串实际上定义了贪吃蛇游戏中的场景地图。每个字符表示了地图中一个格子的属性,如果是0意味着这格子可以通行,如果是1意味着这个格子是墙不可通行。在代码中,我们会根据这些字符来判断贪吃蛇是否撞到障碍物。

二、游戏功能和程序设计

游戏功能

本游戏要实现的主要功能包括:

  • 贪吃蛇在游戏场景中以格子为单位进行运动。
  • 玩家通过键盘方向键来控制贪吃蛇的行走方向。
  • 贪吃蛇吃掉一个果实后,身体会增加一节。奖励一分。
  • 果实随机的出现在地图中。如果被蛇吃掉则再次随机生成一个。
  • 贪吃蛇的头部如果碰上自己身体或墙壁则游戏结束。
  • 在游戏场景上方显示分数。

程序设计

我们会用面向对象的思想来设计游戏,游戏中包括了贪吃蛇、果实和墙壁,因此分别三个类,以及游戏主体类。这些类的关系图示如下。

classDiagram

    Game *-- Snake : Composition
    Game *-- Berry :Composition
    Game *-- Wall : Composition

    class Game {
    snake
    berry
    wall
    ...
    }

    class Snake {
    }

    class Berry {
    }

    class Wall {
    }

Snake类中,需要加载贪吃蛇图片资源文件,定义贪吃蛇身体,行动方向等属性。还需定义蛇的移动和绘图等函数。

Berry类中,同样加载资源文件,定义位置属性,定义绘图函数。

Wall类中,加载地图文件和图片资源,定义绘图函数。

在Game类中,需要场景初始化。定义窗口,初始化上述三种对象。定义分值、时钟等属性。在方法中需要定义碰撞检测、绘图和游戏主循环函数。

三、代码实现

模块加载、位置和方向

代码文件中首先加载必要的模块,random模块我们在第二章提到过,它在游戏中是用于实现果实随机出现的游戏机制。pygame.locals子模块中包含了若干键盘事件常量,能让我们代码更简洁一些。我们还会用到collections模块中的带名称元组,即就是namedtuple对象。

import pygame
import random
from collections import namedtuple
from pygame.locals import K_RIGHT,K_LEFT,K_UP,K_DOWN,QUIT

我们使用namedtuple来定义元组,元组中只有两个元素,即x和y,这个对象用来表示地图中的一个坐标位置,要注意的是这里的坐标位置并不是以像素为单位的,而是以地图中的网格为单位的。另外还定义一个Direction类来表示方向,这个类并不需要实例化使用,只是利用四个类属性值。

Position = namedtuple('Point', 'x, y')

class Direction:
    right = 0
    left = 1
    up = 2
    down = 3

Snake类

用Snake类来实现贪吃蛇的功能,其初始化函数就包括了blocks属性代表蛇的头和身体,它是一个list,然后list中填充了两个坐标对象,第一个是头部的坐标,第二个表示身体的坐标。current_direction属性用于记录当前运动的方向。初始方向为向右侧运动,最后是加载图片资源。

class Snake:

    def __init__(self,block_size):
        self.blocks=[]
        self.blocks.append(Position(20,15))
        self.blocks.append(Position(19,15))
        self.block_size = block_size
        self.current_direction = Direction.right
        self.image = pygame.image.load('snake.png')

Snake类中再来定义负责运动的move函数。蛇有四个可能的运动方向,我们需要根据current_direction来判断下一步的移动。在游戏场景中,想像一个二维坐标系,当current_direction向右时,是向X轴的正值方向移动,而且是移动一个格子单位,所以movesize设置为(1,0)。如果current_direction向左时,movesize设置为(-1,0),表示向X轴的负值方向移动一个格子单位。

蛇的移动是以格子为单位,等价于在移动方向上定义一个新的头部。这个新头new_head的位置是相对于之前的头部位置增加一个格子单位。最后将这个新头插入到原来身体list的最前面。这样就完成了以格子为单位的移动。大家可能会有疑问,蛇头新增加一格之后,蛇尾为什么并没有删减呢?这是因为蛇尾的处理需要依赖一个外部条件,就是有没有吃到果实,我们会在碰撞检测的代码来处理这块逻辑。

    def move(self):
        if (self.current_direction == Direction.right):
            movesize = (1, 0)
        elif (self.current_direction == Direction.left):
            movesize = (-1, 0)
        elif (self.current_direction == Direction.up):
            movesize = (0, -1)
        else:
            movesize = (0, 1)
        head = self.blocks[0]
        new_head = Position(head.x + movesize[0], head.y + movesize[1])  
        self.blocks.insert(0,new_head)

handle_input函数负责用户交互控制方向,贪吃蛇的运动有一个重要特性,它不能直接回头。设想当前方向是向右运动,那么它的后续运动只能有三个可能,继续向右,或向上,或向下。直接回头向左运动是规则禁止的,因为这样会吃掉自己。所以在handle_input函数中需要规避当前运动方向的反方向,如果用户输入向右的方向键,则当前蛇的运动方向不能是向左运动的。只要当前方向不是向左,那就可以将current_direction赋值为向右方向。以此类推,我们基于用户输入方向和当前方向,综合判断后将current_direction进行重新赋值,再调用move函数,蛇就会基于新方向进行移动了。

    def handle_input(self):
        keys = pygame.key.get_pressed()      
        if (keys[K_RIGHT] and self.current_direction != Direction.left):
            self.current_direction = Direction.right
        elif (keys[K_LEFT] and self.current_direction != Direction.right):
            self.current_direction = Direction.left
        elif(keys[K_UP] and self.current_direction != Direction.down):
            self.current_direction = Direction.up
        elif(keys[K_DOWN] and self.current_direction != Direction.up):
            self.current_direction = Direction.down
        self.move()

draw函数负责绘制贪吃蛇对象,贪吃蛇的绘制则略为复杂一些,因为要表现嘴巴一张一合的动画,所以每帧的状态需要不一样,这里我们用frame来跟踪奇数帧还是偶数帧。此外头部的图片要和身体的图片不一样,也要分开绘制。蛇是有多个小节构成,我们用一个for循环来遍历蛇的身体,即blocks属性,对其中元素分别绘图。负责绘图的blit函数我们在第三章见到过,它有三个参数,第一个参数是指图片加载后的对象,第二个参数是绘图的位置坐标,第三个参数是来源图片的切割坐标。

我们先看第二个参数怎么算出来。在blocks中我们存放的是坐标,这个坐标是以格子单位保存的,但是绘图是以像素单位绘制的,所以需要从格子单位转换到像素单位,这种转换只需要乘上格子的大小即可。这样position变量就代表了某节身体的窗口坐标,这个坐标就是绘图函数blit的输入参数之一。

再来看第三个参数怎么算出来。前面提到过,我们加载的贪吃蛇图片是由九个小图拼接成的,在具体画某一节时,需要将需要的部分切割出来。如果是绘制蛇的身体,那就是位置在最右边的小图。切割坐标的定义是由四个数字定义的,分别是需要小图的左上角坐标,以及小图的长宽尺寸。所以我们要的第九个小图,就是在else条件后的语句定义的。理解了这一点,再理解if语句后的代码就容易了,因为需要根据运动方向和游戏帧数来选择切割不同的小图。游戏帧数的取值是0或者1,这样轮换取张嘴或闭嘴的小图,展现出一种动画的效果。大家可以自行代入具体数字进行理解

blit函数是surface的方法函数,surface是窗口对象,我们会通过Game类传进来使用。也会将frame游戏帧数传进来。

    def draw(self,surface,frame):
        for index, block in enumerate(self.blocks):
            positon = (block.x * self.block_size, 
                    block.y * self.block_size)
            if index == 0: # 切割头部用的小图
                src = (((self.current_direction * 2) + frame) * self.block_size,
                         0, self.block_size, self.block_size)
            else: # 切割身体用的小图
                src = (8 * self.block_size, 0, self.block_size, self.block_size)
            surface.blit(self.image, positon, src)

Berry类

Berry类比较简单,初始化函数中定义了格子大小,加载图片资源,定义了初始的位置position。在绘图函数中,获取图片边框对象rect,将position坐标转换成窗口中的像素位置坐标,赋值为rect的左上角位置,最后调用blit函数绘图。

class Berry:

    def __init__(self,block_size):
        self.block_size = block_size
        self.image = pygame.image.load('berry.png')
        self.position = Position(1, 1)     

    def draw(self,surface):
        rect = self.image.get_rect()
        rect.left = self.position.x * self.block_size
        rect.top = self.position.y * self.block_size
        surface.blit(self.image, rect)

Wall类

Wall类负责游戏场景中的地图定义,所以一个核心属性就是map,它通过load_map函数来解析外部文本文件。load_map是通过readlines函数读取文件中每一行字符串,去除换行符后转换成一个嵌套列表,保存在content中返回。负责绘图的draw函数和之前的思路类似,遍历地图中每个字符,如果字符等于“1”,则在这个位置坐标上绘制一个绿色砖块。

class Wall:

    def __init__(self,block_size):
        self.block_size = block_size
        self.map = self.load_map('map.txt')
        self.image = pygame.image.load('wall.png')

    def load_map(self,fileName):
        with open(fileName,'r') as map_file:
            content = map_file.readlines()
            content =  [list(line.strip()) for line in content]
        return content  

    def draw(self,surface):
        for row, line in enumerate(self.map):
            for col, char in enumerate(line):
                if char == '1':
                    position = (col*self.block_size,row*self.block_size)
                    surface.blit(self.image, position) 

Game类定义

上述三个类定义后,我们来定义最重要的Game类。和第三章类似,初始化方法中定义了窗口参数和一个绘图窗口对象surface,另外定义了地图大小,我们的窗口宽度是640,每个地图上的格子大小即block_size定义为16,那么地图的宽度就是640除以16等于40,因为两边还有墙壁,所以贪吃蛇可以游走的地图宽度就是38个格子。之后将Snake、Berry、Wall这三个类实例化。此外的一些参数定义是容易理解,就不再赘述。需要提到的是在最后运行了position_berry函数,用来随机放置果实。

class Game:
    WHITE = (255, 255, 255)
    BLACK = (0, 0, 0)
    def __init__(self,Width=640, Height=480):
        pygame.init()
        self.block_size = 16
        self.Win_width , self.Win_height = (Width, Height)
        self.Space_width = self.Win_width//self.block_size-2
        self.Space_height = self.Win_height//self.block_size-2
        self.surface = pygame.display.set_mode((self.Win_width, self.Win_height))
        self.score = 0
        self.frame = 0
        self.running = True
        self.Clock = pygame.time.Clock()
        self.fps = 10
        self.font = pygame.font.Font(None, 32)
        self.snake = Snake(self.block_size)
        self.berry = Berry(self.block_size)
        self.wall = Wall(self.block_size)
        self.position_berry()

果实放置函数position_berry,是利用了第二章介绍过的random模块,来获得两个随机数字,随机数字表示地图上随机的坐标位置,然后将berry对象中的position属性进行赋值。有一个特殊情况需要考虑,如果放置果实的位置正好在贪吃蛇位置重叠,则重新放置。

    def position_berry(self):
        bx = random.randint(1, self.Space_width)
        by = random.randint(1, self.Space_height)
        self.berry.position = Position(bx, by)
        if self.berry.position in self.snake.blocks:
            self.position_berry()

碰撞检测

berry_collision用于判断贪吃蛇有没有吃到果实,如果蛇头坐标和果实坐标一致,说明吃到果实。那么就重新放置果实并增加分值。如果没有吃到果实,则使用pop函数将贪吃蛇身体从尾部去掉一节。这个pop的目的要和move方法一起理解。在每次move调用中,我们在蛇前部新增了一小节。如果没有吃到果实,需要在蛇尾部删除一小节,这要就表现了蛇整体前进的效果。

    def berry_collision(self):
        head = self.snake.blocks[0]
        if (head.x == self.berry.position.x and
            head.y == self.berry.position.y):
            self.position_berry()
            self.score += 1
        else:
            self.snake.blocks.pop()

随后我们来编写另两个碰撞检测的函数,head_hit_body是检测头部是否碰到身体,只需要取出贪吃蛇的第一个元素就是头部坐标,然后用in语句来判断即可,返回逻辑真或假。head_hit_wall用于检测头部是否碰到四周墙壁,地图数据map是一个嵌套列表,只要把蛇头坐标放到列表的索引中,判断字符是否等于“1”即可,如果判断条件为真,说明碰到墙壁。

    def head_hit_body(self):
        head = self.snake.blocks[0]
        if head in self.snake.blocks[1:]:
            return True 
        return False

    def head_hit_wall(self):
        head = self.snake.blocks[0]
        if (self.wall.map[head.y][head.x] == '1'):
            return True
        return False

绘图输出

draw_data函数用于分值显示,和第三章例子是类似的做法。我们再将各类中的绘图函数整合起来,将游戏元素都显示在窗口中,并用update进行窗口刷新。

    def draw_data(self):
        text = "score = {0}".format(self.score)
        text_img = self.font.render(text, 1, Game.WHITE)
        text_rect = text_img.get_rect(centerx=self.surface.get_width()/2, top=32)
        self.surface.blit(text_img, text_rect)

    def draw(self):
        self.surface.fill(Game.BLACK)
        self.wall.draw(self.surface)
        self.berry.draw(self.surface)
        self.snake.draw(self.surface,self.frame)
        self.draw_data()
        pygame.display.update()

游戏主循环

play函数是负责游戏主循环,也就是游戏的核心逻辑。将之前编写的重要函数进行组装,包括了退出游戏的逻辑,以及用户输入处理,碰撞检测,绘制窗口等。最后使用tick函数控制游戏速度。

    def play(self):
        while self.running:
            for event in pygame.event.get():
                if event.type == QUIT:
                    self.running = False

            self.frame = (self.frame + 1) % 2
            self.snake.handle_input()
            self.berry_collision()
            if self.head_hit_wall() or self.head_hit_body():
                print('Final Score', self.score)
                self.running = False

            self.draw()
            self.Clock.tick(self.fps)

        pygame.quit()

最终在main入口中,将游戏实例化,运行play开始玩游戏吧。看看你能玩到多少分?完整的代码可以参考配套代码中的snak.py文件。

if __name__ == '__main__':
    game = Game()
    game.play()

本章小结

本章使用PyGame完成了贪吃蛇游戏。在这个游戏中,我们设计并实现了四个游戏元素类,分别是Snake类、Berry类、Wall类和Game类。每个类中基本上都包括了若干紧密关联的属性和函数方法。大家应该对如何基于OOP编写游戏有了初步的感觉。

这个游戏实现有两个主要难点,难点之一是蛇的运动是有限制的,它不是以像素为单位移动,而是以格子为单位来移动。所以其移动函数是和以往不一样的。另外需要考虑格子坐标和窗口像素坐标之间的转换。难点之二是蛇的运动还需要带有动画效果。因此在绘图函数中,需要根据不同的方向和奇偶帧数来切割相应的子图。对于本章内容不甚理解的地方,可以采用调试模式慢慢理解。

本游戏中的地图是使用外部文本文件来定义的。这种文本文件其实就是最简单的关卡编辑。你可以尝试修改这个文本文件中的字符0,把某些地方改成1。然后再运行游戏。新的关卡会带来不一样的乐趣。当你对本游戏代码理解透彻之后,还可以尝试在其基础上进行修改,例如在吃掉果实后加入音效,或者是生成两条蛇供两个玩家进行对战。

在第十三章,我们会引入AI玩家,AI会根据贪吃蛇游戏场景信息来自动控制方向,而不需要人类玩家的参与。当然在此前,你最好顺序学习完AI的基础知识部分。在第五章,我们会介绍另一个经典游戏打砖块,它会涉及到新的鼠标控制、数学计算以及两人对战模式,让我们继续前进吧。