Flutter游戏引擎Flame初探,带你实现一个简单小游戏

8,458 阅读17分钟

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

前言

一说到游戏开发,首先想到的是Cocos 2DUnity 3D 等这些强大的游戏开发引擎,市面上很多游戏都是基于这些游戏引擎开发的。我们要想开发一款游戏理所当然的想到也是从这些开发引擎中选择一款来进行开发,但是这些游戏引擎所使用的的开发语言可能跟我们所掌握的开发语言并不匹配,当然我们可以选择去学习一门新的语言来进行开发,毕竟作为一名程序猿学习能力肯定弱不了,但是作为一个 Flutter 开发人员我在想是否有一款专门针对 Flutter 的游戏开发引擎呢?Flutter 作为一个跨平台的开发框架,如果使用 Flutter 开发一款游戏岂不是天然就支持跨平台?答案是肯定的,也就是本篇文章将为大家介绍的 Flame 游戏引擎。

本文是对 Flame 游戏引擎的一个初探,首先会对 Flame 游戏引擎做一个初步的介绍,并通过对 Flame 的基础应用实现一个简单的小游戏,游戏体验地址:坚持到底小游戏 ,游戏效果如下:

game

Flame 使用简介

Flame 是一个开源的基于 Flutter 的游戏引擎,Flame 引擎的目的是为使用 Flutter 开发的游戏会遇到的常见问题提供一套完整的解决方案。目前 Flame 提供了以下功能:

  • 游戏循环 (game loop)
  • 组件/对象系统 (FCS)
  • 特效与粒子效果
  • 碰撞检测
  • 手势和输入支持
  • 图片、动画、精灵图 (sprite) 以及精灵图组
  • 一些简化开发的实用工具类

因为本篇是对 Flame 的初探,将主要介绍第一个功能:游戏循环(game loop)。后续将通过一系列的文章对 Flame 的其他功能一一介绍。

游戏创建

首先在 Flutter 项目依赖里添加 Flame 库的依赖,目前最新版本是 1.1.0

dependencies:
  flame: ^1.1.0

然后创建一个类继承自 FlameGame

import 'package:flame/game.dart';

class CustomGame extends FlameGame{
  
}

最后修改 main.dart 中 main 方法的 runApp 使用创建好的 CustomGame :

void main() {
  final game = CustomGame();
  runApp(GameWidget(game: game));
}

runApp 需要传入一个 Widget,但是 FlameGame 并不是一个 Widget ,所以不能直接传入 runApp,需要使用 Flame 提供的 GameWidget, 其参数 game 传入上面创建的 CustomGame ,这样就创建好了一个游戏,只是现在我们什么都没有加,所以运行是一个黑的什么都没有。

游戏循环(game loop)

游戏循环是一款游戏的本质,即一组反复运行的代码,简单的说就是循环渲染画面到屏幕上。在游戏里我们常见的一个说法是:FPS(Frames Per Second) 即每秒多少帧,比如:60 FPS 代表一秒钟渲染 60 帧,换算下来就是 16 毫秒绘制一帧,整个游戏则是通过一帧一帧的画面循环绘制而成的。

那么在 Flame 中是怎样创建游戏循环的呢?FlameGame 提供了两个核心方法:updaterender,即更新和渲染,游戏运行时会循环调用 update 和 render 方法:

class CustomGame extends FlameGame{
  
  @override
  void render(Canvas canvas){
    super.render(canvas);
  }
  
  @override
  void update(double dt) {
    super.update(dt);
  }
}

render 方法是用于渲染,有一个参数 canvas,这样我们就可以在 render 方法里通过 canvas 绘制我们想要的游戏内容;

update 方法用于更新游戏数据,其参数 dt 是时间间隔,单位是秒,即间隔多久调用一次 update 和 render 方法,前面说了 60 FPS 是 16 毫秒一帧,那么在 60 FPS 的情况下 dt 就等于 0.016 。

比如要在游戏里绘制一个圆,并让这个圆每一帧在 x 和 y 上各移动 1 个像素,则可以在 render 里使用 canvas 绘制一个圆,在 update 里更新圆心的位置,如下:

class CustomGame extends FlameGame{

  Offset circleCenter = const Offset(0, 0);
  final Paint paint = Paint()..color = Colors.yellow;

  @override
  void render(Canvas canvas){
    super.render(canvas);
    canvas.drawCircle(circleCenter, 20, paint);
  }

  @override
  void update(double dt) {
    super.update(dt);
    circleCenter =  circleCenter.translate(1, 1);
  }
}

效果如下:

game1

生命周期

FlameGame 除了 update 和 render 方法外,还提供了一系列的生命周期方法,如下图:

Game Lifecycle Diagram

游戏初次添加到 Flutter 的 Widget 树时会回调 onGameResize, 然后依次回调 onLoadonMount ,之后将循环调用 update 和 render 方法,当游戏从 Flutter 的 Widget 树中移除时调用 onRemove 方法。

当游戏画布大小发生改变时会回调 onGameResize 方法,可以再该方法里重新初始化游戏里相关元素的大小和位置。

onLoad 在整个 FlameGame 的生命周期里只会调用一次,而其他生命周期方法都可能会多次调用,所以我们可以在 onLoad 中进行游戏的一些初始化工作。

实例:坚持到底小游戏

前面介绍了 FlameGame 的基本使用和生命周期,接下来就看看如何使用 FlameGame 实现一个小游戏。

游戏介绍

游戏名字叫坚持到底小游戏,游戏的玩法很简单,就是玩家操作游戏主角躲避四面八方发射过来的子弹,以坚持的时间为成绩,坚持的时间越长成绩越好,游戏的终极目标就是坚持100秒。

游戏的元素也很简单,包括:背景、主角、子弹、成绩、开始/重新开始按钮,接下来就一步步从零实现这个小游戏。

背景

首先第一步是绘制游戏的背景,因为这个游戏比较简单,游戏背景就是一个纯色,所以实现也比较简单,在 render 里使用 canvas 绘制一个全屏的矩形即可,代码如下:

class StickGame extends FlameGame{
  final Paint paint = Paint()..color = const Color.fromARGB(255, 35, 36, 38);
  final Path canvasPath = Path();
  
    @override
  Future<void>? onLoad() async{
    canvasPath.addRect(Rect.fromLTWH(0, 0, canvasSize.x, canvasSize.y));
    return super.onLoad();
  }
  
  @override
  void render(Canvas canvas){
    super.render(canvas);
    canvas.drawPath(canvasPath, paint);
  }
}

声明一个 paint 变量,并设置其颜色即背景颜色,用于 canvas 绘制背景;声明 canvasPath 并在 onLoad 方法中为其添加一个矩形,矩形大小为整个画布的大小,其中 canvasSize 为 FlameGame 的变量,即画布大小;然后再 render 里调用 canvas.drawPath 进行绘制,这样就完成了背景的绘制。

主角

背景绘制完成后,接下来就是绘制我们游戏的主角了。在这个游戏里我们的主角就是一个圆,玩家可以拖动这个圆在画布范围内进行移动躲避子弹。

为了使代码易于管理,我们这里新建一个 TargetComponent 类用来专门处理游戏主角的绘制和相关逻辑。代码如下:

import 'dart:ui';
import 'package:flame/input.dart';
import 'package:flutter/material.dart';

class TargetComponent {
  final Vector2 position;
  final double radius;
  late Paint paint = Paint()..color = Colors.greenAccent;

  TargetComponent({required this.position, this.radius = 20});

  void render(Canvas canvas){
    
  }

}

既然我们的主角是一个圆,那么要绘制一个圆就需要圆心、半径和颜色,所以为 TargetComponent 添加 position 和 radius 构造参数,用于传入圆心的位置和半径,默认半径为 20 ;创建 paint 并指定颜色值用于 canvas 绘制。

TargetComponent 中创建了一个 render 方法,参数是 Canvas,整个方法的定义与 FlameGame 中的 render 方法一直,该方法也是在 FlameGame 的 render 方法中进行调用,在 TargetComponent 的 render 方法中我们就可以实现圆的绘制了:

void render(Canvas canvas){
  canvas.drawCircle(position.toOffset(), radius, paint);
}

在 StickGame 中创建 TargetComponent 并在 render 中调用 TargetComponent 的 render 方法:

class StickGame extends FlameGame{
  late TargetComponent target;
  
  @override
  Future<void>? onLoad() async{
    ...
    target = TargetComponent(position: Vector2(canvasSize.x/2, canvasSize.y/2));
    return super.onLoad();
  }
  
  @override
  void render(Canvas canvas){
    super.render(canvas);
		...
    target.render(canvas);
  }
}

在 onLoad 中创建 TargetComponent 对象,位置传入的是画布的中心点,并在 render 方法中调用了 target 的 render 方法。实现效果如下:

image-20220412193545618

拖动

圆绘制好后,接下来就看怎么实现根据用户的拖动移动这个圆,这里有两个关键点,一个是监听用户拖动事件,一个是改变圆的位置。

Flame 提供了拖动事件的回调,只需 FlameGame 的实现类混入 HasDraggables 类然后实现对应的回调方法即可,如下:

class StickGame extends FlameGame with HasDraggables{
  @override
  void onDragStart(int pointerId, DragStartInfo info) {
    super.onDragStart(pointerId, info);
  }

  @override
  void onDragUpdate(int pointerId, DragUpdateInfo info) {
    super.onDragUpdate(pointerId, info);
  }
  
  @override
  void onDragCancel(int pointerId) {
    super.onDragCancel(pointerId);
  }
  
  @override
  void onDragEnd(int pointerId, DragEndInfo info) {
    super.onDragEnd(pointerId, info);
  }
}

onDragStart 是拖动开始的回调,onDragUpdate 是拖动过程中的回调,onDragCancel 是取消拖动回调,onDragEnd 是拖动结束回调。

在 onDragStart 中我们判断拖动的是否为前面绘制的圆,并设置拖动标识,在 onDragUpdate 中去更新圆的位置。onDragCancel、onDragEnd 中取消拖动标识,实现如下:

	bool isDrag = false;

  @override
  void onDragStart(int pointerId, DragStartInfo info) {
    super.onDragStart(pointerId, info);
    if(target.path.contains(info.eventPosition.game.toOffset())){
      isDrag = true;
    }
  }

  @override
  void onDragUpdate(int pointerId, DragUpdateInfo info) {
    super.onDragUpdate(pointerId, info);
    var eventPosition = info.eventPosition.game;
    if (eventPosition.x < target.radius ||
        eventPosition.x > canvasSize.x - target.radius ||
        eventPosition.y < target.radius ||
        eventPosition.y > canvasSize.y - target.radius) {
      return;
    }

    if(isDrag){
      target.onDragUpdate(pointerId, info);
    }
  }

  @override
  void onDragCancel(int pointerId) {
    super.onDragCancel(pointerId);
    isDrag = false;
  }

  @override
  void onDragEnd(int pointerId, DragEndInfo info) {
    super.onDragEnd(pointerId, info);
    isDrag = false;
  }

在 onDragStart 中判断拖动的点是否在游戏主角圆内,使用的是 Path 的 contains 方法判断,如果是则将 isDrag 设置为 true,并在 onDragCancel、onDragEnd 中将 isDrag 设置为 false。

然后在 onDragUpdate 中处理拖动更新,首先判断拖动的点是否在画布范围内,通过获取拖动的点 info.eventPosition.game 与画布范围以及结合圆的半径进行比较,如果超出画布范围则不处理,防止圆被拖到画布以外;最后调用 target.onDragUpdate 方法,实现如下:

  void onDragUpdate(int pointerId, DragUpdateInfo info) {
    var eventPosition = info.eventPosition.game;
    position.setValues(eventPosition.x, eventPosition.y);
    _updatePath();
  }

  void _updatePath() {
    path.reset();
    path.addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2));
  }

同样是先获取拖动的点坐标,然后将圆心位置设置为拖动坐标,最后调用 _updatePath 更新圆的 Path 路径,更新圆的 Path 路径主要是为了前面判断拖动是否在圆上以及后面为了检测圆与子弹的碰撞。最终实现效果:

game2

子弹

接下来就是绘制子弹,同样先建立一个子弹的组件:BulletComponent,子弹同样是一个圆,可以在画布中进行移动,拥有位置、移动速度、移动角度、半径、颜色属性,如下:

class BulletComponent{

  final Vector2 position;
  final double speed;
  final double angle;
  final double radius;
  late Paint paint = Paint()..color = Colors.orangeAccent;
  late Path path = Path()
    ..addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2));

  BulletComponent({required this.position, this.speed = 5, this.angle = 0, this.radius = 10});
  
}

默认半径为 10,默认角度为 0,默认速度为 5,颜色为 orangeAccent,同时为了便于后面检测子弹与游戏主角的碰撞,这里也定义了子弹的 Path 。

BulletComponent 组件实现 render 和 update 方法,用于绘制和更新,代码如下:

  void render(Canvas canvas){
    canvas.drawCircle(position.toOffset(), radius, paint);
  }

  void update(double dt){
    position.setValues(position.x - cos(angle) * speed , position.y - sin(angle) * speed);
    path.reset();
    path.addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2));
  }

绘制很简单,就是在 position 坐标的位置绘制一个指定半径的圆。更新则是按照设置的速度和角度计算出移动的 x、y 坐标,并将其设置给 position ,最后同样是同步更新子弹的 Path 路径。

创建子弹

子弹组件 BulletComponent 实现完成后,接下来就是创建子弹组件实例,需要为子弹设置位置、半径、速度和角度,那么这些值怎么来呢?

游戏中的子弹需要每隔一段时间随机出现在游戏画布的四周,且子弹的半径也是随机的,出现后以一定速度往游戏主角的目标点移动直到与目标相遇或移动到画布外。需要计算的几个点如下:

  • 位置:随机出现在画布四周
  • 半径:一定范围内随机(半径不能太大也不能太小)
  • 速度:随着时间推移子弹速度越来越快
  • 角度:通过子弹出现点和目标点计算子弹移动的角度

接下来就一步一步计算这些值,首先在 StickGame 中定义一个集合存放创建的子弹,然后定义一个创建子弹的方法:createBullet 并在 onLoad 方法中通过时间间隔循环调用,实现方法如下:

class StickGame extends FlameGame with HasDraggables{
  late Timer timer;
  List<BulletComponent> bullets = [];
  
  @override
  Future<void>? onLoad() async{
  	///....
    timer = Timer(0.1, onTick: () {
      createBullet();
    }, repeat: true);

    return super.onLoad();
  }  
  
    @override
  void render(Canvas canvas){
    super.render(canvas);
    ///...
    for (var bullet in bullets) {
      bullet.render(canvas);
    }
  }
  
  void update(double dt) {
    super.update(dt);
    ///...
   	for (var bullet in bullets) {
        bullet.update(dt);
    }
    timer.update(dt);
  }
  
  void createBullet() {
    ///...
  }
}

在 onLoad 中通过 Timer 每间隔 0.1 秒调用一次创建子弹的方法,注意这里的 Timer 不是 Flutter SDK 中提供的 Timer 而是 Flame 库中提供的 Timer,是根据 update 的时间来计时的,所以需要在 update 中调用 Timer 的 update 方法才能生效,这样做的好处是当游戏暂停时 Timer 的计时也会暂停。

然后在 render 方法和 update 方法中遍历子弹的集合调用子弹的 render 方法和 update 方法用户绘制子弹和更新子弹的位置。

接下来关键代码就在 createBullet 中了:

  void createBullet() {
    /// 随机半径
    var radius = random.nextInt(10) + 5;
    /// 计算位置
    /// 是否在水平方向上,即画布的顶部和底部
    bool isHorizontal = random.nextBool();
    int x = isHorizontal ? random.nextInt(canvasSize.x.toInt()) : random.nextBool() ? radius : canvasSize.x.toInt() - radius;
    int y = isHorizontal ? random.nextBool() ? radius : canvasSize.y.toInt() - radius : random.nextInt(canvasSize.y.toInt());
    var position = Vector2(x.toDouble(), y.toDouble());
    /// 计算角度
    var angle = atan2(y - target.position.y, x - target.position.x);
    /// 计算速度
    var speed = seconds/10 + 5;
    bullets.add(BulletComponent(position: position, angle: angle, radius: radius.toDouble(), speed: speed));
  }

首先随机得到 10 以内的数值然后加上 5 作为子弹的半径,再计算子弹的位置,因为计算位置的时候需要用到半径。

子弹位置的计算先随机一个 bool 值用于确定子弹位置是在画布的水平方向还是竖直方向,即是在画布的顶部底部还是左右两边,如果是水平方向那 x 坐标的值就是随机的,y 坐标的值则随机是 0 或者画布的高度,即随机顶部还是底部,如果是竖直方向则 y 坐标值是随机的,x 的坐标则随机是 0 或者画布的宽度,即画布的左边或右边,当然最后都要减去子弹的半径,防止子弹跑到画布外面去。

子弹角度的计算,知道了子弹的坐标、目标点的坐标,就可以通过 atan2 方法计算出角度了。

最后是速度,速度的初始值是 5 ,随着时间推移速度越来越快,所以这里用游戏时间 seconds 也就是游戏的秒数除以 10 再加上初始速度 5 作为子弹的速度。

效果如下:

game3

基本效果已经有了,但是还没有碰撞检测,发现子弹是穿过目标的,接下来就看看怎样实现碰撞检测。

碰撞检测

还记得前面实现游戏目标和子弹组件的时候里面都有一个 path 变量么,并且这个 path 会随着目标和子弹的更新一起更新,所以我们可以使用 Path 的 combine 方法来检测碰撞。

Path combine(PathOperation operation, Path path1, Path path2)

combine 方法有三个参数,一个是操作类型,后面两个就是两个 path 对象,操作类型有 5 种,比如有两个圆重叠,对应 5 种类型的示意图如下:

image-20220417200849549

其中 intersect 就是我们需要的,即两个 Path 的相交,通过计算两个 Path 的相交的 Path,然后判断这个 Path 的长度是否大于 0 ,如果大于 0 说明两个 Path 有相交,即有重叠说明产生了碰撞,代码实现如下:

  bool collisionCheck(BulletComponent bullet){
    var tempPath = Path.combine(PathOperation.intersect, target.path, bullet.path);
    return tempPath.getBounds().width > 0;
  }

在 update 遍历每个子弹,判断是否与目标有碰撞,如果有碰撞就结束游戏,所以这里增加一个 isRunning 变量,标记游戏是否运行,只有运行时才更新数据:

class StickGame extends FlameGame with HasDraggables{
  bool isRunning = true;
  
  ///...
  
  void stop(){
    isRunning = false;
  }
  
    @override
  void update(double dt) {
    super.update(dt);
    if(isRunning){
      timer.update(dt);
      for (var bullet in bullets) {
        if(collisionCheck(bullet)){
          stop();
          return;
        }else{
          bullet.update(dt);
        }
      }
    }
  }
}

当检测到碰撞时就停止游戏,效果如下:

game4

计时

计时就是记录游戏时长,即游戏的成绩,这里创建一个 seconds 变量,即记录游戏运行了多少秒,然后每次在 update 中增加you'xi 时长,实现如下:

class StickGame extends FlameGame with HasDraggables{
  double seconds = 0;
  
  @override
  void update(double dt) {
    super.update(dt);
    if(isRunning){
      seconds += dt;
      ///....
    }
  }
}

这样就完成了游戏时长的记录了。

文字

前面游戏基本功能基本完成,但是游戏的时长以及开始游戏、重新开始游戏以及游戏结束时游戏的成绩等文字需要显示,所以这里创建一个文字的组件 TextComponent,代码如下:

class TextComponent{
  final Vector2 position;
  String text;
  final Color textColor;
  double textSize;

  final Path path = Path();

  TextComponent({required this.position, required this.text, this.textColor = Colors.white, this.textSize = 40});


  void render(Canvas canvas){
    var textPainter = TextPainter(
        text: TextSpan(
            text: text,
            style: TextStyle(fontSize: textSize, color: textColor)),
        textAlign: TextAlign.center,
        textDirection: TextDirection.ltr);
    textPainter.layout(); // 进行布局
    textPainter.paint(canvas, Offset(position.x - textPainter.width / 2 , position.y - textPainter.height/2)); // 进行绘制
    path.reset();
    path.addRect(Rect.fromLTWH(position.x - textPainter.width / 2, position.y - textPainter.height/2, textPainter.width, textPainter.height));
  }

}

TextComponent 有四个参数,文字的位置、文字内容、文字颜色、文字大小,实现的方法只有一个 render 方法,用于使用 canvas 绘制文字,这里绘制文字使用的是 TextPainter , 最后同样有一个 path 变量,用于记录绘制文字区域的路径,方便后面做文字的点击。

然后在 StickGame 里创建两个文字组件,一个用于显示成绩,一个用于显示开始游戏/重新开始游戏。

class StickGame extends FlameGame with HasDraggables{
  late TextComponent score;
  late TextComponent restartText;
  @override
  Future<void>? onLoad() async{
    score = TextComponent(position: Vector2(40, 40), text: "0", textSize: 30);
    restartText = TextComponent(position: Vector2(canvasSize.x/2, canvasSize.y/2), text: "START", textSize: 50);
    return super.onLoad();
  }  
  
  @override
  void render(Canvas canvas){
    super.render(canvas);
    ///...
    score.render(canvas);
    if(!isRunning){
       restartText.render(canvas);
    }
  }
}

在 onLoad 中创建成绩和开始/重新开始游戏的文字组件,并在 render 中调用其 render 方法,这里只有当游戏停止时才调用 restartText 的 render 方法显示重新开始游戏。其中成绩显示在左上角,重新开始游戏显示到画布中间,默认 restartText 显示的是 START 即开始游戏。

既然有重新开始游戏,那就有开始游戏的方法,同时在结束游戏时也需要更新相应的数据,实现如下:

  void restart(){
    isRunning = true;
    bullets.clear();
    target.resetPosition();
    score.position.setValues(40, 40);
    score.textSize = 30;
    seconds = 0;
  }

  void stop(){
    isRunning = false;
    restartText.text = "RESTART";
    score.position.setValues(restartText.position.x, restartText.position.y - 80);
    score.text = "${seconds.toInt()}s";
    score.textSize = 40;
  }

开始游戏时将 isRunning 设置为 true,然后清空子弹集合,重置游戏目标的位置,将成绩的显示放到左上角并设置成绩文字的大小为 30,游戏时长也重置为 0;游戏结束时将 isRunning 设置为 false,然后修改 restartText 的文字为 RESTART 即重新开始游戏,将成绩的文字移动到重新开始游戏文字的上方并修改其文字为游戏时长,并设置其文字大小为 40 。

点击

前面添加了开始游戏、重新开始游戏的文字,但是未为其添加点击事件,添加点击事件的方法跟前面添加拖动事件的方法类似,混入 HasTappables 实现 onTapUp 方法即可:

class StickGame extends FlameGame with HasDraggables, HasTappables{
  @override
  void onTapUp(int pointerId, TapUpInfo info) {
    super.onTapUp(pointerId, info);
    if(!isRunning && restartText.path.contains(info.eventPosition.game.toOffset())){
      restart();
    }
  }
}

在 onTapUp 方法中判断游戏是否运行中,然后判断开始/重新开始游戏的文字显示区域是否包含点击的点,如果包含则说明点击的是开始/重新开始游戏,则调用 restart() 方法。

最终实现的效果就是文章开始放出来的效果图,如下:

game

回收

最后还缺一步就是回收,当子弹移动到画布外以后需要将子弹回收,即从集合中移除,实现如下:

  void checkBullets(){
    var removeBullets = <BulletComponent>[];
    for (var bullet in bullets) {
      if(!canvasPath.contains(bullet.position.toOffset())){
        removeBullets.add(bullet);
      }
    }
    bullets.removeWhere((element) => removeBullets.contains(element));
  }

最后

本篇文章带领大家对 Flame 游戏引擎做了一个初探,了解了 FlameGame 的基础使用,并通过 FlameGame 实现了一个简单的游戏,在实现游戏的过程中了解了拖拽事件、点击事件的使用方法。当然因为本篇文章只是对 Flame 的一个初探,所以在实现这个小游戏的过程中没有用到其他 Flame 的功能,比如 Flame 的组件、碰撞检测等,使用这些功能能更加快捷方便的实现对应的游戏功能,关于 Flame 的更多功能将在后续文章中一一讲解,敬请期待!

源码:flutter_stick_game

文章已同步发布到公众号:loongwind