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

TankCombat系列文章

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

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

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

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

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

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

游戏介绍

玩法

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

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

更多功能待发现...

效果图

tankCombat2020831533281.gif

开工

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

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

准备

首先我们引入Flame插件

flame: ^0.24.0

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

assets/images/

[图片上传失败...(image-3818fa-1597136568141)]

开始代码部分,我们将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)

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

image

我们实际开发时,需要继承它并在上面两个方法中做我们自己的处理,如这里的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内的方法,这个时候运行一下就可以看到屏幕上面有个摇杆了。

image

按钮

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

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.

image
别忘了在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

坦克大战

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