3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flame(Flutter用2Dゲームライブラリ)を使ってみる

Posted at

Flutterとゲーム開発という言葉はあまり結びつかない気がしますが、Flameというライブラリを使って開発することもできます。

Flameを使う利点

Flameを使った場合、以下のような利点があります。

  • ホットリロードによる開発サイクルの短縮
  • pub.devで提供されている数多くのFlutter/Dart用パッケージを利用できる
    • riverpod統合など、Flame用のパッケージも結構な数があります
  • FlutterのUIと共存できる
  • Dart言語はそこそこ使いやすい

以下のような人には積極的にFlameを選ぶ意義はなさそうです。

  • 高フレームレートが必須なゲームを作りたい
  • 3Dゲームを作りたい
  • Flutterを使ったことがない
  • ゲームエディター環境が欲しい

Flameの基本

よくあるFlutterのウィジェットツリーにGameWidgetを組み込みます。
レンダリングのベースはCanvasらしく、問題なく他のウィジェットと共存します。

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

void main() {
  runApp(
    GameWidget(
      game: FlameGame(),
    ),
  );
}

Componentがベース

FlameGame内の世界ではComponentというクラスをベースにして機能を実装します。ComponentはUnityでいうMonoBehaviourのようなものです。

my_component.dart
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
import 'package:flame/extensions.dart';

class Player extends SpriteComponent {
  Player({super.position}) :
    super(size: Vector2.all(200), anchor: Anchor.center);

  @override
  Future<void> onLoad() async {
    sprite = await Sprite.load('player.png');
  }
}

ライフサイクルメソッド

Flameでは用意されたコンポーネントを使うか、適当なコンポーネントを継承し、ライフサイクルメソッドをオーバーライドすることで機能を実装していきます。

Componentのライフサイクルメソッドには下記があります。

  • 初期化:onLoad
  • 削除:onRemove
  • 更新:update
  • 描画:render

何か作ってみる

Flameを使ってキャラクターがマップの中を移動するものを作ろうと思います。

  • プレイヤーキャラを表示して、画面をなぞって移動させることができる
  • 全描画領域を対象にポストエフェクトをかける
  • FlutterのUIを一緒に表示する
  • 地形との当たり判定と描画プライオリティは省く(面倒なので)

このような感じになります。

flame_game.gif

ソースコード

マップ表示のため flame_tiledというライブラリも使います。
全ソースコードを載せていますがキャラクタ画像、背景データなどは割愛しているので、これだけで動作はしません。 キャラクタ画像の仕様についてという項目でどういう画像を用意すれば良いか説明しています。

パッケージインストール

$ flutter pub add flame flame_tiled

main.dart

ゲーム部分以外のUIを実装しています。

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

import 'my_game.dart';

/// GameWidget用のグローバルキー
final gameWidgetKey = GlobalKey<GameWidgetState>();

/// エントリーポイント
void main() {
  runApp(const MyApp());
}

/// アプリ本体
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flame Demo',
      theme: ThemeData(colorScheme: .fromSeed(seedColor: Colors.deepPurple)),
      home: Scaffold(body: SafeArea(child: _Content())),
    );
  }
}

/// アプリのコンテンツ本体
class _Content extends StatelessWidget {
  const _Content();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Flameによるゲーム部分
        AspectRatio(
          aspectRatio: MyGame.aspectRatio,
          child: GameWidget.controlled(
            key: gameWidgetKey,
            gameFactory: MyGame.new,
            overlayBuilderMap: MyGame.overlayBuilders,
          ),
        ),
        // ゲーム以外のUI部分
        Expanded(
          child: ListView.builder(
            itemCount: 50,
            itemBuilder: (context, index) =>
                ListTile(title: Text('Item $index')),
          ),
        ),
      ],
    );
  }
}

my_game.dart

Worldの生成、メニュー表示の制御、バーチャルコントローラ的な操作を実装しています。
バーチャルコントローラについては、Flameには標準でJoystickComponentというものがありますが、個人的に操作感が良くないと思っているので、ここは自分で実装します。

lib/my_game.dart
import 'dart:math' as math;
import 'dart:ui' show FragmentProgram;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'package:flame/post_process.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show LogicalKeyboardKey;

import 'my_world.dart';
import 'post_processes/heat_haze_post_process.dart';

const _pauseMenuName = 'pauseMenu';

/// Gameクラス。入力処理とビューポートの設定を持っているクラス。
class MyGame extends FlameGame with PanDetector, KeyboardEvents {
  MyGame()
    : super(
        world: MyWorld(),
        camera: CameraComponent.withFixedResolution(
          width: _viewResolutionX,
          height: _viewResolutionY,
        ),
      );

  /// ビューポートの解像度とアスペクト比
  static const _viewResolutionX = 256.0;
  static const _viewResolutionY = 144.0;
  static const aspectRatio = _viewResolutionX / _viewResolutionY;

  /// ドラッグ操作による最大移動距離
  static const _maxPanDistance = 40.0;

  /// Overlayウィジェットのビルダー群
  static Map<String, Widget Function(BuildContext, MyGame)>
  get overlayBuilders => {_pauseMenuName: (_, g) => g.createPauseMenuWidget()};

  /// ドラッグ開始位置
  Vector2 _panStart = Vector2.zero();

  /// プレイヤー操作による移動ベクトル
  Vector2 get inputDirection => _inputDirection;
  Vector2 _inputDirection = Vector2.zero();

  @override
  Future<void> onLoad() async {
    // 画像を事前ロードしておく
    await Flame.images.loadAllImages();

    final fragmentProgram = await FragmentProgram.fromAsset(
      'shaders/raster_scroll.frag',
    );

    camera.postProcess = PostProcessGroup(
      postProcesses: [
        HeatHazePostProcess(
          fragmentProgram: fragmentProgram,
          world: world as MyWorld,
        ),
      ],
    );
  }

  /// キーボードイベントをハンドリングする
  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    // Escapeキーでポーズメニューを表示する
    if (keysPressed.contains(LogicalKeyboardKey.escape)) {
      overlays.add(_pauseMenuName);
      pauseEngine();
    }

    return KeyEventResult.handled;
  }

  /// ドラッグ操作をハンドリングして移動ベクトルを計算する
  @override
  void onPanStart(DragStartInfo info) {
    super.onPanStart(info);
    _panStart = info.eventPosition.global;
  }

  @override
  void onPanUpdate(DragUpdateInfo info) {
    super.onPanUpdate(info);

    final pos = info.eventPosition.global;
    final diff = pos - _panStart;

    final dir = diff.normalized();
    final length = diff.length;

    final clampedDistance = math.min(length, _maxPanDistance);
    final clamped = dir * clampedDistance;

    _inputDirection = clamped / _maxPanDistance;
    if (length > _maxPanDistance) {
      _panStart = pos - clamped;
    }
  }

  @override
  void onPanEnd(DragEndInfo info) {
    super.onPanEnd(info);
    _inputDirection = Vector2.zero();
  }

  /// ポーズメニューのOverlay用ウィジェットを作成する
  Widget createPauseMenuWidget() {
    return Center(
      child: Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.all(Radius.circular(8)),
          color: Colors.black54,
        ),
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text(
              'Paused',
              style: TextStyle(color: Colors.white, fontSize: 24),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () {
                overlays.clear();
                resumeEngine();
              },
              child: const Text('Resume'),
            ),
          ],
        ),
      ),
    );
  }
}

my_world.dart

ワールド内のオブジェクト生成、ワールド座標からビューポート座標への変換、侵入可能かどうかの判定を行っています。
CameraComponentに特定のコンポーネント(多くはプレイヤーキャラ)を追従するモードがあります。
しかし、これは画面端を制御するのが少し面倒なため、今回はワールド座標→ビューポート座標変換をここで実装しています。

lib/my_world.dart
import 'package:flame/components.dart';
import 'package:flame_tiled/flame_tiled.dart';

import 'my_game.dart';
import 'player.dart';

/// World。ゲーム内のオブジェクトを管理する。
class MyWorld extends World with HasGameReference<MyGame> {
  late final Player player;
  late final TiledComponent map;

  /// 注視点(ワールド座標)
  Vector2 _lookAt = Vector2.zero();

  @override
  Future<void> onLoad() async {
    super.onLoad();

    /// タイルマップを追加
    map = await TiledComponent.load('map.tmx', Vector2(24, 24));
    add(map);

    /// プレイヤーを追加
    player = Player(worldPosition: Vector2(196, 128));
    add(player);
  }

  @override
  void update(double dt) {
    player.setVelocity(game.inputDirection);

    /// ワールド座標のどの位置がカメラの中心かを計算する
    final look = player.worldPosition;
    final cameraCenter = game.camera.viewport.virtualSize / 2;
    final viewPortTopLeft = look - cameraCenter;

    if (viewPortTopLeft.x < 0) {
      viewPortTopLeft.x = 0;
    }
    if (viewPortTopLeft.y < 0) {
      viewPortTopLeft.y = 0;
    }

    final bottomRight = viewPortTopLeft + game.camera.viewport.virtualSize;
    final worldSize = map.size;

    if (bottomRight.x > worldSize.x) {
      viewPortTopLeft.x = worldSize.x - game.camera.viewport.virtualSize.x;
    }
    if (bottomRight.y > worldSize.y) {
      viewPortTopLeft.y = worldSize.y - game.camera.viewport.virtualSize.y;
    }

    setLookAt(viewPortTopLeft + cameraCenter);

    player.convertToViewport(this, _lookAt);
  }

  /// 指定したワールド座標に移動可能かどうかを判定する
  bool canMoveTo(Vector2 position) {
    final mapSize = map.size;
    if (position.x < 0 ||
        position.y < 0 ||
        position.x > mapSize.x ||
        position.y > mapSize.y) {
      return false;
    }

    // TODO(any): マップ中の障害物との衝突判定を行う
    return true;
  }

  /// ワールド座標の注視点を設定する
  void setLookAt(Vector2 lookAt) {
    _lookAt = lookAt;
    map.position = -lookAt;
  }
}

world_position.dart

ワールド座標を扱うコンポーネント向けの mixin です。
PositionComponent を継承したクラスに、ワールド座標を保持するプロパティと、それをビューポート座標に変換するための処理を実装します。

mixins/world_position.dart
import 'package:flame/components.dart';

mixin WorldPosition on PositionComponent {
  Vector2 worldPosition = Vector2.zero();

  /// ワールド座標をビューポート座標に変換する
  /// TはPositionComponentとWorldPositionを実装している必要がある
  void convertToViewport(
    World world,
    Vector2 lookAt, {
    /// キャラクターの足元からの高さ (空中に飛んでいる場合などのオフセット)
    double height = 0,
    /// ベースプライオリティ
    int priority = 0,
  }) {
    final pos = worldPosition;
    final wpWithHeight = pos - Vector2(0, height);

    position = wpWithHeight - lookAt;
    priority = (pos.y.toInt() * 2) + 10000 + priority;
  }
}

player.dart

プレイヤーが操作するコンポーネントです。アニメーションの切り替え、座標更新を行なっています。
また、Decoratorという仕組みを使って、スプライトの形が反映される影も追加しています。

lib/player.dart
import 'package:flame/components.dart';
import 'package:flame/flame.dart';
import 'package:flame/rendering.dart';
import 'my_world.dart';

import 'mixins/world_position.dart';

enum PlayerAnimationState {
  idleUp,
  idleLeft,
  idleDown,
  idleRight,
  walkUp,
  walkLeft,
  walkDown,
  walkRight,
  runUp,
  runLeft,
  runDown,
  runRight;

  String get textureName => switch (this) {
    PlayerAnimationState.idleUp => 'idle_up.png',
    PlayerAnimationState.idleLeft => 'idle_left.png',
    PlayerAnimationState.idleDown => 'idle_down.png',
    PlayerAnimationState.idleRight => 'idle_right.png',
    PlayerAnimationState.walkUp => 'walk_up.png',
    PlayerAnimationState.walkLeft => 'walk_left.png',
    PlayerAnimationState.walkDown => 'walk_down.png',
    PlayerAnimationState.walkRight => 'walk_right.png',
    PlayerAnimationState.runUp => 'run_up.png',
    PlayerAnimationState.runLeft => 'run_left.png',
    PlayerAnimationState.runDown => 'run_down.png',
    PlayerAnimationState.runRight => 'run_right.png',
  };

  double get animationStepTime => switch (this) {
    PlayerAnimationState.idleUp => 0.15,
    PlayerAnimationState.idleLeft => 0.15,
    PlayerAnimationState.idleDown => 0.15,
    PlayerAnimationState.idleRight => 0.15,
    PlayerAnimationState.walkUp => 0.1,
    PlayerAnimationState.walkLeft => 0.1,
    PlayerAnimationState.walkDown => 0.1,
    PlayerAnimationState.walkRight => 0.1,
    PlayerAnimationState.runUp => 0.07,
    PlayerAnimationState.runLeft => 0.07,
    PlayerAnimationState.runDown => 0.07,
    PlayerAnimationState.runRight => 0.07,
  };
}

enum _ActionState { idle, walk, run }

/// プレイヤーキャラクター
class Player extends SpriteAnimationGroupComponent<PlayerAnimationState>
    with HasWorldReference<MyWorld>, WorldPosition {
  Player({required Vector2 worldPosition})
    : super(
        size: Vector2(_chipWidth, _chipHeight) * _scale,
        anchor: Anchor.center,
      ) {
    this.worldPosition = worldPosition;
  }

  /// スケールと速度
  static const _scale = 0.7;
  static const _maxSpeed = 60.0; // ピクセル/sec

  /// 停止とみなす速度の閾値
  static const _stopThreshold = _maxSpeed * 0.3;

  /// スプライトチップのサイズ
  static const _chipWidth = 96.0;
  static const _chipHeight = 80.0;

  /// 画像に書き込まれている画像のサイズ x スケール。 衝突判定に使用する
  static const _actualWidth = 12.0 * _scale;
  static const _actualHeight = 35.0 * _scale;

  _ActionState _actionState = _ActionState.idle;

  Vector2 _direction = Vector2(0, 1);
  Vector2 _velocity = Vector2.zero();

  /// 初期化
  @override
  Future<void> onLoad() async {
    super.onLoad();

    animations = {
      for (final s in PlayerAnimationState.values) s: await _createList(s),
    };
    current = PlayerAnimationState.idleDown;
    anchor = const Anchor(0.5, 0.75);
    // 影を追加。baseの値はスプライトの画像によって調整する。
    decorator.addLast(Shadow3DDecorator(base: Vector2(0, 44)));
  }

  /// 更新処理
  @override
  void update(double dt) {
    super.update(dt);

    // 移動速度から1フレームあたりの移動距離を計算する
    final speed = _velocity.length * _maxSpeed;
    final speedForFrame = speed * dt;

    final velocityForFrame = _velocity * speedForFrame;
    final clampedVelocity = _clampVelocity(velocityForFrame);

    // アニメーションを更新する
    if (speed < _stopThreshold) {
      _actionState = _ActionState.idle;
      current = _getAnimation(action: _ActionState.idle, direction: _direction);
    } else {
      // 最後の移動方向を保存する
      _direction = _velocity;
      worldPosition += clampedVelocity;

      _actionState = speed > (_maxSpeed * 0.75)
          ? _ActionState.run
          : _ActionState.walk;
      current = _getAnimation(action: _actionState, direction: _velocity);
    }
  }

  /// 衝突判定を行い、移動可能な速度に制限する
  Vector2 _clampVelocity(Vector2 velocity) {
    var newVelocity = velocity;

    /// 水平方向の衝突判定
    final horizontalVelocity = Vector2(velocity.x, 0);
    if (!world.canMoveTo(worldPosition + horizontalVelocity)) {
      newVelocity.x = 0;
    }

    /// 水平方向のエッジ(キャラの左右端)の衝突判定
    final edgeX = Vector2(velocity.x < 0 ? -_actualWidth : _actualWidth, 0);
    if (!world.canMoveTo(worldPosition - horizontalVelocity + edgeX)) {
      newVelocity.x = 0;
    }

    /// 垂直方向の衝突判定
    final verticalVelocity = Vector2(0, velocity.y);
    if (!world.canMoveTo(worldPosition + verticalVelocity)) {
      newVelocity.y = 0;
    }

    /// アンカーが足元なので、下方向のエッジはチェックしない
    if (velocity.y > 0) {
      return newVelocity;
    }

    final edgeY = Vector2(0, -_actualHeight);
    if (!world.canMoveTo(worldPosition + verticalVelocity + edgeY)) {
      newVelocity.y = 0;
    }

    return newVelocity;
  }

  /// アニメーションを作成する
  Future<SpriteAnimation> _createList(PlayerAnimationState state) async {
    final texture = await Flame.images.load(state.textureName);

    final sprites = [
      for (int idx = 0; idx < 8; idx++)
        Sprite(
          texture,
          srcPosition: Vector2(_chipWidth * idx, 0),
          srcSize: Vector2(_chipWidth, _chipHeight),
        ),
    ];

    return SpriteAnimation.spriteList(
      sprites,
      stepTime: state.animationStepTime,
      loop: true,
    );
  }

  /// 移動速度を設定する
  void setVelocity(Vector2 velocity) {
    _velocity = velocity;
  }

  PlayerAnimationState _getAnimation({
    required _ActionState action,
    required Vector2 direction,
  }) {
    final absX = direction.x.abs();
    final absY = direction.y.abs();

    final isHorizontal = absX >= absY;
    final isUp = direction.y < 0;
    final isLeft = direction.x >= 0;

    switch (action) {
      case _ActionState.idle:
        if (isHorizontal) {
          return isLeft
              ? PlayerAnimationState.idleRight
              : PlayerAnimationState.idleLeft;
        } else {
          return isUp
              ? PlayerAnimationState.idleUp
              : PlayerAnimationState.idleDown;
        }
      case _ActionState.walk:
        if (isHorizontal) {
          return isLeft
              ? PlayerAnimationState.walkRight
              : PlayerAnimationState.walkLeft;
        } else {
          return isUp
              ? PlayerAnimationState.walkUp
              : PlayerAnimationState.walkDown;
        }

      case _ActionState.run:
        if (isHorizontal) {
          return isLeft
              ? PlayerAnimationState.runRight
              : PlayerAnimationState.runLeft;
        } else {
          return isUp
              ? PlayerAnimationState.runUp
              : PlayerAnimationState.runDown;
        }
    }
  }
}

ポストプロセス

マップに使ったアセットが砂漠モチーフだったので、陽炎のイメージでポストプロセスを実装します。
やっていることは、Y座標ごとにサンプル位置を左右にずらす、所謂ラスタスクロールというものです。

lib/post_processes/heat_haze_post_process.dart
import 'dart:ui' show FragmentProgram, FragmentShader, Canvas, Offset, Paint;

import 'package:flame/extensions.dart';
import 'package:flame/post_process.dart';

import '../my_world.dart';

class HeatHazePostProcess extends PostProcess {
  HeatHazePostProcess({required this.fragmentProgram, required this.world});

  final FragmentProgram fragmentProgram;
  final MyWorld world;

  late final FragmentShader shader = fragmentProgram.fragmentShader();

  /// 経過時間
  double time = 0;

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

  @override
  void postProcess(Vector2 size, Canvas canvas) {
    /// 現在のワールドの状態をサブツリーとしてレンダリングする
    final preRenderedSubtree = rasterizeSubtree();

    /// シェーダーにパラメータをセットする
    shader.setFloatUniforms((value) {
      value.setVector(size);
      value.setFloat(time);
    });
    shader.setImageSampler(0, preRenderedSubtree);

    /// 描画
    canvas
      ..save()
      ..drawRect(Offset.zero & size.toSize(), Paint()..shader = shader)
      ..restore();
  }
}

shaders/raster_scroll.frag
#include <flutter/runtime_effect.glsl>

out vec4 fragColor;

uniform vec2 u_size;
uniform float f_time;
uniform sampler2D s_canvas;

void main() {
    vec2 uv = FlutterFragCoord().xy / u_size;

    // 現在のY座標(走査線番号として使用)
    float scanline = FlutterFragCoord().y;

    // ラスタースクロールのパラメータ
    float amplitude = 0.005;      // 波の振幅(横方向のずれの大きさ)
    float frequency = 0.05;      // 波の周波数(走査線あたりの波の細かさ)
    float speed = 2.2;           // 波の移動速度

    // 走査線ごとの横方向オフセットを計算
    // sin波を使って滑らかな波打ち効果を生成
    float offset = sin(scanline * frequency + f_time * speed) * amplitude;

    // オフセットを適用した新しいUV座標
    vec2 distortedUV = vec2(uv.x + offset, uv.y);

    // 画面端の処理(リピートまたはクランプ)
    distortedUV.x = clamp(distortedUV.x, 0, 1); // 0-1の範囲にクランプ

    fragColor = texture(s_canvas, distortedUV);
}

問題点

テクスチャ付きポリゴンを敷き詰めて表示し拡大縮小・移動すると、まれにポリゴンのエッジ部分が欠けることがあります。
flameのSpriteまたはflame_tiledの問題か分かりませんが、同様の問題が発生します。
UVの設定やアンチエイリアスの設定で解消することが多いですが、今回はそこまで追求していません。

アセットについて

itch.ioで購入した以下のアセットを利用しました。

タイルマップは以下のツールで作成しています。
https://www.mapeditor.org/

キャラクタ画像の仕様について

キャラクタのアニメーションに使っている画像は、以下のような仕様になっています。

  • 画像全体で768x80ドットになっている
    • 96x80ドットの画像で8つのパターンが横に並んでいる
  • (アイドリング,歩き,走り) x 4方向で、合計12枚の画像になっている

最後に

株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?