flutter開發遊戲入門(仿谷歌瀏覽器小恐龍Chrome dino)一

1 準備

1.1 FPS和遊戲框架介紹

FPS全稱是「Frames Per Second」,翻譯爲「每秒傳輸幀數」。在代碼中一般會定義一個循環來表示,這個循環由兩部分組成,分別是:更新(update)和渲染(render)。git

上圖中更新( update)部分負責處理對象的狀態,好比設置遊戲中玩家的動做、敵人的位置、地圖的位置等須要更新狀態的對象。

渲染(rende)部分只負責一件事,在更新(update)部分發生變化時,繪製屏幕上的全部對象。chrome

在這個循環中, 每次循環就是遊戲中的一幀,每次循環消耗的時間越短,幀數就越高。canvas

Flutter中有一個插件叫Flame,這個插件提供了一個完整的遊戲開發框架,底層中實現了循環機制,使用它咱們只須要編寫遊戲更新和渲染的代碼。瀏覽器

1.2 遊戲資源文件獲取

在谷歌瀏覽器輸入chrome://dino,打開網頁調試工具,會發現整個遊戲只有一張圖片。bash

它是一張精靈表,也就是把多張圖片合成一張圖片的圖片。先把圖片保存下來,放到flutter項目的assets/images目錄中,重命名爲sprite.png。

1.3 添加Flame插件和圖片

打開項目中的pubspec.yaml文件,配置好插件和圖片 框架

1.4 flutter的座標位置

座標x和y軸都是從左上角開始的dom

2 開始編碼

2.1 設置橫屏,全屏顯示

flame插件提供了一個util類,提供了一些實用的功能,例如獲取屏幕尺寸、設置屏幕方向等,能夠直接用它快速實現橫屏全屏顯示async

  • Flame.util.fullScreen() 隱藏手機頂部狀態欄和底部虛擬按鍵,使應用全屏顯示
  • Flame.util.setLandscape() 設置橫屏

打開main.dart文件,在main方法中輸入如下代碼ide

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Flame.util
    ..fullScreen()
    ..setLandscape();
}
複製代碼

因爲fullScreensetLandscape方法須要等flutter框架和widgets組件綁定後才能調用,因此須要WidgetsFlutterBinding.ensureInitialized來確保已經綁定,否則會報錯工具

2.2 遊戲循環腳手架

flame提供了兩個抽象類,對遊戲循環概念進行了簡單的抽象,它們分別是Game和BaseGame。

它們都定義了update和rende方法:

  • render 接收一個畫布(Canvas)類
  • update 接收從上次update到如今的增量時間,單位:秒

大多數遊戲都是基於這兩個方法實現的

BaseGame繼承了Game,BaseGame實現了以組件(component)爲基礎的game。它提供了一個組件列表,每一個組件都表示遊戲中的一個或多個對象,它們能夠是地圖、人物、動畫等。在BaseGame的rende方法中,會把每一個組件都渲染出來。

本文中使用的是BaseGame

2.3 編寫遊戲類

在lib目錄,添加一個game.dart文件,在裏面建立MyGame類,讓它繼承BaseGame。

import 'dart:ui' as ui;
import 'package:flame/game.dart';
class MyGame extends BaseGame{
    MyGame(ui.Image spriteImage) {}
    @override
    void update(double t) {}
    @override
    void render(Canvas canvas) {}
}
複製代碼

這個遊戲只有一張圖片,因此在構造方法中接收了一個圖片實例。

2.4 載入遊戲

回到main.dart,把編寫的遊戲類顯示出來,main.dart完整代碼:

import 'dart:ui' as ui;
import 'package:flame/flame.dart';
import 'package:flutter/material.dart';
import 'package:fluttergame/game.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  Flame.util
    ..fullScreen()
    ..setLandscape();
  ui.Image image = await Flame.images.load("sprite.png");
  runApp(MyGame(image).widget);
}

複製代碼

編譯運行後,打開遊戲是黑屏的,由於還沒在遊戲中編寫任何東西。

2.5 給遊戲添加背景

咱們知道,BaseGame提供了一個組件列表,咱們能夠把遊戲中每一個對象都封裝成一個組件,而後把它添加進遊戲中。

打開game.dart,在MyGame類的下面添加一個組件類GameBg(繼承Component)

...
class MyGame...
class GameBg extends Component with Resizable {
  Color bgColor;
  GameBg([this.bgColor = Colors.white]);

  @override
  void render(Canvas canvas) {
    Rect bgRect = Rect.fromLTWH(0, 0, size.width, size.height);
    Paint bgPaint = Paint();
    bgPaint.color = bgColor;
    canvas.drawRect(bgRect, bgPaint);
  }

  @override
  void update(double t) {}
}
複製代碼

由於整個遊戲背景都是一種顏色的,因此在上面代碼的render方法中,在canvas上畫了一個寬高都等於屏幕大小的矩形(Rect),屏幕的寬高是經過size這個屬性獲取的。

而size這個屬性是在Resizable這個類中定義的,Resizable是with進來的,裏面覆蓋了Component的resize方法。resize方法接收了一個size參數,每次resize方法被調用的時候都會把size屬性更新了。(關於組件的resize方法在何時被調用,下文會說到。)

背景組件建立好了,須要在MyGame中使用它,先給MyGame添加一個GameBg屬性,方便後面更改背景顏色

...
class MyGame extends BaseGame{
    GameBg gameBg;
...
複製代碼

而後在構造方法中,實例化背景組件,把它添加到組件列表中

...
  MyGame(ui.Image spriteImage) {
    gameBg = GameBg(Colors.white);
    this.add(gameBg);
  }
...
複製代碼

組件是經過BaseGame類的add方法添加進去的,添加進了components這個屬性中,components是一個有序集合。

如今從新運行一下,會發現屏幕已經從黑色變成白色了,咱們的背景組件已經生效了。它爲何會生效呢?

2.6 組件(Component)類的更新、渲染和resize的調用

上文中介紹了Game類的update和render方法,除了這兩個,Game還有一個resize方法,它是在第一次循環和後面屏幕尺寸被改變的時候纔會被調用,接收了一個Size參數,裏面包含了屏幕的寬和高。

組件(Component)是一個抽象類,類中定義resize、update和render這3個方法,它是被BaseGame類調用的。

BaseGame類繼承了Game,它重寫了Game的resize、update和render這3個方法。 在這3個方法中,都遍歷了組件列表,在遍歷中調用了組件的同名方法,把當前接收到的參數傳了進去。

在遊戲渲染的時候,會帶來一個問題,組件的render方法接收的都是Game類的Canvas,是在Game類的Canvas中繪製內容的,因此BaseGame後面添加的組件會在前一個組件的上面。

咱們在開發的時候,就須要先肯定好組件的層次,例如,上面的背景放到了第一層。

2.7 添加地面

在MyGame類中,構造方法接收了一個圖片實例,這是一張精靈表,裏面包含了地面圖片,因此咱們須要把它顯示出來。

Flame提供了Sprite類處理圖片,能夠經過Sprite類的fromImage構造方法加載指定座標的圖片精靈,它接收5個參數:

Sprite.fromImage(
    Image image, {
    double x,
    double y,
    double width,
    double height,
  })
複製代碼

在精靈表中每一個圖像精靈的座標和寬高都須要先測量出來,它們是不變的,因此爲他們寫一個配置類。

在lib目錄建立一個config.dart文件,建立HorizonConfig類,它是地面的配置,裏面包含地面在精靈表中的座標和寬高

class HorizonConfig{
  static double w = 2400/3;
  static double h = 38.0;
  static double y = 104.0;
  static double x = 2.0;
}
複製代碼

上面代碼中地面在精靈表中的寬度是2400的,爲何分紅3份呢?

由於整個遊戲的地面寬度是無限的,可是圖片寬度是有限的,要實現無限地面通常都是加載兩個地面,地面的x座標不斷減小,直到一個地面超出屏幕外面後,再把這個地面設置到另外一個地面的後面。

這種作法的地面都是重複的,因此把這種作法pass掉了。個人作法是將地面分紅了3份,每次循環的時候,最左邊的地面超出了屏幕後就刪掉它,而後在最後一個地面的後面隨機建立整個地面中的某一份。

如今來建立地面組件

在lib中建立一個sprite目錄,後面建立的組件都放在這裏。

而後在這個目錄中建立一個horizon.dart,在裏面寫咱們的地面組件Horizon類

...
class Horizon extends PositionComponent
  with HasGameRef, Tapable, ComposedComponent, Resizable {
    final ui.Image spriteImage;
    Horizon(this.spriteImage);
  }
複製代碼

上面代碼中繼承的是PositionComponent,PositionComponent繼承了Component,添加了一些功能,例如設置組件在遊戲中的座標、組件的寬度等。

with了ComposedComponent類,這個類給PositionComponent提供了一個組件列表,和BaseGame同樣,咱們能夠添加其它組件到這裏,也就是說咱們能夠嵌套組件。

爲Horizon類寫一個建立隨機地面的方法

...
  SpriteComponent createComposer(double x) {
    final Sprite sprite = Sprite.fromImage(spriteImage,
      width: HorizonConfig.w,
      height: HorizonConfig.h,
      y: HorizonConfig.y,
      x: HorizonConfig.w * (Random().nextInt(3)) + HorizonConfig.x
    );
    SpriteComponent horizon = SpriteComponent.fromSprite(
      HorizonConfig.w, HorizonConfig.h, sprite);
    horizon.y = size.height - HorizonConfig.h;
    horizon.x = x;
    return horizon;
  }
...
複製代碼

這個方法中用到了SpriteComponent,SpriteComponent繼承了PositionComponent,提供渲染精靈的功能。

horizon.y = size.height - HorizonConfig.h」 這個代碼把地面設置在屏幕底部,用到了size這個屬性,因此須要resize方法被第一次調用後,咱們才能使用上面的方法

初始化地面

...
  SpriteComponent lastComponent;
  ...
  @override
  void resize(ui.Size size) {
    super.resize(size);
    if(components.isEmpty){
      init();
      return;
    }
  }

  void init(){
    double x = 0;
    int count = (size.width/HorizonConfig.w).ceil() + 1;
    for(int i=0; i<count; i++){
      lastComponent = createComposer(x);
      x += HorizonConfig.w;
      add(lastComponent);
    }
  }
  ...
複製代碼

init方法中,根據屏幕寬度肯定要建立多少個地面。

而後,讓地面都開始動起來

...
@override
  void update(double t) {
    double x =  t * 50 * 6.5;
    for(final c in components){
      final component = c as SpriteComponent;
      //釋放前面超出屏幕的地面, 再從新添加一個在後面
      if(component.x + HorizonConfig.w < 0){
        components.remove(component);
        SpriteComponent horizon = createComposer(lastComponent.x + HorizonConfig.w);
        add(horizon);
        lastComponent = horizon;
        continue;
      }
      component.x -= x;
    }
  }
...
複製代碼

t*50是邏輯上的速度, 6.5是速率,像這種跑酷遊戲通常都是給它定一個邏輯上的速度,再把速率調到滿意爲止

咱們須要獲取最後一個組件的x座標,可是在集合中,要獲取指定的元素都是經過再次遍歷獲取的。因此爲了減小遍歷,設置了一個lastComponent屬性,保存了最後一個組件。

定位到MyGame這個類中,添加地面組件

...
  GameBg gameBg;
  Horizon horizon;

  MyGame(ui.Image spriteImage) {
    gameBg = GameBg(Color.fromRGBO(245, 243, 245, 1));
    horizon = Horizon(spriteImage);
    this
      ..add(gameBg)..add(horizon);
  }
...
複製代碼

運行...

2.8 添加雲朵組件

打開sprite.png,測量雲朵的位置和寬高,而後在config.dart中建立CloudConfig類寫上。

太透明瞭,看了幾遍才找到~

...
class CloudConfig{
  static double w = 92.0;
  static double h = 28.0;
  static double y = 2.0;
  static double x = 166.0;
}
複製代碼

封裝一個獲取指定範圍的隨機數方法

在lib目錄中,建立util.dart,寫上如下代碼

import 'dart:math';
double getRandomNum(double min, double max) =>
    (Random().nextDouble() * (max - min + 1)).floor() + min;
複製代碼

建立雲朵的時候會用到

雲朵組件

在lib/sprite目錄中添加一個cloud.dart文件,在裏面建立一個Cloud類

class Cloud extends PositionComponent
    with HasGameRef, Tapable, ComposedComponent, Resizable {
  final ui.Image spriteImage;
  SpriteComponent lastComponent;
  double maxY = 0;
  double minY = 5;

  Cloud(this.spriteImage);
  
  SpriteComponent createComposer(double x, double y) {
    final Sprite sprite = Sprite.fromImage(spriteImage,
        width: CloudConfig.w,
        height: CloudConfig.h,
        y: CloudConfig.y,
        x: CloudConfig.x);
    SpriteComponent component =
        SpriteComponent.fromSprite(CloudConfig.w, CloudConfig.h, sprite);
    component.x = x;
    component.y = y;
    return component;
  }
}
複製代碼

和地面組件同樣,添加了一個createComposer方法建立雲朵, 雲朵的x和y都是隨機的,可是要控制一下隨機範圍,否則雲朵會覆蓋以前的。雲朵還要在地面的上面,因此定義兩個參數,控制一下y的位置:maxY、minY。

maxY要根據地面的y軸來判斷,因此要在size屬性被加載的時候才能定義。

初始化雲朵

...
  @override
  void resize(ui.Size size) {
    super.resize(size);
    maxY = size.height - CloudConfig.h - HorizonConfig.h;
    if (components.isEmpty) {
      init();
      return;
    }
  }

  void init() {
    int count = 6;
    for (int i = 0; i < count; i++) {
      double x, y;
      y = getRandomNum(minY, maxY);
      x = (lastComponent != null ? lastComponent.x + CloudConfig.w : 0) +
          getRandomNum(1, size.width / 2);
      lastComponent = createComposer(x, y);
      add(lastComponent);
    }
  }
...
複製代碼

init方法中,直接添加隨機位置的雲朵有可能會覆蓋,這裏簡單的處理了一下,添加的雲朵x軸大於上一個的。

而後讓雲朵開始飄

...
@override
  void update(double t) {
    double x = t * 8 * 6.5;
    for (final c in components) {
      final component = c as SpriteComponent;
      if (component.x + CloudConfig.w < 0) {
        double lastX = lastComponent.x + CloudConfig.w;
        if (size.width > lastX) lastX = size.width;
        component.x = lastX + getRandomNum(1, size.width / 2);
        component.y = getRandomNum(minY, maxY);
        lastComponent = component;
        continue;
      }
      component.x -= x;
    }
  }
...
複製代碼

雲朵的速度設置得比地面的速度慢,視差效果,最遠的老是比近的慢。

這裏不像地面組件同樣,再從新建立,而是把超出屏幕左邊的雲朵座標從新設置。座標的x軸也簡單處理了一下,讓它不會覆蓋到其它雲朵上面。

更改MyGame類,把雲朵組件加上

class MyGame extends BaseGame with TapDetector {

  GameBg gameBg;
  Horizon horizon;
  Cloud cloud;

  MyGame(ui.Image spriteImage) {
    gameBg = GameBg(Color.fromRGBO(245, 243, 245, 1));
    horizon = Horizon(spriteImage);
    cloud = Cloud(spriteImage);
    this
      ..add(gameBg)..add(horizon)..add(cloud);
  }
}
複製代碼

運行

結語

這一篇就到這了,下一篇再將這個遊戲完善~

完整代碼:gitee.com/lowbibibi/f…

公衆號:bugporter

點個關注吧~

相關文章
相關標籤/搜索