原文链接:Create a Mobile Game with Flutter and Flame – Beginner Tutorial
为什么要开发移动端游戏呢?绝大多数人都会同意游戏是软件中最精彩的一部分,而我也是其中之一,并且在游戏中发掘了一个异彩纷呈的世界。
游戏的范围十分宽广,可以从简单的线条组合到复杂拟真并且具备丰富自定义故事线的3D游戏。
少数人想要更进一步,尝试开发属于他们自己的游戏。如果你也是其中之一,这篇文章可以在你的启程之路上给你足够的指导。这篇教程更关注于基本的概念,而不是具体开发一个制作精良的游戏。
如果你在任何步骤感到困惑,欢迎给我发邮件或者加入我的Discord。
阅读须知
本文假定您是一名开发者并且具备了软件开发的概念和常识。如果您完全是个小白,只要您有足够的兴趣和动力,也能轻松的掌握这篇文章的内容。
您必须拥有一台性能足够的电脑,能够承担运行IDE,编译一些代码,并且运行Android模拟器的工作。如果您的电脑只够运行IDE和编译代码,您也可以接入Android真机进行调试。
使用Flutter编写的程序可以同时编译和构建在Android和iOS平台。这篇文章主要关注于Android平台的开发。但当你将游戏编写完成后,你可以运行一段不同的命令即可构建运行在iOS平台的游戏。
另外,你需要在你的电脑上做好以下准备:
- Microsoft Visual Studio Code(VS Code)—任何其他的IDE和文本编辑器都可以胜任同样的工作如果你知道怎么用的话。如果你是初学者,建议认准VS Code,去官网下载该软件,并且安装Flutter和Dart的插件。
- Android SDK—这是开发Android应用必备的工具。下载并且安装Android Studio并跟随引导去安装开发Android App所有必须的组件。如果你不想安装Android Studio,只想安装Android SDK,在Android Studio下载页往下翻页到命令行工具部分。
- Flutter SDK/Framework—它和Flame插件是我们开发游戏的主要工具。使用官方文档把你的框架搭建起来,并确保根据指引执行到了第三步Test Drive部分。
开始制作移动游戏
我们的起点非常简单。我们的游戏由黑色的屏幕和中间的白色方块组成,当你点击方块后,方块变成绿色,这样便赢得了游戏。
这次的游戏中我们不会使用额外的图像(图片文件)。
本文的所有代码可以在github中查阅下载。
1. 创建一个Flutter应用
打开终端(命令行界面)然后跳转到你的项目目录,然后输入下列命令:
$ flutter create boxgame
这条命令使用了flutter命令行工具为你初始化创建了一个基本的应用程序。
你可以选择boxgame
之外的任何名称,但如果你已经执行了上述命令,你需要把项目中所有的boxgame
都替换掉。
现在,你既可以使用VS Code打开刚刚创建的boxgame
项目,也可以使用下列命令直接运行你的app:
$ cd boxgame
$ flutter run
第一次运行项目的时候所需的时间可能较长。等到命令执行完毕的时候,你应该能看到这个界面:
注意: 你需要保证你的电脑运行了一台Android虚拟机或者连接了一台开启调试模式的Android设备
2. 安装Flame插件(并且清理项目)
注意:从现在开始,我将用./
来代指项目目录。如果你的boxgame 项目在/home/awesomeguy/boxgame
,那么./lib/main.dart
指的就是/home/awesomeguy/boxgame/lib/main.dart
目录下的文件
使用VS Code启动boxgame
项目。
在./pubspec.yaml
中的dependencies
项加入flame插件
dependencies:
flame: ^0.10.2
如下图所示:
添加完毕后并保存文件后,VS Code会自动安装添加的插件。
你也可以使用终端手动安装插件和依赖,通过在boxgame
的根目录运行flutter packages get
完成。
下一步就是清理掉flutter项目创建时在文件./lib/main.dart
留下的代码,只留下一个空程序。
空程序只有一行代码void main() {}
。另外我们还保留了对material
库的引用,因为我们需要在稍后启动游戏时调用其中的runApp
方法,现在这个文件应该像下面这样:
另一件事是test目录中的文件处于报错状态,因为我们这篇文章不涉及到这一块的内容,所以处理的办法是将test目录删除。
3. 创建游戏主循环
我们现在要开始创建游戏主循环了
但什么是游戏主循环呢?
游戏循环是游戏的主要部分。是电脑不断反复执行的一段指令。
游戏通常有个叫做FPS的参数,它代表了每秒的帧数。60fps的意思就是计算机每秒运行60次主循环。
简而言之: 1帧 = 运行1次主循环
一次基本的主循环由两部分组成: 一次(数据)更新和一次(界面)渲染。
更新部分处理目标的移动(比如角色,敌人,障碍或者地图本身)以及其他需要更新的内容(比如计时器)。绝大多数动作在这阶段发生。例如,计算敌人是否被子弹打中或者计算敌人是否碰到主角等等。
渲染部分负责把所有的目标重绘到屏幕上。它是一个单独的步骤并且一切都是同步的。
为什么要同步?
想象你更新了主角的位置,他没有被命中所以这次渲染结果他没有受伤。
但是,现在有一颗子弹离他几像素的距离,你更新了子弹的位置使它命中了主角。现在主角死了所以你没有绘制子弹。这时你应该已经绘制主角死亡动画的第一帧了。
在下一个循环中,因为主角已经死了所以略过了更新他,转而渲染他的第一帧死亡动画。
这样的逻辑下,会给人一种抖动的感觉,在射击游戏中,当你射中敌人时,他没有倒地,当你再次射击但在子弹再次打到他之前他却突然死了。在60fps这样的高更新率下,非同步逻辑所带来的抖动感也许不会很明显,但如果这种情况频繁出现会给人一种半成品的感觉。
在一次主循环中,只有所有的状态都计算完成后,才能开始绘制步骤。
使用Flame
Flame已经具备了处理这一问题的框架,所以我们需要考虑的只是怎么编写实际的更新和渲染过程。
首先,我们的游戏有两点需要考虑的问题,一是如何全屏显示,二是锁定在竖屏模式。
Flame同样提供了实现这些功能的工具函数。将下面代码加到main.dart
文件的顶部:
import 'package:flame/util.dart';
import 'package:flutter/services.dart';
然后在main
函数中,创建Flame的Util
class。然后使用await
关键字同步调用Util实例中的fullscreen
和setOrientation
两个异步方法。
void main() async {
Util flameUtil = Util();
await flameUtil.fullScreen();
await flameUtil.setOrientation(DeviceOrientation.portraitUp);
}
为了使用Flame插件提供的游戏循环框架,我们需要在./lib
目录下创建名为game.dart
的文件,然后再文件中生成Flame的Game
class的子类,命名为BoxGame:
import 'dart:ui';
import 'package:flame/game.dart';
class BoxGame extends Game {
void render(Canvas canvas) {
// TODO: 执行render逻辑
}
void update(double t) {
// TODO: 执行update逻辑
}
}
让我们把当前的逻辑分析一下:我们引入了Dart的ui
库以使用Canvas
类和后面会用到的Size
类。然后我们引入Flame的game
库,其中包含了我们extend的Game
class。剩下的一切就是一个class的定义和其中的两个方法update
和render
。这两个方法覆写(override)了父类的同名方法。
*注意:@override标注在Dart2版本中是可选的, 同样new
关键字同样也是可选的,所以也被我们省略了。 *
下一步是创建一个BoxGame
的实例,然后将它的widget
属性传递给runApp
方法。
import 'package:boxgame/box-game.dart';
BoxGame game = BoxGame();
runApp(game.widget);
完整的main.dart文件代码如下:
import 'package:flame/util.dart';
import 'package:flutter/services.dart';
import 'package:boxgame/box-game.dart';
void main() async {
Util flameUtil = Util();
await flameUtil.fullScreen();
await flameUtil.setOrientation(DeviceOrientation.portraitUp);
BoxGame game = BoxGame();
runApp(game.widget);
}
现在你的移动App是一个游戏了!
如果此时你运行你的游戏,你只能看到一个空白的屏幕因为你还没有在屏幕上绘制东西。
特别注意:最新的Flutter更新改变了main
函数的逻辑。为了修复这一变化,我们需要最先调用runApp
,main函数的整体更改如下:
void main() {
BoxGame game = BoxGame();
runApp(game.widget);
Util flameUtil = Util();
flameUtil.fullscreen();
flameUtil.setOrientation(DeviceOrientation.portraitUp);
}
fullscreen和setOrientation方法不再需要await
关键字修饰,因为它们会在app启动时异步的执行,所以main
函数前的async
关键字也可以去掉了。
4. 绘制画面
在我们绘制画面之前,我们需要知道屏幕的尺寸。Flutter使用逻辑像素
绘制画面,一英寸的屏幕大概包含了96逻辑像素,大多数的主流移动设备都是与此相同或相近的换算规则,而另一个原因是我们的游戏过于简单,所以你暂时不用担心尺寸适配的问题。
Flame同样建立在这一尺寸系统下,并且Game类包含了一个可供我们覆写的resize
函数。这个函数接收一个Size
参数,我们可以通过它来定义屏幕的尺寸。
首先,我们声明class下的一个名叫screenSize
的Size类型变量,它记录了屏幕的尺寸并且只在屏幕尺寸变化的时候改变。它是我们绘制对象的基础,同样也用于传入resize
函数。
class BoxGame extends Game {
Size screenSize;
screenSize
变量将会被初始化为null
值。这将有助于在渲染时判断我们是否知道屏幕的尺寸。稍后我们会讲到这一点。
接下来,在./lib/box-game.dart
中覆写resize
方法
void resize(Size size) {
screenSize = size;
super.resize(size);
}
注意:上述代码超类的resize
方法其实是空的,但是在覆写方法的时候调用super
函数往往是好的,除非你想要完全覆盖这个方法。我们暂时把它写成上面这样。
注意:实例变量可以在当前class的所有方法中被访问到。比如,变量screenSize
,你可以在resize
方法中改变它的值,然后在render
方法中获取它。
现在boxgame.dart
文件应该如下所示:
canvas和background
现在游戏主循环已经创建好了,我们可以开始绘制游戏画面了。update
函数会保持为空因为我们目前没有需要更新的数据。
在render
函数中,我们可以获取到一个由Flame创建的Canvas
对象。Canvas
挺像一个真实的供我们画图的画布。当我们绘制了游戏画面之后(目前只是一些小方块),Flame会将它绘制到屏幕上。
当绘制canvas时,始终先绘制最下层的对象(比如背景),因为新绘制的对象将会展示在最上层。
首先,我们绘制背景,背景只是简单的黑屏,所以我们使用以下代码:
Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
Paint bgPaint = Paint();
bgPaint.color = Color(0xff000000);
canvas.drawRect(bgRect, bgPaint);
逐行解释下上述命令:第一行使用Rect
对象声明了一个矩形,并分别定义了它的左对齐点,顶部对齐点,宽度和高度。
第二行声明了一个Paint
对象,接下来一行给Paint
对象的color
属性赋颜色值。颜色格式0xaarrggbb
中aa代表透明度,rr,gg,bb代表RGB中对应的值,使用16进制表示。
最后一行使用drawRect
方法将声明的矩形和颜色传入并绘制。
开始运行!
运行你的游戏,屏幕上应该出现了黑色的背景。Flutter有很棒的热更新特性,当你更改代码并保存时,你调试中的app也会同步更新,并且不会重置状态。
尝试改变背景的颜色值体验热更新的特性。
绘制目标方块
接下来,我们在屏幕的中央绘制目标方块:
double screenCenterX = screenSize.width / 2;
double screenCenterY = screenSize.height / 2;
Rect boxRect = Rect.fromLTWH(
screenCenterX - 75,
screenCenterY - 75,
150,
150
);
Paint boxPaint = Paint();
boxPaint.color = Color(0xffffffff);
canvas.drawRect(boxRect, boxPaint);
我们逐行解释上述代码:前两行声明了屏幕中心点的坐标值,它们的值分别是是屏幕长宽尺寸的一半。
接下来的6行与上一部分声明矩形背景的语法相同,在屏幕中心声明了一个长宽为150逻辑像素的小方块,因为该语句的对齐方式是左上对齐,所以坐标需要在之前定义的中心点的基础上分别减去自身长宽的一半(75像素)。
后面三行也与之前一样,创建了颜色填充对象boxPaint
,赋予了并将其color
属性设置为白色,最后绘制图形。
现在该文件如下所示:
项目运行后,屏幕应该像下面这样:
5. 处理输入和胜利逻辑
我们就快完成了!我们只需要接收玩家的输入。首先,我们需要导入Flutter的gestures
库,用于监听屏幕手势:
import 'package:flutter/gestures.dart';
然后增加一个点击的处理函数:
void onTapDown(TapDownDetails d) {
// 执行点击处理逻辑
}
接着在./lib/main.dart
,声明一个GestureRecognizer
类,然后将我们游戏的onTapDown
处理函数绑定到它的onTapDown
事件回调。最后,在runApp()
行之后,使用flameUtil里的addGestureRecognizer方法注册前面创建的GestureRecognizer
的实例。
BoxGame game = BoxGame();
TapGestureRecognizer tapper = TapGestureRecognizer();
tapper.onTapDown = game.onTapDown;
runApp(game.widget);
flameUtil.addGestureRecognizer(tapper);
为了判断游戏是否结束,我们需要创建一个bool
类型的变量来存储当前游戏的状态,将它加入到screenSize
变量的声明下面:
bool hasWon = false;
然后在render方法里,在屏幕中央小方块的创建逻辑中,使用一个判断逻辑替换先前的颜色赋值语句,它将在hasWon
变量为true
时将boxPaint
的color
属性赋值为绿色,否则赋值为白色,该部分代码改为如下所示:
double screenCenterX = screenSize.width / 2;
double screenCenterY = screenSize.height / 2;
Rect boxRect = Rect.fromLTWH(
screenCenterX - 75,
screenCenterY - 75,
150,
150
);
Paint boxPaint = Paint();
if (hasWon) {
boxPaint.color = Color(0xff00ff00);
} else {
boxPaint.color = Color(0xffffffff);
}
// 换掉原先的 boxPaint.color = Color(0xffffffff);
canvas.drawRect(boxRect, boxPaint);
现在我们在onTapDown
处理函数中来完善屏幕点击事件的处理逻辑。判断点击位置是否位于小方块内,如果是,则将hasWon
变量改为true
:
double screenCenterX = screenSize.width / 2;
double screenCenterY = screenSize.height / 2;
if (d.globalPosition.dx >= screenCenterX - 75
&& d.globalPosition.dx <= screenCenterX + 75
&& d.globalPosition.dy >= screenCenterY - 75
&& d.globalPosition.dy <= screenCenterY + 75
) {
hasWon = true;
}
分析上述代码:前两行定义了屏幕的中心点坐标
后面4行是一段长的判断条件,前两行判断点击的位置横向是否在方块内,后两行判断点击位置的纵向位置是否在方块内,若同时满足4个条件,则该点满足在方块4条线围成的矩形内,此时将bool
值hasWon
置为true。
是时候测试游戏了!
运行你的游戏,如果它的功能正常,那么你在点击方块的时候应该就能看到白色的方块变为绿色。
结语
你完成了你的第一个游戏!
它也许根本成为不了时下的爆款,但是你藉由它掌握了游戏主循环,界面绘制以及接收处理输入等概念。所有的游戏都是建立在这些基础的概念之上。希望你能享受创造一款自己的游戏。