公開日:2021.12.17

FlutterでFlameをつかってみた

テクログsmartphone

こんにちは!
のりさんです。

今回は最近気になっている
Flutterの「flame」パッケージについて書こうと思います。

FLAMEとは

2Dゲームエンジンです。
https://flame-engine.org/

つい最近「Flutter2.8」が公開されましたが、
それと同時期にflameのVersion1.0の正式版が公開されたようです。
これを使えばスプライトやアニメーション、衝突判定などを手軽に試すことができそうですね。

ということで
今回はボタン操作で2DゲームのRPG風キャラクターが動く簡単なアプリをつくってみたいと思います。

準備

環境

  • Windows 10
  • Android Studio
  • Flutter 2.8
  • flame 1.0

アプリの内容

  • 2DゲームのRPG風キャラクターがアニメーションする
  • 上下左右ボタンを押すとキャラクターがその方向に向きを変えて移動していく
  • 停止ボタンを押すと移動中のキャラクターが止まる
  • 衝突判定を使って画面端までの範囲を移動可能とする

必要な素材

  • 2DゲームのRPG風キャラクター素材
    ⇒今回フリー素材をインターネット上で探して利用することにしました。
     
    ●アニメーション用の画像 
     縦32x横32で3枚の場合、縦32x横96というイメージです。

     ┗ 配置場所
      【assets/images】
       ・下向き:chara_bottom.png (縦32x横96)
       ・左向き:chara_left.png (縦32x横96)
       ・右向き:chara_right.png (縦32x横96)
       ・上向き:chara_top.png (縦32x横96)

完成アプリ

完成したアプリは以下のようになりました!
衝突判定をわかりやすくするため、衝突判定部分に枠を描画しています。

以下はAndroidですが、Flutterはマルチプラットフォーム対応なのでWebブラウザ等でも動作します!


それでは実際にソースコードをみていきたいと思います。

ソースコード

まずはflameパッケージとassets(画像配置済み)を追加をして「Pub get」していきます。

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.0.0

flutter:
  assets:
    - assets/images/

次にゲーム内にキャラクターを表示するため、キャラクタークラスを作成していきます。

今回は画面端の衝突判定と各アニメーション表示・切り替え等ができるように実装しました。
衝突判定するために「with HasHitboxes、HasCollidables」をクラス拡張として追加しています。


import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flutter/material.dart';

// キャラクターのアニメーション
enum MyCharacterAnimationType{
  walkTop,
  walkRight,
  walkBottom,
  walkLeft,
}

class MyCharacter extends PositionComponent with HasHitboxes, Collidable, HasGameRef {

  // アニメーション設定
   static final Map<MyCharacterAnimationType,Map<String,dynamic>> _animationImages = {
    MyCharacterAnimationType.walkBottom:{'fileName':'chara_bottom.png', 'data': SpriteAnimationData.sequenced(amount: 3, stepTime: 0.2, textureSize: Vector2(32, 32))},
    MyCharacterAnimationType.walkLeft:{'fileName':'chara_left.png', 'data': SpriteAnimationData.sequenced(amount: 3, stepTime: 0.2, textureSize: Vector2(32, 32))},
    MyCharacterAnimationType.walkTop:{'fileName':'chara_top.png', 'data': SpriteAnimationData.sequenced(amount: 3, stepTime: 0.2, textureSize: Vector2(32, 32))},
    MyCharacterAnimationType.walkRight:{'fileName':'chara_right.png', 'data': SpriteAnimationData.sequenced(amount: 3, stepTime: 0.2, textureSize: Vector2(32, 32))},
  };
  // アニメーショングループコンポーネント
  late SpriteAnimationGroupComponent _animationGroup;

  // 当たり判定
  late HitboxPolygon _hitbox;
  // 移動速度
  double _moveSpeed = 3;

  // コンストラクタ
  MyCharacter({required double x, required double y, required double width, required double height}) : super(position: Vector2(x, y), size: Vector2(width, height));

  @override
  Future<void> onLoad() async {

    // キャラクターアニメーションの作成
    _animationGroup = await _createCharaAnimation(this.size);
    add(_animationGroup);

    // 当たり判定の作成
    _hitbox = HitboxPolygon([
      Vector2(-1, -1),
      Vector2(-1, 1),
      Vector2(1, 1),
      Vector2(1, -1),
    ]);
    addHitbox(_hitbox);

    await super.onLoad();

  }

  @override
  void update(double dt) {

    // アニメーションの状態によって歩く向きの切り替え
    switch(this._animationGroup.current){
      case MyCharacterAnimationType.walkTop:
        this.position += Vector2(0, _moveSpeed  * -1);
        break;
      case MyCharacterAnimationType.walkRight:
        this.position += Vector2(_moveSpeed, 0);
        break;
      case MyCharacterAnimationType.walkBottom:
        this.position += Vector2(0, _moveSpeed);
        break;
      case MyCharacterAnimationType.walkLeft:
        this.position += Vector2(_moveSpeed * -1, 0);
        break;
    }

    super.update(dt);

  }

  void changeAnimation(MyCharacterAnimationType animationType){
    this._animationGroup.current = animationType;
  }

  void changeMoveSpeed(double speed){
    this._moveSpeed = speed;
  }

  Future<SpriteAnimationGroupComponent> _createCharaAnimation(Vector2 charaSize) async {

    Map<MyCharacterAnimationType, SpriteAnimation> animations = {};
    _animationImages.forEach((key, map) async {
      animations[key] = await gameRef.loadSpriteAnimation(map['fileName'], map['data']);
    });

    var component = SpriteAnimationGroupComponent<MyCharacterAnimationType>(
      animations: animations,
      current: MyCharacterAnimationType.walkBottom,
      position: Vector2(0, 0),
      size: charaSize,
    );

    return component;
  }

  // デバッグ用ペイント生成
   final hitboxPaint = Paint()
     ..color = Colors.lightBlueAccent
     ..style = PaintingStyle.stroke
     ..strokeWidth = 2;
   final dotPaint = Paint()
     ..color = Colors.blueAccent
     ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    // デバッグ用に当たり判定部分を描画
    _hitbox.render(canvas, hitboxPaint);
    _hitbox.localVertices().forEach((p) => canvas.drawCircle(p.toOffset(), 4, dotPaint));
  }

   @override
   void onCollision(Set<Vector2> intersectionPoints, Collidable other) {

     // 画面端の衝突判定
     if (other is ScreenCollidable) {
       // キャラクターを止める
       this._moveSpeed = 0;

       // 衝突判定外の位置に調整
       var x = this.position.x;
       var y = this.position.y;
       x = (x <= 0 ? 1 : x);
       y = (y <= 0 ? 1 : y);
       intersectionPoints.forEach((pos) {
         if (pos.x >= this.gameRef.canvasSize.x){
           x = this.gameRef.canvasSize.x - this.size.x - 1;
         }
         if (pos.y >= this.gameRef.canvasSize.y){
           y = this.gameRef.canvasSize.y - this.size.y - 1;
         }
       });

       this.position = Vector2(x, y);

     }
   }

}

キャラクタークラスができたので、続いてゲームクラスを作成したいと思います。

FlameGameクラスを継承して作成します。
ゲーム内にキャラクターを配置して、背景を白く塗りつぶすようにしました。
画面端の衝突判定ができるように「with HasCollidables」「add(ScreenCollidable())」を追加しています。


import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'mycharacter.dart';

class MyGame extends FlameGame with HasCollidables {

  late Rect bgRect;
  late MyCharacter character;
  final paint = Paint()..color = Colors.white;

  @override
  Future<void> onLoad() async {
    add(ScreenCollidable());
    character =  MyCharacter(x: 16, y: 16, width: 64, height: 64);
    add(character);
    await super.onLoad();
  }

  @override
  void render(Canvas canvas){
    canvas.drawRect(Rect.fromLTWH(0.0, 0.0, this.canvasSize.x, canvasSize.y), paint);
    super.render(canvas);
  }

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

}

ゲームクラスが完成したので、Flutterのウィジェット(GameWidget)として表示してみたいと思います。

今回はボタンでキャラクターを操作できるようにしたいため
Stackを使ってボタンウィジェットをゲームの上に表示するようにしています。

import 'package:flutter/material.dart';
import 'package:flame/game.dart';
import 'mycharacter.dart';
import 'mygame.dart';

void main() {
  var game = MyGame();

  runApp(MaterialApp(
      theme: new ThemeData(scaffoldBackgroundColor: const Color(0xFFEFEFEF)),
      home: Scaffold(
          appBar: AppBar(
            title: Text("Flutter Flame Game"),
          ),
          body: Stack(children: [
            GameWidget(game: game),
            Positioned(
                left: 150,
                top: 250,
                width: 100,
                height: 50,
                child: ElevatedButton(
                    onPressed: () {
                      game.character.changeAnimation(MyCharacterAnimationType.walkTop);
                      game.character.changeMoveSpeed(3);
                    },
                    child: Text("top", style: TextStyle(fontWeight: FontWeight.bold)))),
            Positioned(
                left: 50,
                top: 300,
                width: 100,
                height: 50,
                child: ElevatedButton(
                    onPressed: () {
                      game.character.changeAnimation(MyCharacterAnimationType.walkLeft);
                      game.character.changeMoveSpeed(3);
                    },
                    child: Text("left", style: TextStyle(fontWeight: FontWeight.bold)))),
            Positioned(
                left: 150,
                top: 300,
                width: 100,
                height: 50,
                child: ElevatedButton(
                  style: ButtonStyle(backgroundColor:  MaterialStateProperty.all<Color>(Colors.lightBlueAccent)),
                    onPressed: () {
                      game.character.changeMoveSpeed(0);
                    },
                    child: Text("stop", style: TextStyle(fontWeight: FontWeight.bold),))),
            Positioned(
                left: 250,
                top: 300,
                width: 100,
                height: 50,
                child: ElevatedButton(
                    onPressed: () {
                      game.character.changeAnimation(MyCharacterAnimationType.walkRight);
                      game.character.changeMoveSpeed(3);
                    },
                    child: Text("right", style: TextStyle(fontWeight: FontWeight.bold)))),
            Positioned(
                left: 150,
                top: 350,
                width: 100,
                height: 50,
                child: ElevatedButton(
                    onPressed: () {
                      game.character.changeAnimation(MyCharacterAnimationType.walkBottom);
                      game.character.changeMoveSpeed(3);
                    },
                    child: Text("bottom"))),
          ]))));
}

感想

思いのほか短時間で実装できました。
衝突判定も簡単に実装できたので、アクションやシューティング等を作ってみるのも面白そうですね。

Webサイトやアプリの中にちょっとしたミニゲームとして組み込むとかもありかもしれません。
それではまた!

この記事を書いた人

のりさん

入社年2014年

出身地東京都

業務内容開発

特技または趣味水泳・筋トレ・旅行

のりさんの記事一覧へ

テクログに関する記事一覧