kivy-ch7-flappy-bird-app

飞翔的小鸟app

上一章,通过制作2048app,我们已经掌握了游戏开发的简单技巧。这一章,我们继续游戏开发,做一个同样很受欢迎的游戏,飞翔的小鸟(Flappy Bird),重点学习一下游戏开发中的横向卷轴模式(Side-Scrolling)。

这是一款由越南独立开发者阮哈东(Dong Nguyen)2013年开发的手机游戏,短时间竟占领了全球各大App Store免费排行榜首位,2014年年末的时候,下载量已经 iOS App Store第一。其设计思路非常有趣,游戏操作就是一个人点击屏幕(或者空格键)保持飞翔,穿过重重障碍。这种简单重复的设计思路现在越来越流行,后面会详细介绍。

移动游戏设计的荆棘之路

经典的二维街机游戏风格在手机上复活了。有大量的经典游戏商业改造版,和30年前唯一不同的就是价签——包括Dizzy,Sonic,Double Dragon和R-Type等等。

这些游戏一个共同的不足就是控制方式感受很差,毕竟触摸屏和陀螺仪目前还不能完全替代摇杆的效果。这也给新游戏提供了卖点——发挥触摸屏的特点,设计一种新的控制方式就能获得成功。

一些开发者通过简单的设计来赢得客户,因为简单游戏有巨大的市场,尤其是低成本和免费的游戏。

那些操作简单的游戏确实很受欢迎,飞翔的小鸟就是如此。这一章,我们将用Kivy来实现这种简单的设计方法。教学大纲如下:

  • 模拟简单的街机游戏
  • 用Kivy部件开发游戏,完成方向控制和二维变换,比如旋转
  • 实现简单的碰撞检测
  • 实现游戏的声音效果

这个游戏没有获胜条件,最小的碰撞都会失败。在原版游戏中,玩家以分数高低论输赢。和上一章类似,如果感兴趣,记分板可以当作练习。

项目介绍

我们要做一个与飞翔的小鸟差不多的版本,姑且取名叫Kivy bird吧。游戏最终界面如下:

kivybird

我们的游戏包括下面三个部分:

  • 背景图案:背景是由一些以不同速度移动哦图层构成,给人一种视差效果。运动速度是不变的,也没有其他游戏事件。背景比较容易做,我们将从这里开始。
  • 障碍物(管道):这是一个单独的图层,也是以固定的速度向玩家移动。与背景不同的是,管道的高度会不断变化,中间留出一段空间让玩家通过。碰到管道游戏失败。
  • 游戏角色(小鸟):小鸟一直往下掉,只能垂直飞翔。玩家点击屏幕,小鸟就向上飞。如果小鸟掉到地上,碰到天花板或管道,游戏都失败。

这就是游戏的基本设计思路。

制作背景动画

我们将用下面的图片来做背景图案:

background

这些图片都可以无缝平铺在一起——这并不是必须的,只是看着会更好看。

如上所述,背景一直是运动的。这种效果可以通过两种方法实现:

  • 直接的方法就是在背景上移动一个大的多边形(或者几个多边形)。只是创建循环的动画需要费点功夫
  • 更有效的方法是创建一些静态多边形(一个是一层)占据整个屏幕,然后让花纹图案动起来。用一个平铺的花纹图案,这个方法可以流畅的实现动画效果,也省不少功夫——不需要重新定位背景上的对象。

我们要第二种方法来实现,因为这更简单有效。首先让我们把kivybird.kv文件做出来:

FloatLayout:
    Background:
        id: background
        canvas:
            Rectangle:
                pos: self.pos
                size: (self.width, 96)
                texture: self.tx_floor
            Rectangle:
                pos: (self.x, self.y + 96)
                size: (self.width, 64)
                texture: self.tx_grass
            Rectangle:
                pos: (self.x, self.height - 144)
                size: (self.width, 128)
                texture: self.tx_cloud

这里的数字都是花纹的尺寸:96是地面高度,64是草的高度,144是云的高度。在实际开发中写这些代码很费劲,不过我们应该尽量简化代码,降低工作量。

你会看到,这里没有移动的部分,就是三个矩形在屏幕的底部和顶部。动画效果需要花纹用Background类中带tx_的属性来实现,下面我们就是。

加载平铺的花纹

让我们建一个辅助函数来加载平铺的花纹,这个函数在后面经常用到,所以把它放在最上面。

首先创建一个Widget类,作为自定义部件的基类,main.py中代码如下:

In [ ]:
from kivy.core.image import Image
from kivy.uix.widget import Widget

class BaseWidget(Widget):
    def load_tileable(self, name):
        t = Image('%s.png' % name).texture
        t.wrap = 'repeat'
        setattr(self, 'tx_%s' % name, t)

创建辅助函数的语句就是t.wrap = 'repeat'。我们要把它应用到每一块花纹上。

我们还需要储存新加载的花纹,用tx_加图片名称来命名。比如,load_tileable('grass')就会把grass.png加载到self.tx_grass属性。

背景部件

现在我们来实现Background部件:

In [ ]:
from kivy.properties import ObjectProperty

class Background(BaseWidget):
    tx_floor = ObjectProperty(None)
    tx_grass = ObjectProperty(None)
    tx_cloud = ObjectProperty(None)
    
    def __init__(self, **kwargs):
        super(Background, self).__init__(**kwargs)
        for name in ('floor', 'grass', 'cloud'):
            self.load_tileable(name)

如果现在执行代码,你会看到花纹被拉伸填充矩形,这是因为还没有指定花纹的坐标。改变每块花纹的uvsize属性就可以了,这样就计算出覆盖多边形需要多少块花纹了。比如,uvsize设为(2, 2)表示填充一个矩形需要4块花纹。

辅助函数可以用来设置uvsize的值,这样我们的花纹就不会变形了:

In [ ]:
def set_background_size(self, tx):
    tx.uvsize = (self.width / tx.width, -1)

这里负坐标值表示花纹可以被切割。Kivy用这种效果来避免高成本的栅格操作,把负担转给GPU(显卡),这样处理起来更轻松。

这个方法依赖于背景的宽度,所以每次size属性变化之后可以用on_size()调用一次。这样就可以在屏幕发生变化的时候保持uvsize属性及时更新了:

In [ ]:
def on_size(self, *args):
    for tx in (self.tx_floor, self.tx_grass, self.tx_cloud):
        self.set_background_size(tx)

现在背景图案就变成这样了: texturebackground

背景动画

下面我们要让背景动起来。首先,我们要在KivyBirdApp类增加一个每秒60下的运动计时器:

In [ ]:
from kivy.app import App
from kivy.clock import Clock

class KivyBirdApp(App):
    def on_start(self):
        self.background = self.root.ids.background
        Clock.schedule_interval(self.update, 0.016)
        
    def update(self, nap):
        self.background.update(nap)

update()方法就是把控制传递给Background部件的update()。当我们需要更多移动的时候,我们再扩展这个方法。

Background.update()里面,我们改变花纹来模拟运动状态:

In [ ]:
def update(self, nap):
    self.set_background_uv('tx_floor', 2 * nap)
    self.set_background_uv('tx_grass', 0.5 * nap)
    self.set_background_uv('tx_cloud', 0.1 * nap)
    
def set_background_uv(self, name, val):
    t = getattr(self, name)
    t.uvpos = ((t.uvpos[0] + val) % self.width, t.uvpos[1])
    self.property(name).dispatch(self)

辅助函数里面的set_background_uv()作用是:

  • 增加uvpos属性的横坐标,水平移动花纹
  • 花纹的属性调用dispatch()表示花纹位置已经改变了

kivybird.kv的画布指令会监听这个变化并及时反馈,把花纹重新渲染出来,这样就会看到流畅的动画了。

set_background_uv()里面控制不同图层速度的因子是随意选择的,可以自定义。

这样背景就完成了,下面我们来做管道。

制作管道

管道分成两部分:高的和低的。中间会留出一个孔给小鸟飞过。每一部分都是有不同长度的管体和管头构成。

pipe

kivybird.kv文件里的布局部件给我们一个好起点:

<Pipe>:
    canvas:
        Rectangle:
            pos: (self.x + 4, self.FLOOR)
            size: (56, self.lower_len)
            texture: self.tx_pipe
            tex_coords: self.lower_coords
        Rectangle:
            pos: (self.x, self.FLOOR + self.lower_len)
            size: (64, self.PCAP_HEIGHT)
            texture: self.tx_pcap
        Rectangle:
            pos: (self.x + 4, self.upper_y)
            size: (56, self.upper_len)
            texture: self.tx_pipe
            tex_coords: self.upper_coords
        Rectangle:
            pos: (self.x, self.upper_y - self.PCAP_HEIGHT)
            size: (64, self.PCAP_HEIGHT)
            texture: self.tx_pcap
    size_hint: (None, 1)
    width: 64

其实很简单,就是把管道从下到上分成四个矩形:

  • 底部管体
  • 底部管头
  • 顶部管体
  • 顶部管头

kvpipe

Background部件的实现过程类似,这些属性都要连接到部件图形显示算法的Python代码中。

管道属性介绍

pipe部件有趣的属性是:

In [ ]:
from kivy.properties import (AliasProperty, 
                             ListProperty,
                             NumericProperty,
                             ObjectProperty)
class Pipe(BaseWidget):
    FLOOR = 96
    PCAP_HEIGHT = 26
    PIPE_GAP = 120
    tx_pipe = ObjectProperty(None)
    tx_pcap = ObjectProperty(None)
    ratio = NumericProperty(0.5)
    lower_len = NumericProperty(0)
    lower_coords = ListProperty((0, 0, 1, 0, 1, 1, 0, 1))
    upper_len = NumericProperty(0)
    upper_coords = ListProperty((0, 0, 1, 0, 1, 1, 0, 1))
    upper_y = AliasProperty(
        lambda self: self.height - self.upper_len,
        None, bind=['height', 'upper_len'])

首先,常量都放在ALL_CAPS里面:

  • FLOOR:地面花纹的高度
  • PCAP_HEIGHT:管头高度
  • PIPE_GAP:留给小鸟飞过的小孔高度

然后就是花纹的属性tx_pipetx_pcap。它们和那些在Background类里面花纹的用法一样

In [ ]:
class Pipe(BaseWidget):
    def __init__(self, **kwargs):
        super(Pipe, self).__init__(**kwargs)
        
        for name in ('pipe', 'pcap'):
            self.load_tileable(name)

ratio属性定义空的位置:0.5表示出现在中间(默认值),0表示出现在屏幕底部(地上),1表示出现在屏幕顶部(天空)。

upper_y是减少输入次数的辅助函数,它是用来计算height - upper_len值的。

还有两个重要的属性lower_coordsupper_coords,用来设置花纹的坐标。

设置花纹的坐标值

Background部件的实现过程中,我们调整了花纹的属性,像uvsizeuvpos来控制渲染效果。这个方法的问题是这么做会影响花纹的所有实例。

只要花纹没有在不同的几何形状中使用这么做就没问题。但是,现在我们需要在所有形状中都控制花纹的属性,因此我们就不能调整uvsizeuvpos了。我们需要用Rectangle.tex_coords

Rectangle.tex_coords属性接受一个8个数字的列表或元组,把花纹的坐标匹配到矩形的四个角落。tex_coords这种匹配方式如下图所示:

coordinatesmap

花纹匹配通常用uv,不用xy。这样可以把几何位置与花纹坐标值区分开,经常容易混淆。

实现管道

这个主题看着有点混乱,让我们一点点来推进:我们要垂直固定管道上的砖块,只需要调整tex_coords的第5和第7个元素。另外,tex_coords的值和uvsize里面的值是一个意思。基于管道高度调整砖块的坐标如下所示:

In [ ]:
def set_coords(self, coords, len):
    len /= 16 # height of the texture
    coords[5:] = (len, 0, len) # set the last 3 items

然后就是用ratio和屏幕高度来计算管道的长度:

In [ ]:
def on_size(self, *args):
    pipes_length = self.height - (
        Pipe.FLOOR + Pipe.PIPE_GAP + 2 * Pipe.PCAP_HEIGHT)
    self.lower_len = self.ratio * pipes_length
    self.upper_len = pipes_length - self.lower_len
    self.set_coords(self.lower_coords, self.lower_len)
    self.set_coords(self.upper_coords, self.upper_len)

这段on_size()代码用来使所有的属性与屏幕尺寸保持同步。要反映ratio的变化,需要这样:

In [ ]:
self.bind(ratio=self.on_size)

你可能发现在代码中我们没改变这个属性。这是因为管道的整个生命周期将通过KivyBirdApp类来处理,马上你就会看到。

生成管道

要创建一堆望不到头的管道森林,我们需要把它们摆满屏幕,用循环队列就可以实现。

我们让两个管道的间距是半屏宽,这样可以给玩家充分的准备时间,这样屏幕上同时会出现最多3个管道。为了方便测量,我们需要做4个管道。

实现代码如下:

In [ ]:
class KivyBirdApp(App):
    pipes = []
    
    def on_start(self):
        self.spacing = 0.5 * self.root.width
        # ...
        
    def spawn_pipes(self):
        for p in self.pipes:
            self.root.remove_widget(p)
            
        self.pipes = []
        
        for i in range(4):
            p = Pipe(x=self.root.width + (self.spacing * i))
            p.ratio = random.uniform(0.25, 0.75)
            self.root.add_widget(p)
            self.pipes.append(p)

pipes列表的使用应该考虑实现细节。我们可以遍历子部件列表来连接管道,但是只是更好看一点儿。

spawn_pipes()方法开始部分的清除代码允许我们后面重启程序更方便。

我们还用随机分布来控制ratio参数。这里用[0.25, 0.75]作为随机范围,而不是常用的[0, 1],是为了让小孔生成的位置更容易一些。

循环移动管道

与背景图案通过改变uvpos属性模拟运动的方式不同,管道真正移动。更新KivyBirdApp.update()方法来实现管道的循环更新:

In [ ]:
def update(self, nap):
    self.background.update(nap)
    
    for p in self.pipes:
        p.x -= 96 * nap
        if p.x <= -64: # pipe gone off screen
            p.x += 4 * self.spacing
            p.ratio = random.uniform(0.25, 0.75)

和之前的动画一样,96是随机的移动速度因子;因子越大速度越快。

每个管道的ratio数值都是随机生成的,这样就为玩家创建一个新的管子。界面如下图所示:

movingpipes

制作小鸟

下面我们来制作小鸟: bird

这个很简单,直接用Kivy的Image部件(kivy.uix.image.Image)实现Bird类就行。

kivybird.kv文件里面,我们需要几个属性来处理小鸟图片:

Bird:
    id: bird
    pos_hint: {'center_x': 0.3333, 'center_y': 0.6}
    size: (54, 54)
    size_hint: (None, None)
    source: 'bird.png'

这是Bird类的Python实现:

In [ ]:
from kivy.uix.image import Image as ImageWidget

class Bird(ImageWidget):
    pass

在实现细节之前,我们需要完成一些基础工作。

游戏玩法回顾

现在,让我们回忆一下游戏的过程:

  1. 首先,在没有任何管道和重力的时候,确定鸟的初始位置。这个状态用playing = False代码表示
  2. 只要玩家开始了游戏(点击屏幕或者用键盘敲空格键),代码就变成playing = True,管道开始生成,重力开始影响小鸟的状态。玩家需要持续的动作保持小鸟不掉下来
  3. 如果发生碰撞,游戏重回playing = False,每个物体都会静止下来,等待玩家重新启动,然后回到步骤2重新开始

为了实现这些,我们需要获取玩家输入的内容,很容易做到,因为我们只关心事件是否发生,不关心在哪里发生,整个屏幕就是一个大的按钮。

接受用户输入

下面是实现代码:

In [ ]:
from kivy.core.window import Window, Keyboard
class KivyBirdApp(App):
    playing = False
    def on_start(self):
        # ...
        Window.bind(on_key_down=self.on_key_down)
        self.background.on_touch_down = self.user_action
        
    def on_key_down(self, window, key, *args):
        if key == Keyboard.keycodes['spacebar']:
            self.user_action()
            
    def user_action(self, *args):
        if not self.playing:
            self.spawn_pipes()
            self.playing = True

这就是用户输入处理方式:on_key_down事件处理键盘输入,检查玩家是否敲了空格键。on_touch_down事件处理其他事件。最后都调用user_action()方法,执行spawn_pipes(),并把playing设置成True

实现小鸟上下飞行

紧接着,我们要实现重力让小鸟在一个方向上飞行。这里,我们引入Bird.speed属性和一个新常量——加速度。每一帧的速度矢量都向下增加,造成一种匀加速下降运行。如下面的代码所示:

In [ ]:
class Bird(ImageWidget):
    ACCEL_FALL = 0.25
    
    speed = NumericProperty(0)
    
    def gravity_on(self, height):
        # Replace pos_hint with a value
        self.pos_hint.pop('center_y', None)
        self.center_y = 0.6 * height
        
    def update(self, nap):
        self.speed -= Bird.ACCEL_FALL
        self.y += self.speed

playing变成True时,gravity_on()方法会被调用。把self.bird.gravity_on(self.root.height)插入到KivyBirdApp.user_action()方法中:

In [ ]:
if not self.playing:
    self.bird.gravity_on(self.root.height)
    self.spawn_pipes()
    self.playing = True

这个方法可以有效的重置鸟的初始位置,从pos_hint里面把'center_y'移除。

self.bird类似前面的self.background。下面的代码应该放在KivyBirdApp.on_start()里面:

self.background = self.root.ids.background
self.bird = self.root.ids.bird

我们还得从KivyBirdApp.update()方法里面调用Bird.update()。这样做有个好处,可以在不玩游戏的时候为升级游戏对象加一个防护:

In [ ]:
def update(self, nap):
    self.background.update(nap)
    if not self.playing:
        return # don't move bird or pipes
    
    self.bird.update(nap)
    # rest of the code omitted

你会发现,任何时候Background.update()方法都可以被调用;其他方法都是必要的时候才调用。

这里没有实现保持小鸟在空中的能力,下面会实现。

保持在空中

要让飞翔的小鸟跳着飞行也很简单。我们改写Bird.speed就行,把它设置一个正数值,当小鸟持续跌落的时候让它正常延迟。让我们在Bird类里面增加方法:

In [ ]:
ACCEL_JUMP = 5

def bump(self):
    self.speed = Bird.ACCEL_JUMP

现在,我们需要在KivyBirdApp.user_action()方法的最后调用self.bird.bump()就可以了,只要重复点击屏幕或按空格键都可以保持在空中。

旋转小鸟

旋转小鸟是为了让游戏更生动,当它飞行的时候,沿着它的飞行轨迹旋转,看着很生动。向上飞行的时候朝着右上角旋转,向下飞行的时候朝着左下角旋转。

角度计算的方法如下:

In [ ]:
class Bird(ImageWidget):
    speed = NumericProperty(0)
    angle = AliasProperty(
        lambda self: 5 * self.speed,
        None, bind=['speed'])

这里的速度因子5是随意设置的。

现在,要让小鸟旋转起来,我们要在kivybird.kv里面加入:

<Bird>:
    canvas.before:
        PushMatrix
        Rotate:
            angle: root.angle
            axis: (0, 0, 1)
            origin: root.center
    canvas.after:
        PopMatrix

这个操作会改变OpenGL使用的局部坐标系统,影响后面所有的渲染。不要忘了保存(PushMatrix)和恢复(PopMatrix)坐标系统的状态,否则致命的错误可能会发生,导致整个画面变形。

如果您遇到莫名的app渲染问题,看看OpenGL的底层指令。

这样,小鸟就可以沿着既定的轨道飞行了。

碰撞监测

这个游戏最重要的事情之一就是碰撞监测,当鸟碰到地板、天花板和管道都要结束游戏。

用地面和屏幕高度与小鸟的高度bird.y对比,就可以轻松确认小鸟是否已经碰到。在KivyBirdApp实现如下:

In [ ]:
def test_game_over(self):
    if self.bird.y < 90 or \
            self.bird.y > self.root.height - 50:
        return True
    return False

监测是否碰到管道有点困难。我们要分两步来监测:首先,我们用Kivy的collide_widget()方法来测试横坐标,然后检查纵坐标是否在合理的范围之内(管道上下两段的lower_lenupper_len属性)。KivyBirdApp.test_game_over()方法最终实现如下:

In [ ]:
def test_game_over(self):
    screen_height = self.root.height
    
    if self.bird.y < 90 or \
            self.bird.y > screen_height - 50:
        return True
    
    for p in self.pipes:
        if not p.collide_widget(self.bird):
            continue
            
        # The gap between pipes
        if (self.bird.y < p.lower_len + 116 or
            self.bird.y > screen_height - (
                p.upper_len + 75)):
            return True
        
    return False

如果监测失败就会返回False,游戏会结束。

游戏结束

当碰撞发生是回发生什么呢?我们只要把self.playing改为False就行。监测结果可以在所有的计算完成后增加到KivyBirdApp.update()最后:

In [ ]:
def update(self, nap):
    # ...
    if self.test_game_over():
        self.playing = False

这个状态要等用户重新开始游戏才会消失。写碰撞监测代码最给力的部分就是边玩边测试,就是可以同时出现不同的游戏失败状态:

gameover

虽然游戏失败,效果还是很Q的。

制作声效

这部分和Kivy开发没啥关系了,就是演示一些制作游戏和应用声效的工具。

声效的最大问题都不是技术上的。创建高质量的声效不是简单的事儿,软件工程师毕竟没有音乐和声乐工程师专业。另外,很多应用实际上没用声效,所以声效通常是被忽略了。

不过制作声效的简便工具还是不少的。Bfxr就是一个很棒的免费电子合成器。用法很简单,就是单击一些设置按钮配置好音效,然后点击Save to Disk保存到电脑上就行了,用Bfxr可以很轻松地为app创建声效。

bfxr

Kivy声音播放

在程序处理时,Kivy提供了声音播放的API:

In [ ]:
from kivy.core.audio import SoundLoader
snd = SoundLoader.load('sound.wav')
snd.play()

play()方法就开始播放。不过这个简单的方法在游戏里面使用有点问题。

很多时候,你需要让声音随着你的动作不断的重复。Kivy的sound类的问题就是只能在指定的时间内播放一次。

可行方式如下:

  • 等前一个播放终止(默认的行为,后面的事件都会静音)
  • 为每个事件停止播放然后重启播放,还是有问题(可能引起延迟)

要解决这个问题,我们需要创建一个sound对象的队列,这样每次调用play()就产生一个Sound对象。当队列结束时,我们可以从头开始。只要队列足够长,我们就可以完全不用担心Sound的限制了。实际上,长度为10就可以。实现代码如下:

In [ ]:
class MultiAudio:
    _next = 0
    
    def __init__(self, filename, count):
        self.buf = [SoundLoader.load(filename)
                    for i in range(count)]
        
    def play(self):
        self.buf[self._next].play()
        self._next = (self._next + 1) % len(self.buf)

用法如下:

In [ ]:
snd = MultiAudio('sound.wav', 5)
snd.play()

上面第二个参数就是队列的长度。看看我们是如何改写Sound API的play()方法的。这样在简单的程序里直接替换Sound就可以。

添加声效

下面让我们把声效添加到kivy Bird游戏里。

有两个地方需要使用声音文件,一个是小鸟向上飞,一个是撞到东西。

第一个事件,通过单击和切换来初始化,在速度快的时候重复很频繁,我们用一个队列。第二个事件,游戏结束,不会频繁的发生,所以就用一个Sound对象:

In [ ]:
snd_bump = MultiAudio('bump.wav', 4)
snd_game_over = SoundLoader.load('game_over.wav')

用前面加载过的MultiAudio类就行。剩下的事情就是把play()添加到适当的位置:

In [ ]:
if self.test_game_over():
    snd_game_over.play()
    self.playing = False
    
def user_action(self, *args):
    snd_bump.play()

这样,飞翔的小鸟就有声音了,希望你喜欢。

总结

这一章,我们做了一个Kivy小游戏,用到了画布指令和部件。

作为UI工具包,Kivy提供了很多好东西,允许我们自由的组合、新建任何部件,可以做微信客户端和视频游戏等等。Kivy属性实现的一个特别值得称赞的地方就是可以无限制的组织数据,帮助我们充分消除不必要的内容(比如在属性没有发生变化的时候重新刷屏)。

Kivy的另一个令人惊奇也是反直觉的特点就是它的性能很好——虽然Python并不以性能著称。部分原因是因为Kivy底层系统是Cython写的,被编译成机器码,性能和C语言有一拼。另外,如果配置合适,显卡加速也可以用来保证动画流程运行。

下一章我们将继续提供图形渲染性能。

源代码