開始用Flutter做游戲吧

一點(diǎn)點(diǎn)基礎(chǔ)

游戲主循環(huán)(GameLoop

游戲主循環(huán)是游戲的核心,計(jì)算機(jī)一次又一次運(yùn)行的一組指令,用通俗的話來說,如果游戲有生命,那么游戲主循環(huán)就是游戲的心跳。

同時(shí)為了更好的理解游戲主循環(huán),還需要引入一個(gè)計(jì)算機(jī)圖像領(lǐng)域的知識(shí)——FPS,F(xiàn)PS全稱是“Frames Per Second”,翻譯為“每秒傳輸幀數(shù)”,意思就是,如果游戲以60FPS運(yùn)行,則計(jì)算機(jī)每秒運(yùn)行60次游戲主循環(huán)??偨Y(jié)一下就是,1幀==游戲主循環(huán)的一次運(yùn)行。

通常來說,游戲主循環(huán)由兩部分組成——更新(update)和渲染(render)。

游戲更新與渲染

如上圖,更新(update)部分負(fù)責(zé)處理對(duì)象的移動(dòng),這里的對(duì)象可以是主角、NPC、敵人、障礙物、地圖和其他需要更新的參數(shù)。你在游戲里能看到的大部分動(dòng)作都在這部分發(fā)生,比如,計(jì)算主角的98K射出的子彈是否接觸到敵人。

而渲染(render)部分通常只負(fù)責(zé)一件事,在更新(update)部分發(fā)生變化時(shí),繪制屏幕上的所有對(duì)象,以便玩家看到的一切都是同步的。

游戲同步機(jī)制

在游戲中,同步機(jī)制是非常重要的,可以想象一下,現(xiàn)在更新一個(gè)NPC的位置,NPC處于正常狀態(tài),所以,你讓NPC開始移動(dòng)。但是,此時(shí)有一個(gè)子彈距離NPC只有幾個(gè)像素的距離,你更新了子彈,它會(huì)擊中NPC。

現(xiàn)在NPC已經(jīng)死了,所以你不用繪制子彈。這個(gè)時(shí)候,你應(yīng)該繪制NPC倒地動(dòng)畫的第一幀。

然后,在下一個(gè)游戲主循環(huán)中,您將跳過更新NPC位置,因?yàn)镹PC已經(jīng)死了,所以您改為渲染NPC垂死動(dòng)畫的第一幀,而不是倒地動(dòng)畫第二幀。

這會(huì)給玩家?guī)硪环N游戲不穩(wěn)定的感覺,玩家在玩射擊游戲,射擊一個(gè)NPC的時(shí)候,NPC不會(huì)倒地,玩家再次射擊,但是在子彈擊中NPC之前,NPC就死了。

非同步渲染的不穩(wěn)定性能可能不易被察覺,特別是當(dāng)每秒運(yùn)行60幀的高幀頻率下,但如果這種情況經(jīng)常發(fā)生,玩家還是會(huì)感覺出來的,然后就罵辣雞游戲了。

所以,最好提前計(jì)算好所有內(nèi)容,并且當(dāng)計(jì)算完成后最終確定所有對(duì)象的狀態(tài)時(shí),再開始繪制屏幕。

開始擼碼

使用Flame插件

pubspec.yaml下添加flame插件,并通過flutter packages get命令下載插件,或者使用Visual Studio Code保存文件會(huì)自動(dòng)下載插件。

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^0.1.2

  flame: ^0.13.0

Flame插件已經(jīng)提供了一個(gè)完整的游戲開發(fā)框架,所以我們只需要專心編寫實(shí)際的更新和渲染過程。首先,需要將應(yīng)用程序轉(zhuǎn)化為游戲模式,要做兩個(gè)操作:全屏和縱向。而令人感到巴適的是,F(xiàn)lame插件已經(jīng)封裝好了這些實(shí)用的功能,我們只需要編寫調(diào)用代碼就可以了。

我們先在main.dart的頂部添加以下引用。

import 'package:flame/util.dart';
import 'package:flutter/services.dart';

然后在main.dartmain函數(shù)內(nèi)部創(chuàng)建Flame的Util類的實(shí)例,調(diào)用其實(shí)例的全屏(fullScreen)和設(shè)置方向(setOrientation)函數(shù),同時(shí)要注意,因?yàn)檫@些函數(shù)的返回值類型是未來(Future),所以要在這些函數(shù)前面添加等待(await)。

未來(Future)、異步(async)和等待(await)是一種特殊的編碼方法,它讓那些需要長時(shí)間才能處理完成的代碼在不同的線程上完成,而且不會(huì)阻塞主線程。

為了能夠等待(await)未來(Future)處理完成,相關(guān)的代碼必須在異步(async)函數(shù)內(nèi),所以我們必須修改main函數(shù),使它成為一個(gè)異步函數(shù)。

void main() async {
  Util flameUtil = Util();
  await flameUtil.fullScreen();
  await flameUtil.setOrientation(DeviceOrientation.portraitUp);

到這里為止,我們的main.dart里面應(yīng)該有以下代碼。

import 'package:flutter/material.dart';

import 'package:flame/util.dart';
import 'package:flutter/services.dart';

void main() async {
  Util flameUtil = Util();
  await flameUtil.fullScreen();
  await flameUtil.setOrientation(DeviceOrientation.portraitUp);
}

游戲主循環(huán)腳手架

在開頭,我們知道在一個(gè)游戲應(yīng)用中,游戲是在游戲主循環(huán)里面運(yùn)行的。Flame插件已經(jīng)提供了可以直接使用的游戲主循環(huán)腳手架,要使用這個(gè)腳手架,就要用到Flame的游戲(Game)抽象類。

創(chuàng)建一個(gè)名稱為box-game.dart的新文件,然后開始編寫BoxGame類,。

import 'dart:ui';

import 'package:flame/game.dart';

class BoxGame extends Game {
  void render(Canvas canvas) {
    // TODO: 實(shí)現(xiàn)渲染
  }

  void update(double t) {
    // TODO: 實(shí)現(xiàn)更新
  }
}

上面的代碼中,導(dǎo)入dart:ui庫,這樣的話,等一下我們就可以使用畫布(Canvas)類和大小(Size)類。然后導(dǎo)入package:flame/game.dart庫,這個(gè)庫里面包括我們現(xiàn)在使用的游戲(Game)抽象類,這個(gè)類有兩個(gè)方法:更新(update)和渲染(render),我們直接用同名方法覆蓋了它們。

Dart 2.x版本中,@override注釋和new關(guān)鍵字是可選的,所以在這里也不需要寫。

接下來,我們?cè)?code>main.dart文件中創(chuàng)建BoxGame類的實(shí)例,并將其widget屬性傳遞給runApp函數(shù)。同時(shí),引用我們剛才創(chuàng)建的package:hello_flame/box-game.dart,讓BoxGame類可以在main.dart中使用。

...
import 'package:hello_flame/box-game.dart';

void main() async {
  ...
  BoxGame game = BoxGame();
  runApp(game.widget);

到這里為止,我們的main.dart里面應(yīng)該有以下代碼。

import 'package:flutter/material.dart';

import 'package:flame/util.dart';
import 'package:flutter/services.dart';

import 'package:hello_flame/box-game.dart';

void main() async {
  Util flameUtil = Util();
  await flameUtil.fullScreen();
  await flameUtil.setOrientation(DeviceOrientation.portraitUp);

  BoxGame game = BoxGame();
  runApp(game.widget);
}

現(xiàn)在我們的應(yīng)用程序可以被稱為游戲了,運(yùn)行游戲,會(huì)顯示一個(gè)空白的黑屏,因?yàn)槲覀冞€沒有在屏幕上繪制具體的內(nèi)容。

屏幕的大小和尺寸

Flame這個(gè)游戲開發(fā)框架是以Flutter為基礎(chǔ)的,而Flutter在屏幕上繪制時(shí)使用邏輯像素,因此,我們?cè)贔lame上調(diào)整游戲?qū)ο蟮拇笮r(shí)也是使用邏輯像素。

實(shí)際上,游戲(Game)抽象類上有個(gè)調(diào)整(resize)方法,這個(gè)方法接受大?。?code>Size)類參數(shù),使用這個(gè)參數(shù)就可以確定設(shè)備的屏幕大小。

首先在box-game.dart文件中,添加一個(gè)BoxGame類的實(shí)例變量screenSize,這個(gè)變量用于保持屏幕的大小,只有當(dāng)屏幕的大小發(fā)生變化時(shí)才會(huì)更新,它也是Flame在屏幕上繪制對(duì)象時(shí)的基礎(chǔ)。screenSizeSize類型的變量,與傳遞給調(diào)整(resize)方法的參數(shù)一致。

類變量screenSize的初始值為null,可以用來判斷渲染過程中是否已知屏幕大小。接下來,我們編寫一個(gè)同名方法覆蓋調(diào)整(resize)方法。

class BoxGame extends Game {
  Size screenSize;

  ...

  void resize(Size size) {
    screenSize = size;
    super.resize(size);
  }

到這里為止,我們的box-game.dart里面應(yīng)該有以下代碼。

import 'dart:ui';

import 'package:flame/game.dart';

class BoxGame extends Game {
  Size screenSize;

  void render(Canvas canvas) {
    // TODO: 實(shí)現(xiàn)渲染
  }

  void update(double t) {
    // TODO: 實(shí)現(xiàn)更新
  }

  void resize(Size size) {
    screenSize = size;
    super.resize(size);
  }
}

繪制畫布和背景

到這一步,游戲主循環(huán)已經(jīng)存在,可以開始繪制一些對(duì)象了。在渲染(render)方法中,我們可以訪問畫布(Canvas),這個(gè)畫布(Canvas)是Flame提供的,在畫布(Canvas)上繪制游戲圖形之后,F(xiàn)lame會(huì)將其繪制并將整個(gè)畫布繪制到屏幕上。

在畫布上繪圖時(shí),就像我們拿著畫筆畫畫一樣,先繪制最底層的背景對(duì)象,然后在上面繪制一些動(dòng)物、植物或建筑物對(duì)象。

現(xiàn)在我們可以開始繪制背景,這個(gè)例子中游戲背景只是一個(gè)黑屏,可以使用以下代碼繪制。

  void render(Canvas canvas) {
    // TODO: 實(shí)現(xiàn)渲染
    // 在整個(gè)屏幕上繪制黑色背景
    Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
    Paint bgPaint = Paint();
    bgPaint.color = Color(0xff000000);
    canvas.drawRect(bgRect, bgPaint);

上面代碼中,第一行聲明了一個(gè)與屏幕一樣大小的矩形(Rect),坐標(biāo)位于(0,0),即屏幕的左上角,我們就用這個(gè)當(dāng)游戲背景了。

然后,第二行聲明一個(gè)繪制(Paint)類對(duì)象,其后尾隨配置這個(gè)繪制(Paint)類對(duì)象的顏色(Color)。

最后一行代碼使用前面定義的矩形(Rect)和繪制(Paint)實(shí)例在畫布(Canvas)上繪制一個(gè)矩形。

繪制上層的對(duì)象

接下來的步驟中,我們會(huì)在屏幕的中間繪制一個(gè)游戲?qū)ο?,在?dāng)前游戲中,游戲?qū)ο笫且粋€(gè)小矩形圖案。

  void render(Canvas canvas) {
    ...

    // 畫一個(gè)盒子,如果獲勝則將其設(shè)為綠色,否則為白色
    double screenCenterX = screenSize.width / 2;
    double screenCenterY = screenSize.height / 2;
    Rect boxRect = Rect.fromLTWH(
      screenCenterX - 75,
      screenCenterY - 75,
      150,
      150,
    );
    Paint boxPaint = Paint();
    boxPaint.color = Color(0xffffffff);
    canvas.drawRect(boxRect, boxPaint);
  }

上面代碼中,前面2行代碼聲明兩個(gè)變量,分別是用于保持屏幕中心坐標(biāo)的變量,分別為屏幕寬度和高度的一半。

接下來的6行代碼聲明了一個(gè)150x150個(gè)邏輯像素大小的矩形,它位于屏幕中間,但是會(huì)向左偏移75個(gè)像素,向上偏移75個(gè)像素。

其余的代碼前面繪制畫布和背景的代碼差不多,此時(shí)運(yùn)行游戲,就可以看到黑色背景上有一個(gè)白色的矩形對(duì)象。

處理輸入和勝利條件

到這里,我們已經(jīng)完成了大部分內(nèi)容,現(xiàn)在只需要接受玩家的輸入了。在box-game.dart文件中,先導(dǎo)入Flutter的手勢(shì)庫(package:flutter/gestures.dart),然后還要添加點(diǎn)擊操作的處理函數(shù)。

...
import 'package:flutter/gestures.dart';

class BoxGame extends Game {
  ...

  void onTapDown(TapDownDetails d) {
    // 處理點(diǎn)擊
  }
}

然后回到main.dart文件中,注冊(cè)一個(gè)手勢(shì)識(shí)別器(GestureRecognizer)并將其點(diǎn)擊(onTapDown)事件鏈接到游戲的點(diǎn)擊(onTapDown)處理程序。同時(shí),我們也不要忘記在這里導(dǎo)入Flutter的手勢(shì)庫(package:flutter/gestures.dart),以便在此文件中可以使用手勢(shì)識(shí)別器(GestureRecognizer)類。

再然后,定位到main函數(shù)內(nèi)部,聲明一個(gè)點(diǎn)擊手勢(shì)識(shí)別器(TapGestureRecognizer)并將其點(diǎn)擊(onTapDown)事件分配給游戲的點(diǎn)擊(onTapDown)處理程序。最后使用Flutter的工具庫package:flame/util.dart中的添加手勢(shì)識(shí)別器(addGestureRecognizer)函數(shù)注冊(cè)手勢(shì)識(shí)別器。

...
import 'package:flutter/gestures.dart';

void main() async {
  ...

  BoxGame game = BoxGame();
  TapGestureRecognizer tapper = TapGestureRecognizer();
  
  tapper.onTapDown = game.onTapDown;
  runApp(game.widget);
  flameUtil.addGestureRecognizer(tapper);
}

到這里為止,我們的main.dart里面應(yīng)該有以下代碼。

import 'package:flutter/material.dart';

import 'package:flame/util.dart';
import 'package:flutter/services.dart';

import 'package:hello_flame/box-game.dart';

import 'package:flutter/gestures.dart';

void main() async {
  Util flameUtil = Util();
  await flameUtil.fullScreen();
  await flameUtil.setOrientation(DeviceOrientation.portraitUp);

  BoxGame game = BoxGame();
  TapGestureRecognizer tapper = TapGestureRecognizer();

  tapper.onTapDown = game.onTapDown;
  runApp(game.widget);
  flameUtil.addGestureRecognizer(tapper);
}

現(xiàn)在,我們?cè)倩氐?code>box-game.dart文件中來,添加另一個(gè)實(shí)例變量hasWon來判斷玩家是否勝利,定義一個(gè)布爾(bool)變量,默認(rèn)為false表示玩家未取得勝利。

然后在渲染(render)方法里面,寫一個(gè)條件判斷,如果玩家已經(jīng)勝利,將boxPaint的顏色設(shè)置成綠色,否則為白色。

class BoxGame extends Game {
  ...
  bool hasWon = false;

  void render(Canvas canvas) {
    ...

    Paint boxPaint = Paint();
    if (hasWon) {
      boxPaint.color = Color(0xff00ff00);
    } else {
      boxPaint.color = Color(0xffffffff);
    }
    canvas.drawRect(boxRect, boxPaint);
  }

  ...
}

最后我們還需要在游戲的點(diǎn)擊(onTapDown)處理程序中添加邏輯代碼,判斷玩家是否點(diǎn)擊了中間的矩形,如果是,就將hasWon變量的值轉(zhuǎn)換為true,表示玩家已經(jīng)取得勝利。

  void onTapDown(TapDownDetails d) {
    // 處理點(diǎn)擊
    double screenCenterX = screenSize.width / 2;
    double screenCenterY = screenSize.height / 2;
    if (d.globalPosition.dx >= screenCenterX - 75 &&
        d.globalPosition.dx <= screenCenterX + 75 &&
        d.globalPosition.dy >= screenCenterY - 75 &&
        d.globalPosition.dy <= screenCenterY + 75) {
      hasWon = true;
    }
  }

上面代碼中,前面2行用來確定屏幕中心點(diǎn)的坐標(biāo),后面的5行多條件判斷的if語句,用來判斷點(diǎn)擊坐標(biāo)是否位于屏幕中間的150x150邏輯像素范圍內(nèi)。

如果是,就轉(zhuǎn)換hasWon變量的值,并在下次調(diào)用渲染(render)方法時(shí)反映在屏幕上。同時(shí)我們這里將更新(update)方法留空了,因?yàn)檫@個(gè)游戲里不會(huì)更新任何內(nèi)容呀。

到這里為止,我們的box-game.dart里面應(yīng)該有以下代碼。

import 'dart:ui';

import 'package:flutter/gestures.dart';
import 'package:flame/game.dart';

class BoxGame extends Game {
  Size screenSize;
  bool hasWon = false;

  void render(Canvas canvas) {
    // 在整個(gè)屏幕上繪制黑色背景
    Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
    Paint bgPaint = Paint();
    bgPaint.color = Color(0xff000000);
    canvas.drawRect(bgRect, bgPaint);

    // 畫一個(gè)盒子,如果獲勝則將其設(shè)為綠色,否則為白色
    double screenCenterX = screenSize.width / 2;
    double screenCenterY = screenSize.height / 2;
    Rect boxRect = Rect.fromLTWH(
      screenCenterX - 75,
      screenCenterY - 75,
      150,
      150,
    );
    Paint boxPaint = Paint();
    if (hasWon) {
      boxPaint.color = Color(0xff00ff00);
    } else {
      boxPaint.color = Color(0xffffffff);
    }
    canvas.drawRect(boxRect, boxPaint);
  }

  void update(double t) {
    // TODO: 實(shí)現(xiàn)更新
  }

  void resize(Size size) {
    screenSize = size;
    super.resize(size);
  }

  void onTapDown(TapDownDetails d) {
    // 處理點(diǎn)擊
    double screenCenterX = screenSize.width / 2;
    double screenCenterY = screenSize.height / 2;
    if (d.globalPosition.dx >= screenCenterX - 75 &&
        d.globalPosition.dx <= screenCenterX + 75 &&
        d.globalPosition.dy >= screenCenterY - 75 &&
        d.globalPosition.dy <= screenCenterY + 75) {
      hasWon = true;
    }
  }
}

運(yùn)行游戲,可以看到效果如下所示。

開始用Flutter做游戲吧
?著作權(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)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 翻譯:https://medium.com/flutter-io/hummingbird-building-flu...
    zeppliu閱讀 3,890評(píng)論 0 1
  • 看到一個(gè)很好的故事,和大家分享: 有兩個(gè)人 一個(gè)是西醫(yī), 一個(gè)是商人。 要路過一座山, 這座山上 有很多毒蛇, 沒...
    七仔和土土閱讀 284評(píng)論 0 0
  • 沒有專注力的人生,就仿佛大睜著雙眼,卻什么也看不見。 —村上春樹《眠》 不知道從什么時(shí)候...
    燕妮在職場(chǎng)閱讀 1,020評(píng)論 4 11
  • 天大地大,哪里都是家。休閑到梅州,享受慢生活,梅州被譽(yù)為世界客都,素有“文化之鄉(xiāng)、華僑之鄉(xiāng)、足球之鄉(xiāng)”的美譽(yù),五華...
    GUOSIYU_bf3d閱讀 140評(píng)論 0 0
  • 女人懂事,能成事 這是一個(gè)大道理,當(dāng)然,女權(quán)主義者們會(huì)認(rèn)為我這個(gè)觀點(diǎn)是錯(cuò)誤的,是一種依附于男權(quán)的奴性。但是,我要說...
    黑咖啡_鐘閱讀 264評(píng)論 0 0

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