はじめに
2Dアニメーションソフトウェアといえば、日本国内では国産のLive2Dが大人気で、様々なモバイルゲームやVtuber(Virtual You Tuber)のモデルにも利用されています。
そんな中で昨今、国内外でも人気が高いモバイルゲームアプリ「勝利の女神:NIKKE」が、Spineを利用しているということで、今回はFlutterでSpineを導入する方法を簡単にまとめてみました。
SHIFT UPの過去作「デスティニーチャイルド」ではLive2Dが使われていたみたいです。
前提
- Windows環境 (Mac環境の方はショートカットコマンド部分を適宜読み替えてください)
- Flutterでの開発環境が整っていること
- vscode(Visual Studio Code)が利用できること
Spineランタイムの導入
こちらに日本語での導入方法が書かれています。
しかしながら、サンプル(example)を確認しながら組み込めるほどのノウハウが筆者にはまだ無いため、最低限、以下ができることを確認できれば良いかと思います。
- アニメーションの表示
- アニメーションの切替
なお、筆者はSpineに関する知識もほとんどないので、spineデータについてはサンプルを利用させていただきました。
新規プロジェクトの立ち上げ
vscodeを開き、Ctrl+Shift+P
でコマンドパレットを開き、Flutter: New Project
で新規プロジェクトを作ります。
Spineランタイムのインストール
ターミナルを開き、プロジェクト直下で以下を実行します。
$ flutter pub add spine_flutter
すると、pubspec.yaml
に以下が追記されるかと思います。
dependencies:
flutter:
...
spine_flutter: ^4.2.26
なお、バージョン情報(4.2.26部分)は執筆時点のものになります。
spine_flutterのバージョンとspineのバージョンは必ず揃える必要があります。
spine-flutterのmajor.minorバージョンとエクスポートを行ったSpineエディターのmajor.minorが一致していることを確認してください。
今回の例ではspine_flutter
が4.2.26
となっているので、4.2
のバージョンでエクスポートしたファイルを用いるようにしてください。
(試しに4.1のエクスポートファイルを利用したところ、何も表示されませんでした)。
WEB用にデプロイするためにはcanvaskitが必要になります。必要に応じて導入しましょう。
$ flutter build web --web-renderer canvaskit
スケルトンとアニメーションデータの準備
今回は導入手順の内容に従って、アセットをプロジェクトルート/assets/
に配置します。
assets
ディレクトリを作成したら、スケルトンとアニメーションのデータを格納しましょう。
(エクスポートファイルのmajor.minorバージョンはspine_flutterのmajor.minorバージョンと揃えてください)
.
├── assets
│ └── spineboy-pro.skel
│ └── spineboy.atlas
│ └── spineboy.png
├── lib
│ └── main.dart
├── pubspec.yaml
And More...
(jsonは無くても動くようです。仕組みの理解はまだ先…)
アプリケーションにアセットを追加するため、pubspec.yaml
を修正します。
flutter:
...
# To add assets to your application, add an assets section, like this:
assets:
- assets/
...
アニメーション表示
それでは実際にアニメーションを表示させてみます。
import 'package:flutter/material.dart';
import 'package:spine_flutter/spine_flutter.dart';
void main() async {
// spine_flutterの初期化
WidgetsFlutterBinding.ensureInitialized();
await initSpineFlutter(enableMemoryDebugging: false);
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({super.key});
// SpineWidgetControllerの初期化
final controller = SpineWidgetController(onInitialized: (controller) {
// トラック0にwalkアニメーションをセットし、それをループさせます
controller.animationState.setAnimationByName(0, "walk", true);
});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home : spineDemo(),
);
}
Widget spineDemo() {
return Scaffold(
appBar: AppBar(title: const Text('Simple Animation')),
// SpineWidgetの確認
body: SpineWidget.fromAsset("assets/spineboy.atlas", "assets/spineboy-pro.skel", controller),
);
}
}
基本的にはドキュメントのとおりです。
(サンプルだとspine-flutter/example/lib/simple_animation.dart
にあたる部分です)
WidgetsFlutterBinding.ensureInitialized();
await initSpineFlutter(enableMemoryDebugging: false);
ここでspine_flutter
の初期化を行っています。なお、ドキュメントにも記載がありますが、void main() {/*処理*/}
からvoid main() async {/*処理*/}
に変更する必要があるようです。
// SpineWidgetControllerの初期化
final controller = SpineWidgetController(onInitialized: (controller) {
// トラック0にwalkアニメーションをセットし、それをループさせます
controller.animationState.setAnimationByName(0, "walk", true);
});
クラスの先頭でSpineWidgetController
を初期化しています。SpineWidgetController
はSpineアニメーションを動作させるために使用します。
// SpineWidgetの確認
body: SpineWidget.fromAsset("assets/spineboy.atlas", "assets/spineboy-pro.skel", controller),
fromAssetを使うことでアセットを参照することができます。
第一引数にatlas、第二引数にskel、第三引数にコントローラを指定します。
なお、fromFile
でファイルシステムから、fromHttp
でURLから対象を指定できるようですね。
動作確認(その1)
実際に動かしてみましょう。次のようになれば成功です。

(実際にはキャラクターが歩いているはずです)
アニメーション切替
ボタンを押下したらアニメーションが切り替わるようにしてみましょう。
main.dart
を以下のように変更してください。
...
body: SpineWidget.fromAsset("assets/spineboy.atlas", "assets/spineboy-pro.skel", controller),
// 追加: START 画面下部にボタンを追加
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
IconButton(
icon: const Icon(Icons.play_arrow),
onPressed: () {
// アニメーションの適用
controller.animationState.setAnimationByName(0, "walk", true);
},
),
IconButton(
icon: const Icon(Icons.arrow_upward),
onPressed: () {
// アニメーションの適用
controller.animationState.setAnimationByName(0, "jump", true);
},
),
IconButton(
icon: const Icon(Icons.run_circle_outlined),
onPressed: () {
// アニメーションの適用
controller.animationState.setAnimationByName(0, "run", true);
},
),
IconButton(
icon: const Icon(Icons.horizontal_rule_sharp),
onPressed: () {
// アニメーションの適用
controller.animationState.setAnimationByName(0, "shoot", true);
},
),
IconButton(
icon: const Icon(Icons.stop),
onPressed: () {
// アニメーションの適用
controller.animationState.clearTrack(0);
},
),
],
),
)
// 追加: END
...
bottomNavigationBar
で画面下部にナビゲーションバーを追加します。
Row
でIconButton
widgetを並べて、それぞれがonPressed
によって押下時に処理ができるようにしています。
ボタン押下時にSpineWidgetController
のAnimationState
でアニメーションを設定しています。
動作確認(その2)
実際に動かしてみましょう。

例によって静止画ですが、これは左から3番目のボタンを押したときのものですね。
キャラクターのアニメーションはボタン押下によってwalk
からrun
へと切り替わりました。
なお、一番右のボタンはclearTrack()
を呼んでいます。
これは setAnimationByName
の第一引数で指定しているトラック番号をクリアする処理です。これにより、アニメーションがその瞬間で静止します。
アニメーションのイベントを検知する。
TrackEntry
のsetListener
を用いることで、アニメーションのイベントを検知して別の処理をさせることもできます。
main.dart
を修正してみましょう。
...
// 追加:START
import 'package:logging/logging.dart';
import 'dart:developer' as developer;
// 追加:END
import 'package:spine_flutter/spine_flutter.dart';
...
// spine_flutterの初期化
WidgetsFlutterBinding.ensureInitialized();
await initSpineFlutter(enableMemoryDebugging: false);
// 追加:START ログ出力内容をカスタマイズ
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
developer.log(
'${record.level.name}: ${record.time}: ${record.message}',
name: record.loggerName,
time: record.time,
// sequenceNumber: record.sequenceNumber,
level: record.level.value,
// zone: record.zone,
error: record.error,
stackTrace: record.stackTrace,
);
});
// 追加:END
...
final controller = SpineWidgetController(onInitialized: (controller) {
// トラック0にwalkアニメーションをセットし、それをループさせます
controller.animationState.setAnimationByName(0, "walk", true);
});
final _logger = Logger('LOGGER_SAMPLE'); // 追加:ロガーの初期化
...
IconButton(
icon: const Icon(Icons.horizontal_rule_sharp),
onPressed: () {
// 変更及び追加: START AnimationStateイベント検出確認
final entry = controller.animationState.setAnimationByName(0, "shoot", true); // 戻り値TrackEntryを受け取る
entry.setListener((type, trackEntry, event) {
// complete: このアニメーションが1ループ完了(complete)するごとに発生
if (type == EventType.complete) {
// ログ出力
_logger.info("Complete! ($type) ");
} else {
_logger.info("Not Complete ($type) ");
}
});
// 変更及び追加: END
},
),
...
shoot
のアニメーションを設定する際にsetListener
を追加してみました。
これはshoot
アニメーションに何らかのイベントが発火されたタイミングでログ出力を行う処理ですね。
ここではloggerを利用しているため、外部パッケージの導入が必要になります。コマンドで入れておきましょう。
$ flutter pub add logging
ロガーに関する説明と使い方については本筋とは関係ないので割愛します。
...
final entry = controller.animationState.setAnimationByName(0, "shoot", true); // 戻り値TrackEntryを受け取る
entry.setListener((type, trackEntry, event) {
// complete: このアニメーションが1ループ完了(complete)するごとに発生
if (type == EventType.complete) {
// ログ出力
_logger.info("Complete! ($type)");
} else {
_logger.info("Not Complete ($type)");
}
});
...
setAnimationByName
はTrackEntry
を返しますので、これを変数に格納します。
そしてsetListener
でコールバック関数を登録します。
例えば今回の例では、AnimationStateが再生中のアニメーションに対してイベントを発行した時に、そのイベントがcomplete
であるかそれ以外のときにログ出力をするようにしています。
実際にログを見てみると、次のような感じになります。
[LOGGER_SAMPLE] INFO: 2024-06-08 23:24:33.009: Not Complete (EventType.start)
[LOGGER_SAMPLE] INFO: 2024-06-08 23:24:33.656: Complete! (EventType.complete)
[LOGGER_SAMPLE] INFO: 2024-06-08 23:24:36.023: Not Complete (EventType.interrupt)
[LOGGER_SAMPLE] INFO: 2024-06-08 23:24:36.039: Not Complete (EventType.end)
[LOGGER_SAMPLE] INFO: 2024-06-08 23:24:36.040: Not Complete (EventType.dispose)
イベントが発火されるタイミングはこちらに記載があります。
これを使うことで、たとえばキャラクターのアニメーションが終了してから別のwidgetを呼び出す、ということができそうですね。
また、ゲームであれば、①キャラクターが倒された場合にdeadアニメーションを再生し、②アニメーション開始時のstartでキャラクターを操作不能にし、③アニメーション完了時のcompleteでゲームオーバー画面を表示する、という演出もできそうですね。
まとめ
Spine Flutterランタイムではほかにも様々なことができます。
サンプルの中には物理演算を用いたアニメーション例(example/lib /physics.dart
)もありますし、「ボーンのトランスフォームの設定」にもあるようにマウス座標やタッチ座標に合わせてボーンを動かすようにすることもできるようです。
とはいえ、さすがにSpine
の知識(2Dアニメーションの知識)なしでは難しいので、今回はここまで。