kivy-ch9-shmup-app

射击app

前面提到过,在这一章我们来做射击(shoot-em-up,简写shmup)app,一个快节奏的射击游戏,比魂斗罗简单许多。

shmup

做一个在屏幕上同时移动不同内容的游戏,需要大量的渲染来实现,在移动端(或多平台支持)也是如此。这一章我们就来做这些事情,上一章的知识和源代码已经带我们入了门。

教学大纲如下:

  • 用Kivy的纹理图集(Texture atlases)完成本来需要用底层代码实现的纹理坐标值的设置工作
  • 继续用GLSL开发一个质点原型,然后用这个原型做不同的游戏角色
  • 实现二维射击游戏的——一个控件,鼠标和触摸屏,基本冲突发现子弹

后面会涉及到大量细节,如果看不明白就运行一下文末的源代码。

项目的限制

我们做的app比较简单,功能有限,至少有以下限制:

  • 为了简化,忽略了奖惩机制,2048里面也是这样
  • 这个游戏只有一个敌人角色,简单模式
  • 许多优化被忽略了,可以少写一些代码

如果感兴趣可以自己做。下面我们来看一下Kivy的纹理处理相关内容,后面会用到。

纹理图集简介

纹理图集(也叫sprite sheets)是一种应用开发中把图象组合成更大纹理的方法。与只是把一堆单个图象载入应用相比,这么做有些好处:

  • 应用打开更快,读一个大文件比读许多小文件要快。如果你有几百个这样的图片,用这种方法性能提升会很明显——网页上更是如此:图片太多会严重占用HTTP请求资源,在移动设备上这点更加明显
  • 一次性渲染也会很更快。用纹理映射可以只改变需要变化的纹理坐标,而不需要引起其他内容的变化
  • 当有一个大的模型时,像GLSL类的渲染,用纹理图集方法更适合。另外,纹理的坐标值更容易获取,也不需要二次绑定纹理

在HTML和CSS里面常用类似的方法,叫CSS图片合并(CSS sprites)。原理是一样的。网页app通常是获取网络资源,如果大量图片存在会占用HTTP请求数,用CSS图片合并可以很好的降低HTTP请求占用。

这一章,我们要介绍以下内容:

  • 用Kivy的CLI工具创建纹理映射
  • 文件格式化和.atlas文件结构
  • Kivy应用纹理图集的用法

如果你已经掌握了相关内容,可以直接跳到GLSL使用纹理图集一节。

创建一个图集

和网页开发不同,那里没有标准工具处理这个任务,Kivy框架用一个命令行工具处理图集映射。

python –m kivy.atlas <atlas_name> <texture_size> <images…>

在Mac系统上,把python替换成kivy,因为安装的时候Kivy.app会调用Python解释器。

这样会创建至少两个文件,由所有的图像是否满足一个设定大小的纹理来决定。本章假设texture_size的值足够包含所有图像。

所有输出文件都是atlas_name开头的参数:

  • 图集的索引称作<atlas_name>.atlas
  • 纹理有一个后缀<atlas_name>-0.png(这个文件总是存在的),<atlas_name>-1.png等等

图集结构

.atlas是JSON格式的文件,用来描述纹理映射的位置。

{
    "game-0.png": {
        "player": [2, 170, 78, 84],
        "bullet": [82, 184, 24, 16]
    }
}

纹理的名称就是其源文件的文件名,没有后缀,foo.png就是foo。后面的数值对应的是[x, y, width, height],所有值都是像素。

组合纹理就是把一堆图片合并起来获得想要的内容,如下图所示。通常,为了利用空间会紧密排在一起。

创建图集的时候,Kivy谨慎会处理每个图集的边框,同时考虑图片可能因渲染后效果引起尺寸改变的情况。这就是为什么你需要为图片组合边距留出充分的像素。这样做效果并不可见,但是很有必要。

textureatlas

在Kivy代码里面用图集的方法和http方式类似,atlas://后面跟图集的路径。如下所示:

Image:
    source: 'flags/Israel.png'
Image:
    source: 'atlas://flags/Israel'

Kivy使用图集的简易方法

要演示上面的方法,我们用前面用过的图标icon_clock.pngicon_paint.png来试试:

kivyatlas

要创建图集,我们用下面的命令:

kivy -m kivy.atlas icons 512 icon_clock.png icon_paint.png

如果不是Mac系统,用python命令。运行之后会出现如下提示:

[INFO] Kivy v1.9.1
[INFO] [Atlas] create an 512x512 rgba image
('Atlas created at', 'icons.atlas')
1 image have been created

之后就会出现两个文件icons.atlasicons-0.png

现在可以删除源图片文件。不过最好还是保留,有可能后面更新图集的时候还会用到。

图集准备好以后,我们来做一个简单的app。basic.py文件代码如下:

In [ ]:
from kivy.app import App

class BasicApp(App):
    pass

if __name__ == '__main__':
    BasicApp().run()

basic.kv文件里面加载布局很简单:

BoxLayout:
    orientation: 'horizontal'

    Image:
        source: 'atlas://icons/icon_clock'

    Image:
        source: 'atlas://icons/icon_paint'

运行代码,效果如下图所示:

kivyatlaseasy

在GLSL代码使用图集

Kivy对图集的支持非常简单,但是GLSL应用里面没这么容易。好在.atlas是JSON格式,所以我们可以用Python的json模块来处理。然后,我们可以将像素坐标值转换成OpenGL的UV坐标值。

由于我们知道每个纹理的绝对尺寸,我们可以计算出每个图片组合的顶点与中心的相对位置。这样就可以实现对图集按照其原始形式进行渲染,保持等比例变化。

UV映射的数据结构

每个图集都有很多数据,为了方便管理数据,我们需要一个数据结构:

In [ ]:
from collections import namedtuple

UVMapping = namedtuple('UVMapping', 'u0 v0 u1 v1 su sv')

这个数据类型和C语言的结构体类似,和下面的代码差不多:

In [ ]:
class UVMapping:
    def __init__(self, u0, v0, u1, v1, su, sv):
        self.u0 = u0 # top left corner
        self.v0 = v0 # ---
        self.u1 = u1 # bottom right corner
        self.v1 = v1 # ---
        self.su = su # equals to 0.5 * width
        self.sv = sv # equals to 0.5 * height

注意,这些代码只是演示命名数组的原理,并不是完全相同。每个属性的定义如下:

属性 定义
u0, v0 图集左上角的UV坐标
u1, v1 图集右下角的UV坐标
su 图集宽度一半,在建立顶点数组的时候用
sv 图集高度一半,在建立顶点数组的时候用

这样做让代码可读性更好,原来的tup[3]就可以用tup.v1表示。同时,UVMapping是元组类型,一种不可变的、内存结构合理的数据结构,可以通过索引连接所有属性。

图集加载器

现在,让我们写一个函数来描述图集加载的过程,包括处理JSON,确定坐标值等等。这个函数在程序的最后使用:

In [ ]:
import json
from kivy.core.image import Image

def load_atlas(atlas_name):
    with open(atlas_name, 'rb') as f:
        atlas = json.loads(f.read().decode('utf-8'))
        
    tex_name, mapping = atlas.popitem()
    tex = Image(tex_name).texture
    tex_width, tex_height = tex.size
    
    uvmap = {}
    for name, val in mapping.items():
        x0, y0, w, h = val
        x1, y1 = x0 + w, y0 + h
        uvmap[name] = UVMapping(
            x0 / tex_width, 1 - y1 / tex_height,
            x1 / tex_width, 1 - y0 / tex_height,
            0.5 * w, 0.5 * h)
        
    return tex, uvmap

记住我们现在处理的是最简单的情况:一个图集由一个纹理构成。这也可能是最有效的配置方式,所以这个限制应该不会影响我们的代码,尤其是因为图集的生成完全在我们控制之下。

因为坐标值是通过Kivy的坐标系统实现的,所有我们需要把纵坐标调整一下,用OpenGL的左上角为原点的坐标系统。否则,图集就会颠倒(不过,在我们的小游戏里面这不是什么大问题。这种bug可能要在代码里长期存在,虽然没被注意到,也没什么大碍)。

load_atlas('icons.atlas')函数返回的是icons-0.png加载的纹理,和图集里每个纹理的UV描述,类似下面的结果:

>>> load_atlas('icons.atlas')

(<Texture size=(512, 512)...>,
{'icon_paint': UVMapping(u0=0.2578125, v0=0.00390625,
                u1=0.5078125, v1=0.25390625,
                su=64.0, sv=64.0),
'icon_clock': UVMapping(...)})

有了这个数据格式,我们就可以从纹理中挑出每个合并图形然后渲染到屏幕上,下面就来实现。

从图集中渲染合并图形

把上面的内容放到一起,我们用类似前面的GLSL纹理映射例子来实现一个新版本。

这里的tex_atlas.py文件与上一章的内容类似。通过load_atlas()函数来生成订单数组:

In [ ]:
from kivy.graphics import Mesh
from kivy.graphics.instructions import RenderContext
from kivy.uix.widget import Widget

# ......
class GlslDemo(Widget):
    def __init__(self, **kwargs):
        Widget.__init__(self, **kwargs)
        self.canvas = RenderContext(use_parent_projection=True)
        self.canvas.shader.source = 'tex_atlas.glsl'

        fmt = (
            (b'vCenter',     2, 'float'),
            (b'vPosition',   2, 'float'),
            (b'vTexCoords0', 2, 'float'),
        )

        texture, uvmap = load_atlas('icons.atlas')

        a = uvmap['icon_clock']
        vertices = (
            128, 128, -a.su, -a.sv, a.u0, a.v1,
            128, 128,  a.su, -a.sv, a.u1, a.v1,
            128, 128,  a.su,  a.sv, a.u1, a.v0,
            128, 128, -a.su,  a.sv, a.u0, a.v0,
        )
        indices = (0, 1, 2, 2, 3, 0)

        b = uvmap['icon_paint']
        vertices += (
            256, 256, -b.su, -b.sv, b.u0, b.v1,
            256, 256,  b.su, -b.sv, b.u1, b.v1,
            256, 256,  b.su,  b.sv, b.u1, b.v0,
            256, 256, -b.su,  b.sv, b.u0, b.v0,
        )
        indices += (4, 5, 6, 6, 7, 4)

        with self.canvas:
            Mesh(fmt=fmt, mode='triangles',
                 vertices=vertices, indices=indices,
                 texture=texture)

这点代码除了通常的GLSL初始化过程,就是把load_atlas()结果复制到vertices数组。我们选了两个不同的记录:icon_clock(用变量a表示)和icon_paint(用变量b表示),然后把它们放到顶点数组里。

顶点数据格式包含以下内容:

  • vCenter:这是合并图片在屏幕上的位置,应该和指定合并图片的所有顶点有相同的值
  • vPosition:顶点与合并图片中心的相对位置,与vCenter无关
  • vTexCoords0:每个顶点的UV坐标值,决定纹理要渲染的部分

只有合并图片的位置(数组的前两个数值)不能从UV映射关系中找到,其他数值都可以从load_atlas()获得。

tex_atlas.glsl相关文件着色器代码如下:

---vertex
$HEADER$

attribute vec2 vCenter;

void main(void)
{
    tex_coord0 = vTexCoords0;
    mat4 move_mat = mat4
        (1.0, 0.0, 0.0, vCenter.x,
         0.0, 1.0, 0.0, vCenter.y,
         0.0, 0.0, 1.0, 0.0,
         0.0, 0.0, 0.0, 1.0);
    vec4 pos = vec4(vPosition.xy, 0.0, 1.0) * move_mat;
    gl_Position = projection_mat * modelview_mat * pos;
}

---fragment
$HEADER$

void main(void)
{
    gl_FragColor = texture2D(texture0, tex_coord0);
}

这里只有最简单的功能——定位和显示纹理。类似的着色器可以用在游戏最后,那时将增加一个控制相对大小的属性,vScale

如果你不理解这段代码,请看看上一章的内容。

最后运行程序的效果如下图所示:

atlasglsl

下面,我们来开发一个粒子系统作为整个游戏其他对象的基础。

设计重用粒子系统

当我们有很多类似的对象时,写一个具有简单功能的粒子系统是通用的做法。后面我们的飞船、子弹等等都用这个粒子系统来扩展。

其实,上一章的屏保程序整个就是一个很好的粒子系统,不过还缺少一个配置能力,也不能轻易重用。因此,这里我们要改变这些GLSL代码。

值得一提的是,这里用的方法——每个粒子的四周用纹理渲染——并非底层渲染的最佳方案。但是,这么做非常直截了当,容易理解,而且与任何支持GLSL语言OpenGL的实现兼容。

如果你打算更系统的学习OpenGL,你可能会用把纹理的四周渲染改为点渲染,或者类似的概念,这已经超出的本书的范围。

类继续关系

粒子系统的API由两个类构成:PSWidget执行渲染,Particle类表示每个粒子。

这两个类将与设计紧密耦合,在通常的OOP理论中这么做很有问题,但是可以改善我们app的性能:粒子可以直接连接渲染模块的顶点数组,来改变mesh网格——复制次数更少,考虑到需要同时处理很多粒子,这么做可以大大提升性能。

粒子系统部件的实现和GLSL部件没什么不同,除了现在它是一个子类。PSWidgetParticle类都是抽象基类,也就是说,它们不能直接通过调用PSWidget()来实例化。

增强这个现在有很多不同的方法。我们可以用Python标准模块abc来创建true抽象类(abc其实就是抽象类)。虽然这对Java程序员很有用,但是对Python程序员来说并不常用。

为了简化,我们为所有需要改写的方法添加NotImplementedError异常处理。这使得基类没有元类和复杂的继承关系就不能使用,就像abc模块说明的那样。

PSWidget渲染类

下面是PSWidget类代码:

In [ ]:
class PSWidget(Widget):
    indices = []
    vertices = []
    particles = []

    def __init__(self, **kwargs):
        Widget.__init__(self, **kwargs)
        self.canvas = RenderContext(use_parent_projection=True)
        self.canvas.shader.source = self.glsl

        self.vfmt = (
            (b'vCenter', 2, 'float'),
            (b'vScale', 1, 'float'),
            (b'vPosition', 2, 'float'),
            (b'vTexCoords0', 2, 'float'),
        )

        self.vsize = sum(attr[1] for attr in self.vfmt)

        self.texture, self.uvmap = load_atlas(self.atlas)

这和前面的GLSL初始化类似,有一些属性尚未定义。self.glsl属性将加载着色器的文件名,self.atlas是纹理映射的文件名,被当作是纹理为渲染实例提供的唯一来源。

这么我们还没有生成顶点数组:这件事留给子类去做。但是,我们应该提供一个简单的方式为派生类处理内部数据结构。因此,make_particles方法可以容易的加入大量类似粒子:

In [ ]:
def make_particles(self, Cls, num):
    count = len(self.particles)
    uv = self.uvmap[Cls.tex_name]

    for i in range(count, count + num):
        j = 4 * i
        self.indices.extend((
            j, j + 1, j + 2, j + 2, j + 3, j))

        self.vertices.extend((
            0, 0, 1, -uv.su, -uv.sv, uv.u0, uv.v1,
            0, 0, 1,  uv.su, -uv.sv, uv.u1, uv.v1,
            0, 0, 1,  uv.su,  uv.sv, uv.u1, uv.v0,
            0, 0, 1, -uv.su,  uv.sv, uv.u0, uv.v0,
        ))

        p = Cls(self, i)
        self.particles.append(p)

Cls类的质点数量(num),把它们增加到部件的self.particles列表,然后同时生成self.vertices。每个粒子类型应该显示一个tex_name属性,用来在UV映射中查找出正确的合并图片,这个数据结构由前面的图集(PSWidget.uvmap)派生出来。

其实,这个辅助函数是可选的,但是很有用。部件的具体类的在渲染之前的初始化阶段调用这个函数。

这个部件基类的最后部分就是渲染函数:

In [ ]:
def update_glsl(self, nap):
    for p in self.particles:
        p.advance(nap)
        p.update()

    self.canvas.clear()

    with self.canvas:
        Mesh(fmt=self.vfmt, mode='triangles',
             indices=self.indices, vertices=self.vertices,
             texture=self.texture)

canvas.clear()调用开始的代码和前面的GLSL例子类似。前面这段代码是在迭代所有粒子时调用两个方法:advance()方法计算粒子的新状态(由粒子决定),update()保持顶点数组中必要的数据的同步。

这里主要是为了代码的可读性,并没有过多考虑性能,如果需要优化性能,有如下建议:

  • 循环部分可以并行处理
  • 代码还可以完全用另一个线程,不用每一帧都升级(优化可能应用到粒子选择的类,比如,不影响主程序背景色的填充物)

这个方法更多的实现细节会在后面介绍。

Particle

下面的代码就是Particle类,表示每个合并图片。源自满天星app的Star类,没有运动部分(后面的子类会实现):

In [ ]:
class Particle:
    x = 0
    y = 0
    size = 1

    def __init__(self, parent, i):
        self.parent = parent
        self.vsize = parent.vsize
        self.base_i = 4 * i * self.vsize
        self.reset(created=True)

    def update(self):
        for i in range(self.base_i,
                       self.base_i + 4 * self.vsize,
                       self.vsize):
            self.parent.vertices[i:i + 3] = (
                self.x, self.y, self.size)

    def reset(self, created=False):
        raise NotImplementedError()

    def advance(self, nap):
        raise NotImplementedError()

self.parent保存一个引用到父类PSWidget中,方便后面的信息交互。前面也出现过的update()方法,让多边形四个顶点的变化与粒子位置和比例保持同步(xysize属性)。

这里还有一个方法没有出现在Star里面,就是advance(),它应该被改写,因为没有为屏幕改变设置默认的动作,完全由粒子决定如何变化。后面你会看到,粒子系统可以用来创建不同的效果。

reset()方法是在粒子的生命周期的最后重新初始化粒子(比如,已经离开屏幕或用完TTL的粒子)。虽然这里都是粒子系统,但是任何系统都会有一些要被恢复到原始状态的粒子。另外,这里也没有设置默认的行为让我们调用,所有这个函数什么也没有。

从一个虚拟方法触发NotImplementedError错误是提醒开发者,可以在派生类里定义该方法的内容。我们也可以忽略后面两个方法,但是这样做有可能引发AttributeError错误。保留方法的定义,即使没有实现,也是很好的做法,可以减少其他开发者的猜测(或者过段时间再看代码的时候,会感觉一头雾水,不知道自己怎么写的)。

reset()方法里面的created参数。一些粒子系统在第一次生成的时候可能需要额外的(或不同的)初始化过程。前面也有过类似的情况,如满天星app里面,星星在屏幕的右手边生成。如果我们不考虑已生成状态,所有的星星都会出现的屏幕的最右侧,而且有同样的横坐标x,看起来就是一条直线。这样的结果肯定不是我们想要的,所以我们让通过将created变量设置成True使得星星的位置完全随机,这样就会看到漂亮的初始分布了。

调用reset()方法意味着后面重生的粒子会比第一次生成的多很多,所以把created变量设置成False

现在基类的工作都完成了。后面你会看到,游戏的实现会变得很简单。下面我们就用粒子系统来创建游戏的角色。

制作游戏

我们的app将用前面做好的模块来构建:根部件是PSWidget的子类叫Game,所有的游戏角色都由粒子系统Particle类派生出来。

In [ ]:
from kivy.base import EventLoop
from kivy.clock import Clock

class Game(PSWidget):
    glsl = 'game.glsl'
    atlas = 'game.atlas'

    def initialize(self):
        pass
    
class GameApp(App):
    def build(self):
        EventLoop.ensure_window()
        return Game()

    def on_start(self):
        self.root.initialize()
        Clock.schedule_interval(
            self.root.update_glsl, 60 ** -1)

这里用的两个文件解释如下:

  • game.glsl着色器和starfield.glsl是一样的
  • game.atlas纹理映射包含下列纹理:
    • star:和上一章的星星一样
    • player:朝向右边的飞船
    • trail:飞船发射的火球
    • bullet:飞船发射的炮弹
    • ufo:外星人朝向左边

上面的代码还没有在屏幕上显示出来,因为我们还没有生成顶点数组,下面我们来实现它们。

实现星星

现在我再建一个简单的星空。这次它从右向左运动,和前面的Kivy Bird游戏一样。

要创建一个简单的平行视差效果,我们把星星分成三个平面,然后让它们有不同的速度。一个平面上的星星比较多也比较大,快速移动,另一个比较少慢速移动。一旦星星飞出屏幕就在左边的随机位置重生。

下面我们来实现:

In [ ]:
from random import randint, random

class Star(Particle):
    plane = 1
    tex_name = 'star'

    def reset(self, created=False):
        self.plane = randint(1, 3)

        if created:
            self.x = random() * self.parent.width
        else:
            self.x = self.parent.width

        self.y = random() * self.parent.height
        self.size = 0.1 * self.plane

    def advance(self, nap):
        self.x -= 20 * self.plane * nap
        if self.x < 0:
            self.reset()

tex_name是必须有的,引用game.atlas里面的纹理。

随机生成一个星星的位置和所属的平面,无论初始化(created=True)是否被调用。

advance()方法就是一旦星星飞出屏幕就重生。

为了使用粒子系统,我们需要用PSWidget类的make_particles()方法来增加一些星星。在Game.initialize()里面:

In [ ]:
def initialize(self):
    self.make_particles(Star, 200)

下面就可以看到效果图:

starfield

实现宇宙飞船

我们只需要一个宇宙飞船(单人模式),用一个粒子就可以实现了。这么做是为了和后面的代码统一,这个对象的构建和其他对象没什么区别。

飞船一直粘在鼠标位置,要实现这个效果,我们把鼠标的位置储存到Game属性里,用player_xplayer_y来表示,然后把飞船图片加载到里面。代码如下所示:

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

class Game(PSWidget):
    def update_glsl(self, nap):
        self.player_x, self.player_y = Window.mouse_pos
        
        PSWidget.update_glsl(self, nap)

由于飞船实在用户的控制之下,没有其他逻辑要实现,只要把图片移动到鼠标位置就可以了:

In [ ]:
class Player(Particle):
    tex_name = 'player'
    
    def reset(self, created=False):
        self.x = self.parent.player_x
        self.y = self.parent.player_y
        
    advance = reset

你会发现reset()advance()方法是一样的。还有飞船的初始化:

In [ ]:
def initialize(self):
    self.make_particles(Star, 200)
    self.make_particles(Player, 1)

下面就是效果图:

spaceship

实现飞船的尾巴或火焰

科幻小说里面的飞船都跟着一个尾巴。这个尾巴用下面的算法实现:

  1. 粒子在引擎附件生成,尺寸是随机的。粒子的尺寸也是它的存活时间(time to live,TTL)
  2. 它以一个恒定的速度飞离飞船,尺寸不断减小
  3. 最终粒子的尺寸会比原来小10%

当有很多粒子来时,这个效果会很好看。不过截屏是看不出来了,你可以运行一下代码试试。代码如下所示:

In [ ]:
class Trail(Particle):
    tex_name = 'trail'

    def reset(self, created=False):
        self.x = self.parent.player_x + randint(-30, -20)
        self.y = self.parent.player_y + randint(-10, 10)

        if created:
            self.size = 0
        else:
            self.size = random() + 0.6

    def advance(self, nap):
        self.size -= nap
        if self.size <= 0.1:
            self.reset()
        else:
            self.x -= 120 * nap

其实现方式很简单,用同样的player_xplayer_y属性来决定飞船的位置。在初始化阶段,添加许多粒子来实现效果:

In [ ]:
def initialize(self):
    self.make_particles(Star, 200)
    self.make_particles(Trail, 200)
    self.make_particles(Player, 1)

截图效果如下所示:

spaceshiptail

还有敌人和子弹两个粒子系统没有实现。和前面看到的角色不同,它们都是在某个时间立刻出现,而敌人和子弹不是立刻出现的,两者都需要等一个特定的事件发生,然后逐渐增加数量,发射一颗子弹或者生成一个敌人。

但是,之前我们需要分配固定数量的粒子,因为顶点数组的增减会让代码变得复杂,这不是我们想要的。

方法是给粒子增加一个新的布尔变量属性,决定粒子是否属于激活状态,然后激活需要的粒子。这个方法后面会提到。

实现子弹

我们想让飞船的大炮在我们单击鼠标或触摸屏幕的时候能够发射子弹。用firing属性就可以实现:

In [ ]:
class Game(PSWidget):
    firing = False
    fire_delay = 0
    
    def on_touch_down(self, touch):
        self.firing = True
        self.fire_delay = 0
        
    def on_touch_up(self, touch):
        self.firing = False

要在两次射击之间增加延迟,我们引入一个变量fire_delay。这个变量会按帧递减到0,然后一个新的子弹生成,fire_delay开始增大。在firing变量为True的时候循环:

In [ ]:
def update_glsl(self, nap):
    self.player_x, self.player_y = Window.mouse_pos
    
    if self.firing:
        self.fire_delay -= nap
        
    PSWidget.update_glsl(self, nap)

现在,让我们看看这个粒子的状态。开始的时候,所有的子弹都没激活(active=False),移出屏幕(坐标值x=-100, y=-100设置子弹位置,可以在渲染的时候不让它们出现)。代码如下:

In [ ]:
class Bullet(Particle):
    active = False
    tex_name = 'bullet'
    
    def reset(self, created=False):
        self.active = False
        self.x = -100
        self.y = -100

当遍历所有子弹之后,我们跳过那些没激活的子弹,保留firing_delay不是0的子弹。这时,我们激活一个子弹,然后把它放到玩家面前,启动firing_delay变量到倒计时。

激活的子弹想星星一样移动,与星星方向相反。不像星星,子弹飞出屏幕后不会重生。它们回到不激活状态,从屏幕上消失。代码如下:

In [ ]:
def advance(self, nap):
    if self.active:
        self.x += 250 * nap
        if self.x > self.parent.width:
            self.reset()
    
    elif (self.parent.firing and 
          self.parent.fire_delay <= 0):
        self.active = True
        self.x = self.parent.player_x + 40
        self.y = self.parent.player_y
        self.parent.fire_delay += 0.3333

fire_delay属性设置为1/3秒,子弹发射的频率是每秒三发(3 rounds per second,RPS)。效果如下图所示:

spaceshipguns

实现敌人

敌人的概念和子弹类似,但是它们是连续出现的,我们不需要firing这样的标记,用一个spawn_delay就够了。代码实现如下:

In [ ]:
class Game(PSWidget):
    spawn_delay = 1
    
    def update_glsl(self, nap):
        self.player_x, self.player_y = Window.mouse_pos
        
        if self.firing:
            self.fire_delay -= nap
            
        self.spawn_delay -= nap
        PSWidget.update_glsl(self, nap)

在初始化阶段,我们创建了一个预定义数量的敌人,开始不激活。为了实现后面对子弹的碰撞检测,我们需要存储一个子弹列表(Game.particles):

In [ ]:
def initialize(self):
    self.make_particles(Star, 200)
    self.make_particles(Trail, 200)
    self.make_particles(Player, 1)
    self.make_particles(Enemy, 25)
    self.make_particles(Bullet, 25)
    self.bullets = self.particles[-25:]

这些代码看着很复杂,因为这里涉及到很多不同的运动状态。为了固定x方向的速度,每个敌人还带一个随机垂直运动矢量v。当这样的粒子从屏幕边上的顶部到底部离开屏幕时,粒子的v属性不断改变,在屏幕上看到的是敌人又回到屏幕的效果。

其他的规则与子弹类似:当敌人到达屏幕的底部,它重置然后消失再重生。代码如下:

In [ ]:
class Enemy(Particle):
    active = False
    tex_name = 'ufo'
    v = 0

    def reset(self, created=False):
        self.active = False
        self.x = -100
        self.y = -100
        self.v = 0

    def advance(self, nap):
        if self.active:
            if self.check_hit():
                snd_hit.play()

                self.reset()
                return

            self.x -= 200 * nap
            if self.x < -50:
                self.reset()
                return

            self.y += self.v * nap
            if self.y <= 0:
                self.v = abs(self.v)
            elif self.y >= self.parent.height:
                self.v = -abs(self.v)

        elif self.parent.spawn_delay <= 0:
            self.active = True
            self.x = self.parent.width + 50
            self.y = self.parent.height * random()
            self.v = randint(-100, 100)
            self.parent.spawn_delay += 1

这段代码的设计思路很简单:

  1. 检查是否被一个子弹击中,或者要重置
  2. 水平移动,检查是否离开了屏幕,然后重置
  3. 垂直移动,检查是否离开了屏幕,改变速度矢量
  4. 如果spawn_delay已经到0,就重生一个敌人,然后启动spawn_delay
碰撞检测

我们还没实现的Enemy类的另一个有趣功能是check_hit()方法。有两种情况敌人会撞到:飞船和子弹。为了简化,我们设定玩家是无敌的,敌人碰到任何物体都会被消灭:

In [ ]:
def check_hit(self):
    if math.hypot(self.parent.player_x - self.x,
                  self.parent.player_y - self.y) < 60:
        return True

    for b in self.parent.bullets:
        if not b.active:
            continue

        if math.hypot(b.x - self.x, b.y - self.y) < 30:
            b.reset()
            return True

math.hypot()是计算两物体中心距离,我们假设所有的物体都以这个方法监测。不能用没激活的子弹碰撞(if not b.active),因为没激活的子弹在屏幕上是看不到的。因此,它们不会在屏幕上撞击任何物体。

这样游戏就完成了。

fullgame

改善功能

游戏有很多地方可以改进,尤其是游戏玩法上。当然这只是一个原型,不是商品,可以慢慢改进。

如果你感兴趣,下面的建议留给你完成:

  • 游戏需要一个“Game Over”状态。胜利的状态不一定有,失败必须有,和上一章的类似
  • 增加角色,实现多种敌人,更多攻击的方式,也可以让敌人攻击飞船,增加关卡的难度,照着街机雷电游戏去做就行
  • 增加声音效果,可以仿照Kivy Bird那一章的内容。MultiAudio类也可以重用

总结

这章的重点是用粒子系统来实现不同的游戏角色。这可能不是你了解的最好方法,但还是让我们把细节放下,来总结一下整本书的重点。

一路走来,我们基本上学完了Python和Kivy游戏开发的过程,能胜任的领域很多:

  • 桌面和移动应用开发
  • 文字、图像、声音合成内容等应用的开发
  • 网络聊天应用的开发,以及社交网络,远程控制等程序
  • 视频游戏开发

通过这本书,我们还为如何有效使用新技术提供了一些基本原则:

  • 把其他领域的经验迁移过来。Kivy虽然不一样,但并非完全不同,很多其他领域的方法都可以在这里重用
  • 努力探索实现的过程。理解框架工作的内部原理可以为调试提供极大帮助。
  • 如果文档缺失,请读源代码。毕竟,它是Python。
  • 遇到问题请用搜索引擎。你遇到的问题别人也遇到过。

总之,我们衷心希望你能喜欢这次旅程。