kivy-ch6-2048-app

2048 app

后面的章节,我们将逐步加深难度来介绍Kivy在游戏领域的开发过程,包括状态的管理,角色控制,音效和图象快速渐变的实现等。

这里提到的内容都是当今游戏开发中不可或缺的,因此有很多软件可以基于同样的算法和性能来实现这些功能,就像视频游戏一样。

但是,不积跬步,无以至千里。我们要踏出的第一步就是实现老少皆宜的2048游戏。

教学大纲如下:

  • 创建具有可视化的外观和行为的Kivy部件
  • 用自带的图形命令在画板上绘制
  • 用屏幕上的绝对位置随意排列部件(非结构化布局)
  • 用Kivy自带的动画支持平滑移动部件

在前面用过布局部件之后,用绝对坐标放置部件听着像是一种倒退,但是在需要很多交互的应用和游戏里面,实乃必要之举。例如,虽然在很多游戏里面的矩形块可以用GridLayout部件来表示,但是要实现从一个位置到了一个位置的简单移动都很麻烦。这是由于部件要不断重新绘制,用固定的布局来实现效率极低。

游戏介绍

2048游戏是一个数学游戏,在一个4x4的表格里玩。里面有2,4,8,...,2048,4096,8192共13种方块(可以调节难度,一般玩到2048,所以游戏叫2048),每次随机出现一种方块,可以通过上、下、左、右四个方向把所有方块直线移动最大范围,对把相同的相邻方块相加,数字翻倍,然后消除旧方块,同时出现新方块,循环往复,直到所有的表格都被填满,且没有相邻的数字可以相加为止,游戏结束。

纸上得来终觉浅,绝知此事要躬行。说那么多不如玩一把,如下图所示: 2048board

游戏概念简介

游戏有很多不同的状态:通过一系列的状态完成,像开始界面,地图界面,塔防界面等等,不同的游戏都有具体的界面组合。每个游戏都不一样,也没有多少共性。

但有一个基本特性是大多数游戏都是关于输赢的。这虽然微不足道,但是玩家对游戏的感知最终是通过游戏的界面和输赢来体现的。

很多游戏并不注重“GAME OVER”的设计,有的甚至没有,这会给玩家留下很不好的感受。这样的游戏通常也提供了一个强大的本土优势和劣势状态来弥补。 比如,如果你在魔兽世界或其他MMPRPG(Massive Multiplayer Online Role Playing Game,大型多人在线角色扮演游戏)里面不能赢也不会彻底挂掉,那你一定会在线复活或者修理设备这样的任务来回血。 如果你的游戏确实非常棒,时间久了以后,你也会获得一群游戏达人,这在只论输赢的游戏里面是没有的(也免不了菜鸟玩家)。这就需要不断的提供大BOSS,保持挑战性。

2048这个游戏的设计挺好,随着方块的出现,越来越多的方块不能被合并,游戏的难度几何级增大。

刚开始的时候很简单,玩家可以随意移动不需要动脑子。随着游戏的继续,更多的方块沉淀下来,没有找到合并的机会,可用空间不断减少,危机感来了,合理的合并策略就是必须深思熟虑的了。

2048的游戏理念非常值得借鉴:开始的时候很容易,让人爱不释手,游戏的难度不断增加,引人入胜。

随机性

由于每次所有的16个方块都会移动,玩家如果不注意可能结果是难以预料的。尽管是完全确定的,算法还会被认为是有一点随机性。这就让2048看起来更像街机游戏,有点靠运气,也会带来惊喜。

随机性的好处就是永远不知道下一个巧克力是什么味道的,这让游戏变得更好玩。

2048设计思路

我们的设计思路如下:

  • 一个4x4的网格
  • 每一回合都会执行下面的动作:
    • 玩家只可以沿一个方向移动所有方块
    • 把相同的两个方块相加生成一个新方块
    • 新的2个方块在空白的格子里面产生
  • 玩家得到一个2048就赢了
  • 当网格中没有空白,也不能合并时就输了

上述几条就是2048的设计思路,后面我们就一步一步来实现它。

选2048的理由

有人可能会问,我们为什么要做一个已经家喻户晓的游戏,而不是做个新的。下面说说这么做的道理:

这里先说点儿软件开发的事情,虽然有点离题,但重建一个知名项目的合理性并不是每个人都知道。如果这里不把事情说清楚,下一章依然跳不过这道坎。

重建2048(可谓重做轮子)的根本原因是游戏开发实在太复杂,具体解释如下:

  • 好的游戏方案很难得到,因为那需要一堆创意
  • 好游戏不能太复杂,要能快速上手,但是又不能太简单无聊,要有后劲。这一点更难了
  • 不同算法实现难易程度迥异。在静态的二维网格里查找路径,比在动态的三维空间里做难度要小得多;用AI(artificial intelligence,人工智能)来做射击游戏虽然很简单,还是可以取得不错的成果。如果用AI来做策略游戏,那就可以让电脑更聪明、更难以捉摸,让游戏呈现出足够的挑战性和多样性
  • 注重细节和不断优化是好游戏不可或缺的,这需要大量的专业人员来共同努力

这里只是抛砖引玉,并不是要吓唬大家远离游戏开发,但是游戏开发中有太多地方会出错了,所以不要犹豫把搞不定的部分外包出去。这会大大降低你的投入成本,提高产品发布的效率。

一个务实的游戏开发项目(特别是像本书这种零预算的项目)就应该是避免高成本的创造性探索,特别是在游戏内容设计方面。如果你不能为这个项目获得投资,它的独创性就没什么价值了。这就是为什么做游戏的时候首先考虑已有的项目。

不过,也没必要完全抄袭别人的创意——调整游戏的一些部分可以更好玩,同时也能锻炼自己的能力。

实际上,大多数游戏都是借用其他人的创意,玩法,有时候游戏场景都和以前的游戏类似,甚至没什么多样性(无论质量孰优孰劣,这总不是什么好事,就如今天的工业产品一般)。

简化特性

回到2048游戏,值得注意的是,它的规则非常简单,看着十分普通。但是它好玩的地方就是它也非常的难;2048一直很流行,在许多应用商店和网页上都有。

2048的流行实在太流行了,从头开始重建很有价值,不仅仅只是为了学习它。现在,你应该相信做2048是一个多么酷的事情了吧,那就让我们开始吧。

实现2048网格

到目前为止,我们都是用Kivy自带的部件,这一章我们打算建造自己的部件:Board(2048的网格)和Tile(里面的方块,像地上的瓷砖)。

让我们从创建背景色这些简单任务入手。一种做法可能是用背景图片,这种方法会遇到屏幕尺寸的问题(我们前面说过)。

我们要用的方法是创建一个Board部件,在画布上绘制网格。这样,网格的位置和大小就可以通过Kivy来定义,这和我们前面学过的文本框和按钮一样。

最简单的起点就是设置网格的尺寸和位置。有效的做法是用FloatLayout部件;这是Kivy提供的一个布局类,支持尺寸和位置的设置。建立game.kv文件,其代码如下:

#:set padding 20
FloatLayout:
    Board:
        id: board
        pos_hint: {'center_x': 0.5, 'center_y': 0.5}
        size_hint: (None, None)
        center: root.center
        size: [min(root.width, root.height) - 2 * padding] * 2

Board部件位于整个屏幕的正中间的正方形,上下、左右边距分别对称。为了尽可能的占有屏幕空间,我们在屏幕的宽和高中选择最小值,然后去掉左右边距。

要看到结果,就需要在Python文件中定义Board部件,然后加载一些内容(空部件也是看不见的)。在main.py文件中添加代码:

In [ ]:
from kivy.graphics import BorderImage
from kivy.uix.widget import Widget

spacing = 15

class Board(Widget):
    def __init__(self, **kwargs):
        super(Board, self).__init__(**kwargs)
        self.resize()
    def resize(self, *args):
        self.cell_size = (0.25 * (self.width - 5 * spacing), ) * 2
        self.canvas.before.clear()
        with self.canvas.before:
            BorderImage(pos=self.pos, size=self.size,
                        source='board.png')
on_pos = resize
on_size = resize

类似于game.kv里面的padding定义,我们在Python文件的开头定义了spacing。这是网格内构成格子的网的厚度,用来表示后面出现方块的边距,这样在视觉上就显得轻松一些,不那么拥挤。因为是4x4的网格,自然代码里面要剪掉5条边距。

resize()方法在Board部件初始化阶段(__init__())创建,或者由on_poson_size事件调用。当部件绘制完成后,就计算方块的大小cell_size

$$方块尺寸(cell\ size) = \frac {{ 网格尺寸 - (方块数量 + 1) \times 边距 }} {{方块数量}}$$

这里的尺寸(size)是指宽度或高度,因为它们都是方块,用哪个都一样。

然后我们渲染背景色,先清除之前的图象指令组canvas.before,然后用元素填充(暂时先用BorderImage)。canvas.beforecanvas.aftercanvas相反,是在部件渲染之前执行的。这样就做是为了让背景色处于所有元素的下面。

画布指令组是Kivy组织底层图形操作的方式,比如在画布上复制图象,画线,执行OpenGL命令等。关于画布的介绍可以参见第二章画图app。 每个画布指令都在kivy.graphics命名空间里面,都是canvas对象的子类,如canvas.beforecanvas.after,类似于子部件与容器部件或根部件的继承关系。 这种子部件的不同在于其具有一个复杂的生命周期,可以布置在屏幕上,响应事件和其他一些动作。但是,渲染指令却相反,就是用来绘制图形的,功能单一。比如,Color指令就是改变颜色,Image指令就是画图形等等。

这里的背景图片是一个有圆角矩形,因为BorderImage指令渲染用的背景图片board.png是第一章介绍过的9-patch图,类似于按钮是有的图形。

构建所有格子

我们的网格是二维的,通过两个for构建二维数组可以实现:

In [ ]:
for x in range(4):
    for y in range(4):
        # code that uses cell at (x, y)

这样的代码需要两次缩进不太好看,而且程序里面经常用到,这里我们用Python生成器来改善一下:

In [ ]:
# In main.py
def all_cells():
    for x in range(4):
        for y in range(4):
            yield (x, y)

这样每次用到的时候就直接调用函数即可:

In [ ]:
for x, y in all_cells():
    # code that uses cell at (x, y)

这和在两个循环内执行代码基本一致,只是隐藏了细节,让代码更简洁,而且用起来更加灵活。

下面,我们就使用网格坐标board_xboard_y,这是用来定位每一个格子的,不是屏幕上的像素坐标。

生成空格子

网格的大小和位置都由Board部件决定,但是每个格子的位置是不确定的。下面,我们就计算每个格子在屏幕上的坐标值,并把它们在画布上画出来。

屏幕上的一个格子的位置需要考虑spacing,计算如下:

In [ ]:
# In main.py
class Board(Widget):
    def cell_pos(self, board_x, board_y):
        return (self.x + board_x *
                (self.cell_size[0] + spacing) + spacing,
                self.y + board_y *
                (self.cell_size[1] + spacing) + spacing)

画布操作通常都是绝对坐标值,所以我们计算的时候要增加Board的位置(self.xself.y)。

现在我们重复算法就可以算出所有格子的位置,之后就是在画布上画出来。调整一下canvas.before就可以了:

In [ ]:
from kivy.graphics import Color, BorderImage
from kivy.utils import get_color_from_hex

with self.canvas.before:
    BorderImage(pos=self.pos, size=self.size,source='board.png')
    Color(*get_color_from_hex('CCC0B4'))
    for board_x, board_y in all_cells():
        BorderImage(pos=self.cell_pos(board_x, board_y),
                    size=self.cell_size,source='cell.png')

渲染图片时,Color指令和第二章画图app里面取色功能一样:可以用同样的白色图片或者底色把每个方块涂成不同的颜色。

还要注意cell_poscell_size的使用——都是真实屏幕的坐标值。它们会随着窗口的尺寸改变而变化,经过计算再画到屏幕上。这里我们用更简单的board_xboard_y坐标。网格截图如下:

renderscreen

网格数据结构

根据游戏的设计思路,现在我们需要让网格保持一个自动的内部实现,要实现它,我们可以用一个简单的二维数组来表示:

In [ ]:
[[None, None, None, None],
[None, None, None, None],
[None, None, None, None],
[None, None, None, None]]

这里None表示格子里面是空的,没有方块。这个数据结构可以通过嵌套的for循环来实现:

In [ ]:
class Board(Widget):
    b = None
    def reset(self):
        self.b = [[None for i in range(4)] for j in range(4)]

我们把reset()函数放在前面的位置,除了可以初始化游戏的状态,还可以在游戏失败之后生成一个新游戏。

这里,用Python的列表综合(list comprehension)并不是必须的;只是为了让代码显得紧凑点。如果你不喜欢这种方式,也可以用带缩进的两个带for语句来实现。

变量的命名方式

在这里,变量b是可以的,因为这个变量是类的属性,不会在API上用。后面的代码里还会经常出现这个变量,这么用可以少敲几次键盘。类似的做法还有在for循环里面用ij

在Python里面,私有属性一般在前面加一个下划线,_name。我们这么不这么用,因为这里变量很短,加下划线显得累赘。整个类都是在app内部使用,基本上就是一个独立的模块。

Board.b当作是一个局部变量,尤其因为Board在我们的app里面是一个单独的部件:任何时候都应该只有一个实例。

reset()函数调用

在游戏初始化阶段应该调用Board.reset()来复位整个网格。做这件事的事件是on_start,如下所示:

In [ ]:
# In main.py
from kivy.app import App
class GameApp(App):
    def on_start(self):
        board = self.root.ids.board
        board.reset()

稳定性测试

我们还没有为网格添加任何内容,但我们也写一个稳定性测试,can_move()。这个辅助函数用来测试是否我们可以把方块放在具体的格子里面。

这个测试有两部分。首先,我们需要保证坐标值都是可用的(不会超出网格),这部分检查放在valid_cell()函数里。然后,我们检查网格上的格子,看看它是不是空的(等于None)。如果可以移动过去,而且格子是空的就返回True,否则就返回False。代码如下:

In [ ]:
# In main.py, under class Board:
def valid_cell(self, board_x, board_y):
    return (board_x >= 0 and board_y >= 0 and
            board_x <= 3 and board_y <= 3)
def can_move(self, board_x, board_y):
    return (self.valid_cell(board_x, board_y) and
            self.b[board_x][board_y] is None)

这些方法在实现方块移动的时候会用到,现在我们来创建方块。

实现方块

这节是介绍实现方块的Tile部件。方块比Board部件更动态化。我们要为Tile类创建一个Kivy属性,来实现方块因任何变化而引起的自动重新绘制。

Kivy属性不同于Python的地方就是:Python的属性基本上就是绑定到一个类的实例上,可能再加上getter和setter函数。Kivy的属性还有一个功能,那就是它们发出的事件改变时,你就可以观察到有趣的属性,并相应调整其他相关变量,或者重绘屏幕。

这些工作绝大部分都是在自动完成的:让你做出一个改变,比如部件的possize属性,事件on_poson_size就被触发。

有趣的是,.kv文件里面的所有属性都是自动传播的。比如,你写了如下代码:

Label:
    pos: root.pos

root.pos属性改变时,pos值也发生了变化,它们会一直保持同步。我们创建Tile部件时要用这个特性。首先,我们声明渲染部件需要用到的属性:

In [ ]:
# In main.py
from kivy.properties import ListProperty, NumericProperty
class Tile(Widget):
    font_size = NumericProperty(24)
    number = NumericProperty(2) # Text shown on the tile
    color = ListProperty(get_color_from_hex(tile_colors[2]))
    number_color = ListProperty(get_color_from_hex('776E65'))

这就是我们画一个方块需要的代码;属性名称应该足够清楚了,color属性是方块的背景色,number属性是方块的显示的数值。

如果你们想现在就运行代码,请把tile_colors[2]替换成一个可用的颜色值,比如'#EEE4DA',后面我们会实现这个列表。

然后,在game.kv里面,我们定义部件:

<Tile>:
    canvas:
        Color:
            rgb: self.color
        BorderImage:
            pos: self.pos
            size: self.size
            source: 'cell.png'
    Label:
        pos: root.pos
        size: root.size
        bold: True
        color: root.number_color
        font_size: root.font_size
        text: str(root.number)

Label的后三个属性是自定义属性。canvas里面的self是指<Tile>,并不是canvas自己。这是因为canvas只是部件的一个属性。另外,Label是一个内嵌的部件,所以它用root.XXX来表示<Tile>的属性。这里,<Tile>是一个顶层的定义,所以可以运行。

方块初始化

在2048游戏里面,不同数值的方块颜色是不一样的,我们也要可以实现这种效果,这需要一个颜色-数值映射关系,下面是原始2048的颜色:

In [ ]:
# In main.py
colors = (
    'EEE4DA', 'EDE0C8', 'F2B179', 'F59563',
    'F67C5F', 'F65E3B', 'EDCF72', 'EDCC61',
    'EDC850', 'EDC53F', 'EDC22E')

为了把颜色匹配到数值,可以用指数计算来实现:

In [ ]:
tile_colors = {2 ** i: color for i, color in
               enumerate(colors, start=1)}

这样就可以获得我们想要的效果了:

In [ ]:
{2: 'EEE4DA',
4: 'EDE0C8',
# ...
1024: 'EDC53F',
2048: 'EDC22E'}

颜色完成之后,我们就可以实现Tile类的初始化Tile.__init__方法了。

In [ ]:
class Tile(Widget):
    font_size = NumericProperty(24)
    number = NumericProperty(2)
    color = ListProperty(get_color_from_hex(tile_colors[2]))
    number_color = ListProperty(get_color_from_hex('776E65'))
    
    def __init__(self, number=2, **kwargs):
        super(Tile, self).__init__(**kwargs)
        self.font_size = 0.5 * self.width
        self.number = number
        self.update_colors()
        
    def update_colors(self):
        self.color = get_color_from_hex(tile_colors[self.number])
        if self.number > 4:
            self.number_color = get_color_from_hex('F9F6F2')

简单解释一下:

  • font_size:设置成cell_size的一半,这是随意设置的。当然也不能放一个绝对字号在这里,因为屏幕传递尺寸是不统一的,所有最好的办法是保持字号的弹性
  • number:方块的数值,默认值为2
  • color:方块的背景色,是由前面number映射得到的
  • number_color:这也是基于数值number的属性,但是变化更少。只有两种颜色,一种深色的用于浅色背景,一种浅色的用于亮色背景;因此需要检查if self.number > 4

其他的属性都是通过kwargs参数传递到父类的,包括位置和尺寸属性,下一小节会详述。

颜色值放在update_colors()辅助函数里面,因为合并方块的时候需要用到。

现在,你可以通过下面代码来创建一个方块:

In [ ]:
tile = Tile(pos=self.cell_pos(x, y), size=self.cell_size)
self.add_widget(tile)

这样,一个新的方块就出现在屏幕上了。上面的代码应该在Board类里面。还要把self改成Board的一个实例。

缩放方块

另一个关于方块的问题还没解决,就是需要让方块与网格等比例缩放。我们先做一个辅助函数来一次更新所有Tile属性:

In [ ]:
class Tile(Widget):
    # Other methods skipped to save space
    def resize(self, pos, size):
        self.pos = pos
        self.size = size
        self.font_size = 0.5 * self.width

经过这个方法不是必须的,但它可以让代码更简练。

真正的代码将被放Board.resize()方面的最后,将由绑定的Kivy属性触发。通过计算cell_sizecell_pos的新数值,把方法应用到所有的方块上:

In [ ]:
def resize(self, *args):
    # Previously-seen code omitted
    for board_x, board_y in all_cells():
        tile = self.b[board_x][board_y]
        if tile:
            tile.resize(pos=self.cell_pos(board_x, board_y),
                        size=self.cell_size)

这个方法和我们前面用的自动属性绑定方法完全相反:我们用一种中心化、明确的方式来重新放缩所有部件。有些人可能会发觉这种方法更容易读,少一些Python代码的神奇变化(比如,通过Python代码你可以在属性handler里面放置断点;Kivy的.kv文件里要是出错很难调试,只能等错误出来)。

实现游戏

现在我们已经把各个模块都做出来了,下面就按照游戏的思路来实现它。我们需要生成方块、移动方块、合并方块。

生成方块就是在空格里面随机产生方块,思路如下:

  1. 找出所有的空格
  2. 随机选择一个空格
  3. 在空格位置生成一个方块
  4. 把方块加到网格里(Board.b),然后用add_widget()把方块显示出来

生成方块的Python代码如下:

In [ ]:
# In main.py, a method of class Board:
def new_tile(self, *args):
    empty_cells = [(x, y) for x, y in all_cells() # Step 1
                   if self.b[x][y] is None]
    x, y = random.choice(empty_cells) # Step 2
    tile = Tile(pos=self.cell_pos(x, y), # Step 3
                size=self.cell_size)
    self.b[x][y] = tile # Step 4
    self.add_widget(tile)

在游戏开始和每次移动之后都会生成方块。马上我们就来实现移动方块,现在我们可以生成方块了:

In [ ]:
def reset(self):
    self.b = [[None for i in range(4)]
              for j in range(4)] # same as before
    self.new_tile()
    self.new_tile() # put down 2 tiles

如果你执行代码,你就会看到有两个方块随机出现在网格里。

randomtile

移动方块

要让方块移动更高效,我们需要把每一个输入事件映射到一个方向矢量中。然后,Board.move()方法接受这个矢量再重新排列网格。一个方向矢量是标准的(它的长度等于1),在我们的app里,我们只要把它加到方块目前的坐标值上,就可以获得方块的新位置。

2048游戏允许4个方向,所有映射函数很简单:

In [ ]:
from kivy.core.window import Keyboard

key_vectors = {
    Keyboard.keycodes['up']: (0, 1),
    Keyboard.keycodes['right']: (1, 0),
    Keyboard.keycodes['down']: (0, -1),
    Keyboard.keycodes['left']: (-1, 0),
}    

这里的'up''right''down''left'是Kviy的键盘映射keycodes代码。

Window.bind()就可以监听Kivy的键盘事件了:

In [ ]:
# In main.py, under class Board:
def on_key_down(self, window, key, *args):
    if key in key_vectors:
        self.move(*key_vectors[key])
        
# Then, during the initialization (in GameApp.on_start())
Window.bind(on_key_down=board.on_key_down)

Board.move()方法就可以调用了。它接受方向矢量的dir_xdir_y值,从key_vectors[key]里面获取,*args就是依次获取元组、列表的元素作为参数。

控制迭代器序列

在实现Board.move()方法之前,我们需要做一个all_cells()生成器函数;正确的迭代顺序依赖于移动的方向。

比如,当向上移动的时候,我们要从最上面第一的格子开始。这样我们就可以确保所有的方块都可以紧密的排列到最上方。如果迭代的方式不对,网格里面就会看到洞,因为下面的格子没有正确的移动到最上方的空格子里。

正确迭代的代码如下:

In [ ]:
def all_cells(flip_x=False, flip_y=False):
    for x in (reversed(range(4)) if flip_x else range(4)):
        for y in (reversed(range(4)) if flip_y else range(4)):
            yield (x, y)
实现move()方法

这样,我们就可以实现最简单版本的Board.move()函数了。现在,我们只能移动方块,马上我们就把合并功能也加上。

这是移动方块的思路:

  1. 遍历所有存在的方块
  2. 对每个方块都沿着指定的方向向前移动到底
  3. 如果方块的坐标值不再变化,再到下一个方块
  4. 把方块转换到新的坐标值,再到下一个方块

Python代码实现如下:

In [ ]:
def move(self, dir_x, dir_y):
    for board_x, board_y in all_cells(dir_x > 0, dir_y > 0):
        tile = self.b[board_x][board_y]
        if not tile:
            continue
            
        x, y = board_x, board_y
        while self.can_move(x + dir_x, y + dir_y):
            self.b[x][y] = None
            x += dir_x
            y += dir_y
            self.b[x][y] = tile
            
        if x == board_x and y == board_y:
            continue # nothing has happened
        anim = Animation(pos=self.cell_pos(x, y),
                         duration=0.25, transition='linear')
        anim.start(tile)

这里的can_move()函数我们前面已经做过。

这个Animation的API和浏览器里面的CSS变换效果一样。我们需要提供:

  • 我们想变换的属性值(这里是pos
  • 变换持续时间()
  • 变换类型('linear'表示变换的速度不变)

这样,Kivy就可以将一个部件普从一个状态平滑的变换成另一个状态。

Kivy提供了很多变换类型,具体可以参考文档

绑定触摸控件

除了键盘绑定,然我们在增加一个触摸控件绑定。由于鼠标输入事件和Kivy的触摸功能一样,我们的代码同样可以支持鼠标操作。

我们需在Board类中增加一个事件handler:

In [ ]:
from kivy.vector import Vector
# A method of class Board:
def on_touch_up(self, touch):
    v = Vector(touch.pos) - Vector(touch.opos)
    if v.length() < 20:
        return
    
    if abs(v.x) > abs(v.y):
        v.y = 0
    else:
        v.x = 0
        
    self.move(*v.normalize())

要让Board.move()运行,我们得把每一个手势都转换成一个单位矢量。代码解释如下:

  1. if v.length() < 20:检查手势移动是否足够长。如果移动距离特别短,就当成是点击或者切换,不算移动
  2. if abs(v.x) > abs(v.y):手势的横坐标和纵坐标比较,把较小的坐标设为0,方向就沿着较大坐标那一侧
  3. 把矢量标准化,然后提供给Board.move()

最后一点充分解释了为什么你不能用自己的方式随意实现像方向这样的数学表达式。

合并方块

现在把同样数值方块相加合并成另一个,代码实现很容易。我们建一个新的辅助函数can_combine(),与can_move()类似,这个函数返回True如果我们可以把当前的方块与一个位置上的方块合并,如果坐标值是一样的,而且方块的数值相同。

这就是实现的方法。如果与can_move()对比,会发现基本一样:

In [ ]:
def can_combine(self, board_x, board_y, number):
    return (self.valid_cell(board_x, board_y) and
            self.b[board_x][board_y] is not None and
            self.b[board_x][board_y].number == number)

我们可以为Board.move()实现方块合并的功能了。

就是把下面的代码加到while self.can_move()后面:

In [ ]:
if self.can_combine(x + dir_x, y + dir_y, tile.number):
    self.b[x][y] = None
    x += dir_x
    y += dir_y
    self.remove_widget(self.b[x][y])
    self.b[x][y] = tile
    tile.number *= 2
    tile.update_colors()

这段代码把移动方块那部分也加进来了,不过这里用remove_widget()来移除被组合的方块,然后把新方块的数值翻倍,同时把对应的颜色也调整过来。

这样,我们的方块组合就完成了。现在游戏已经还不能玩,我们还要增加一些。

增加方块

每一轮结束之后,我们还要生成新的方块。要完成这样,生成新方块需要在方块合并序列的末尾,在上一轮方块完成移动的时候。

好在有一个合适的事件Animation.on_complete可以解决问题。由于我们同时运行了很多数值相等的方块的合并,我们就只需要把事件绑定第底一个Animation实例上——它们都是同时运行的,有相同的持续时间,所有在同时批量处理第一个和最后一个合并时不应该有明显的时间差。

这个实现和我们前面的Board.move()方法类似:

In [ ]:
def move(self, dir_x, dir_y):
    if self.moving:
        return

    dir_x = int(dir_x)
    dir_y = int(dir_y)

    for board_x, board_y in all_cells(dir_x > 0, dir_y > 0):
        tile = self.b[board_x][board_y]
        if not tile:
            continue

        x, y = board_x, board_y
        while self.can_move(x + dir_x, y + dir_y):
            self.b[x][y] = None
            x += dir_x
            y += dir_y
            self.b[x][y] = tile

        if self.can_combine(x + dir_x, y + dir_y, tile.number):
            self.b[x][y] = None
            x += dir_x
            y += dir_y
            self.remove_widget(self.b[x][y])
            self.b[x][y] = tile
            tile.number *= 2
            if (tile.number == 2048):
                print('You win the game')

            tile.update_colors()

        if x == board_x and y == board_y:
            continue  # nothing has happened

        anim = Animation(pos=self.cell_pos(x, y),
                         duration=0.25, transition='linear')
        if not self.moving:
            anim.on_complete = self.new_tile
            self.moving = True

        anim.start(tile)

一旦合并结束,on_complete事件都触发,new_tile()就被调用,游戏继续。

这里使用一个布尔值moving是为了保证new_tile()不会在一轮被调用两次。如果不检查,就可能网格立刻被堆满。

同步回合

你可能已经发现,在当前实现的方块合并部分有一个bug:玩家可以在前面一轮还没结束之前启动新一轮。解决这个bug最简单的方法就是增加移动方块的持续时间,比如设置成10秒:

In [ ]:
# This is for demonstration only
anim = Animation(pos=self.cell_pos(x, y),
                 duration=10, transition='linear')

这种解决方法忽略了在方块已经准备生成后move()调用的顺序。要考虑这些就要增加前面用过的moving。现在,它要成为Board类的属性。另外,还有调整一些代码:

In [ ]:
class Board(Widget):
    moving = False
    def move(self, dir_x, dir_y):
        if self.moving:
            return
        
        # ......
            anim = Animation(pos=self.cell_pos(x, y),
                             duration=0.25,
                             transition='linear')
            if not self.moving:
                anim.on_complete = self.new_tile
                self.moving = True
            anim.start(tile)

别忘了在new_tile()里面把moving设置成False,否则在第一轮之后其他的方块会被移除。

游戏结束

还有一件事就是游戏结束的处理。在本章开始的时候我们讨论过赢与输的条件,所以这里我们用同样的逻辑来实现它们。

游戏胜利的情况

测试玩家是否已经到达2048很简单,就是找出Board.move()函数里面是否出现一个合并成2048的方块:

In [ ]:
tile.number *= 2
if (tile.number == 2048):
    print('You win the game')

这里把赢得游戏的UI设计忽略了,你可以做一个好看的界面来表达胜利的喜悦,自己试试看吧。

另外,如果要测试,建议把难道调低,可以把2048改成64,这样测试起来方便。

游戏失败的情况

游戏失败的算法有点复杂,当然可以用不同的方式来表达。最简单的方式就是在每次移动之前遍历整个网格,查看方块是否死锁了:

In [ ]:
def is_deadlocked(self):
    for x, y in all_cells():
        if self.b[x][y] is None:
            return False # Step 1
        
        number = self.b[x][y].number
        if self.can_combine(x + 1, y, number) or \
                self.can_combine(x, y + 1, number):
            return False # Step 2
    return True # Step 3

我们需要对网格中的每一个方块进行以下测试:

  1. 如果发现空格子,这就意味着没死锁——其他的方块可以移动到这里
  2. 否则,如果方块还可以合并,那么游戏继续
  3. 如果以上测试都失败了,我们不能发现任何一个方块满足以上测试,那么游戏失败

gamefail

new_tile()里面实现这些测试比较合适:

In [ ]:
def new_tile(self, *args):
    empty_cells = [(x, y) for x, y in all_cells() if self.b[x][y] is None]
    
    # 生成一个新方块(忽略)
    
    if len(empty_cells) == 1 and self.is_deadlocked():
        print('Game over (board is deadlocked)')
        
    self.moving = False # 看前面的“回合同步”

前置条件len(empty_cells) == 1可以减少检查的次数,如果还有空格就不检查。需要注意这时is_deadlocked()方法可能就是返回False,因此这就是一个优化,不会影响游戏的运行。

这也是一个次优的,性能优先,可以继续改进的方法,代价是代码变长了。还有个优化方法就是跳过最后一行和一列,这样每次迭代时就不用检查边界,就是can_combine()做的事情。

但是,在这里使用效果可以忽略,因为每一轮都至少有一次检查,我们大部分时间都在等玩家完成操作。

下一步计划

游戏这下可以玩了,不过还有很多值得值得改进。如果你想进一步完善2048,可以参考下面的建议:

  • 增加更多动画——它们可以重新出更强的交互性
  • 增加一个记分板,可以保持分数,然后传递到服务器端,形成高分榜
  • 改造游戏规则,做成其他类似2048的游戏
  • 做一个算法来提前预测游戏结果。比如提示玩家,“不管怎么玩,再玩7轮就GAME OVER了,谢谢参与”
  • 彻底改变规则,增加一个多玩家PK模式

如果你想看更复杂的2048游戏,可以看这里。这个项目由Kivy的核心开发者Mathieu Virbel创建,整合了Google Play,最佳成绩,高分榜等等

读其他人的代码是学习编程的好方法。

总结

这一章,我们重建了2048游戏。也展现了许多其他项目里可以重用代码的实现细节:

  • 创建一个可缩放的面板,适应任意分辨率、任意方向的屏幕
  • 通过Kivy的Animation的API来实现平滑移动
  • 同时实现触摸屏手势和键盘方向键控制方向的功能

可见,Kivy框架可以很好的支持游戏开发,画布渲染和动画支持在开发视频游戏是更加重要。Kivy的原型设计也很容易,虽然比JavaScript要难一点(现代浏览器是非常强大的平台,在快速原型方面基本不可能被打败)。

如果你不使用某个平台的系统级API,Python的跨平台能力依然闪亮。也就是说,你的游戏可以在任何平台上运行,让更多的玩家参与。

Kivy也不会和主流的应用发布平台冲突,可以在Apple AppStore,Google Play,甚至Steam上发布。

当然,与成熟的游戏引擎像Unreal Engine或Unity相比,Kivy缺少很多特性和大多数交叉编译工具链。这是由于Kivy是一个一般目的的UI框架,并不是专业的游戏引擎;把不同类别的软件进行比较是不太合理的。

总之,Kivy在偶尔独立开发游戏时是个不错的选择。愤怒的小鸟就曾通过Python和Kivy来实现,想想咱们错过的机会多大啊。(不过也不要沮丧,这更是一种鼓励。Rivio的游戏道理也不是一番风顺的。)

下一章我们打算用Kivy写一个街机游戏。它将以一种非常规的方式,用类似Kivy部件的概念来创建一个交互的横向卷轴模式(Side-Scrolling),源自另一块流行的、单人开发的休闲游戏,飞翔的小鸟(Flappy Bird)。