flutter开发游戏入门(仿谷歌浏览器小恐龙Chrome dino)一

2,860 阅读10分钟

1 准备

1.1 FPS和游戏框架介绍

FPS全称是“Frames Per Second”,翻译为“每秒传输帧数”。在代码中通常会定义一个循环来表示,这个循环由两部分组成,分别是:更新(update)和渲染(render)。

上图中更新(update)部分负责处理对象的状态,比如设置游戏中玩家的动作、敌人的位置、地图的位置等需要更新状态的对象。

渲染(rende)部分只负责一件事,在更新(update)部分发生变化时,绘制屏幕上的所有对象。

在这个循环中, 每次循环就是游戏中的一帧,每次循环消耗的时间越短,帧数就越高。

Flutter中有一个插件叫Flame,这个插件提供了一个完整的游戏开发框架,底层中实现了循环机制,使用它我们只需要编写游戏更新和渲染的代码。

1.2 游戏资源文件获取

在谷歌浏览器输入chrome://dino,打开网页调试工具,会发现整个游戏只有一张图片。

它是一张精灵表,也就是把多张图片合成一张图片的图片。先把图片保存下来,放到flutter项目的assets/images目录中,重命名为sprite.png。

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();
}

由于fullScreensetLandscape方法需要等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类中,构造方法接收了一个图片实例,这是一张精灵表,里面包含了地面图片,所以我们需要把它显示出来。

Flame提供了Sprite类处理图片,可以通过Sprite类的fromImage构造方法加载指定坐标的图片精灵,它接收5个参数:

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);
  }
}

运行

结语

这一篇就到这了,下一篇再将这个游戏完善~

完整代码:gitee.com/lowbibibi/f…

公众号:bugporter

点个关注吧~