【译】使用Flutter和Flame开发移动端游戏——新手教程

原文链接:Create a Mobile Game with Flutter and Flame – Beginner Tutorial

为什么要开发移动端游戏呢?绝大多数人都会同意游戏是软件中最精彩的一部分,而我也是其中之一,并且在游戏中发掘了一个异彩纷呈的世界。

游戏的范围十分宽广,可以从简单的线条组合到复杂拟真并且具备丰富自定义故事线的3D游戏。

少数人想要更进一步,尝试开发属于他们自己的游戏。如果你也是其中之一,这篇文章可以在你的启程之路上给你足够的指导。这篇教程更关注于基本的概念,而不是具体开发一个制作精良的游戏。

如果你在任何步骤感到困惑,欢迎给我发邮件或者加入我的Discord

阅读须知

本文假定您是一名开发者并且具备了软件开发的概念和常识。如果您完全是个小白,只要您有足够的兴趣和动力,也能轻松的掌握这篇文章的内容。

您必须拥有一台性能足够的电脑,能够承担运行IDE,编译一些代码,并且运行Android模拟器的工作。如果您的电脑只够运行IDE和编译代码,您也可以接入Android真机进行调试。

使用Flutter编写的程序可以同时编译和构建在Android和iOS平台。这篇文章主要关注于Android平台的开发。但当你将游戏编写完成后,你可以运行一段不同的命令即可构建运行在iOS平台的游戏。

另外,你需要在你的电脑上做好以下准备:

  1. Microsoft Visual Studio Code(VS Code)—任何其他的IDE和文本编辑器都可以胜任同样的工作如果你知道怎么用的话。如果你是初学者,建议认准VS Code,去官网下载该软件,并且安装FlutterDart的插件。
  2. Android SDK—这是开发Android应用必备的工具。下载并且安装Android Studio并跟随引导去安装开发Android App所有必须的组件。如果你不想安装Android Studio,只想安装Android SDK,在Android Studio下载页往下翻页到命令行工具部分。
  3. Flutter SDK/Framework—它和Flame插件是我们开发游戏的主要工具。使用官方文档把你的框架搭建起来,并确保根据指引执行到了第三步Test Drive部分。

开始制作移动游戏

我们的起点非常简单。我们的游戏由黑色的屏幕和中间的白色方块组成,当你点击方块后,方块变成绿色,这样便赢得了游戏。

这次的游戏中我们不会使用额外的图像(图片文件)。

本文的所有代码可以在github中查阅下载。

1. 创建一个Flutter应用

打开终端(命令行界面)然后跳转到你的项目目录,然后输入下列命令:

$ flutter create boxgame

这条命令使用了flutter命令行工具为你初始化创建了一个基本的应用程序。

你可以选择boxgame之外的任何名称,但如果你已经执行了上述命令,你需要把项目中所有的boxgame都替换掉。

现在,你既可以使用VS Code打开刚刚创建的boxgame项目,也可以使用下列命令直接运行你的app:

$ cd boxgame
$ flutter run

第一次运行项目的时候所需的时间可能较长。等到命令执行完毕的时候,你应该能看到这个界面:

App初始界面

注意: 你需要保证你的电脑运行了一台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实例中的fullscreensetOrientation两个异步方法。

void main() async {
  Util flameUtil = Util();
  await flameUtil.fullScreen();
  await flameUtil.setOrientation(DeviceOrientation.portraitUp);
}

为了使用Flame插件提供的游戏循环框架,我们需要在./lib目录下创建名为game.dart的文件,然后再文件中生成Flame的Gameclass的子类,命名为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的Gameclass。剩下的一切就是一个class的定义和其中的两个方法updaterender。这两个方法覆写(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时将boxPaintcolor属性赋值为绿色,否则赋值为白色,该部分代码改为如下所示:

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条线围成的矩形内,此时将boolhasWon置为true。

是时候测试游戏了!

运行你的游戏,如果它的功能正常,那么你在点击方块的时候应该就能看到白色的方块变为绿色。

结语

你完成了你的第一个游戏!
它也许根本成为不了时下的爆款,但是你藉由它掌握了游戏主循环,界面绘制以及接收处理输入等概念。所有的游戏都是建立在这些基础的概念之上。希望你能享受创造一款自己的游戏。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容