Flutter&Flame——TankCombat游戏开发(一)

4,472 阅读8分钟

TankCombat系列文章

如果你还不了解Flame可以看这里:

见微知著,Flutter在游戏开发的表现及跨平台带来的优势

Flutter&Flame——TankCombat游戏开发(一)

Flutter&Flame——TankCombat游戏开发(二)

Flutter&Flame——TankCombat游戏开发(三)

Flutter&Flame——TankCombat游戏开发(四)

游戏介绍

玩法

我们要实现一个坦克大战:

玩家控制蓝色坦克,出生于屏幕中间
绿色和黄色为敌军坦克,出生于屏幕四角(随机)
发射的炮弹可以击毁坦克
敌军坦克在被摧毁后,会随机重生,但总体敌军数量保持4个
坦克可以发射炮弹,并分别旋转坦克身体和炮塔

更多功能待发现...

效果图

开工

一口吃不了一个胖子,我们将项目拆分,先实现背景、摇杆和绘制一辆坦克

摇杆主要借鉴自官方,如果你已经在官方的教程里学会了,可以略过此章

准备

首先我们引入Flame插件

flame: ^0.24.0

之后添加背景图片资源文件:

assets/images/

开始代码部分,我们将main函数清空,如下:

main()async{
}

添加如下代码,(还是老规矩,代码多时我会将说明添加到注解里。)

void main()async{
	//确保flutter启动成功
  WidgetsFlutterBinding.ensureInitialized();
	//为flame加载资源文件
  loadAssets();
    ///设置横屏
    await SystemChrome.setPreferredOrientations([
      DeviceOrientation.landscapeRight,
      DeviceOrientation.landscapeLeft
    ]);

    ///全面屏
    await SystemChrome.setEnabledSystemUIOverlays([]);
    
    //这个稍后解释
    final TankGame tankGame = TankGame();
    
    runApp(...)//这里下面详细交代
  
}

loadAssets();的代码如下,主要是加载图片资源以备开发时候的使用

void loadAssets(){
  Flame.images.loadAll([
    'new_map.webp',
  ]);
}

接下来的tankgame,我们需要说一下它的父类Game

Game

Game是Flame的核心,也是我们游戏的驱动力,它内部有两个主要的方法,就是上篇文章提到的

render(Canvas c)和update(double t)

这里再贴一下官方的流程图:

我们实际开发时,需要继承它并在上面两个方法中做我们自己的处理,如这里的TankGame:

class TankGame extends Game{

	@override
  void render(Canvas canvas) {}
  
  @override
  void update(double t) {}
  
  ///resize 这里的方法在屏幕尺寸变动和第一次初始化时会调用,
  ///我们可以在这里获取到屏幕的尺寸
  @override
  void resize(Size size) {}

}

接下来看最后一行的runApp(...)

runApp(...)

这里如app开发一样,是我们要加载widget的地方,可以看一下game里面有个widget变量,就是在这里面用的,不过现在我们先考虑一下布局。

通过观察,可以发现摇杆是悬浮于地图上方的,所以这里用stack比较合适。代码如下:

  runApp(Directionality(textDirection: TextDirection.ltr,
      child: Stack(
    children: [
		///我们将游戏内容如tank,地图等放在最底层
      tankGame.widget,
		
        //上层放摇杆
      Column(
        children: [
			//这个widget可以将摇杆挤在底部,内部是一个Expanded
          Spacer(),
          //两个发射按钮 位于屏幕两端
          Row(
            children: [
              SizedBox(width: 48),
              FireButton(
                onTap: tankGame.onFireButtonTap,
              ),
              Spacer(),
              FireButton(
                onTap: tankGame.onFireButtonTap,
              ),
              SizedBox(width: 48),
            ],
          ),
          //让发射按钮和摇杆保持一定间距
          SizedBox(height: 20),
          //两个摇杆 位于屏幕两端,发射按钮下方
          Row(
            children: [
              SizedBox(width: 48),
              JoyStick(
                onChange: (Offset delta)=>tankGame.onLeftJoypadChange(delta),
              ),
              Spacer(),
              JoyStick(
                onChange: (Offset delta)=>tankGame.onRightJoypadChange(delta),
              ),
              SizedBox(width: 48)
            ],
          ),
          SizedBox(height: 24)
        ],
      ),

    ],
  )));

这样我们的基本布局就算完成了,先对布局结构有一个了解,具体内部什么样子,我们一步一步来。

Component&Sprite

在游戏开发前,我们需要先简单了解一下两个东西

component : 组件(我觉得它跟游戏开发中的 刚体 很像),如子弹、坦克等游戏角色都属于component
sprite	: 这个内部方法很简单,主要是将图形绘制在游戏界面上

由component的定义可以知道,它与游戏的每帧都有关系,因此需要增加两个与game的update和render对应的方法,为了便于理解,我们依然为component的这两个方法命名为:update和render,同时抽出来:

abstract class BaseComponent{
  void render(Canvas canvas);
  void update(double t);
}

搞定! 下面再来布置一下我们的Game(TankGame)

TankGame

TankGame继承自Game,我们从这里可以获得游戏场景大小,同时通过update和render驱动各个component,代码如下:

class TankGame extends Game{
	//用来保存游戏场景尺寸
	Size screenSize;
    //游戏背景
    BattleBackground bg;

	@override
  void render(Canvas canvas) {
  	//没有初始化成功的话,不进行绘制
  	if(screenSize == null)return;
    //绘制背景
    bg.render(canvas);
  }
  
  @override
  void update(double t) {
  	if(screenSize == null)return;
  }
  
  ///resize 这里的方法在屏幕尺寸变动和第一次初始化时会调用,
  ///我们可以在这里获取到屏幕的尺寸
  @override
  void resize(Size size) {
  	screenSize = size;
    //初始化一个背景sprite
    if(bg == null){
      bg = BattleBackground(this);
    }
    
  }

}

我们在game里保存下场景尺寸,并且初始化一个bg,同时在render里调用bg的render方法,将背景绘制到游戏上,让我们看一下BattleBackground

背景

背景(BattleBackground)实现非常简单,它的代码如下:

class BattleBackground with BaseComponent{

  final TankGame game;

  Sprite bgSprite;
  Rect bgRect;

  BattleBackground(this.game){
  	//将bgSprite初始化,并将地图图片引入进来
    bgSprite = Sprite('new_map.webp');
    //根据游戏场景尺寸确定一个rect,用来告诉sprite绘制区域
    bgRect = Rect.fromLTWH(0, 0, game.screenSize.width, game.screenSize.height);
  }

  @override
  void render(Canvas canvas) {
    bgSprite.renderRect(canvas, bgRect);
  }

  @override
  void update(double t) {

  }

}

因为咱们的地图目前并没有什么变化,所以update方法可以不管,只需要render里绘制一下即可。

这里的大致流程是,game启动后,会循环调用下面的方法:

(TankGame)update->render->update->...

我们在game的render中调用背景的render方法,就可以绘制图片了。

至此,背景就添加成功了,下面我们制作摇杆

摇杆

我们这里要用到widget,起名叫JoyStick。如果你会flutter开发,那么接下来的代码是非常简单的。

首先声明一个JoyStick

class JoyStick extends StatefulWidget{
	
    //用于回传摇杆移动的方位
  final void Function(Offset) onChange;

  const JoyStick({Key key, this.onChange}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return JoyStickState();
  }

}

class JoyStickState extends State<JoyStick> {}

state内部实现如下,代码比较多我将说明写在注释里

class JoyStickState extends State<JoyStick> {

  //摇杆中间的圆的位置,简称 摇杆头
  Offset delta = Offset.zero;

  //更新 摇杆头的位置,并将位置传出去(这样就可以控制坦克了)
  void updateDelta(Offset newD){
    widget.onChange(newD);
    setState(() {
      delta = newD;
    });
  }
	
    //这个是根据用户移动摇杆头时的控制计算,主要是确保摇杆头的活动范围不能超出 外层白圈
  void calculateDelta(Offset offset){
    Offset newD = offset - Offset(bgSize/2,bgSize/2);
    updateDelta(Offset.fromDirection(newD.direction,min(bgSize/4, newD.distance)));//活动范围控制在bgSize之内
  }
	
    //摇杆外层的白圈尺寸,摇杆头的尺寸跟这个也有关系
  final double bgSize = 120;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: bgSize,height: bgSize,
      
      child: Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(bgSize/2)
        ),
        //监听用户手势
        child: GestureDetector(
          ///摇杆底部白圈
          child: Container(
            decoration: BoxDecoration(
              color: Color(0x88ffffff),
              borderRadius: BorderRadius.circular(bgSize/2),
            ),
            child: Center(
              child: Transform.translate(offset: delta,
                ///摇杆头
                child: SizedBox(
                  width: bgSize/2,height: bgSize/2,
                  child: Container(
                    decoration: BoxDecoration(
                      color: Color(0xccffffff),
                      borderRadius: BorderRadius.circular(30),
                    ),
                  ),
                ),),
            ),
          ),
          onPanDown: onDragDown,
          onPanUpdate: onDragUpdate,
          onPanEnd: onDragEnd,
        ),
      ),
    );
  }
	//三个方法主要用于获取用户触摸位置的数据
  void onDragDown(DragDownDetails d) {
    calculateDelta(d.localPosition);
  }

  void onDragUpdate(DragUpdateDetails d) {
    calculateDelta(d.localPosition);
  }

  void onDragEnd(DragEndDetails d) {
    updateDelta(Offset.zero);
  }
}

这样摇杆部分就完了,回看runApp内的方法,这个时候运行一下就可以看到屏幕上面有个摇杆了。

按钮

就是俩白圈,我直接上代码了:

class FireButton extends StatelessWidget {
  final void Function() onTap;

  const FireButton({Key key, this.onTap}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 64,width: 64,
      child: Container(
        decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(32)
        ),
        child: GestureDetector(
          child:Container(
            decoration: BoxDecoration(
              color: Color(0x88ffffff),
              borderRadius: BorderRadius.circular(32),
            ),
          ),
          onTap: onTap,
        ),
      ),
    );
  }
}

接下来我们开始绘制坦克

绘制坦克

首先我们需要坦克的图片,并加载进flame.

别忘了在pub中添加,并get一下

之后回到main函数中的loadAssets()方法,加载刚才的图片资源:

void loadAssets(){
  Flame.images.loadAll([
    'new_map.webp',
    'tank/t_body_blue.webp',
    'tank/t_turret_blue.webp',
    'tank/t_body_green.webp',
    'tank/t_turret_green.webp',
    'tank/t_body_sand.webp',
    'tank/t_turret_sand.webp',
    'tank/bullet_blue.webp',
    'tank/bullet_green.webp',
    'tank/bullet_sand.webp',
    'explosion/explosion1.webp',
    'explosion/explosion2.webp',
    'explosion/explosion3.webp',
    'explosion/explosion4.webp',
    'explosion/explosion5.webp',
  ]);
}

ok,资源加载完毕,开始代码部分。

以玩家坦克为例我们先要继承一下baseComponent,同时我们需要分别控制身体和炮塔,所以需要分别进行绘制,即两个Sprite。

class Tank extends BaseComponent{
  final TankGame game;
  Sprite bodySprite,turretSprite;
  
    //坦克出生位置
  Offset position;

  Tank(this.game,{this.position}){
  	//炮塔
    turretSprite = Sprite('tank/t_turret_blue.webp');
    //坦克身体
    bodySprite= Sprite('tank/t_body_blue.webp');

  }
  
	//调整坦克整体大小的系数
  final double ratio = 0.7;
  
  @override
  void render(Canvas canvas){
  	drawBody(Canvas canvas);
  }
  @override
  void update(double t){}
  
}

我们在render方法中添加一个drawBody()方法,来绘制坦克 :

void drawBody(Canvas canvas){
	//对画布操作前要先保存一下
	canvas.save();
    canvas.translate(position.dx, position.dy);
    //绘制tank身体
    bodySprite.renderRect(canvas,Rect.fromLTWH(-20*ratio, -15*ratio, 38*ratio, 32*ratio));
    // 绘制炮塔
    turretSprite.renderRect(canvas, Rect.fromLTWH(-1, -2*ratio, 22*ratio, 6*ratio));
    canvas.restore();
}
坦克大小我是直接写的数值,而后面的ratio,是我用来调整大小用的。

现在我们的‘不会动’坦克就绘制完成了。

后面我们需要将摇杆和坦克联系起来已达到控制坦克的目的,不过碍于篇幅(我现在滑动页面都已经卡顿了)且控制坦克这三个方法需要详尽的说一下,因此我将挪到下一篇再讲,谢谢大家阅读。

再次感谢官方的文档及其贡献者,给我提供了很大的帮助,如果你很着急可以直接查阅官方文档

Demo

坦克大战