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

# はじめに

2Dアニメーションソフトウェアといえば、日本国内では国産のLive2Dが大人気で、様々なモバイルゲームやVtuber(Virtual You Tuber)のモデルにも利用されています。

https://www.live2d.jp/showcase/

そんな中で昨今、国内外でも人気が高いモバイルゲームアプリ「勝利の女神：NIKKE」が、Spineを利用しているということで、今回はFlutterでSpineを導入する方法を簡単にまとめてみました。

https://ja.esotericsoftware.com/

https://nikke-jp.com/

https://pickups.jp/interviews/spotlight/47243/

:::note info
SHIFT UPの過去作「デスティニーチャイルド」ではLive2Dが使われていたみたいです。
:::

# 前提

- Windows環境 (Mac環境の方はショートカットコマンド部分を適宜読み替えてください）
- Flutterでの開発環境が整っていること
- vscode(Visual Studio Code)が利用できること

https://docs.flutter.dev/get-started/install/windows/desktop?tab=vscode

# Spineランタイムの導入

こちらに日本語での導入方法が書かれています。

https://ja.esotericsoftware.com/spine-flutter

しかしながら、サンプル(example)を確認しながら組み込めるほどのノウハウが筆者にはまだ無いため、最低限、以下ができることを確認できれば良いかと思います。

- アニメーションの表示
- アニメーションの切替

なお、筆者はSpineに関する知識もほとんどないので、spineデータについてはサンプルを利用させていただきました。

### 新規プロジェクトの立ち上げ

vscodeを開き、`Ctrl+Shift+P`でコマンドパレットを開き、`Flutter: New Project`で新規プロジェクトを作ります。

### Spineランタイムのインストール

ターミナルを開き、プロジェクト直下で以下を実行します。

```shell:プロジェクト直下でコマンド実行
$ flutter pub add spine_flutter
```

すると、`pubspec.yaml`に以下が追記されるかと思います。

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

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

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

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

今回の例では`spine_flutter`が`4.2.26`となっているので、`4.2`のバージョンでエクスポートしたファイルを用いるようにしてください。
（試しに4.1のエクスポートファイルを利用したところ、何も表示されませんでした）。
:::

:::note info
WEB用にデプロイするためにはcanvaskitが必要になります。必要に応じて導入しましょう。

```shell
$ flutter build web --web-renderer canvaskit
```
:::

# スケルトンとアニメーションデータの準備

今回は導入手順の内容に従って、アセットを`プロジェクトルート/assets/`に配置します。
`assets`ディレクトリを作成したら、スケルトンとアニメーションのデータを格納しましょう。
（エクスポートファイルのmajor.minorバージョンはspine_flutterのmajor.minorバージョンと揃えてください）

```shell:格納例
.
├── assets
│   └── spineboy-pro.skel
│   └── spineboy.atlas
│   └── spineboy.png
├── lib
│   └── main.dart
├── pubspec.yaml

And More...
```

（jsonは無くても動くようです。~~仕組みの理解はまだ先…~~）

アプリケーションにアセットを追加するため、`pubspec.yaml`を修正します。

```yaml:pubspec.yaml
flutter:

...

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

# アニメーション表示

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

```dart: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`にあたる部分です）

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

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


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

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

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

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

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

### 動作確認（その１）

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

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801017/b95cb0ae-df88-c4e5-151a-d183eadf6b68.png">

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


# アニメーション切替

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

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

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

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

### 動作確認（その２）

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

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801017/96ac4d1d-cc4d-5c5f-a0f2-c644fbe57a4b.png">

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

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

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

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

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

```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を利用しているため、外部パッケージの導入が必要になります。コマンドで入れておきましょう。

```shell:プロジェクト直下でコマンド実行
$ flutter pub add logging
```

https://pub.dev/packages/logging

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


```dart: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)");
                  }
                });
...
```

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

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

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

```shell:操作：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)
```

イベントが発火されるタイミングはこちらに記載があります。

https://ja.esotericsoftware.com/spine-flutter#AnimationState%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88

:::note info
これを使うことで、たとえばキャラクターのアニメーションが終了してから別のwidgetを呼び出す、ということができそうですね。

また、ゲームであれば、①キャラクターが倒された場合にdeadアニメーションを再生し、②アニメーション開始時のstartでキャラクターを操作不能にし、③アニメーション完了時のcompleteでゲームオーバー画面を表示する、という演出もできそうですね。
:::

# まとめ
Spine Flutterランタイムではほかにも様々なことができます。

サンプルの中には物理演算を用いたアニメーション例(`example/lib
/physics.dart`)もありますし、「ボーンのトランスフォームの設定」にもあるようにマウス座標やタッチ座標に合わせてボーンを動かすようにすることもできるようです。

とはいえ、さすがに`Spine`の知識（2Dアニメーションの知識）なしでは難しいので、今回はここまで。