1 准备
1.1 FPS和游戏框架介绍
FPS全称是“Frames Per Second”,翻译为“每秒传输帧数”。在代码中通常会定义一个循环来表示,这个循环由两部分组成,分别是:更新(update)和渲染(render)。
渲染(rende)部分只负责一件事,在更新(update)部分发生变化时,绘制屏幕上的所有对象。
在这个循环中, 每次循环就是游戏中的一帧,每次循环消耗的时间越短,帧数就越高。
Flutter中有一个插件叫Flame,这个插件提供了一个完整的游戏开发框架,底层中实现了循环机制,使用它我们只需要编写游戏更新和渲染的代码。
1.2 游戏资源文件获取
在谷歌浏览器输入chrome://dino,打开网页调试工具,会发现整个游戏只有一张图片。
1.3 添加Flame插件和图片
打开项目中的pubspec.yaml文件,配置好插件和图片
1.4 flutter的坐标位置
坐标x和y轴都是从左上角开始的
2 开始编码
2.1 设置横屏,全屏显示
flame插件提供了一个util类,提供了一些实用的功能,例如获取屏幕尺寸、设置屏幕方向等,可以直接用它快速实现横屏全屏显示
- Flame.util.fullScreen() 隐藏手机顶部状态栏和底部虚拟按键,使应用全屏显示
- Flame.util.setLandscape() 设置横屏
打开main.dart文件,在main方法中输入以下代码
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Flame.util
..fullScreen()
..setLandscape();
}
由于fullScreen和setLandscape方法需要等flutter框架和widgets组件绑定后才能调用,所以需要WidgetsFlutterBinding.ensureInitialized来确保已经绑定,不然会报错
2.2 游戏循环脚手架
flame提供了两个抽象类,对游戏循环概念进行了简单的抽象,它们分别是Game和BaseGame。
它们都定义了update和rende方法:
- render 接收一个画布(Canvas)类
- update 接收从上次update到现在的增量时间,单位:秒
大多数游戏都是基于这两个方法实现的
BaseGame继承了Game,BaseGame实现了以组件(component)为基础的game。它提供了一个组件列表,每个组件都表示游戏中的一个或多个对象,它们可以是地图、人物、动画等。在BaseGame的rende方法中,会把每个组件都渲染出来。
本文中使用的是BaseGame
2.3 编写游戏类
在lib目录,添加一个game.dart文件,在里面创建MyGame类,让它继承BaseGame。
import 'dart:ui' as ui;
import 'package:flame/game.dart';
class MyGame extends BaseGame{
MyGame(ui.Image spriteImage) {}
@override
void update(double t) {}
@override
void render(Canvas canvas) {}
}
这个游戏只有一张图片,所以在构造方法中接收了一个图片实例。
2.4 载入游戏
回到main.dart,把编写的游戏类显示出来,main.dart完整代码:
import 'dart:ui' as ui;
import 'package:flame/flame.dart';
import 'package:flutter/material.dart';
import 'package:fluttergame/game.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Flame.util
..fullScreen()
..setLandscape();
ui.Image image = await Flame.images.load("sprite.png");
runApp(MyGame(image).widget);
}
编译运行后,打开游戏是黑屏的,因为还没在游戏中编写任何东西。
2.5 给游戏添加背景
我们知道,BaseGame提供了一个组件列表,我们可以把游戏中每个对象都封装成一个组件,然后把它添加进游戏中。
打开game.dart,在MyGame类的下面添加一个组件类GameBg(继承Component)
...
class MyGame...
class GameBg extends Component with Resizable {
Color bgColor;
GameBg([this.bgColor = Colors.white]);
@override
void render(Canvas canvas) {
Rect bgRect = Rect.fromLTWH(0, 0, size.width, size.height);
Paint bgPaint = Paint();
bgPaint.color = bgColor;
canvas.drawRect(bgRect, bgPaint);
}
@override
void update(double t) {}
}
因为整个游戏背景都是一种颜色的,所以在上面代码的render方法中,在canvas上画了一个宽高都等于屏幕大小的矩形(Rect),屏幕的宽高是通过size这个属性获取的。
而size这个属性是在Resizable这个类中定义的,Resizable是with进来的,里面覆盖了Component的resize方法。resize方法接收了一个size参数,每次resize方法被调用的时候都会把size属性更新了。(关于组件的resize方法在什么时候被调用,下文会说到。)
背景组件创建好了,需要在MyGame中使用它,先给MyGame添加一个GameBg属性,方便后面更改背景颜色
...
class MyGame extends BaseGame{
GameBg gameBg;
...
然后在构造方法中,实例化背景组件,把它添加到组件列表中
...
MyGame(ui.Image spriteImage) {
gameBg = GameBg(Colors.white);
this.add(gameBg);
}
...
组件是通过BaseGame类的add方法添加进去的,添加进了components这个属性中,components是一个有序集合。
现在重新运行一下,会发现屏幕已经从黑色变成白色了,我们的背景组件已经生效了。它为什么会生效呢?
2.6 组件(Component)类的更新、渲染和resize的调用
上文中介绍了Game类的update和render方法,除了这两个,Game还有一个resize方法,它是在第一次循环和后面屏幕尺寸被改变的时候才会被调用,接收了一个Size参数,里面包含了屏幕的宽和高。
组件(Component)是一个抽象类,类中定义resize、update和render这3个方法,它是被BaseGame类调用的。
BaseGame类继承了Game,它重写了Game的resize、update和render这3个方法。 在这3个方法中,都遍历了组件列表,在遍历中调用了组件的同名方法,把当前接收到的参数传了进去。
在游戏渲染的时候,会带来一个问题,组件的render方法接收的都是Game类的Canvas,是在Game类的Canvas中绘制内容的,所以BaseGame后面添加的组件会在前一个组件的上面。
我们在开发的时候,就需要先确定好组件的层次,例如,上面的背景放到了第一层。
2.7 添加地面
在MyGame类中,构造方法接收了一个图片实例,这是一张精灵表,里面包含了地面图片,所以我们需要把它显示出来。
Sprite.fromImage(
Image image, {
double x,
double y,
double width,
double height,
})
在精灵表中每个图像精灵的坐标和宽高都需要先测量出来,它们是不变的,所以为他们写一个配置类。
在lib目录创建一个config.dart文件,创建HorizonConfig类,它是地面的配置,里面包含地面在精灵表中的坐标和宽高
class HorizonConfig{
static double w = 2400/3;
static double h = 38.0;
static double y = 104.0;
static double x = 2.0;
}
上面代码中地面在精灵表中的宽度是2400的,为什么分成3份呢?
因为整个游戏的地面宽度是无限的,但是图片宽度是有限的,要实现无限地面一般都是加载两个地面,地面的x坐标不断减少,直到一个地面超出屏幕外面后,再把这个地面设置到另一个地面的后面。
这种做法的地面都是重复的,所以把这种做法pass掉了。我的做法是将地面分成了3份,每次循环的时候,最左边的地面超出了屏幕后就删掉它,然后在最后一个地面的后面随机创建整个地面中的某一份。
现在来创建地面组件
在lib中创建一个sprite目录,后面创建的组件都放在这里。
然后在这个目录中创建一个horizon.dart,在里面写我们的地面组件Horizon类
...
class Horizon extends PositionComponent
with HasGameRef, Tapable, ComposedComponent, Resizable {
final ui.Image spriteImage;
Horizon(this.spriteImage);
}
上面代码中继承的是PositionComponent,PositionComponent继承了Component,添加了一些功能,例如设置组件在游戏中的坐标、组件的宽度等。
with了ComposedComponent类,这个类给PositionComponent提供了一个组件列表,和BaseGame一样,我们可以添加其它组件到这里,也就是说我们可以嵌套组件。
为Horizon类写一个创建随机地面的方法
...
SpriteComponent createComposer(double x) {
final Sprite sprite = Sprite.fromImage(spriteImage,
width: HorizonConfig.w,
height: HorizonConfig.h,
y: HorizonConfig.y,
x: HorizonConfig.w * (Random().nextInt(3)) + HorizonConfig.x
);
SpriteComponent horizon = SpriteComponent.fromSprite(
HorizonConfig.w, HorizonConfig.h, sprite);
horizon.y = size.height - HorizonConfig.h;
horizon.x = x;
return horizon;
}
...
这个方法中用到了SpriteComponent,SpriteComponent继承了PositionComponent,提供渲染精灵的功能。
“horizon.y = size.height - HorizonConfig.h” 这个代码把地面设置在屏幕底部,用到了size这个属性,所以需要resize方法被第一次调用后,我们才能使用上面的方法
初始化地面
...
SpriteComponent lastComponent;
...
@override
void resize(ui.Size size) {
super.resize(size);
if(components.isEmpty){
init();
return;
}
}
void init(){
double x = 0;
int count = (size.width/HorizonConfig.w).ceil() + 1;
for(int i=0; i<count; i++){
lastComponent = createComposer(x);
x += HorizonConfig.w;
add(lastComponent);
}
}
...
在init方法中,根据屏幕宽度确定要创建多少个地面。
然后,让地面都开始动起来
...
@override
void update(double t) {
double x = t * 50 * 6.5;
for(final c in components){
final component = c as SpriteComponent;
//释放前面超出屏幕的地面, 再重新添加一个在后面
if(component.x + HorizonConfig.w < 0){
components.remove(component);
SpriteComponent horizon = createComposer(lastComponent.x + HorizonConfig.w);
add(horizon);
lastComponent = horizon;
continue;
}
component.x -= x;
}
}
...
t*50是逻辑上的速度, 6.5是速率,像这种跑酷游戏一般都是给它定一个逻辑上的速度,再把速率调到满意为止
我们需要获取最后一个组件的x坐标,但是在集合中,要获取指定的元素都是通过再次遍历获取的。所以为了减少遍历,设置了一个lastComponent属性,保存了最后一个组件。
定位到MyGame这个类中,添加地面组件
...
GameBg gameBg;
Horizon horizon;
MyGame(ui.Image spriteImage) {
gameBg = GameBg(Color.fromRGBO(245, 243, 245, 1));
horizon = Horizon(spriteImage);
this
..add(gameBg)..add(horizon);
}
...
运行...
2.8 添加云朵组件
打开sprite.png,测量云朵的位置和宽高,然后在config.dart中创建CloudConfig类写上。
太透明了,看了几遍才找到~
...
class CloudConfig{
static double w = 92.0;
static double h = 28.0;
static double y = 2.0;
static double x = 166.0;
}
封装一个获取指定范围的随机数方法
在lib目录中,创建util.dart,写上以下代码
import 'dart:math';
double getRandomNum(double min, double max) =>
(Random().nextDouble() * (max - min + 1)).floor() + min;
创建云朵的时候会用到
云朵组件
在lib/sprite目录中添加一个cloud.dart文件,在里面创建一个Cloud类
class Cloud extends PositionComponent
with HasGameRef, Tapable, ComposedComponent, Resizable {
final ui.Image spriteImage;
SpriteComponent lastComponent;
double maxY = 0;
double minY = 5;
Cloud(this.spriteImage);
SpriteComponent createComposer(double x, double y) {
final Sprite sprite = Sprite.fromImage(spriteImage,
width: CloudConfig.w,
height: CloudConfig.h,
y: CloudConfig.y,
x: CloudConfig.x);
SpriteComponent component =
SpriteComponent.fromSprite(CloudConfig.w, CloudConfig.h, sprite);
component.x = x;
component.y = y;
return component;
}
}
和地面组件一样,添加了一个createComposer方法创建云朵, 云朵的x和y都是随机的,但是要控制一下随机范围,不然云朵会覆盖之前的。云朵还要在地面的上面,所以定义两个参数,控制一下y的位置:maxY、minY。
maxY要根据地面的y轴来判断,所以要在size属性被加载的时候才能定义。
初始化云朵
...
@override
void resize(ui.Size size) {
super.resize(size);
maxY = size.height - CloudConfig.h - HorizonConfig.h;
if (components.isEmpty) {
init();
return;
}
}
void init() {
int count = 6;
for (int i = 0; i < count; i++) {
double x, y;
y = getRandomNum(minY, maxY);
x = (lastComponent != null ? lastComponent.x + CloudConfig.w : 0) +
getRandomNum(1, size.width / 2);
lastComponent = createComposer(x, y);
add(lastComponent);
}
}
...
init方法中,直接添加随机位置的云朵有可能会覆盖,这里简单的处理了一下,添加的云朵x轴大于上一个的。
然后让云朵开始飘
...
@override
void update(double t) {
double x = t * 8 * 6.5;
for (final c in components) {
final component = c as SpriteComponent;
if (component.x + CloudConfig.w < 0) {
double lastX = lastComponent.x + CloudConfig.w;
if (size.width > lastX) lastX = size.width;
component.x = lastX + getRandomNum(1, size.width / 2);
component.y = getRandomNum(minY, maxY);
lastComponent = component;
continue;
}
component.x -= x;
}
}
...
云朵的速度设置得比地面的速度慢,视差效果,最远的总是比近的慢。
这里不像地面组件一样,再重新创建,而是把超出屏幕左边的云朵坐标重新设置。坐标的x轴也简单处理了一下,让它不会覆盖到其它云朵上面。
更改MyGame类,把云朵组件加上
class MyGame extends BaseGame with TapDetector {
GameBg gameBg;
Horizon horizon;
Cloud cloud;
MyGame(ui.Image spriteImage) {
gameBg = GameBg(Color.fromRGBO(245, 243, 245, 1));
horizon = Horizon(spriteImage);
cloud = Cloud(spriteImage);
this
..add(gameBg)..add(horizon)..add(cloud);
}
}
运行
结语
这一篇就到这了,下一篇再将这个游戏完善~
公众号:bugporter
点个关注吧~