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

1 准备

1.1 FPS和游戏框架介绍

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

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

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

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

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

1.2 游戏资源文件获取

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

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

1.3 添加Flame插件和图片

打开项目中的pubspec.yaml文件,配置好插件和图片 框架

1.4 flutter的坐标位置

坐标x和y轴都是从左上角开始的dom

2 开始编码

2.1 设置横屏,全屏显示

flame插件提供了一个util类,提供了一些实用的功能,例如获取屏幕尺寸、设置屏幕方向等,能够直接用它快速实现横屏全屏显示async

  • Flame.util.fullScreen() 隐藏手机顶部状态栏和底部虚拟按键,使应用全屏显示
  • Flame.util.setLandscape() 设置横屏

打开main.dart文件,在main方法中输入如下代码ide

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

点个关注吧~

相关文章
相关标签/搜索