一点点基础
游戏主循环(GameLoop
)
游戏主循环是游戏的核心,计算机一次又一次运行的一组指令,用通俗的话来说,如果游戏有生命,那么游戏主循环就是游戏的心跳。
同时为了更好的理解游戏主循环,还需要引入一个计算机图像领域的知识——FPS,FPS全称是“Frames Per Second”,翻译为“每秒传输帧数”,意思就是,如果游戏以60FPS运行,则计算机每秒运行60次游戏主循环。总结一下就是,1帧==游戏主循环的一次运行。
通常来说,游戏主循环由两部分组成——更新(update
)和渲染(render
)。
如上图,更新(update
)部分负责处理对象的移动,这里的对象可以是主角、NPC、敌人、障碍物、地图和其他需要更新的参数。你在游戏里能看到的大部分动作都在这部分发生,比如,计算主角的98K射出的子弹是否接触到敌人。
而渲染(render
)部分通常只负责一件事,在更新(update
)部分发生变化时,绘制屏幕上的所有对象,以便玩家看到的一切都是同步的。
游戏同步机制
在游戏中,同步机制是非常重要的,可以想象一下,现在更新一个NPC的位置,NPC处于正常状态,所以,你让NPC开始移动。但是,此时有一个子弹距离NPC只有几个像素的距离,你更新了子弹,它会击中NPC。
现在NPC已经死了,所以你不用绘制子弹。这个时候,你应该绘制NPC倒地动画的第一帧。
然后,在下一个游戏主循环中,您将跳过更新NPC位置,因为NPC已经死了,所以您改为渲染NPC垂死动画的第一帧,而不是倒地动画第二帧。
这会给玩家带来一种游戏不稳定的感觉,玩家在玩射击游戏,射击一个NPC的时候,NPC不会倒地,玩家再次射击,但是在子弹击中NPC之前,NPC就死了。
非同步渲染的不稳定性能可能不易被察觉,特别是当每秒运行60帧的高帧频率下,但如果这种情况经常发生,玩家还是会感觉出来的,然后就骂辣鸡游戏了。
所以,最好提前计算好所有内容,并且当计算完成后最终确定所有对象的状态时,再开始绘制屏幕。
开始撸码
使用Flame插件
在pubspec.yaml
下添加flame
插件,并通过flutter packages get
命令下载插件,或者使用Visual Studio Code保存文件会自动下载插件。
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
flame: ^0.13.0
Flame插件已经提供了一个完整的游戏开发框架,所以我们只需要专心编写实际的更新和渲染过程。首先,需要将应用程序转化为游戏模式,要做两个操作:全屏和纵向。而令人感到巴适的是,Flame插件已经封装好了这些实用的功能,我们只需要编写调用代码就可以了。
我们先在main.dart
的顶部添加以下引用。
import 'package:flame/util.dart';
import 'package:flutter/services.dart';
然后在main.dart
的main
函数内部创建Flame的Util
类的实例,调用其实例的全屏(fullScreen
)和设置方向(setOrientation
)函数,同时要注意,因为这些函数的返回值类型是未来(Future
),所以要在这些函数前面添加等待(await
)。
未来(Future
)、异步(async
)和等待(await
)是一种特殊的编码方法,它让那些需要长时间才能处理完成的代码在不同的线程上完成,而且不会阻塞主线程。
为了能够等待(await
)未来(Future
)处理完成,相关的代码必须在异步(async
)函数内,所以我们必须修改main
函数,使它成为一个异步函数。
void main() async {
Util flameUtil = Util();
await flameUtil.fullScreen();
await flameUtil.setOrientation(DeviceOrientation.portraitUp);
到这里为止,我们的main.dart
里面应该有以下代码。
import 'package:flutter/material.dart';
import 'package:flame/util.dart';
import 'package:flutter/services.dart';
void main() async {
Util flameUtil = Util();
await flameUtil.fullScreen();
await flameUtil.setOrientation(DeviceOrientation.portraitUp);
}
游戏主循环脚手架
在开头,我们知道在一个游戏应用中,游戏是在游戏主循环里面运行的。Flame插件已经提供了可以直接使用的游戏主循环脚手架,要使用这个脚手架,就要用到Flame
的游戏(Game
)抽象类。
创建一个名称为box-game.dart
的新文件,然后开始编写BoxGame
类,。
import 'dart:ui';
import 'package:flame/game.dart';
class BoxGame extends Game {
void render(Canvas canvas) {
// TODO: 实现渲染
}
void update(double t) {
// TODO: 实现更新
}
}
上面的代码中,导入dart:ui
库,这样的话,等一下我们就可以使用画布(Canvas
)类和大小(Size
)类。然后导入package:flame/game.dart
库,这个库里面包括我们现在使用的游戏(Game
)抽象类,这个类有两个方法:更新(update
)和渲染(render
),我们直接用同名方法覆盖了它们。
在Dart 2.x
版本中,@override
注释和new
关键字是可选的,所以在这里也不需要写。
接下来,我们在main.dart
文件中创建BoxGame
类的实例,并将其widget
属性传递给runApp
函数。同时,引用我们刚才创建的package:hello_flame/box-game.dart
,让BoxGame
类可以在main.dart
中使用。
...
import 'package:hello_flame/box-game.dart';
void main() async {
...
BoxGame game = BoxGame();
runApp(game.widget);
到这里为止,我们的main.dart
里面应该有以下代码。
import 'package:flutter/material.dart';
import 'package:flame/util.dart';
import 'package:flutter/services.dart';
import 'package:hello_flame/box-game.dart';
void main() async {
Util flameUtil = Util();
await flameUtil.fullScreen();
await flameUtil.setOrientation(DeviceOrientation.portraitUp);
BoxGame game = BoxGame();
runApp(game.widget);
}
现在我们的应用程序可以被称为游戏了,运行游戏,会显示一个空白的黑屏,因为我们还没有在屏幕上绘制具体的内容。
屏幕的大小和尺寸
Flame这个游戏开发框架是以Flutter为基础的,而Flutter在屏幕上绘制时使用逻辑像素,因此,我们在Flame上调整游戏对象的大小时也是使用逻辑像素。
实际上,游戏(Game
)抽象类上有个调整(resize
)方法,这个方法接受大小(Size
)类参数,使用这个参数就可以确定设备的屏幕大小。
首先在box-game.dart
文件中,添加一个BoxGame
类的实例变量screenSize
,这个变量用于保持屏幕的大小,只有当屏幕的大小发生变化时才会更新,它也是Flame在屏幕上绘制对象时的基础。screenSize
是Size
类型的变量,与传递给调整(resize
)方法的参数一致。
类变量screenSize
的初始值为null,可以用来判断渲染过程中是否已知屏幕大小。接下来,我们编写一个同名方法覆盖调整(resize
)方法。
class BoxGame extends Game {
Size screenSize;
...
void resize(Size size) {
screenSize = size;
super.resize(size);
}
到这里为止,我们的box-game.dart
里面应该有以下代码。
import 'dart:ui';
import 'package:flame/game.dart';
class BoxGame extends Game {
Size screenSize;
void render(Canvas canvas) {
// TODO: 实现渲染
}
void update(double t) {
// TODO: 实现更新
}
void resize(Size size) {
screenSize = size;
super.resize(size);
}
}
绘制画布和背景
到这一步,游戏主循环已经存在,可以开始绘制一些对象了。在渲染(render
)方法中,我们可以访问画布(Canvas
),这个画布(Canvas
)是Flame提供的,在画布(Canvas
)上绘制游戏图形之后,Flame会将其绘制并将整个画布绘制到屏幕上。
在画布上绘图时,就像我们拿着画笔画画一样,先绘制最底层的背景对象,然后在上面绘制一些动物、植物或建筑物对象。
现在我们可以开始绘制背景,这个例子中游戏背景只是一个黑屏,可以使用以下代码绘制。
void render(Canvas canvas) {
// TODO: 实现渲染
// 在整个屏幕上绘制黑色背景
Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
Paint bgPaint = Paint();
bgPaint.color = Color(0xff000000);
canvas.drawRect(bgRect, bgPaint);
上面代码中,第一行声明了一个与屏幕一样大小的矩形(Rect
),坐标位于(0,0),即屏幕的左上角,我们就用这个当游戏背景了。
然后,第二行声明一个绘制(Paint
)类对象,其后尾随配置这个绘制(Paint
)类对象的颜色(Color
)。
最后一行代码使用前面定义的矩形(Rect
)和绘制(Paint
)实例在画布(Canvas
)上绘制一个矩形。
绘制上层的对象
接下来的步骤中,我们会在屏幕的中间绘制一个游戏对象,在当前游戏中,游戏对象是一个小矩形图案。
void render(Canvas canvas) {
...
// 画一个盒子,如果获胜则将其设为绿色,否则为白色
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);
}
上面代码中,前面2行代码声明两个变量,分别是用于保持屏幕中心坐标的变量,分别为屏幕宽度和高度的一半。
接下来的6行代码声明了一个150x150个逻辑像素大小的矩形,它位于屏幕中间,但是会向左偏移75个像素,向上偏移75个像素。
其余的代码前面绘制画布和背景的代码差不多,此时运行游戏,就可以看到黑色背景上有一个白色的矩形对象。
处理输入和胜利条件
到这里,我们已经完成了大部分内容,现在只需要接受玩家的输入了。在box-game.dart
文件中,先导入Flutter的手势库(package:flutter/gestures.dart
),然后还要添加点击操作的处理函数。
...
import 'package:flutter/gestures.dart';
class BoxGame extends Game {
...
void onTapDown(TapDownDetails d) {
// 处理点击
}
}
然后回到main.dart
文件中,注册一个手势识别器(GestureRecognizer
)并将其点击(onTapDown
)事件链接到游戏的点击(onTapDown
)处理程序。同时,我们也不要忘记在这里导入Flutter的手势库(package:flutter/gestures.dart
),以便在此文件中可以使用手势识别器(GestureRecognizer
)类。
再然后,定位到main
函数内部,声明一个点击手势识别器(TapGestureRecognizer
)并将其点击(onTapDown
)事件分配给游戏的点击(onTapDown
)处理程序。最后使用Flutter的工具库package:flame/util.dart
中的添加手势识别器(addGestureRecognizer
)函数注册手势识别器。
...
import 'package:flutter/gestures.dart';
void main() async {
...
BoxGame game = BoxGame();
TapGestureRecognizer tapper = TapGestureRecognizer();
tapper.onTapDown = game.onTapDown;
runApp(game.widget);
flameUtil.addGestureRecognizer(tapper);
}
到这里为止,我们的main.dart
里面应该有以下代码。
import 'package:flutter/material.dart';
import 'package:flame/util.dart';
import 'package:flutter/services.dart';
import 'package:hello_flame/box-game.dart';
import 'package:flutter/gestures.dart';
void main() async {
Util flameUtil = Util();
await flameUtil.fullScreen();
await flameUtil.setOrientation(DeviceOrientation.portraitUp);
BoxGame game = BoxGame();
TapGestureRecognizer tapper = TapGestureRecognizer();
tapper.onTapDown = game.onTapDown;
runApp(game.widget);
flameUtil.addGestureRecognizer(tapper);
}
现在,我们再回到box-game.dart
文件中来,添加另一个实例变量hasWon
来判断玩家是否胜利,定义一个布尔(bool
)变量,默认为false
表示玩家未取得胜利。
然后在渲染(render
)方法里面,写一个条件判断,如果玩家已经胜利,将boxPaint
的颜色设置成绿色,否则为白色。
class BoxGame extends Game {
...
bool hasWon = false;
void render(Canvas canvas) {
...
Paint boxPaint = Paint();
if (hasWon) {
boxPaint.color = Color(0xff00ff00);
} else {
boxPaint.color = Color(0xffffffff);
}
canvas.drawRect(boxRect, boxPaint);
}
...
}
最后我们还需要在游戏的点击(onTapDown
)处理程序中添加逻辑代码,判断玩家是否点击了中间的矩形,如果是,就将hasWon
变量的值转换为true
,表示玩家已经取得胜利。
void onTapDown(TapDownDetails d) {
// 处理点击
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;
}
}
上面代码中,前面2行用来确定屏幕中心点的坐标,后面的5行多条件判断的if
语句,用来判断点击坐标是否位于屏幕中间的150x150逻辑像素范围内。
如果是,就转换hasWon
变量的值,并在下次调用渲染(render
)方法时反映在屏幕上。同时我们这里将更新(update
)方法留空了,因为这个游戏里不会更新任何内容呀。
到这里为止,我们的box-game.dart
里面应该有以下代码。
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flame/game.dart';
class BoxGame extends Game {
Size screenSize;
bool hasWon = false;
void render(Canvas canvas) {
// 在整个屏幕上绘制黑色背景
Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
Paint bgPaint = Paint();
bgPaint.color = Color(0xff000000);
canvas.drawRect(bgRect, bgPaint);
// 画一个盒子,如果获胜则将其设为绿色,否则为白色
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);
}
canvas.drawRect(boxRect, boxPaint);
}
void update(double t) {
// TODO: 实现更新
}
void resize(Size size) {
screenSize = size;
super.resize(size);
}
void onTapDown(TapDownDetails d) {
// 处理点击
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;
}
}
}
运行游戏,可以看到效果如下所示。