🦴

SpineとHTML FormでLoginCritterっぽいモノを作りたかった

2022/08/31に公開

LoginCritterにインスパイアされ、SpineとHTML Formを組み合わせて、似たようなモノを作ってみた。
…が、いろいろと失敗した話です。

作ったもの

Spine x HTML Form with Kiritan Tohoku 画面キャプチャ
画面キャプチャ

URLはコチラです。
> Spine x HTML Form with Kiritan Tohoku

モデルは、みなさんご存じの東北きりたんです。
ユーザーの操作に合わせて、きりたんが下記のアニメーションをします。

  • ユーザーネーム入力欄にフォーカスすると、きりたんがキャレット[1]を目で追いかけます
  • パスワード入力欄にフォーカスすると、きりたんが目をつむります
  • ユーザーネーム・パスワード入力欄からフォーカスを外すと、その入力値に応じて…
    • 入力した値が正しければ、きりたんが「うんうん」とうなずきます。
    • 入力した値に問題あれば、きりたんが「ダメだよ」と首を横に振ります。
  • ユーザーネーム・パスワードともに一回は入力済み かつ どちらにもフォーカスしていない場合…
    • どちらの値も正しければ、きりたんが笑顔になります
    • いずれかの値に問題があれば、きりたんが困り顔になります


ユーザーネーム・パスワードの条件式は下記の通りです。

  • ユーザーネーム: 1文字以上入力していること
  • パスワード: 半角数字と半角英小文字を含んだ8文字以上を入力していること

実装した内容について軽く説明しますと…
Spineで作成したアニメーションは、spine-webglを使って動かしています。
以前に投稿した記事にてspine-webglに触れているので、もし興味があればご覧ください。)

Spineアニメーションとinput要素を連動させる仕組みは単純です。
SpineAppクラス(spineApp.tsで定義しているクラス)の initializeupdateメソッドに、該当の処理を記述しています。

initializeは、Spineエクスポートデータの読み込み完了時に発火するメソッドです。
このメソッドにて、スケルトンやアニメーションのセットアップ等の初期化処理を行います。
今回はinitializeで、input要素のfocusとblurイベント発生時にアニメーションが切り替わるよう設定しています。
一例として、下記がパスワード入力開始時の処理です。

this.form.getInputPasswordEl().addEventListener('focus', () => {
  if (!(this.state instanceof spine.AnimationState)) return
  // 各アニメーションをリセット
  this.state.setEmptyAnimation(5)
  this.state.setEmptyAnimation(6)
  this.animations[1].alpha = this.animations[2].alpha = 0
  // きりたんが目をつむるアニメーション(close_eye)をセット
  this.state.setAnimation(3, 'close_eye', false)
})


updateは、定期的な画面更新時(requestAnimationFrame)に発火します。
このメソッドの中で、ユーザーネーム入力時のキャレットを目で追うアニメーションを更新しています。

Spineにはアルファ値というものがあり、これを利用することでアニメーション間のポーズを合成できます。
今回は、向かって右下を見るアニメーション(look_down_r)と、向かって左下を見るアニメーション(look_down_l)を用意しています。
ユーザーネーム入力時は、入力された文字列の長さを元にして上記の2つのアニメーションのアルファ値を切り替え、キャレットを目で追うアニメーションを実現しています。
1つ注意点として、アルファ値は0〜1の範囲で指定する必要があります。
0〜1の範囲外の数値も指定できますが、ポーズが過剰に合成され、イラストが崩れてしまいます。

下記が該当の処理の抜粋です。

if (this.form.getIsFocusUserNameEl()) {
  // ユーザーネーム入力中の場合
  // this.centerNumは、input[type="text"]の中央あたりの文字数です。とりあえず目測で15にしています。
  // 最大文字数: 30
  const max = this.centerNum * 2
  // 最小文字数: 0
  const min = 0
  // キャレットの位置を計算
  const caretPosition = this.form.getUserNameValue().length / max - min

  // 各アニメーションのアルファ値を更新
  this.animations[1].alpha = caretPosition >= 1 ? 1 : caretPosition
  this.animations[2].alpha = 1 - caretPosition <= 0 ? 0 : 1 - caretPosition
} else {
  // ユーザーネームを入力していない場合
  // アルファ値を0にして、ポーズが合成されないようにする
  this.animations[1].alpha = this.animations[2].alpha = 0
}

失敗した事・学んだ事

ソースコードとSpineアニメーションを作成するにあたり、失敗した事・学んだ事をつらつらと書いていきます。

アニメーションの各イベントにコールバックを設定したい時

「あるアニメーションが完了した後に○○を実行したい」など、アニメーションの各イベントにコールバックを設定したい場合、spine.AnimationStateListenerオブジェクトを生成して、AnimationStateオブジェクトのaddListener関数へ引数として渡して実行すればOKです。

下記がアニメーション完了時(complete)のコールバックの実装例です。

const listener: spine.AnimationStateListener = {
  complete: (entry: spine.TrackEntry) => {
    // entry.trackIndexを元に「どのアニメーションが完了したか」を判定できます
    if (entry.trackIndex === 4) {
      // トラックが4のアニメーションが完了した際、if文内の処理が実行されます。
      …
    }
  }
}
// AnimationStateオブジェクトのaddListener関数へ渡す
animationState.addListener(listener)

アニメーション完了時以外にも、様々なタイミングでイベントを設定できます。
Unityの記事ですが、SpineイベントとAnimationStateのコールバックが参考になります。

アタッチメント単体にキー設定はできない

Spineでは、スロットに対してキーフレームを打ち込み、カラーを変更する事でフェードアニメーションを実現できます。
方法は下記の通りです。

  1. アニメーション設定モードに切り替え
  2. スロットを選択してキーフレームを打ち込む
  3. ツリービュー下部の「カラー」から不透明度を変更する

同じような事がアタッチメント単体にもできるものと勝手に思い込んでいました。
Spineでは、アタッチメント単体にキー設定はできません。
フェードアニメーションを適用したい場合は、アタッチメント分のスロットを用意した上で、スロットに対してキーフレーム打ち込み → カラー設定を行う必要があります。

本当は、口の切り替わりにフェードを適用して、滑らかに切り替えたかったのですが、アタッチメントの管理をミスりまして、1つのスロットの中に口のアタッチメント全てを詰め込んでしまいました。
その上で通常状態の口のアタッチメントや、笑顔の口のアタッチメントの表示・非表示を切り替えています。
…なので、きりたんの口が瞬時に切り替わってしまっているわけですね。

はい。これは単純に私がSpineエディタを使いこなせていないだけです。
精進します。

アタッチメントの表示切り替えはアルファ値によるポーズ合成の対象ではない

「よくよく考えてみれば、そりゃそうだな」って話なのですが。。
Spineアニメーションにおいて、アタッチメントの表示切り替えはアルファ値とは無関係に行われます。
例えば、ポーズA(下位トラック)にポーズB(上位トラック)を合成する時、もしポーズBのスロットCでアタッチメントの表示切り替えを行っていた場合、問答無用でポーズBのスロットCで上書きされます。

Spineエディタ上のプレビューで確認している時はさして気にならなかったのですが、この挙動を知らないままランタイムの実装に入って痛い目を見ました。

アニメーション管理をちゃんとしないと酷い目にあう

今回の一番の反省点です。
Spineランタイムでは、setAnimation関数を使ってアニメーションを適用していくのですが、
適用したアニメーションのアルファ値などを任意のタイミングで更新したい場合、setAnimation関数の返り値を保持して、その都度、更新をかける必要があります。

今回のソースコードでは、initializeメソッドにてsetAnimation関数の返り値をインスタンスプロパティ(private animations)に保持するようにしています。
…が、更新が必要なアニメーションのみ保持する処理となっています。

言い訳としますと、当初はinitializeメソッドにて全アニメーションをanimationsプロパティに詰め込んでおいて、各イベント発火時にアニメーションやアルファ値を切り替える算段でした。
しかしながら、先述の「アタッチメントの表示切り替えはアルファ値によるポーズ合成の対象ではない」に記載した挙動を考慮できていなかったため、setAnimationを実行する度に下位トラックのアニメーションのアタッチメントが上書きされていき、アルファ値を更新してアニメーションを制御していく目論見が完全に外れました。
結果として、各要素のイベント発生時にアニメーション切替がおきるよう処理を記述しまくる という力技で対応する羽目になり、カオスなコードになってしまいました。


正直に白状すると、Spine x TypeScriptの情報ってなかなか少なくて、アニメーションをどう作り・どう管理するのが適切か、全然検討ついていないです。。
(…というか、そもそもSpineエディタの使い方から怪しいレベルです。)

Spineランタイム上でのアニメーション管理に明るい方がいらっしゃいましたら、ぜひアドバイスいただきたいです。(チラッ

まとめ

まぁ、いろいろと失敗はありましたが、WebのUIとSpineアニメーションを連動させるギミックは実装できたので、とりあえずヨシッ!とします。

個人的な目標として、SpineアニメーションとPixijsThree.jsを組み合わせて、リッチなキービジュアルを作成する事を目標に据えています。
せっかく有志の方々によるプラグインがありますので、ぜひ使い倒したいところ。

また、Spine x Webの作品を作ったり、知見が貯まったら、記事にしてみたいなと思います。

脚注
  1. 文字入力時に表示される点滅する縦棒 ↩︎

Discussion