开始用Flutter做游戏吧

一点点基础

游戏主循环(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.dartmain函数内部创建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在屏幕上绘制对象时的基础。screenSizeSize类型的变量,与传递给调整(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;
    }
  }
}

运行游戏,可以看到效果如下所示。

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

推荐阅读更多精彩内容