[Spine]Spine-Unityでマリオ的なキャラ動作を実装

Spine-Unityのキャラ動作まとめ

[Unityバージョン] 2019.4.2f1で制作

Spineでキャラクターのアニメーションを作り、Unityでマリオ的な動作を実装する。マリオ的動作の実装に関しては過去記事で一度挑戦したが、坂道で滑るという問題点が発生していたので今回はそのリベンジをする。

完成したもの

とりあえず今回できたものを見せる。坂道問題やその他もろもろの問題が解決している。

準備 Spineでアニメーション制作

今回Spine側で4つのアニメーションを付けた。jumpに関しては体の移動はUnityで行うのでSpineでは体の移動はさせない(一度失敗した)。attackは他のアニメーションとは別のトラックで制御するので下半身は動かさない(それにより走りながら攻撃する場合、足の挙動が変にならない)。

SpineでUnity用に書き出すと3つのファイルが書き出される。
①Jsonファイル(アニメーションやその他情報)
②pngファイル(パーツ画像を並べた物)
③atlasファイル(パーツ情報)
この3つをUnityのAssetフォルダに入れてUnityに認識させる。そこらへんは過去記事を参考に。

UnityでとりあえずRigidbodyとColliderを実装

RigidbodyやColliderなど基本的なことは過去記事と同じ様に実装する。地面とキャラにCollider2D、加えてキャラにRigidbody2Dを付けるとパラメーターをいじらなくてもとりあえずキャラは地面に立つ。

Rigidbodyは以下のように設定する。空気抵抗0重力0。Physical Material 2Dを新規作成しFrictionを0にしてアタッチすると摩擦が0になり壁でつっかえることはなくなるが、坂道でずり下がる現象が起きる。その対処法は後述する。

スクリプトでやっていること


Unityでマリオ的動作をさせるためにスクリプトで行っていることをまとめてみる

・基本update関数でボタン操作を検知をするだけ
・ボタン操作によりキャラのx軸y軸の移動&キャラのアニメーション切替
・ジャンプは記述が多くなったのでメソッド化
・着地判定&壁判定&天井判定はPhysics2D.Linecast
・着地時は重力0&坂の角度を検知して坂に沿う移動により床滑りをなくす

以上。割と単純だ。

ジャンプのカーブはAnimationCurve

前にも記事にしたけどAnimationCurveでジャンプ放物線をUnityのエディターから視覚的に作成できるおかげで作業がすごく楽になった。スクリプトで計算式を作らなくて済むので非常に便利だ。

velY = curve.Evaluate(jumpTime) * -grav;

-重力値をカーブにかけているのは、ジャンプ終了後に下に下がるスピードに差が出ない様にするためだ。そのためカーブのy軸は-1で終わるようにする。そうすれば-1 * -grav = gravとなる。

ちなみに天井に当たったらアニメーションカーブを変更している。0スタートの緩いカーブなのがミソだ。速すぎず遅すぎずジャンプを終了させる。天井はLinecastで検知している。

ジャンプの着地直前の次ジャンプ検知

ジャンプ中に2回目のジャンプをしようとした時に着地判定の関係でボタン検知の判定が厳しかったので、実際の着地用LinecastのGroundとは別に連続ジャンプ判定用LinecastのGround2を別途用意した。Ground2はLineを長めにして接地判定を緩くしている。Groundを伸ばせば済む話だと思ったが、そうすると着地時キャラが宙に浮いてしまう。

RaycastHit2D Ground = Physics2D.Linecast(pos + transform.up * 1f, pos - transform.up * 0.2f, gro);
RaycastHit2D Ground2 = Physics2D.Linecast(pos + transform.up * 1f, pos - transform.up * 3f, gro);

次ジャンプ検知用boolのsecondJumpを用意し、Ground2が地面に接地している状態でジャンプキーを押したらオンにする。secondJumpがオンでかつGroundがオンになったらジャンプボタンを押さずともジャンプするよう設定する。

if (Input.GetKeyDown(KeyCode.Space) && Ground2)
{
    secondJump = true;
}

滑らずに坂道に沿って移動する

摩擦を0かつ重力ONで坂に着地するとどんな角度でも下に下がってしまう問題解決のために、着地時は重力を0にした。しかしそうすると坂のくだりで挙動がおかしくなる。重力0なので平行に移動するが、下り坂に対して平行移動すると接地検知から外れるので急に下がったりして階段状にガクガクな移動になってしまう。

下り坂の移動がガクガクしている。接地判定がonになったりoffになったりするためだ。

その解決策がズバリ坂に沿って移動…つまり坂の傾斜角を取得して、横移動に対しての縦移動値を取得する。これには三角関数(というか三角比)とかが重要になってくるのだが、要点だけ押さえておく。

直角三角形において一つの角θを決めると三角形はすべて相似になる

このことから

直角三角形において角度と1辺の長さが判れば残り2辺も判る。

なので角度から縦移動値(sin)を求めるためのMathfも存在する。とりあえず角度をMathf.Deg2Radでラジアン値に変換し、Mathf.SinでSinに変換する。でそれを横移動値でかければ縦移動値がわかる。

float _Rotation = Ground.transform.localEulerAngles.z;//地面の傾斜角を取得
float rad = _Rotation * Mathf.Deg2Rad;//角度をラジアンに変換
float sin = Mathf.Sin(rad);//ラジアンをSinに変換

角度からラジアンを計算する方法は参考サイトもしくは参考サイトの図を見ると解りやすいと思う。

参考サイトのように角度(あるいはラジアン)からsin cos tanを求めるのは複雑&力技っぽいので自力で記述する必要はなさそう。

急斜面でガクガクの対処

坂対策を行ったが、急すぎる坂に対しては検知用LinecastのGroundが接触できないので角度が取得できずガクガクな動きをしてしまう。なので対策として壁検知用Linecastのwallを作成した。

wall = Physics2D.Linecast(pos + (transform.up * 1f), pos + (transform.up * 1f) + transform.right * 1.2f * direction, gro);

これでwallが壁に触れたら横移動を0にすればガクガクが改善される。

解りづらいが体と垂直な白線が出ている。これが検知用Linecastのwallだ。

キャラの方向転換

左右移動でキャラの向きも変えるのだが、Scaleで変えるかflipXで変えるかで一瞬迷う。参考サイトのようにネガティブスケールになるとあまりよろしくないという意見もある。だが、Unityのデバッグで以下のような文言が出る。かつフォーラムで公式もScaleを推奨している。なので今回はScaleで反転させる。

Skeleton.FlipX’は廃止されました: ‘代わりにScaleXを使用してください。 FlipXは、ScaleXが負のときです。

if(skeleton.ScaleX > 0)
{
    skeleton.ScaleX = -1;
}

UpdateとFixedUpdate

UpdateとFixedUpdateの2種類あるので注意。Update関数は毎フレーム正確に呼ばれるが、1秒間に呼ばれる回数が一定ではない。FixedUpdateは1秒間に呼ばれる回数が一定になる。パソコンの処理能力などでUpdate関数は1秒間に呼ばれる回数が変化するため、両者は使い分けが必要になる。Inputなどの検知系はUpdateで毎フレームチェックしないといけない。逆にRigidbodyやtransform代入系はFixedUpdateで一定した代入を行わないとパソコン性能で差が出てしまう。参考サイト

Spine-Unityランタイムでアニメーションを切替

Spine-Unityランタイムのライブラリを使ってアニメーションを制御する。ライブラリの構造は複雑で要素も多いが、使うのはアニメーション部分だけなので、あまり気にしない。

大まかな概要は公式サイトを参考にする。だいたい3つのクラスでアニメーションを制御する。アニメーション適用関連はAnimationStateクラス。イベント管理はAnimationStateListenerクラス。トラック管理関連はTrackEntryクラス。

クラスのメソッド一覧は参考サイトを翻訳して参考にすべし。

とりあえずアニメーションを再生させるにはAnimationStateクラスのSetAnimationを使う。それをTrackEntry型の変数に入れることにより、TrackEntryクラスにある再生速度調整のTimeScaleメソッドとかが使える。

TrackEntry trackEntry = charaState.SetAnimation(0, "stay", true);
trackEntry.TimeScale = 1f;

第1引数の0はトラック番号だ。今回は0トラックにて待機&走行&ジャンプのアニメーションを切り替える。トラックを分けない理由は3つのアニメーションはブレンドさせないから。逆に攻撃アニメーションは他3つのアニメーションとブレンドさせたいのでトラックを別にしている。

if (Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.RightArrow))
{
    isMove = true;
    if(!isJump)
    {
    trackEntry = charaState.SetAnimation(0, "run", true);
    trackEntry.TimeScale = 5f;                
    }
}

上のスクリプトでは左右ボタンキー検知の部分からの抜粋。注意なのはGetKeyではなくGetKeyDownで1回だけ呼び出すようにすることだ。GetKeyだと押してる間ずっと呼ばれ続けてアニメーションが停止して見える。

else if(Input.GetKeyDown(KeyCode.Space) &&  Ground || secondJump && Ground)
{
    trackEntry = charaState.SetEmptyAnimation(0, 0);
    trackEntry = charaState.AddAnimation(0, "jump", false,0);
    trackEntry.TimeScale = 1f;
    isJump = true;
    if(secondJump)
    {
        secondJump = false;
    }
}

上のスクリプトはジャンプアニメーション切替の部分からの抜粋。ジャンプに関しては即座にアニメーションを切り替えたいので一度SetEmptyAnimationをかましてからAddAnimationでアニメーションを入れている

if(Input.GetKeyDown(KeyCode.UpArrow))
{
    trackEntry1 = charaState.SetEmptyAnimation(1, 0);
    trackEntry1 = charaState.AddAnimation(1, "attack", false,0);
    charaState.Complete += OnCompleteSpineAnim;
}
void OnCompleteSpineAnim(TrackEntry trackEntry)
{
    if(trackEntry == trackEntry1)
    {
        charaState.Complete -= OnCompleteSpineAnim;
        charaState.AddEmptyAnimation(1, 0.05f, 0);
    }
}

上のスクリプトは攻撃ボタン検知の部分からの抜粋。攻撃アニメーションはトラック番号を別にしている。攻撃に関してはアニメーション終了時に素に戻りたいので、AnimationStateListenerクラスのCompleteを使って終了を検知する。charaState.Complete += OnCompleteSpineAnim;とするとアニメーション終了時にメソッドを呼び出せるメソッドの引数はトラック番号なのでif条件で該当するトラック番号の終了を検知できる。

トラック1のアニメーションは終了したら空にするのだが、ここでclearTrackを入れると即座に切り替わるので見栄えがよくない。そのためAddEmptyAnimationを入れて多少の補間を入れるとよい。

スクリプトまとめ

最後にスクリプトを添付しておく。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Spine;
using Spine.Unity;

public class hummerboy_test : MonoBehaviour
{
    public AnimationCurve curve;//カーブ取得
    public AnimationCurve curve2;//カーブ取得2

    public LayerMask gro;//地面レイヤー
    RaycastHit2D Ground;//当たったレイヤー情報
    RaycastHit2D Ground2;//当たったレイヤー情報2
    RaycastHit2D ceiling;//当たったレイヤー情報3
    RaycastHit2D wall;//当たったレイヤー情報4

    public Rigidbody2D rigid;//Rigidbody
    Vector3 pos;//Transform

    public SkeletonAnimation charaAnim;//spineのSkeletonAnimation
    Spine.AnimationState charaState;//spineのAnimationState
    Skeleton skeleton;//spineのskeleton
    TrackEntry trackEntry;//spineのトラック
    TrackEntry trackEntry1;//spineのトラック1
    float _Rotation;//地面の傾斜角

    const float grav = -1.5f;//重力
    const float move = 0.4f;//移動力

    float velX = 0;//X軸速度 初期値
    float velY = grav;//Y軸速度 初期値
    float Hori;//Horizontal値
    bool isJump;//ジャンプ中か
    bool isMove;//横移動中か
    int direction;//向き
    float sin;//坂道の高さ
    bool secondJump;//連続ジャンプするか
    bool isCeiling;//天井に当たったか
    float jumpTime;//跳躍時間

    void Start()
    {
        //Spineアニメーション呼び出し
        charaState = charaAnim.state;
        skeleton = charaAnim.Skeleton;
        trackEntry = charaState.SetAnimation(0, "stay", true);
    }

    //Input検知系はUpdate,Rigidbodyやtransform代入系はFixedUpdate
    void Update()
    {
        pos = transform.position;
        //着地判定ラインを生成 Groundは実際の着地判定用 Ground2は連続ジャンプ検知用
        Ground = Physics2D.Linecast(pos + transform.up * 1f, pos - transform.up * 0.2f, gro);
        Debug.DrawLine(pos + transform.up * 1f, pos - transform.up * 0.2f, Color.red);
        Ground2 = Physics2D.Linecast(pos + transform.up * 1f, pos - transform.up * 3f, gro);
        Debug.DrawLine(pos + transform.up * 1f, pos - transform.up * 3f, Color.blue);
        ceiling = Physics2D.Linecast(pos + transform.up * 1f, pos - transform.up * -2.6f, gro);
        Debug.DrawLine(pos + transform.up * 1f, pos - transform.up * -2.6f, Color.green);
        wall = Physics2D.Linecast(pos + (transform.up * 1f), pos + (transform.up * 1f) + transform.right * 1.2f * direction, gro);
        Debug.DrawLine(pos + (transform.up * 1f), pos + (transform.up * 1f) + transform.right * 1.2f * direction, Color.white);

        //地面の傾斜角から斜め移動値取得
        if(Ground)
        {
          _Rotation = Ground.transform.localEulerAngles.z;//地面の傾斜角を取得
          float rad = _Rotation * Mathf.Deg2Rad;//角度をラジアンに変換
          sin = Mathf.Sin(rad);//ラジアンをSinに変換
        }

        //左右キー検知 移動値取得 & 方向転換
        Hori = Input.GetAxis("Horizontal");

        if (Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.RightArrow))
        {
            isMove = true;
            if(!isJump)
            {
            trackEntry = charaState.SetAnimation(0, "run", true);
            trackEntry.TimeScale = 5f;                
            }
        }
        else if (Input.GetKey(KeyCode.LeftArrow))
        {
            velX = move * Hori;
            if(skeleton.ScaleX > 0)
            {
                skeleton.ScaleX = -1;
            }
            direction = -1;
        }
        else if (Input.GetKey(KeyCode.RightArrow))
        {
            velX = move * Hori;
            if(skeleton.ScaleX < 0)
            {
                skeleton.ScaleX = 1;
            }
            direction = 1;
        }
        else if (Input.GetKeyUp(KeyCode.LeftArrow) || Input.GetKeyUp(KeyCode.RightArrow))
        {
            if(!isJump)
            {
            trackEntry = charaState.SetAnimation(0, "stay", true);
            trackEntry.TimeScale = 1f;              
            }            
        }
        else
        {
            isMove = false;
            velX = 0;
        }
        if(wall)
        {
            velX = 0;
        }


        //ジャンプキー検知&着地判定
        if (isJump)
        {
            Jumping();
        }
        else if(Input.GetKeyDown(KeyCode.Space) &&  Ground || secondJump && Ground)
        {
            trackEntry = charaState.SetEmptyAnimation(0, 0);
            trackEntry = charaState.AddAnimation(0, "jump", false,0);
            trackEntry.TimeScale = 1f;
            isJump = true;
            if(secondJump)
            {
                secondJump = false;
            }
        }
        if(!isJump && Ground)
        {
           if(isMove)
          {
            velY = sin * velX;
          }
          else
          {
            velY = 0;
          }
        }
        else if(!isJump && !Ground)
        {
            velY = grav;
        }

        if(Input.GetKeyDown(KeyCode.UpArrow))
        {
            trackEntry1 = charaState.SetEmptyAnimation(1, 0);
            trackEntry1 = charaState.AddAnimation(1, "attack", false,0);
            charaState.Complete += OnCompleteSpineAnim;
        }
    }
    //Input検知系はUpdate,Rigidbodyやtransform代入系はFixedUpdate
    void FixedUpdate()
    {
        //移動 velX velYをMovePositionに代入
        rigid.MovePosition(pos + new Vector3(velX,velY,0));
    }

    //ジャンプ関数  
    void Jumping()
    {
        jumpTime += Time.deltaTime;
        if (Input.GetKeyDown(KeyCode.Space) && Ground2)
        {
            secondJump = true;
        }
        if(ceiling)
        {
            isCeiling = true;   
        }
        if (jumpTime >= 0.05f && Ground)
        {
            jumpTime = 0;
            isJump = false;
            isCeiling = false;
            if(isMove)
            {
                trackEntry = charaState.SetEmptyAnimation(0, 0);
                trackEntry = charaState.AddAnimation(0, "run", true,0);
                trackEntry.TimeScale = 5f;
            }
            else
            {
                trackEntry = charaState.SetEmptyAnimation(0, 0);
                trackEntry = charaState.AddAnimation(0, "stay", true,0);
                trackEntry.TimeScale = 1f;
            }
        }
        else
        {
            if(isCeiling)
            {
                velY = curve2.Evaluate(jumpTime) * -grav;
            }
            else
            {
                velY = curve.Evaluate(jumpTime) * -grav;
            }
        }
    }

    void OnCompleteSpineAnim(TrackEntry trackEntry)
    {
        if(trackEntry == trackEntry1)
        {
            charaState.Complete -= OnCompleteSpineAnim;
            charaState.AddEmptyAnimation(1, 0.05f, 0);
        }
    }
}

コメント