Flutterとゲーム開発という言葉はあまり結びつかない気がしますが、Flameというライブラリを使って開発することもできます。
Flameを使う利点
Flameを使った場合、以下のような利点があります。
- ホットリロードによる開発サイクルの短縮
- pub.devで提供されている数多くのFlutter/Dart用パッケージを利用できる
- riverpod統合など、Flame用のパッケージも結構な数があります
- FlutterのUIと共存できる
- Dart言語はそこそこ使いやすい
以下のような人には積極的にFlameを選ぶ意義はなさそうです。
- 高フレームレートが必須なゲームを作りたい
- 3Dゲームを作りたい
- Flutterを使ったことがない
- ゲームエディター環境が欲しい
Flameの基本
よくあるFlutterのウィジェットツリーにGameWidgetを組み込みます。
レンダリングのベースはCanvasらしく、問題なく他のウィジェットと共存します。
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
runApp(
GameWidget(
game: FlameGame(),
),
);
}
Componentがベース
FlameGame内の世界ではComponentというクラスをベースにして機能を実装します。ComponentはUnityでいうMonoBehaviourのようなものです。
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_tiledというライブラリも使います。
全ソースコードを載せていますがキャラクタ画像、背景データなどは割愛しているので、これだけで動作はしません。 キャラクタ画像の仕様についてという項目でどういう画像を用意すれば良いか説明しています。
パッケージインストール
$ flutter pub add flame flame_tiled
main.dart
ゲーム部分以外のUIを実装しています。
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というものがありますが、個人的に操作感が良くないと思っているので、ここは自分で実装します。
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に特定のコンポーネント(多くはプレイヤーキャラ)を追従するモードがあります。
しかし、これは画面端を制御するのが少し面倒なため、今回はワールド座標→ビューポート座標変換をここで実装しています。
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 を継承したクラスに、ワールド座標を保持するプロパティと、それをビューポート座標に変換するための処理を実装します。
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という仕組みを使って、スプライトの形が反映される影も追加しています。
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座標ごとにサンプル位置を左右にずらす、所謂ラスタスクロールというものです。
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();
}
}
#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を使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。
