0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterでSpineランタイムを使ってみよう

Posted at

はじめに

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に以下が追記されるかと思います。

pubspec.yamlより抜粋
dependencies:
  flutter:
...
  spine_flutter: ^4.2.26

なお、バージョン情報(4.2.26部分)は執筆時点のものになります。

spine_flutterのバージョンとspineのバージョンは必ず揃える必要があります。

spine-flutterのmajor.minorバージョンとエクスポートを行ったSpineエディターのmajor.minorが一致していることを確認してください。

今回の例ではspine_flutter4.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を修正します。

pubspec.yaml
flutter:

...

  # To add assets to your application, add an assets section, like this:
  assets:
    - assets/
...

アニメーション表示

それでは実際にアニメーションを表示させてみます。

lib/main.dart
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にあたる部分です)

main.dartより抜粋
  WidgetsFlutterBinding.ensureInitialized();
  await initSpineFlutter(enableMemoryDebugging: false);

ここでspine_flutterの初期化を行っています。なお、ドキュメントにも記載がありますが、void main() {/*処理*/}からvoid main() async {/*処理*/}に変更する必要があるようです。

main.dartより抜粋
  // SpineWidgetControllerの初期化
  final controller = SpineWidgetController(onInitialized: (controller) {
    // トラック0にwalkアニメーションをセットし、それをループさせます
    controller.animationState.setAnimationByName(0, "walk", true);
  });

クラスの先頭でSpineWidgetControllerを初期化しています。SpineWidgetControllerはSpineアニメーションを動作させるために使用します。

main.dartより抜粋
      // SpineWidgetの確認
      body: SpineWidget.fromAsset("assets/spineboy.atlas", "assets/spineboy-pro.skel", controller),

fromAssetを使うことでアセットを参照することができます。
第一引数にatlas、第二引数にskel、第三引数にコントローラを指定します。

なお、fromFileでファイルシステムから、fromHttpでURLから対象を指定できるようですね。

動作確認(その1)

実際に動かしてみましょう。次のようになれば成功です。

(実際にはキャラクターが歩いているはずです)

アニメーション切替

ボタンを押下したらアニメーションが切り替わるようにしてみましょう。

main.dartを以下のように変更してください。

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で画面下部にナビゲーションバーを追加します。
RowIconButtonwidgetを並べて、それぞれがonPressedによって押下時に処理ができるようにしています。

ボタン押下時にSpineWidgetControllerAnimationStateでアニメーションを設定しています。

動作確認(その2)

実際に動かしてみましょう。

例によって静止画ですが、これは左から3番目のボタンを押したときのものですね。
キャラクターのアニメーションはボタン押下によってwalkからrunへと切り替わりました。

なお、一番右のボタンはclearTrack()を呼んでいます。
これは setAnimationByNameの第一引数で指定しているトラック番号をクリアする処理です。これにより、アニメーションがその瞬間で静止します。

アニメーションのイベントを検知する。

TrackEntrysetListenerを用いることで、アニメーションのイベントを検知して別の処理をさせることもできます。

main.dartを修正してみましょう。

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

ロガーに関する説明と使い方については本筋とは関係ないので割愛します。

main.dartより抜粋
...
                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)");
                  }
                });
...

setAnimationByNameTrackEntryを返しますので、これを変数に格納します。
そしてsetListenerでコールバック関数を登録します。

例えば今回の例では、AnimationStateが再生中のアニメーションに対してイベントを発行した時に、そのイベントがcompleteであるかそれ以外のときにログ出力をするようにしています。

実際にログを見てみると、次のような感じになります。

操作:walkからrunにした後、jumpに変更
[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アニメーションの知識)なしでは難しいので、今回はここまで。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?