Flutter遊戲引擎Flame初探,帶你實現一個簡單小遊戲

語言: CN / TW / HK

theme: cyanosis

我正在參加掘金社群遊戲創意投稿大賽個人賽,詳情請看:遊戲創意投稿大賽

前言

一說到遊戲開發,首先想到的是Cocos 2DUnity 3D 等這些強大的遊戲開發引擎,市面上很多遊戲都是基於這些遊戲引擎開發的。我們要想開發一款遊戲理所當然的想到也是從這些開發引擎中選擇一款來進行開發,但是這些遊戲引擎所使用的的開發語言可能跟我們所掌握的開發語言並不匹配,當然我們可以選擇去學習一門新的語言來進行開發,畢竟作為一名程式猿學習能力肯定弱不了,但是作為一個 Flutter 開發人員我在想是否有一款專門針對 Flutter 的遊戲開發引擎呢?Flutter 作為一個跨平臺的開發框架,如果使用 Flutter 開發一款遊戲豈不是天然就支援跨平臺?答案是肯定的,也就是本篇文章將為大家介紹的 Flame 遊戲引擎。

本文是對 Flame 遊戲引擎的一個初探,首先會對 Flame 遊戲引擎做一個初步的介紹,並通過對 Flame 的基礎應用實現一個簡單的小遊戲,遊戲體驗地址:堅持到底小遊戲 ,遊戲效果如下:

game

Flame 使用簡介

Flame 是一個開源的基於 Flutter 的遊戲引擎,Flame 引擎的目的是為使用 Flutter 開發的遊戲會遇到的常見問題提供一套完整的解決方案。目前 Flame 提供了以下功能:

  • 遊戲迴圈 (game loop)
  • 元件/物件系統 (FCS)
  • 特效與粒子效果
  • 碰撞檢測
  • 手勢和輸入支援
  • 圖片、動畫、精靈圖 (sprite) 以及精靈圖組
  • 一些簡化開發的實用工具類

因為本篇是對 Flame 的初探,將主要介紹第一個功能:遊戲迴圈(game loop)。後續將通過一系列的文章對 Flame 的其他功能一一介紹。

遊戲建立

首先在 Flutter 專案依賴裡新增 Flame 庫的依賴,目前最新版本是 1.1.0

yaml dependencies: flame: ^1.1.0

然後建立一個類繼承自 FlameGame

```dart import 'package:flame/game.dart';

class CustomGame extends FlameGame{

} ```

最後修改 main.dart 中 main 方法的 runApp 使用建立好的 CustomGame :

dart void main() { final game = CustomGame(); runApp(GameWidget(game: game)); }

runApp 需要傳入一個 Widget,但是 FlameGame 並不是一個 Widget ,所以不能直接傳入 runApp,需要使用 Flame 提供的 GameWidget, 其引數 game 傳入上面建立的 CustomGame ,這樣就建立好了一個遊戲,只是現在我們什麼都沒有加,所以執行是一個黑的什麼都沒有。

遊戲迴圈(game loop)

遊戲迴圈是一款遊戲的本質,即一組反覆執行的程式碼,簡單的說就是迴圈渲染畫面到螢幕上。在遊戲裡我們常見的一個說法是:FPS(Frames Per Second) 即每秒多少幀,比如:60 FPS 代表一秒鐘渲染 60 幀,換算下來就是 16 毫秒繪製一幀,整個遊戲則是通過一幀一幀的畫面迴圈繪製而成的。

那麼在 Flame 中是怎樣建立遊戲迴圈的呢?FlameGame 提供了兩個核心方法:updaterender,即更新和渲染,遊戲執行時會迴圈呼叫 update 和 render 方法:

```dart class CustomGame extends FlameGame{

@override void render(Canvas canvas){ super.render(canvas); }

@override void update(double dt) { super.update(dt); } } ```

render 方法是用於渲染,有一個引數 canvas,這樣我們就可以在 render 方法裡通過 canvas 繪製我們想要的遊戲內容;

update 方法用於更新遊戲資料,其引數 dt 是時間間隔,單位是秒,即間隔多久呼叫一次 update 和 render 方法,前面說了 60 FPS 是 16 毫秒一幀,那麼在 60 FPS 的情況下 dt 就等於 0.016 。

比如要在遊戲裡繪製一個圓,並讓這個圓每一幀在 x 和 y 上各移動 1 個畫素,則可以在 render 裡使用 canvas 繪製一個圓,在 update 裡更新圓心的位置,如下:

```dart class CustomGame extends FlameGame{

Offset circleCenter = const Offset(0, 0); final Paint paint = Paint()..color = Colors.yellow;

@override void render(Canvas canvas){ super.render(canvas); canvas.drawCircle(circleCenter, 20, paint); }

@override void update(double dt) { super.update(dt); circleCenter = circleCenter.translate(1, 1); } } ```

效果如下:

game1

生命週期

FlameGame 除了 update 和 render 方法外,還提供了一系列的生命週期方法,如下圖:

Game Lifecycle Diagram

遊戲初次新增到 Flutter 的 Widget 樹時會回撥 onGameResize, 然後依次回撥 onLoadonMount ,之後將迴圈呼叫 update 和 render 方法,當遊戲從 Flutter 的 Widget 樹中移除時呼叫 onRemove 方法。

當遊戲畫布大小發生改變時會回撥 onGameResize 方法,可以再該方法裡重新初始化遊戲裡相關元素的大小和位置。

onLoad 在整個 FlameGame 的生命週期裡只會呼叫一次,而其他生命週期方法都可能會多次呼叫,所以我們可以在 onLoad 中進行遊戲的一些初始化工作。

例項:堅持到底小遊戲

前面介紹了 FlameGame 的基本使用和生命週期,接下來就看看如何使用 FlameGame 實現一個小遊戲。

遊戲介紹

遊戲名字叫堅持到底小遊戲,遊戲的玩法很簡單,就是玩家操作遊戲主角躲避四面八方發射過來的子彈,以堅持的時間為成績,堅持的時間越長成績越好,遊戲的終極目標就是堅持100秒。

遊戲的元素也很簡單,包括:背景、主角、子彈、成績、開始/重新開始按鈕,接下來就一步步從零實現這個小遊戲。

背景

首先第一步是繪製遊戲的背景,因為這個遊戲比較簡單,遊戲背景就是一個純色,所以實現也比較簡單,在 render 裡使用 canvas 繪製一個全屏的矩形即可,程式碼如下:

```dart class StickGame extends FlameGame{ final Paint paint = Paint()..color = const Color.fromARGB(255, 35, 36, 38); final Path canvasPath = Path();

@override

Future? onLoad() async{ canvasPath.addRect(Rect.fromLTWH(0, 0, canvasSize.x, canvasSize.y)); return super.onLoad(); }

@override void render(Canvas canvas){ super.render(canvas); canvas.drawPath(canvasPath, paint); } } ```

宣告一個 paint 變數,並設定其顏色即背景顏色,用於 canvas 繪製背景;宣告 canvasPath 並在 onLoad 方法中為其新增一個矩形,矩形大小為整個畫布的大小,其中 canvasSize 為 FlameGame 的變數,即畫布大小;然後再 render 裡呼叫 canvas.drawPath 進行繪製,這樣就完成了背景的繪製。

主角

背景繪製完成後,接下來就是繪製我們遊戲的主角了。在這個遊戲裡我們的主角就是一個圓,玩家可以拖動這個圓在畫布範圍內進行移動躲避子彈。

為了使程式碼易於管理,我們這裡新建一個 TargetComponent 類用來專門處理遊戲主角的繪製和相關邏輯。程式碼如下:

```dart import 'dart:ui'; import 'package:flame/input.dart'; import 'package:flutter/material.dart';

class TargetComponent { final Vector2 position; final double radius; late Paint paint = Paint()..color = Colors.greenAccent;

TargetComponent({required this.position, this.radius = 20});

void render(Canvas canvas){

}

} ```

既然我們的主角是一個圓,那麼要繪製一個圓就需要圓心、半徑和顏色,所以為 TargetComponent 新增 position 和 radius 構造引數,用於傳入圓心的位置和半徑,預設半徑為 20 ;建立 paint 並指定顏色值用於 canvas 繪製。

TargetComponent 中建立了一個 render 方法,引數是 Canvas,整個方法的定義與 FlameGame 中的 render 方法一直,該方法也是在 FlameGame 的 render 方法中進行呼叫,在 TargetComponent 的 render 方法中我們就可以實現圓的繪製了:

dart void render(Canvas canvas){ canvas.drawCircle(position.toOffset(), radius, paint); }

在 StickGame 中建立 TargetComponent 並在 render 中呼叫 TargetComponent 的 render 方法:

```dart class StickGame extends FlameGame{ late TargetComponent target;

@override Future? onLoad() async{ ... target = TargetComponent(position: Vector2(canvasSize.x/2, canvasSize.y/2)); return super.onLoad(); }

@override void render(Canvas canvas){ super.render(canvas); ... target.render(canvas); } } ```

在 onLoad 中建立 TargetComponent 物件,位置傳入的是畫布的中心點,並在 render 方法中呼叫了 target 的 render 方法。實現效果如下:

image-20220412193545618

拖動

圓繪製好後,接下來就看怎麼實現根據使用者的拖動移動這個圓,這裡有兩個關鍵點,一個是監聽使用者拖動事件,一個是改變圓的位置。

Flame 提供了拖動事件的回撥,只需 FlameGame 的實現類混入 HasDraggables 類然後實現對應的回撥方法即可,如下:

```dart class StickGame extends FlameGame with HasDraggables{ @override void onDragStart(int pointerId, DragStartInfo info) { super.onDragStart(pointerId, info); }

@override void onDragUpdate(int pointerId, DragUpdateInfo info) { super.onDragUpdate(pointerId, info); }

@override void onDragCancel(int pointerId) { super.onDragCancel(pointerId); }

@override void onDragEnd(int pointerId, DragEndInfo info) { super.onDragEnd(pointerId, info); } } ```

onDragStart 是拖動開始的回撥,onDragUpdate 是拖動過程中的回撥,onDragCancel 是取消拖動回撥,onDragEnd 是拖動結束回撥。

在 onDragStart 中我們判斷拖動的是否為前面繪製的圓,並設定拖動標識,在 onDragUpdate 中去更新圓的位置。onDragCancel、onDragEnd 中取消拖動標識,實現如下:

```dart bool isDrag = false;

@override void onDragStart(int pointerId, DragStartInfo info) { super.onDragStart(pointerId, info); if(target.path.contains(info.eventPosition.game.toOffset())){ isDrag = true; } }

@override void onDragUpdate(int pointerId, DragUpdateInfo info) { super.onDragUpdate(pointerId, info); var eventPosition = info.eventPosition.game; if (eventPosition.x < target.radius || eventPosition.x > canvasSize.x - target.radius || eventPosition.y < target.radius || eventPosition.y > canvasSize.y - target.radius) { return; }

if(isDrag){
  target.onDragUpdate(pointerId, info);
}

}

@override void onDragCancel(int pointerId) { super.onDragCancel(pointerId); isDrag = false; }

@override void onDragEnd(int pointerId, DragEndInfo info) { super.onDragEnd(pointerId, info); isDrag = false; } ```

在 onDragStart 中判斷拖動的點是否在遊戲主角圓內,使用的是 Path 的 contains 方法判斷,如果是則將 isDrag 設定為 true,並在 onDragCancel、onDragEnd 中將 isDrag 設定為 false。

然後在 onDragUpdate 中處理拖動更新,首先判斷拖動的點是否在畫布範圍內,通過獲取拖動的點 info.eventPosition.game 與畫布範圍以及結合圓的半徑進行比較,如果超出畫布範圍則不處理,防止圓被拖到畫布以外;最後呼叫 target.onDragUpdate 方法,實現如下:

```dart void onDragUpdate(int pointerId, DragUpdateInfo info) { var eventPosition = info.eventPosition.game; position.setValues(eventPosition.x, eventPosition.y); _updatePath(); }

void _updatePath() { path.reset(); path.addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2)); } ```

同樣是先獲取拖動的點座標,然後將圓心位置設定為拖動座標,最後呼叫 _updatePath 更新圓的 Path 路徑,更新圓的 Path 路徑主要是為了前面判斷拖動是否在圓上以及後面為了檢測圓與子彈的碰撞。最終實現效果:

game2

子彈

接下來就是繪製子彈,同樣先建立一個子彈的元件:BulletComponent,子彈同樣是一個圓,可以在畫布中進行移動,擁有位置、移動速度、移動角度、半徑、顏色屬性,如下:

```dart class BulletComponent{

final Vector2 position; final double speed; final double angle; final double radius; late Paint paint = Paint()..color = Colors.orangeAccent; late Path path = Path() ..addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2));

BulletComponent({required this.position, this.speed = 5, this.angle = 0, this.radius = 10});

} ```

預設半徑為 10,預設角度為 0,預設速度為 5,顏色為 orangeAccent,同時為了便於後面檢測子彈與遊戲主角的碰撞,這裡也定義了子彈的 Path 。

BulletComponent 元件實現 render 和 update 方法,用於繪製和更新,程式碼如下:

```dart void render(Canvas canvas){ canvas.drawCircle(position.toOffset(), radius, paint); }

void update(double dt){ position.setValues(position.x - cos(angle) * speed , position.y - sin(angle) * speed); path.reset(); path.addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2)); } ```

繪製很簡單,就是在 position 座標的位置繪製一個指定半徑的圓。更新則是按照設定的速度和角度計算出移動的 x、y 座標,並將其設定給 position ,最後同樣是同步更新子彈的 Path 路徑。

建立子彈

子彈元件 BulletComponent 實現完成後,接下來就是建立子彈元件例項,需要為子彈設定位置、半徑、速度和角度,那麼這些值怎麼來呢?

遊戲中的子彈需要每隔一段時間隨機出現在遊戲畫布的四周,且子彈的半徑也是隨機的,出現後以一定速度往遊戲主角的目標點移動直到與目標相遇或移動到畫布外。需要計算的幾個點如下:

  • 位置:隨機出現在畫布四周
  • 半徑:一定範圍內隨機(半徑不能太大也不能太小)
  • 速度:隨著時間推移子彈速度越來越快
  • 角度:通過子彈出現點和目標點計運算元彈移動的角度

接下來就一步一步計算這些值,首先在 StickGame 中定義一個集合存放建立的子彈,然後定義一個建立子彈的方法:createBullet 並在 onLoad 方法中通過時間間隔迴圈呼叫,實現方法如下:

```dart class StickGame extends FlameGame with HasDraggables{ late Timer timer; List bullets = [];

@override Future? onLoad() async{ ///.... timer = Timer(0.1, onTick: () { createBullet(); }, repeat: true);

return super.onLoad();

}

@override

void render(Canvas canvas){ super.render(canvas); ///... for (var bullet in bullets) { bullet.render(canvas); } }

void update(double dt) { super.update(dt); ///... for (var bullet in bullets) { bullet.update(dt); } timer.update(dt); }

void createBullet() { ///... } } ```

在 onLoad 中通過 Timer 每間隔 0.1 秒呼叫一次建立子彈的方法,注意這裡的 Timer 不是 Flutter SDK 中提供的 Timer 而是 Flame 庫中提供的 Timer,是根據 update 的時間來計時的,所以需要在 update 中呼叫 Timer 的 update 方法才能生效,這樣做的好處是當遊戲暫停時 Timer 的計時也會暫停。

然後在 render 方法和 update 方法中遍歷子彈的集合呼叫子彈的 render 方法和 update 方法使用者繪製子彈和更新子彈的位置。

接下來關鍵程式碼就在 createBullet 中了:

dart void createBullet() { /// 隨機半徑 var radius = random.nextInt(10) + 5; /// 計算位置 /// 是否在水平方向上,即畫布的頂部和底部 bool isHorizontal = random.nextBool(); int x = isHorizontal ? random.nextInt(canvasSize.x.toInt()) : random.nextBool() ? radius : canvasSize.x.toInt() - radius; int y = isHorizontal ? random.nextBool() ? radius : canvasSize.y.toInt() - radius : random.nextInt(canvasSize.y.toInt()); var position = Vector2(x.toDouble(), y.toDouble()); /// 計算角度 var angle = atan2(y - target.position.y, x - target.position.x); /// 計算速度 var speed = seconds/10 + 5; bullets.add(BulletComponent(position: position, angle: angle, radius: radius.toDouble(), speed: speed)); }

首先隨機得到 10 以內的數值然後加上 5 作為子彈的半徑,再計運算元彈的位置,因為計算位置的時候需要用到半徑。

子彈位置的計算先隨機一個 bool 值用於確定子彈位置是在畫布的水平方向還是豎直方向,即是在畫布的頂部底部還是左右兩邊,如果是水平方向那 x 座標的值就是隨機的,y 座標的值則隨機是 0 或者畫布的高度,即隨機頂部還是底部,如果是豎直方向則 y 座標值是隨機的,x 的座標則隨機是 0 或者畫布的寬度,即畫布的左邊或右邊,當然最後都要減去子彈的半徑,防止子彈跑到畫布外面去。

子彈角度的計算,知道了子彈的座標、目標點的座標,就可以通過 atan2 方法計算出角度了。

最後是速度,速度的初始值是 5 ,隨著時間推移速度越來越快,所以這裡用遊戲時間 seconds 也就是遊戲的秒數除以 10 再加上初始速度 5 作為子彈的速度。

效果如下:

game3

基本效果已經有了,但是還沒有碰撞檢測,發現子彈是穿過目標的,接下來就看看怎樣實現碰撞檢測。

碰撞檢測

還記得前面實現遊戲目標和子彈元件的時候裡面都有一個 path 變數麼,並且這個 path 會隨著目標和子彈的更新一起更新,所以我們可以使用 Path 的 combine 方法來檢測碰撞。

dart Path combine(PathOperation operation, Path path1, Path path2)

combine 方法有三個引數,一個是操作型別,後面兩個就是兩個 path 物件,操作型別有 5 種,比如有兩個圓重疊,對應 5 種類型的示意圖如下:

image-20220417200849549

其中 intersect 就是我們需要的,即兩個 Path 的相交,通過計算兩個 Path 的相交的 Path,然後判斷這個 Path 的長度是否大於 0 ,如果大於 0 說明兩個 Path 有相交,即有重疊說明產生了碰撞,程式碼實現如下:

dart bool collisionCheck(BulletComponent bullet){ var tempPath = Path.combine(PathOperation.intersect, target.path, bullet.path); return tempPath.getBounds().width > 0; }

在 update 遍歷每個子彈,判斷是否與目標有碰撞,如果有碰撞就結束遊戲,所以這裡增加一個 isRunning 變數,標記遊戲是否執行,只有執行時才更新資料:

```dart class StickGame extends FlameGame with HasDraggables{ bool isRunning = true;

///...

void stop(){ isRunning = false; }

@override

void update(double dt) { super.update(dt); if(isRunning){ timer.update(dt); for (var bullet in bullets) { if(collisionCheck(bullet)){ stop(); return; }else{ bullet.update(dt); } } } } } ```

當檢測到碰撞時就停止遊戲,效果如下:

game4

計時

計時就是記錄遊戲時長,即遊戲的成績,這裡建立一個 seconds 變數,即記錄遊戲運行了多少秒,然後每次在 update 中增加you'xi 時長,實現如下:

```dart class StickGame extends FlameGame with HasDraggables{ double seconds = 0;

@override void update(double dt) { super.update(dt); if(isRunning){ seconds += dt; ///.... } } } ```

這樣就完成了遊戲時長的記錄了。

文字

前面遊戲基本功能基本完成,但是遊戲的時長以及開始遊戲、重新開始遊戲以及遊戲結束時遊戲的成績等文字需要顯示,所以這裡建立一個文字的元件 TextComponent,程式碼如下:

```dart class TextComponent{ final Vector2 position; String text; final Color textColor; double textSize;

final Path path = Path();

TextComponent({required this.position, required this.text, this.textColor = Colors.white, this.textSize = 40});

void render(Canvas canvas){ var textPainter = TextPainter( text: TextSpan( text: text, style: TextStyle(fontSize: textSize, color: textColor)), textAlign: TextAlign.center, textDirection: TextDirection.ltr); textPainter.layout(); // 進行佈局 textPainter.paint(canvas, Offset(position.x - textPainter.width / 2 , position.y - textPainter.height/2)); // 進行繪製 path.reset(); path.addRect(Rect.fromLTWH(position.x - textPainter.width / 2, position.y - textPainter.height/2, textPainter.width, textPainter.height)); }

} ```

TextComponent 有四個引數,文字的位置、文字內容、文字顏色、文字大小,實現的方法只有一個 render 方法,用於使用 canvas 繪製文字,這裡繪製文字使用的是 TextPainter , 最後同樣有一個 path 變數,用於記錄繪製文字區域的路徑,方便後面做文字的點選。

然後在 StickGame 裡建立兩個文字元件,一個用於顯示成績,一個用於顯示開始遊戲/重新開始遊戲。

```dart class StickGame extends FlameGame with HasDraggables{ late TextComponent score; late TextComponent restartText; @override Future? onLoad() async{ score = TextComponent(position: Vector2(40, 40), text: "0", textSize: 30); restartText = TextComponent(position: Vector2(canvasSize.x/2, canvasSize.y/2), text: "START", textSize: 50); return super.onLoad(); }

@override void render(Canvas canvas){ super.render(canvas); ///... score.render(canvas); if(!isRunning){ restartText.render(canvas); } } } ```

在 onLoad 中建立成績和開始/重新開始遊戲的文字元件,並在 render 中呼叫其 render 方法,這裡只有當遊戲停止時才呼叫 restartText 的 render 方法顯示重新開始遊戲。其中成績顯示在左上角,重新開始遊戲顯示到畫布中間,預設 restartText 顯示的是 START 即開始遊戲。

既然有重新開始遊戲,那就有開始遊戲的方法,同時在結束遊戲時也需要更新相應的資料,實現如下:

```dart void restart(){ isRunning = true; bullets.clear(); target.resetPosition(); score.position.setValues(40, 40); score.textSize = 30; seconds = 0; }

void stop(){ isRunning = false; restartText.text = "RESTART"; score.position.setValues(restartText.position.x, restartText.position.y - 80); score.text = "${seconds.toInt()}s"; score.textSize = 40; } ```

開始遊戲時將 isRunning 設定為 true,然後清空子彈集合,重置遊戲目標的位置,將成績的顯示放到左上角並設定成績文字的大小為 30,遊戲時長也重置為 0;遊戲結束時將 isRunning 設定為 false,然後修改 restartText 的文字為 RESTART 即重新開始遊戲,將成績的文字移動到重新開始遊戲文字的上方並修改其文字為遊戲時長,並設定其文字大小為 40 。

點選

前面添加了開始遊戲、重新開始遊戲的文字,但是未為其新增點選事件,新增點選事件的方法跟前面新增拖動事件的方法類似,混入 HasTappables 實現 onTapUp 方法即可:

dart class StickGame extends FlameGame with HasDraggables, HasTappables{ @override void onTapUp(int pointerId, TapUpInfo info) { super.onTapUp(pointerId, info); if(!isRunning && restartText.path.contains(info.eventPosition.game.toOffset())){ restart(); } } }

在 onTapUp 方法中判斷遊戲是否執行中,然後判斷開始/重新開始遊戲的文字顯示區域是否包含點選的點,如果包含則說明點選的是開始/重新開始遊戲,則呼叫 restart() 方法。

最終實現的效果就是文章開始放出來的效果圖,如下:

game

回收

最後還缺一步就是回收,當子彈移動到畫布外以後需要將子彈回收,即從集合中移除,實現如下:

dart void checkBullets(){ var removeBullets = <BulletComponent>[]; for (var bullet in bullets) { if(!canvasPath.contains(bullet.position.toOffset())){ removeBullets.add(bullet); } } bullets.removeWhere((element) => removeBullets.contains(element)); }

最後

本篇文章帶領大家對 Flame 遊戲引擎做了一個初探,瞭解了 FlameGame 的基礎使用,並通過 FlameGame 實現了一個簡單的遊戲,在實現遊戲的過程中瞭解了拖拽事件、點選事件的使用方法。當然因為本篇文章只是對 Flame 的一個初探,所以在實現這個小遊戲的過程中沒有用到其他 Flame 的功能,比如 Flame 的元件、碰撞檢測等,使用這些功能能更加快捷方便的實現對應的遊戲功能,關於 Flame 的更多功能將在後續文章中一一講解,敬請期待!

原始碼:flutter_stick_game

文章已同步釋出到公眾號:loongwind