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

2,740 阅读8分钟

前言

这篇文章是接着上一章写的,如果没有看过上一章,可以通过查看公众号"bugporter"的历史记录获取上一章的内容,或者通过以下链接查看。

juejin.cn/post/684490…

优化上一章的代码

上一章所有需要用到屏幕尺寸的 组件(Component)类都是在resize方法中接收到包含屏幕尺寸的Size参数后才构建的。但是每个类都这样写,有点不友好,所以我把构造方法改了一下,让它直接接收Size参数,然后在MyGame类的resize方法中,把接收到Size参数给到组件后再实例化这些组件。

之前的地面(Horizon)组件类示例:

lib/sprite/horizon.dart

class Horizon ...{
  ...
  Horizon(this.spriteImage);
  
  @override
  void resize(ui.Size size) {
    super.resize(size);
    if(components.isEmpty){
      init();
      return;
    }
  }
  ...
}

更改后

class Horizon ...{
  ...
  ui.Size size;

  Horizon(this.spriteImage, this.size){
    init();
  }
  //不再需要重写resize了

其它组件类也这样改,然后我们在MyGame类的resize中,才实例化这些组件

lib/game.dart

Class MyGame...
  @override
  void resize(ui.Size size) {
    if(components.isEmpty){
      gameBg = GameBg(Color.fromRGBO(245, 243, 245, 1));
      horizon = Horizon(spriteImage, size);
      cloud = Cloud(spriteImage, size);
      obstacle = Obstacle(spriteImage, size);
    }
    super.resize(size);
  }
  ...

创建小恐龙

在上一章已经完成了游戏背景、地面、和天空(云朵),现在来创建游戏最重要的一部分,游戏主角,那个会跳不会rap也不会篮球的 小恐龙(dino)

除了跳小恐龙还会什么?

这里面有两个状态我解释一下:

  1. 等待: 游戏未开始时小恐龙的样子,开始后它需要跑到屏幕的一定距离,我们才能控制它

  2. 惊讶: 这图像中的小恐龙很惊讶,因为它碰到障碍物,Game Over了!

知道这些状态后,需要测量出这些状态对应的图像位置和大小,然后把它写到配置中。

lib/config.dart

...
class DinoConfig{
  static double h = 94.0;
  static double y = 2.0;
}
class DinoJumpConfig{
  static double w = 88.0;
  static double x = 1336.5;
}
class DinoWaitConfig{
  static double w = 88.0;
  static double x = 1336.5+88;
}
class DinoRunConfig{
  static double w = 88;
  final double x;

  const DinoRunConfig._internal({this.x});

  static List<DinoRunConfig> list = [
    DinoRunConfig._internal(
      x: 1336.5+(88*2)
    ),
    DinoRunConfig._internal(
      x: 1336.5+(88*3)
    ),
  ];
}
class DinoDieConfig{
  static double w = 88;
  static double x = 1336.5+(88*4);
}
class DinoDownConfig{
  static double w = 118;
  final double x;

  const DinoDownConfig._internal({this.x});

  static List<DinoDownConfig> list = [
    DinoDownConfig._internal(
        x: 1866.0
    ),
    DinoDownConfig._internal(
        x: 1866.0+118
    ),
  ];
}

上面代码中,我为小恐龙每个状态的图像位置都创建了一个配置类。在这些配置中,它们的h(高)和y轴有些不是一样的,所以我把它放到DinoConfig中,把这些状态的高和y轴都强制一样,可以方便控制它的y轴实现跳跃。不然的话,需要计算每个状态的跳跃高度,还有站在地面上的高度。

里面的蹲和站两个跑步状态是由多个图像组成的动画,所以我为它们写了一个私有的构造方法,并通过一个静态的List返回每个图像不同的地方。

为什么要这样返回呢?是因为在flame这个框架中,它为我们提供了一个动画Animation类来创建动画,我们可以通过它的spriteList构造方法来创建。在这个方法中,需要一个Sprite类型的List,所以我们可以通过遍历配置中的List,把创建的Sprite对象加入到动画组件的List中。

栗子

List<Sprite> runSpriteList = [];
DinoRunConfig.list.forEach((DinoRunConfig config){
  runSpriteList.add(Sprite.fromImage(spriteImage,
        x: config.x,
        y: DinoConfig.y,
        width: DinoRunConfig.w,
        height: DinoConfig.h),
    );
});
//AnimationComponent 动画组件,需要3个参数,宽、高和动画对象。
//stepTime每帧的时间,loop是否循环播放
AnimationComponent(
    DinoRunConfig.w,
    DinoConfig.h,
    Animation.spriteList(runSpriteList, stepTime: 0.1, loop: true));

这里面有个地方需要注意一下,如果在父组件中把这个动画组件添加进去了,但是重写了父的update方法时,还需要在父的update中调用动画组件的update方法,这个动画才会播放。

配置写好了,现在来创建主角的组件。打开lib/script目录,在这个目录下创建一个dino.dart

在dino.dart中,先创建一个枚举,把小恐龙在整个游戏中的状态写上

enum DinoStatus {
  waiting,
  running,
  jumping,
  downing,
  die,
}

五个状态,分别是:等待中、跑步中、跳跃中、正在蹲着和game over了

创建好了之后,在枚举代码的下边,我们创建一个组件类dino。在这个类中定义一个list属性,并把上面枚举对应状态的组件都添加进去,最后还需要一个status属性来记录小恐龙当前的状态。

enum DinoStatus...
class Dino extends Component{
  List<PositionComponent> actualDinoList = List(5);
  DinoStatus status = DinoStatus.waiting; //默认是等待中
  
  Dino(ui.Image spriteImage, this.size) {
    final double height = DinoConfig.h;
    final double yPos = DinoConfig.y;
    
    //创建枚举对应的组件,加进list属性
    //waiting
    actualDinoList[0] = SpriteComponent.fromSprite(
        DinoWaitConfig.w,
        height,
        Sprite.fromImage(spriteImage,
            x: DinoWaitConfig.x,
            y: yPos,
            width: DinoWaitConfig.w,
            height: height));

    //running
    List<Sprite> runSpriteList = [];
    DinoRunConfig.list.forEach((DinoRunConfig config){
      runSpriteList.add(Sprite.fromImage(spriteImage,
            x: config.x,
            y: yPos,
            width: DinoRunConfig.w,
            height: height),
        );
    });
    actualDinoList[1] = AnimationComponent(
        DinoRunConfig.w,
        height,
        Animation.spriteList(runSpriteList,
            stepTime: 0.1,
            loop: true));


    //jumping
    actualDinoList[2] = SpriteComponent.fromSprite(
        DinoJumpConfig.w,
        height,
        Sprite.fromImage(spriteImage,
            x: DinoJumpConfig.x,
            y: yPos,
            width: DinoJumpConfig.w,
            height: height));

    //downing
    List<Sprite> downSpriteList = [];
    DinoDownConfig.list.forEach((DinoDownConfig config){
      downSpriteList.add(Sprite.fromImage(spriteImage,
          x: config.x,
          y: yPos,
          width: DinoDownConfig.w,
          height: height),
      );
    });
    actualDinoList[3] = AnimationComponent(
        DinoDownConfig.w,
        height,
        Animation.spriteList(downSpriteList,
            stepTime: 0.1,
            loop: true));

    //die
    actualDinoList[4] = SpriteComponent.fromSprite(
        DinoDieConfig.w,
        height,
        Sprite.fromImage(spriteImage,
            x: DinoDieConfig.x,
            y: yPos,
            width: DinoDieConfig.w,
            height: height));
  }
}

状态对应的组件加到list了,我们还需要根据当前的状态来渲染不同的组件。

首先在类中定义一个获取器,返回当前的状态对应的组件

Dino(ui.Image spriteImage, this.size)...

//获取当前状态对应的组件
PositionComponent get actualDino => actualDinoList[status.index];

然后重写render方法,把当前状态的组件渲染出来

...
  @override
  void render(ui.Canvas c) {
    actualDino.render(c);
  }
...

现在,小恐龙组件已经被创建好了,我们回到MyGame这个类中,把它添加进去

class MyGame...
  ...
  Dino dino;
  
  @override
  void resize(ui.Size size) {
    if(components.isEmpty){
      ...
      dino = Dino(spriteImage, size);
      this
        ..add(gameBg)..add(horizon)..add(cloud)..add(dino)
    ...

ps: ... 是省略之前的代码的意思

打包运行:

恐龙飞起来了,是因为在添加时,还没给它设置y轴的位置,所以默认是0的。

现在我们给它添加一个y轴的位置,屏幕高-(地面高+恐龙高-再站下一点点的距离)

class Dino...
  ...
  double maxY;
  double x,y;
  
  Dino(ui.Image spriteImage, this.size) {
    final double height = DinoConfig.h;
    final double yPos = DinoConfig.y;
    maxY = size.height - (HorizonConfig.h + height - 22);
    x = 0;
    y = maxY;
    
    //waiting
    actualDinoList[0] = SpriteComponent.fromSprite(
        DinoWaitConfig.w,
        height,
        Sprite.fromImage(spriteImage,
            x: DinoWaitConfig.x,
            y: yPos,
            width: DinoWaitConfig.w,
            height: height))
    ..x=x..y=y;
    
   ... 其他组件也这样设置一下x和y。
  }
  
  @override
  void render(ui.Canvas c) {
    actualDino..x=x..y=y;
    actualDino.render(c);
  }

上面代码的maxY: 地面的位置,也就是恐龙最大的y轴位置。

dino类不需要添加子组件,因为它每次都是根据状态来渲染一个组件的,只是起到了调度的作用,所以没有继承PositionComponent,而是继承了基础的Component类。这样做的话,需要给它一个x和y属性,我们在渲染子组件的时候,把子组件的x和y设置成dino类的,可以方便外面控制或者获取。

现在再运行:

给游戏添加跳和蹲的按钮

打开main.dart文件,调用runApp方法时,是获取了Game的widget属性作为参数给runApp方法的。既然Game类返回了widget,那么我们也可以把它放到flutter的其它组件中,例如给它套一个Stack, 把游戏返回的widget放在底下,把一些按钮添加到游戏的上面,然后通过按钮的点击事件,实现对游戏的控制。

但是想偷懒,不想写一堆flutter的widget怎么办?

在flame0.18.0以上的版本,提供了一个HasWidgetsOverlay类,只要我们在Game类中with了这个类,就可以使用addWidgetOverlay方法,把一个widget添加到游戏的上面了,它底层就是使用Stack封装的。

打开game.dart文件,给MyGame类添加一个创建按钮的方法

...
class MyGame...
    Widget createButton({@required IconData icon, double right=0, double
      bottom=0,
      ValueChanged<bool>
      onHighlightChanged}){
        return Positioned(
          right: right,
          bottom: bottom,
          child: MaterialButton(
            onHighlightChanged: onHighlightChanged,
            onPressed: (){},
            splashColor: Colors.transparent,
            highlightColor: Colors.transparent,
            child: Container(
              width: 50,
              height: 50,
              decoration: new BoxDecoration(
                color: Color.fromRGBO(0, 0, 0, 0.5),
                //设置四周圆角 角度
                borderRadius: BorderRadius.all(Radius.circular(50)),
                //设置四周边框
                border: new Border.all(width: 2, color: Colors.black),
              ),
              child: Icon(icon, color: Colors.black,),
            ),
          ),
        );
    }
    ...

该方法接收一个按钮长按事件的回调函数onHighlightChanged,要想按钮监听长按事件,必须要给按钮一个点击事件onPressed,所以我在按钮的onPressed中写了一个空的回调函数。

为什么不直接用点击事件呢?

因为点击事件是在手指离开屏幕之后才触发的,会有一点延迟,所以用长按事件,可以监听到玩家按下和松开,在这里我需要它按下后就马上跳,还有蹲下需要一直按住按钮。

onHighlightChanged每次点击都会触发两次,在按下和松开按钮的时候触发,回调中接收了一个bool类型的参数,按下是true、松开是false

然后我们在MyGame的resize方法中,创建跳和蹲的按钮,然后调用addWidgetOverlay添加到游戏的上面

void resize(ui.Size size) {
      ...
      this
        ..add(gameBg)..add(horizon)..add(cloud)..add(dino)..add(obstacle)
        ..addWidgetOverlay('upButton', createButton(
          icon: Icons.arrow_drop_up,
          right: 50,
          bottom: 120,
          onHighlightChanged: (isOn)=>dino?.jump(isOn),
        ))
        ..addWidgetOverlay('downButton', createButton(
          icon: Icons.arrow_drop_down,
          right: 50,
          bottom: 50,
          onHighlightChanged: (isOn)=>dino?.down(isOn),
        ));
        ...

在onHighlightChanged中调用dino类的jump和down方法,这两个方法还没有,我们需要在dino类中实现它。

class Dino...
  ...
  bool isJump = false;
  bool isDown = false;
  double jumpVelocity = 0.0;
  ...
  void jump(bool isOn) {
    if(status == DinoStatus.running && isOn){
      status = DinoStatus.jumping;
      this.jumpVelocity = jumpPos;
      isJump = true;
      return;
    }
    isJump = false;
  }

  void down(bool isOn){
    isDown = isOn;
    if(status == DinoStatus.running && isOn){
      status = DinoStatus.downing;
      return;
    }
    if(status == DinoStatus.downing && !isOn){
      status = DinoStatus.running;
      return;
    }
  }
  
  @override
  void update(double t) {
    if (status == DinoStatus.jumping) {
      y += jumpVelocity;
      jumpVelocity += gravity;
      if(y > maxY){
        status = DinoStatus.running;
        y = maxY;
        //一直按住,不断跳
        jump(isJump);
        //跳的过程中按了蹲,角色落地时蹲下
        down(isDown);
      }
    }
    actualDino.update(t);
  }

跳跃的时候给了它一个瞬间向上的力,然后不断给它一个重力让它回到地面。只有跑的时候能跳或者蹲,如果是跳,回到地面后还按着跳没松开那么将继续跳,蹲的时候按下马上蹲,松开了就站着跑。

把默认状态改为runing, 运行后..

录成gif看着有点卡,实际上是很流畅的..

下一章继续完善...