Flutter&Flame——TankCombat游戲開(kāi)發(fā)(一)

TankCombat系列文章

如果你還不了解Flame可以看這里:

見(jiàn)微知著,F(xiàn)lutter在游戲開(kāi)發(fā)的表現(xiàn)及跨平臺(tái)帶來(lái)的優(yōu)勢(shì)

Flutter&Flame——TankCombat游戲開(kāi)發(fā)(一)

Flutter&Flame——TankCombat游戲開(kāi)發(fā)(二)

Flutter&Flame——TankCombat游戲開(kāi)發(fā)(三)

Flutter&Flame——TankCombat游戲開(kāi)發(fā)(四)

游戲介紹

玩法

我們要實(shí)現(xiàn)一個(gè)坦克大戰(zhàn):

玩家控制藍(lán)色坦克,出生于屏幕中間
綠色和黃色為敵軍坦克,出生于屏幕四角(隨機(jī))
發(fā)射的炮彈可以擊毀坦克
敵軍坦克在被摧毀后,會(huì)隨機(jī)重生,但總體敵軍數(shù)量保持4個(gè)
坦克可以發(fā)射炮彈,并分別旋轉(zhuǎn)坦克身體和炮塔

更多功能待發(fā)現(xiàn)...

效果圖

tankCombat2020831533281.gif

開(kāi)工

一口吃不了一個(gè)胖子,我們將項(xiàng)目拆分,先實(shí)現(xiàn)背景、搖桿和繪制一輛坦克

搖桿主要借鑒自官方,如果你已經(jīng)在官方的教程里學(xué)會(huì)了,可以略過(guò)此章

準(zhǔn)備

首先我們引入Flame插件

flame: ^0.24.0

之后添加背景圖片資源文件:

assets/images/

[圖片上傳失敗...(image-3818fa-1597136568141)]

開(kāi)始代碼部分,我們將main函數(shù)清空,如下:

main()async{
}

添加如下代碼,(還是老規(guī)矩,代碼多時(shí)我會(huì)將說(shuō)明添加到注解里。)

void main()async{
    //確保flutter啟動(dòng)成功
  WidgetsFlutterBinding.ensureInitialized();
    //為flame加載資源文件
  loadAssets();
    ///設(shè)置橫屏
    await SystemChrome.setPreferredOrientations([
      DeviceOrientation.landscapeRight,
      DeviceOrientation.landscapeLeft
    ]);

    ///全面屏
    await SystemChrome.setEnabledSystemUIOverlays([]);
    
    //這個(gè)稍后解釋
    final TankGame tankGame = TankGame();
    
    runApp(...)//這里下面詳細(xì)交代
  
}

loadAssets();的代碼如下,主要是加載圖片資源以備開(kāi)發(fā)時(shí)候的使用

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

接下來(lái)的tankgame,我們需要說(shuō)一下它的父類(lèi)Game

Game

Game是Flame的核心,也是我們游戲的驅(qū)動(dòng)力,它內(nèi)部有兩個(gè)主要的方法,就是上篇文章提到的

render(Canvas c)和update(double t)

這里再貼一下官方的流程圖:

image

我們實(shí)際開(kāi)發(fā)時(shí),需要繼承它并在上面兩個(gè)方法中做我們自己的處理,如這里的TankGame:

class TankGame extends Game{

    @override
  void render(Canvas canvas) {}
  
  @override
  void update(double t) {}
  
  ///resize 這里的方法在屏幕尺寸變動(dòng)和第一次初始化時(shí)會(huì)調(diào)用,
  ///我們可以在這里獲取到屏幕的尺寸
  @override
  void resize(Size size) {}

}

接下來(lái)看最后一行的runApp(...)

runApp(...)

這里如app開(kāi)發(fā)一樣,是我們要加載widget的地方,可以看一下game里面有個(gè)widget變量,就是在這里面用的,不過(guò)現(xiàn)在我們先考慮一下布局。

通過(guò)觀察,可以發(fā)現(xiàn)搖桿是懸浮于地圖上方的,所以這里用stack比較合適。代碼如下:

  runApp(Directionality(textDirection: TextDirection.ltr,
      child: Stack(
    children: [
        ///我們將游戲內(nèi)容如tank,地圖等放在最底層
      tankGame.widget,
        
        //上層放搖桿
      Column(
        children: [
            //這個(gè)widget可以將搖桿擠在底部,內(nèi)部是一個(gè)Expanded
          Spacer(),
          //兩個(gè)發(fā)射按鈕 位于屏幕兩端
          Row(
            children: [
              SizedBox(width: 48),
              FireButton(
                onTap: tankGame.onFireButtonTap,
              ),
              Spacer(),
              FireButton(
                onTap: tankGame.onFireButtonTap,
              ),
              SizedBox(width: 48),
            ],
          ),
          //讓發(fā)射按鈕和搖桿保持一定間距
          SizedBox(height: 20),
          //兩個(gè)搖桿 位于屏幕兩端,發(fā)射按鈕下方
          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)
        ],
      ),

    ],
  )));

這樣我們的基本布局就算完成了,先對(duì)布局結(jié)構(gòu)有一個(gè)了解,具體內(nèi)部什么樣子,我們一步一步來(lái)。

Component&Sprite

在游戲開(kāi)發(fā)前,我們需要先簡(jiǎn)單了解一下兩個(gè)東西

component : 組件(我覺(jué)得它跟游戲開(kāi)發(fā)中的 剛體 很像),如子彈、坦克等游戲角色都屬于component
sprite  : 這個(gè)內(nèi)部方法很簡(jiǎn)單,主要是將圖形繪制在游戲界面上

由component的定義可以知道,它與游戲的每幀都有關(guān)系,因此需要增加兩個(gè)與game的update和render對(duì)應(yīng)的方法,為了便于理解,我們依然為component的這兩個(gè)方法命名為:update和render,同時(shí)抽出來(lái):

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

搞定! 下面再來(lái)布置一下我們的Game(TankGame)

TankGame

TankGame繼承自Game,我們從這里可以獲得游戲場(chǎng)景大小,同時(shí)通過(guò)update和render驅(qū)動(dòng)各個(gè)component,代碼如下:

class TankGame extends Game{
    //用來(lái)保存游戲場(chǎng)景尺寸
    Size screenSize;
    //游戲背景
    BattleBackground bg;

    @override
  void render(Canvas canvas) {
    //沒(méi)有初始化成功的話,不進(jìn)行繪制
    if(screenSize == null)return;
    //繪制背景
    bg.render(canvas);
  }
  
  @override
  void update(double t) {
    if(screenSize == null)return;
  }
  
  ///resize 這里的方法在屏幕尺寸變動(dòng)和第一次初始化時(shí)會(huì)調(diào)用,
  ///我們可以在這里獲取到屏幕的尺寸
  @override
  void resize(Size size) {
    screenSize = size;
    //初始化一個(gè)背景sprite
    if(bg == null){
      bg = BattleBackground(this);
    }
    
  }

}

我們?cè)趃ame里保存下場(chǎng)景尺寸,并且初始化一個(gè)bg,同時(shí)在render里調(diào)用bg的render方法,將背景繪制到游戲上,讓我們看一下BattleBackground

背景

背景(BattleBackground)實(shí)現(xiàn)非常簡(jiǎn)單,它的代碼如下:

class BattleBackground with BaseComponent{

  final TankGame game;

  Sprite bgSprite;
  Rect bgRect;

  BattleBackground(this.game){
    //將bgSprite初始化,并將地圖圖片引入進(jìn)來(lái)
    bgSprite = Sprite('new_map.webp');
    //根據(jù)游戲場(chǎng)景尺寸確定一個(gè)rect,用來(lái)告訴sprite繪制區(qū)域
    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) {

  }

}

因?yàn)樵蹅兊牡貓D目前并沒(méi)有什么變化,所以u(píng)pdate方法可以不管,只需要render里繪制一下即可。

這里的大致流程是,game啟動(dòng)后,會(huì)循環(huán)調(diào)用下面的方法:

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

我們?cè)趃ame的render中調(diào)用背景的render方法,就可以繪制圖片了。

至此,背景就添加成功了,下面我們制作搖桿

搖桿

我們這里要用到widget,起名叫JoyStick。如果你會(huì)flutter開(kāi)發(fā),那么接下來(lái)的代碼是非常簡(jiǎn)單的。

首先聲明一個(gè)JoyStick

class JoyStick extends StatefulWidget{
    
    //用于回傳搖桿移動(dòng)的方位
  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內(nèi)部實(shí)現(xiàn)如下,代碼比較多我將說(shuō)明寫(xiě)在注釋里

class JoyStickState extends State<JoyStick> {

  //搖桿中間的圓的位置,簡(jiǎn)稱(chēng) 搖桿頭
  Offset delta = Offset.zero;

  //更新 搖桿頭的位置,并將位置傳出去(這樣就可以控制坦克了)
  void updateDelta(Offset newD){
    widget.onChange(newD);
    setState(() {
      delta = newD;
    });
  }
    
    //這個(gè)是根據(jù)用戶(hù)移動(dòng)搖桿頭時(shí)的控制計(jì)算,主要是確保搖桿頭的活動(dòng)范圍不能超出 外層白圈
  void calculateDelta(Offset offset){
    Offset newD = offset - Offset(bgSize/2,bgSize/2);
    updateDelta(Offset.fromDirection(newD.direction,min(bgSize/4, newD.distance)));//活動(dòng)范圍控制在bgSize之內(nèi)
  }
    
    //搖桿外層的白圈尺寸,搖桿頭的尺寸跟這個(gè)也有關(guān)系
  final double bgSize = 120;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: bgSize,height: bgSize,
      
      child: Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(bgSize/2)
        ),
        //監(jiān)聽(tīng)用戶(hù)手勢(shì)
        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,
        ),
      ),
    );
  }
    //三個(gè)方法主要用于獲取用戶(hù)觸摸位置的數(shù)據(jù)
  void onDragDown(DragDownDetails d) {
    calculateDelta(d.localPosition);
  }

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

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

這樣搖桿部分就完了,回看runApp內(nèi)的方法,這個(gè)時(shí)候運(yùn)行一下就可以看到屏幕上面有個(gè)搖桿了。

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,
        ),
      ),
    );
  }
}

接下來(lái)我們開(kāi)始繪制坦克

繪制坦克

首先我們需要坦克的圖片,并加載進(jìn)flame.

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

之后回到main函數(shù)中的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,資源加載完畢,開(kāi)始代碼部分。

以玩家坦克為例我們先要繼承一下baseComponent,同時(shí)我們需要分別控制身體和炮塔,所以需要分別進(jìn)行繪制,即兩個(gè)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');

  }
  
    //調(diào)整坦克整體大小的系數(shù)
  final double ratio = 0.7;
  
  @override
  void render(Canvas canvas){
    drawBody(Canvas canvas);
  }
  @override
  void update(double t){}
  
}

我們?cè)趓ender方法中添加一個(gè)drawBody()方法,來(lái)繪制坦克 :

void drawBody(Canvas canvas){
    //對(duì)畫(huà)布操作前要先保存一下
    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();
}
坦克大小我是直接寫(xiě)的數(shù)值,而后面的ratio,是我用來(lái)調(diào)整大小用的。

現(xiàn)在我們的‘不會(huì)動(dòng)’坦克就繪制完成了。

后面我們需要將搖桿和坦克聯(lián)系起來(lái)已達(dá)到控制坦克的目的,不過(guò)礙于篇幅(我現(xiàn)在滑動(dòng)頁(yè)面都已經(jīng)卡頓了)且控制坦克這三個(gè)方法需要詳盡的說(shuō)一下,因此我將挪到下一篇再講,謝謝大家閱讀。

再次感謝官方的文檔及其貢獻(xiàn)者,給我提供了很大的幫助,如果你很著急可以直接查閱官方文檔

Demo

坦克大戰(zhàn)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容