2021.12.17
STAFF BLOG
スタッフブログ
TECHNICAL
テクログ
こんにちは!
のりさんです。
今回は最近気になっている
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サイトやアプリの中にちょっとしたミニゲームとして組み込むとかもありかもしれません。
それではまた!