开始用Flutter做游戏吧

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/hekaiyou/article/details/94555242

一点点基础

游戏主循环(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;
    }
  }
}

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

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/hekaiyou/article/details/94555242
今日推荐