你有没有想过开发一款电子游戏?那你来对地方了。这是一系列持续更新的2D简易移动游戏的开发教程。
这篇教程是之前一篇入门简介
的延续。这篇文章,我们的目标是写一款具有可玩性的打地鼠风格的游戏。
这个游戏叫做打苍蝇(Langaw),任务是在苍蝇接触到垃圾堆之前尽可能多的拍死它们,你必须从苍蝇手里保护垃圾堆,毕竟,谁会愿意它飞满苍蝇呢。
用户的操作方式仍然是简单的点击。在这一部分,我们仍然不会使用图片文件所以苍蝇们看起来只是一些绿色的小方块,当它们被点击到时,它们变成红色然后落到屏幕底部。
本文所有的代码都在该Github目录。
1. 创建一个Flame/Flutter游戏
创建项目
VS Code中的Flutter插件有一个生成Flutter项目的命令,只需要在VS Code中同时按下Ctrl + Shift + P
然后输入"flutter"。从下拉选项中选择Flutter: New Project
,输入项目名称,然后选择项目目录。
同样的,你可以在使用以下命令在命令行中当前目录创建名为langaw的Flutter项目:
$ flutter create langaw
你也可以把langaw
改为其他名字。
等到命令运行完成,使用VS Code打开项目,进入下一步。
清理无关代码
就像上一节处理的一样,我们会删除test
目录,因为介绍测试内容将会花掉比这整个系列更多的内容。
最后,删除Flutter的启动逻辑,打开
./lib/main.dart
然后删掉void main
声明下的所有内容,经过处理之后的main.dart
文件应该看起来像下面这样:
import 'package:flutter/material.dart';
void main() {}
我们留下了material
库的引用,因为接下来还会用到它的runApp
方法。
引入Flame插件
下一步,我们要安装Flame插件,打开./pubspec.yaml
文件,然后在cupertino_icons: ^0.1.2
行下面加上flame: ^0.10.2
。
可选步骤:删除该文件中所有#开头的注释。
此时该文件应该如下所示:
完成并保存之后,需要运行如下命令安装引入的包:
$ flutter packages get
注意:如果你在VS Code中安装了Flutter和Dart的插件,每当你保存pubspec.yaml
时,它都会自动运行flutter packages get
命令,无需你手动执行。
游戏初始化
我们需要让我们的游戏始终保持竖屏,同样也要开启全屏,没有顶部通知栏和底部按钮栏。
我们将用Flame库中的Util
类来实现上述功能。回到./lib/main.dart
文件,使用下列代码引入util
库和Flutter的service
库。service
库给我们提供了DeviceOrientation
类,通过它我们能获取设备的放置方向。
import 'package:flame/util.dart';
import 'package:flutter/services.dart';
然后我们将main函数加上async
关键字,将它转化为异步函数,以便我们在函数体中使用await
关键字,然后在函数体中加入执行全屏和竖屏的语句:
Util flameUtil = Util();
await flameUtil.fullScreen();
await flameUtil.setOrientation(DeviceOrientation.portraitUp);
main.dart
文件现在应该像这样:
虽然我们的游戏还没有任何内容,但你现在运行的话,如果没有任何报错,并且全屏开启,那说明你的步骤正确。
可以通过在VS Code中按下F5
,你同样也可以在命令行的当前目录下执行如下命令来运行项目:
$ flutter run
如下图所示的白色屏幕就是正常的,只要没有任何报错:
2. 为Game类配置主循环
我们需要一个具有游戏主循环逻辑的Game
类,它是游戏的核心,包含了所有非玩家输入的逻辑。
Game类
接下来我们创建一个文件./lib/langaw-game.dart
,并加入下列代码:
import 'dart:ui';
import 'package:flame/game.dart';
class LangawGame extends Game {
Size screenSize;
void render(Canvas canvas) {}
void update(double t) {}
void resize(Size size) {}
}
简析:我们引入了Dart
的ui
库,以使用Canvas
和Size
类。为了使用Flame的游戏主循环框架,我们引入了Flame的game
库。然后我们创建了一个继承自Flame的Game
类的LangawGame类。这个类包含了覆写自Game类的3个同名函数。同时,我们声明了一个名为screenSize
的Size类用于存储屏幕尺寸。
该文件应该如下图所示:
引入和运行game类
现在我们需要把game
类导入到main
函数中,当游戏运行时,它会从game
的实例LangawGame
类启动。让我们回到./lib/main.dart
然后导入我们新创建的langaw-game.dart
文件:
import 'package:langaw/langaw-game.dart';
然后,创建一个LangawGame
类的实例,然后将LangawGame
类的widget
属性传入runApp
方法:
LangawGame game = LangawGame();
runApp(game.widget);
现在,./lib/main.dart
如下图所示:
屏幕尺寸
让我们回到./lib/langaw-game.dart
中继续完善我们的游戏。我们现在要获取屏幕尺寸,为后面绘制画面作准备。
有一些特定的事件会引起Flutter重新计算Canvas
暴露给应用的尺寸(在本例中为LangawGame
)。其中一个事件就是游戏开始运行时。其他的事件比如横置屏幕会引起屏幕的横竖尺寸互换。我们现在制作的游戏支支持竖屏模式,所以我们不用考虑这个问题。
有的手机支持多种分辨率所以玩家可以在他们游玩的时候更改分辨率。我们需要做些准备,适配这一特性,以保证当Canvas
的尺寸改变后,Flutter通过resize
方法通知我们时,我们能重新计算我们的尺寸。
顺带一提,Canvas
类就是我们绘制包括背景,敌人和用户界面在内的各种元素的地方。
现在,我们把下面这行代码加入到resize
方法中:
screenSize = size;
这行代码的作用是当屏幕尺寸改变时,将改变后的尺寸赋值给screenSize
,以便于后面在游戏主循环中调用。
绘制背景
首先我们要在屏幕上绘制背景。
把下列代码置于render
方法中:
# 使用`Rect`的`fromLTWH`方法创建了一个`Rect`类的实例`bgRect`,它的左上位置与坐标为(0, 0),它的尺寸与屏幕的长宽一致
Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
# 创建Paint类的实例,并将传入了色号的Color对象赋给color属性
Paint bgPaint = Paint();
bgPaint.color = Color(0xff576574);
#调用canvas的drawRect方法,传入图形和绘制参数
canvas.drawRect(bgRect, bgPaint);
现在文件应该如下图所示:
现在运行游戏,游戏的背景应该变成如下颜色:
适配不同尺寸的手机
在我们创建我们的第一个游戏组件之前,有一个需要处理的问题:
手机尺寸
根据这篇文章的统计,在2015年一共有超过24000款不同的安卓设备,更不用讲现在这个数字会扩大到什么程度。
不同的机型往往有着不同的尺寸和高宽比,我们需要想办法尽可能的去适配它们。
现在来介绍下高宽比,它是设备宽和高的比率,它会在你切换横竖屏的时候发生切换。
现在市面上的手机有很多不同的高宽比,比如3:2
,4:3
,8:5
,5:3
,16:9
甚至18.5:9
,最常见的比例是16:9
,我们将这个比例当作基准。
因为我们只使用竖屏模式,我们事实上是使用9:16
作为基准,更具体的,我们把宽度9作为基准,将其他的宽高比转化为9:13.5
,9:12
,9:14.4
,9:15
,9:16
以及 9:18.5
。
这样的话,我们的游戏界面始终保持9只苍蝇的宽度,无论手机是短是长,都只影响苍蝇上下飞的距离长短。
重要的是,无论屏幕的尺寸和宽高比是多少,一只苍蝇的宽度始终保持在屏幕的1/9,在这样的设计下,9只苍蝇总能横向占满屏幕。
实现网格系统
为了获取网格(即一只苍蝇的占位)的尺寸,我们需要增加一个tileSize
参数来记录网格的大小,将下列声明代码置于screenSize
声明行之下:
double tileSize;
这个变量将会保存屏幕宽度1/9的长度值,便于我们在game
类的任何位置访问,因此我们需要把tileSize
的赋值语句置于resize
方法中,以便于我们能在屏幕尺寸变化的第一时间更新它的参数。
void resize(Size size) {
screenSize = size;
tileSize = screenSize.width / 9;
}
现在game
类应该如下图所示:
3. 创建苍蝇组件
现在我们要来创建我们的第一个游戏组件了。
组件,有时候被称作对象或游戏对象,是在游戏中担任某种角色的对象,比如游戏的主角,敌人,土地,地图,UI的一部分,子弹等等。有些组件往往会在它的位置覆盖图像,以便于玩家知道它们的位置。
并不是所有的组件都具有位置和绘画属性。有的组件只是为了实现某个功能,并不会在屏幕上展示,这种组件被称作controllers
(控制器)。控制器用于控制游戏的行为。
为什么要使用组件?
想想你之前玩过的较大的游戏,如果把所有的逻辑都放在一个类里面,那它应该至少有成千上万行代码,这样的项目维护起来一定很头痛。
另外一点就是,有了组件,我们可以利用dart面向对象的特性。我们可以在游戏主循环中创建类的实例,使用其中封装好的方法和数据,而不用过多的考虑其内部的逻辑。
你可以把一个组件想象成一个小的主循环,或者是主循环的一部分,它们同样具有update
和render
方法。
我们的第一个组件
我们需要创建目录./lib/components
来存放组件,在该目录中,创建一个新文件fly.dart
。
打开./lib/components/fly.dart
然后将下列代码写入该文件:
import 'dart:ui';
class Fly {
void render(Canvas c) {}
void update(double t) {}
}
文件内容和游戏主循坏非常相似。原因是当这个组件需要更新和渲染时,游戏主循坏将会调用组件的上述两个方法。
位置和尺寸
苍蝇(fly)组件需要知道它的位置和大小,所以我们要创建变量来存储这些信息。
我们可以用double x
,double y
,double width
,double height
四个变量来保存上述信息,但是我们还可以更优化存入的数据。
对于传入的数据结构,我们有太多的选择,我们可以传x / y
和width / height
,也可以传dart:ui
库里的Size
和Offset
类,但这些组合还是包含了两个变量,我们能不能把变量优雅的压缩到一个呢?
答案是肯定的,还记得我们绘制背景时用到的Rect
类吗?在构建这个类的实例的时候,我们就通过fromLTWH
工厂函数传入了所需的x, y, width, height
全部参数。
唯一的缺点是Rect
实例是不可变的(immutable)。意味着你不能通过赋值的方式改变它的任何属性(无论是top
或者left
)。但是你可以通过它的shift
和translate
方法来移动它。
我们来创建一个名为flyRect
的Rect
类的实例:
Rect flyRect;
然后我们需要引入game
类以便使用它的screenSize
之类的参数:
import 'package:langaw/langaw-game.dart';
接下来,在flyRect
行下面,加入另一行实例变量作为父级game
类的引用:
final LangawGame game;
注意:final
变量相当于作用域中的常量。它的值是不可变的。因为我们的苍蝇在它的生命周期里只存在于一个game
类中,它的父类不需要是动态的。
最后一件事,我们需要创建一个constructor
来初始化这些实例变量的值。
Constructors
是当类的实例创建时运行的方法。它只会运行一次所以它最适合用来执行初始化命令。在变量声明语句下面加入下面的代码:
# 定义一个与class同名的constructor
Fly(this.game, double x, double y) {
flyRect = Rect.fromLTWH(x, y, game.tileSize, game.tileSize);
}
这个constructor
接收3个参数,一个是父级的game
类,将它绑定给刚刚创建的game
变量,剩下的x
和y
代表苍蝇出现的位置。
在constructor
中,我们使用传入的x
和y
以及父类game
中的tileSize
作为长和宽创建了一个新的矩形,并将它赋值给了flyRect
。
现在这个文件应该如下所示:
!()[https://jap.alekhin.io/wp-content/uploads/2019/02/fly-class.jpg]
绘制苍蝇
这一步,我们需要实现绘制苍蝇组件的代码。
我们已经知道了,绘制一个矩形,需要一个Rect
类(上一步已经生成了)和一个Paint
类,为了避免在render
方法中反复初始化Paint
类,我们把它作为一个实例变量来存储。
在flyRect
的声明中增加下面这行代码:
Paint flyPaint;
然后在constructor
中初始化flyPaint
变量:
flyPaint = Paint();
flyPaint.color = Color(0xff6ab04c);
现在我们可以开始绘制了,在render
方法中加入下列代码:
c.drawRect(flyRect, flyPaint);
就是这样,当创建一个Fly
实例并执行它的render
方法时,一只苹果绿色的方块就会出现在屏幕上指定的位置。
现在Fly
类应该如下图所示:
4. 召唤苍蝇
在我们随心所欲的召唤苍蝇之前,我们先要解决几个技术问题。当我们的游戏运行时,初始的screenSize
为null
,我们需要等到resize
方法触发才知道屏幕的尺寸,那么它初次触发的时机时什么时候呢?
如果我们回到./lib/langaw-game.dart
,你将看到当render
方法运行的时候,screenSize
已经被赋值了,这是因为它们之间的调用顺序是这样的:
-
class
实例被创建(constructor
运行,但是因为该类中没有没有该方法所以跳略过); - Flutter调用
resize
方法然后screenSize
被赋值; - 游戏主循坏开始;
- 游戏主循坏:
update
方法被调用; - 游戏主循坏:
render
方法被调用; - 游戏主循坏结束,回到步骤3。
这种流程带来的影响有好处也有问题。理想状态下,我们希望初始化的代码在constructor
中执行,因为我们只希望它运行一次。
好处:resize
方法在对象创建之后几乎是立即就执行了,所以我们可以在resize
方法中执行初始化逻辑。但是...
坏处:resize
方法在屏幕尺寸变化时会再次被触发。如果我们把初始化逻辑放在这里,那么它有可能被执行多次。再次强调,初始化逻辑只应该被执行一次。想象一下你的游戏主角,在你翻转屏幕的时候,resize
方法被触发了,在主角出生的位置又出现了一个主角,或者主角的位置和状态被重置到了启动时的状态,这多糟糕啊。
有一种不优雅的做法,仍然在resize
中执行初始化逻辑,额外使用一个bool
类型的变量来记录初始化逻辑是否执行过,若它为false
则执行初始化逻辑,并在初始化逻辑中将该bool
值置为true
,若它为true
则表明初始化逻辑已经执行过,跳过执行。
下面我来介绍Flame提供的一个函数,可以更优雅的处理这个问题。
在初始化时等待尺寸参数
打开./lib/langaw-game.dart
文件,在LangawGame
类中创建两个新的方法constructor
和initialize
。
constructor
方法只包含一行代码:对initialize
方法的调用。
我们要使用一个异步函数来等待屏幕尺寸的值所以我们要给initialize
函数加上async
关键字将其异步化。
这也是为什么我们没有在constructor
中执行初始化逻辑,而是直接调用了initialize
方法,因为constructor
不能是异步的。
要在LangawGame
类中加入的代码如下:
LangawGame() {
initialize();
}
void initialize() async {}
接下来,我们需要用到Flame Util中的initialDimensions
函数,所以需要引入flame
库:
import 'package:flame/flame.dart';
然后在initialize
方法中加入下面这行代码:
# Flame.util.initialDimensions()的返回值类型为`Size`
resize(await Flame.util.initialDimensions());
我们的resize
方法接收一个Size
类型的参数,当Flame.util.initialDimensions()
方法返回Size
参数时,resize
就会接收到参数并执行。
此时./lib/langaw-game.dart
文件应该如下图所示:
准备生成苍蝇
还记得我们前面提到的控制器(controllers)吗?那些不具有形态和位置的组件。在我们的这个游戏中,召唤的逻辑过于简单所以我们不会创建一个专门的组件来处理它而是把它放在LangawGame
类中。
我们需要引用fly.dart
文件以调用Fly
组件:
import 'package:langaw/components/fly.dart';
在Dart中,没有数组的数据类型,但我们有和它很相似甚至可能更好的List
类型,现在我们来创建一个List
变量:
List flies;
然后在initialize
中初始化它的值:
flies = List();
新的更改如下图所示:
尽管我们现在的
flies
参数还为空,我们已经可以使用forEach
方法循环调用该List
中各元素的update
和render
方法。
这也是为什么我们要在
initialize
中尽快初始化flies
的参数,因为null
值不能调用forEach
方法,会导致报错。
元素绘制的顺序会直接影响它的样子。苍蝇们之间没有重合部分,所以不会互相影响,但是需要展示在背景的上层,所以绘制要在背景之后。因此我们在绘制背景的代码后面加入下列代码:
flies.forEach((Fly fly) => fly.render(canvas));
将下列代码加入到update
方法
flies.forEach((Fly fly) => fly.update(t));
现在,./lib/langaw-game.dart
增加的代码如下:
生成苍蝇
到这一步,我们终于可以开始生成苍蝇了,将下列方法加入到game
类中:
void spawnFly() {
flies.add(Fly(this, 50, 50));
}
简析:创建一个Fly
类,并将它加入到flies
这个List
中。如果你还记得的话,我们构建Fly
实例需要传入3个变量:LangawGame
的实例,x
坐标参数,y
坐标参数。
现在我们可以在initialize
方法中调用它了,将它放置在resize
函数后面:
spawnFly();
这一步骤我们添加的代码如下图:
运行游戏,现在我们能在屏幕左上角看到我们加上的苍蝇小方块:
在我们进入到下一步之前,我们在
spawnFly
方法中加入一点有趣的东西,将苍蝇的位置x
和y
改为随机数,为此我们需要引入Dart的math
库:
import 'dart:math';
然后我们创建一个实例变量来存储math
库中的Random
类:
Random rnd;
在initialize
中创建Random
的实例,并赋值给rnd
:
rnd = Random();
现在该文件应该如下图所示:
Random
类有一个方法nextDouble
,它会返回一个0
到1
之间的double
类型的浮点数。我们要做的就是将这个随机数与苍蝇的x
和y
的最大值相乘,即可得到x
和y
的两个随机值,因为我们目前使用的定位是左上对齐的,在苍蝇方块不溢出屏幕的前提下,x
的最大值为screenSize.width - tileSize
, y
的最大值为screenSize.height - tileSize
,所以计算最大值的代码如下:
double x = rnd.nextDouble() * (screenSize.width - tileSize);
double y = rnd.nextDouble() * (screenSize.height - tileSize);
将上面的代码置入spawnFly
方法中:
现在你每次运行游戏,绿色小方块都会出现在不同的位置:
5. 击落苍蝇
在编写逻辑之前,我们先要能接收玩家的输入。我们开头已经规定了,当玩家点击苍蝇时,苍蝇方块应该变成红色然后下落到屏幕外。因此我们要在game
类中编写一个函数来处理点击事件。我们需要处理的onTapDown
事件带有TapDownDetails
类的参数,为此我们需要引入flutter的gestures
库:
import 'package:flutter/gestures.dart';
然后将点击的回调函数加入到game
类中:
void onTapDown(TapDownDetails d) {}
如下图所示:
现在回到
./lib/main.dart
文件,把gestures
库也加入到该文件中:
import 'package:flutter/gestures.dart';
然后生成一个手势识别器,将game
中的onTapDown
回调方法绑定在它的onTapDown
属性上。
TapGestureRecognizer tapper = TapGestureRecognizer();
tapper.onTapDown = game.onTapDown;
flameUtil.addGestureRecognizer(tapper);
如下图所示:
现在打开./lib/components/fly.dart
,给Fly
实例增加一个点击的回调函数。
void onTapDown() {}
如下图:
然后我们回到
./lib/langaw-game.dart
文件。在game
类的onTapDown
方法中,我们要循环遍历List flies
中每个Fly
实例判断点击的位置是否在该方块的矩形范围内。
Rect
类中有个有用的方法contains
,这个方法接收Offset
对象作为参数,返回这个Offset
是否在矩形范围内的bool
值。
而
onTapDown
的回调函数中传入的TapDownDetails
实例对象中有一个叫做globalPosition
的属性,它就是记录了点击位置的Offset
类型变量。
由此我们便可以将
globalPosition
传入Rect
的contains
方法来判断该次点击是否命中当前Fly
实例对象所在的矩形区域:
flies.forEach((Fly fly) {
if (fly.flyRect.contains(d.globalPosition)) {
# 点击位置命中当前Fly的矩形范围,执行Fly类的onTapDown的方法,执行点击逻辑
fly.onTapDown();
}
});
这一步的改动如下图所示:
拍扁苍蝇
现在是时候来编写苍蝇被击中的处理逻辑了。
首先我们要把目标的颜色改为红色Color(0xffff4757)
,在Fly
类的onTapDown
处理函数中加入下列语句:
flyPaint.color = Color(0xffff4757);
如图所示:
现在运行游戏,当你点击绿色苍蝇,它会变为红色。
击落苍蝇
为了实现苍蝇落地的效果,我们需要使用到我们一直忽略的游戏主循环的一个部分。
update
方法用于处理游戏的变化逻辑
中非用户输入引发的部分。苍蝇的坠落动画就属于这一部分。由于苍蝇只有死了才需要执行坠落动画,所以我们需要在./lib/components/fly.dart
的Fly
类中加入一个bool
变量来记录这个状态:
bool isDead = false;
当点击当前实例时,需要把isDead
置为true
,在onTapDown
回调中加入下列代码:
isDead = true;
然后我们要开始编辑Fly
类的update
方法。当我们判断该实例的isDead
变量为true
时,我们需要更改当前矩形对象的top
坐标值来使它下落。
所以我们在update
方法中加入下列逻辑:
if (isDead) {
flyRect = flyRect.translate(0, game.tileSize * 12 * t);
}
简析:先强调一点,主循坏的update
方法每帧执行一次。所以当苍蝇死了之后,我们相当于每帧调用一次Rect
的translate
方法,将它向下平移一段距离。所以game.tileSize * 12 * t
就是每次向下平移的距离。现在我们来介绍t
值,t
被称为时间增量,实际上是1/FPS的比值。在这个例子中每秒钟苍蝇下落的距离是game.tileSize * 12
,乘以这个系数t
之后我们就能得到每帧下落的距离。
最后,我们的文件应该如下图所示:
召唤更多
为了让游戏更具有可玩性,我们需要在一个苍蝇被击落后,重新召唤一个苍蝇,所以我们在onTapDown
回调中加入下列代码:
game.spawnFly();
如下图所示:
现在我们还有最后一个问题:已经被击落的苍蝇会一直下落下去,虽然从表面上看它会飞出屏幕,并不影响游戏逻辑,但是出于回收内存和减少函数执行等优化考虑,我们需要清除已经飞出屏幕的Fly
实例。
我们需要给Fly
类增加一个bool
类型的参数:
bool isOffScreen = false;
若当前实例的flyRect
中的top
属性超过了屏幕高度,则将isOffScreen
置为true
:
if (flyRect.top > game.screenSize.height) {
isOffScreen = true;
}
这一步的改动如下图所示:
最后,我们需要清除
List
中isOffScreen
为true
的Fly
实例项,我们将使用List
中的removeWhere
方法,该方法会遍历List
中的每一项,然后删除回调函数中返回值为true
的项,我们在./lib/langaw-game.dart
的update
方法中加入下列代码:
flies.removeWhere((Fly fly) => fly.isOffScreen);
这一步的更改如下图所示:
到此,我们这一节的开发任务就全部完成了,可以开始试玩游戏了!
总结
经过这段有些冗长的教程,我们创建了一个比上一篇交互性更强一点的游戏。在这一节的内容中,游戏主循环的概念应该更加清晰了,另外我们还正式使用了update
方法。