はじめに
Unity 3DでUpdateとFixedUpdate間のタイミングを考慮したジャンプ(基礎)
Unity 3Dでアニメーションを考慮したジャンプ その1(単アニメーション)
の続きです。
上記記事で設定したジャンプアニメーションをベースに改良していきます。
想定環境
- Windows 10
- Unity 2018.2.14f1
- プレイヤーキャラクターに Unity Technologies Japanオリジナルキャラクター Unityちゃん を利用
- 素のUnityで実行できるコードになっています
アニメーション分割
前回記事 にて確認した通り、単アニメーションでのジャンプはジャンプの挙動が様々に変化するゲームには向いていません。
なので、ジャンプの内容が変わってもきちんと対応出来るように変更します。
アニメーションを分割し、アニメーション間の遷移をAnimatorとコードを使って行うことで、遷移のタイミングをずらしたり、遷移先のアニメーションを動的に変更できるようになります。
どこからするの
アニメーションを持っているfbxファイルを選択するとInspectorに表示される、Animation
から分割することが出来ます。
Clipsの欄の右下にある+ボタンを押すことで、アニメーションを追加することが出来ます。
どうやってするの
増やしたアニメーションは、そのままではJUMP00の丸コピーなので、まだ分割されていません。
これを、以下4つのモーションに分割します。
- ジャンプ前
- 上昇中
- 下降中
- 着地
Inspectorの下にアニメーションを再生できる表示がありますので、これを使ってアニメーションを再生しながら、それぞれの区切りとなる時間で各モーションを分割していきます。
ジャンプ前のモーションの時間を指定したところです。
どんなモーションなのか自分の分かりやすい名前に変更し、Lengthのところで分割するモーションの時間を指定します。
また、Root Transform Position (Y) を feet (足元)に設定しています。
ここはアニメーション毎に最適な原点位置が違うので、モーションとして最も自然な形になるように、またコライダーの位置ともズレが起きないように調整が必要です。
これをあと3回繰り返します。
上昇中からジャンプ頂点を越えて下降中に移行するモーションは、下降中モーションに含めたほうが自然な再生が出来ると思います。
なので、上昇中モーションはジャンプ頂点に入る少し前のところまでで切ります。
同様の理由で、下降中モーションも着地に入る手前のところで切ります。
これでアニメーションが分割出来ました。
Inspectorをスクロールして一番右下のApplyボタンを押すと、分割したアニメーションがfbxに反映されます。
Animator を更新
分割したアニメーションの制御のために、AnimatorにもStateを追加します。
パラメータはすべてBoolで追加し、Transitionでは同名のパラメータがONになったら遷移するように設定します。
溜めて大ジャンプ
今回、一連のジャンプ記事で筆者がやりたかった内容がこれです。
ジャンプボタンを押している間ジャンプ力を溜めて、離したときに溜まっているジャンプ力でジャンプの強さを変更します。
溜めたジャンプ力によって上昇中と下降中の状態の長さが変わるので、アニメーションの遷移をコードで制御します。
仕様の再検討
コードでアニメーションの遷移をするので、遷移条件の判定と遷移処理を追加します。
- ジャンプキー入力開始(key down)ではジャンプ溜め開始
- ジャンプキーが離されたら(key up)ジャンプ開始
- ジャンプキー入力開始(key down)でジャンプ前モーション
- ジャンプキーが離されたら(key up)上昇中モーション
- ジャンプの頂点を過ぎて下降を開始したら下降中モーション
- 着地したら着地モーション
を実装します。
一番ポイントになるところは、ジャンプの頂点を越えて下降中になるところの判定でしょうか。
raycastで地面との距離をとれるようにしてあるので、前フレームとの差をとって、+(上昇中)か、-(下降中)かで判断を行います。
ただ、ジャンプ頂点の判定にraycastの距離を使っていて、測定できる距離に上限をとっているので、上限以上にジャンプしてしまうとモーションの遷移が行なえません。
raycastの測定距離上限は、ジャンプで想定される最大高さに多少のマージンを足した値にしておく必要があります。
また、ジャンプキーの入力について、上記の仕様ではkey down、key upと書きましたが、使うメソッドとしては UnityEngine.Input.GetButton
で変わりありません。
最初は GetButtonDown
と GetButtonUp
で実装したのですが、ジャンプキーを押してから溜めモーションに移行するまでに本当に少しですが時間がかかるので、その間にジャンプキーを離すと、ジャンプキーが離されてから溜めモーションに移行して次の GetButtonUp
が来るのを待ってしまうため、もう一度ジャンプキーを押さないとジャンプ出来ません。
なので、押されている間trueになり、離されたらfalseになる GetButton
のほうがここでは都合が良かったです。
状態管理
こういった特定の状態から条件によって行うべき内容が変化して次へ次へと移行していく形式のものは、バグが紛れ込みやすくなるのでその扱いには気をつけないといけません。
一番単純な構造としては
public enum JumpState {
IDLE, // 入力待ち状態
WAITING, // ジャンプ溜め状態
RISING, // 上昇中状態
FALLING, // 下降中状態
LANDING, // 着地状態
}
と状態を定義し、遷移条件判定や各状態の内容処理の場合にそれぞれでswitchをかける
switch(jump_state) {
case IDLE: break;
case WAITING: break;
case RISING: break;
case FALLING: break;
case LANDING: break;
}
というものがありますが、同じswitch文が各所に出現したり、どこで何の処理をしているのか分かりづらくなるなど問題が多く、この形の処理は出来る限り避けた方が懸命です。
ではどうするのかと言うと、前回までに書いてきた既存のコードは捨てて、一から定義し直すことになります。
ここまで来て書き直しとかめんどいやりたくない・・・と思うかもしれませんが、時間がかかっても最も正攻法なやり方をとるのが実は一番近道だと知っているのがプログラマですので、ここは我慢してゼロベースで考え直します。(と自分に言い聞かせている)
とはいえ、外側の管理構造は変えますが、詳細な処理部分については流用が効く部分も多いので、本当に完全にゼロからというわけではありません。
ここまで作ってきた内容が無駄になるわけではありません。(と自分に言い聞かせている)
さて、switch
するのは良くないので、じゃあどうするかと言うと、各状態をクラスとして実装し Dictionary
に置いておき、状態が変わるごとに必要なクラスを取り出して使う、という方法があります。
Dictionary<JumpState, IJumpState> _jump_state_list;
JumpState _state_old;
IJumpState _state_instance;
public void Update() {
var state = _state_instance.stayUpdate(); // 状態を更新
if (state == _state_old) return; // 変更があるまで現在の状態を維持
_state_instance.exit(); // 状態終了時の処理(今回は使わないかも)
_state_instance = _jump_state_list[state]; // 遷移後の状態に切り替え
_state_instance.enter(); // 状態開始時の処理
_state_old = state; // 切り替え判定用に前フレームの状態を保持
}
使わないのはswitchの分岐処理だけなので、JumpStateはさっきのEnumがそのまま使えます。
Start()
や FixedUpdate()
でも処理が必要ですが、基本構造はこれで回せるはずです。
これを実現するために、
interface IJumpState {
JumpState stay_update(); // 状態に留まっている間Updateで実行される
void stay_fixed_update(); // 状態に留まっている間FixedUpdateで実行される
void enter(); // 状態に遷移してきた時に一度だけ実行される
void exit(); // 状態から遷移していく時に一度だけ実行される
}
というインターフェイスを用意します。
これは各状態を実装する際の基盤とするもので、インターフェイスを継承したクラスはインターフェイスにあるメソッドを全て実装しなければなりません。
管理クラスから見ると、このインターフェイスを継承しているクラスにはこれらのメソッドが実装されていることが保証される事になります。
各状態の処理をそれぞれのクラスに記述し、上位の管理クラスでは各クラスの保持とどのクラスの処理を実行するのかだけを管理するようにします。
JumpStateIdle
入力待ちのアイドル状態を管理します。
ジャンプキー入力開始(key down)でジャンプ溜め状態に移行します。
using UnityEngine;
using JumpState = animJump.JumpState; // UniRxのJumpStateと命名が被ってしまっているので、UniRxが入っている場合に必要です。
namespace animJump
{
class JumpStateIdle : IJumpState
{
private Animator _animator;
private string _anim_name = "Idle";
public JumpStateIdle(Animator animator) {
_animator = animator;
}
public JumpState stayUpdate() {
if (_animator.isName(_anim_name) && Input.GetButton("Jump")) {
return JumpState.WAITING;
}
return JumpState.IDLE;
}
public void enter() {
_animator.animationStart(_anim_name);
}
public void stayFixedUpdate() {}
public void exit() {}
}
}
本来の Animator
には animationStart
というメソッドはありませんが、C#の拡張メソッド機能を使って追加してあります。
using System.Collections.Generic;
using UnityEngine;
public static class AnimatorExtensions
{
public static void radioBool(this Animator anim, string on) {
foreach (var param in anim.parameters) {
if (param.type != AnimatorControllerParameterType.Bool) continue;
anim.SetBool(param.name, (param.name == on) ? true : false);
}
}
public static void animationStart(this Animator anim, string anim_name, int layer_no = 0)
{
anim.radioBool(anim_name);
}
public static bool isName(this Animator anim, string anim_name, int layer_no = 0)
{
var state_info = anim.GetCurrentAnimatorStateInfo(layer_no);
return state_info.IsName(anim_name);
}
public static bool animationEnd(this Animator anim, string anim_name, int layer_no = 0)
{
var state_info = anim.GetCurrentAnimatorStateInfo(layer_no);
if (!state_info.IsName(anim_name)) return false;
if (state_info.normalizedTime < 1f) return false;
return true;
}
}
animationStart
自体は一緒に追加した radioBool
の呼び出しのみで、radioBool
メソッドは引数で指定したBoolパラメータのみをtrueとし、他のBoolパラメータはすべてfalseに落とすメソッドです。
同時に追加している isName
は、現在進行中のアニメーションが指定したアニメーションかどうかを判定し、animationEnd
は、アニメーションが終了したかどうかを判定するメソッドです。
後で使います。
JumpStateWaiting
ジャンプ溜め中の状態を管理します。
ジャンプキーが離されたら(key up)上昇中状態に移行します。
using UnityEngine;
using JumpState = animJump.JumpState;
namespace animJump
{
class JumpStateWaiting : IJumpState
{
private Animator _animator;
private JumpData _jump_data;
private string _anim_name = "Waiting";
public JumpStateWaiting(Animator animator, JumpData jump_data) {
_animator = animator;
_jump_data = jump_data;
}
public JumpState stayUpdate() {
_jump_data.power_up();
if (_animator.isName(_anim_name) && !Input.GetButton("Jump")) {
return JumpState.RISING;
}
return JumpState.WAITING;
}
public void enter() {
_animator.animationStart(_anim_name);
}
public void stayFixedUpdate() {}
public void exit() {}
}
}
ジャンプキーを押している長さの分だけジャンプパワーを溜めますが、溜めるのはWaitingで使うのがRisingで、別クラスでの操作になるため、横串でデータのやり取りをするためのクラス JumpData
を追加しました。
using UnityEngine;
namespace animJump
{
class JumpData
{
private float _power;
public float power {
get { return _power; }
}
private float _power_default = 5f;
private float _power_up;
private float _power_max;
public JumpData(float up, float max)
{
_power_up = up;
_power_max = max;
power_reset();
}
public void power_up()
{
if (_power_max <= _power) return;
_power += _power_up;
}
public void power_reset()
{
_power = _power_default;
}
}
}
毎フレーム毎に溜めるパワーの値と、溜められるパワーの上限を外部から与えられるようにしてあります。
管理クラスを通してInspectorで設定できるようにするとUnity的な使い方が出来ます。
JumpStateRising
上昇中の状態を管理します。
初回のFixedUpdate()で、Rigidbody.AddForceを使って上方へのジャンプを行います。
また、ジャンプの頂点を過ぎて下降を開始したら下降中の状態へ移行します。
using UnityEngine;
using JumpState = animJump.JumpState;
namespace animJump
{
class JumpStateRising : IJumpState
{
private Animator _animator;
private JumpData _jump_data;
private JumpDistance _jump_distance;
private Rigidbody _rigid_body;
private bool _allow_add_force = true;
private string _anim_name = "Rising";
public JumpStateRising(Animator animator, JumpData jump_data,
JumpDistance jump_distance, Rigidbody rigid_body) {
_animator = animator;
_jump_data = jump_data;
_jump_distance = jump_distance;
_rigid_body = rigid_body;
}
public JumpState stayUpdate() {
if (_animator.isName(_anim_name) && _jump_distance.isFalling()) {
_allow_add_force = true;
_jump_data.power_reset();
return JumpState.FALLING;
}
return JumpState.RISING;
}
public void enter() {
_animator.animationStart(_anim_name);
}
public void stayFixedUpdate() {
if (!_allow_add_force) return;
_allow_add_force = false;
_rigid_body.AddForce(Vector3.up * _jump_data.power, ForceMode.Impulse);
}
public void exit() {}
}
}
ジャンプが下降に移る際の判定のために、新しく JumpDistance
クラスを追加しました。
各状態がキャラと地面との細かい距離を知って判定する必要は無いので、判定処理を丸ごと任せて結果だけを貰います。
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
namespace animJump
{
class JumpDistance
{
private float _distance;
public float distance {
get { return _distance; }
set {
_distance = value;
float old_distance = (0 < _old_distance_list.Count) ?
_old_distance_list.Last() :
value;
_old_distance_diff_list.Enqueue(value - old_distance);
if (_distance_list_limit < _old_distance_diff_list.Count) {
_old_distance_diff_list.Dequeue();
}
_old_distance_list.Enqueue(value);
if (_distance_list_limit < _old_distance_list.Count) {
_old_distance_list.Dequeue();
}
}
}
private Queue<float> _old_distance_diff_list = new Queue<float>();
private Queue<float> _old_distance_list = new Queue<float>();
private int _distance_list_limit = 5;
private float _ground_distance_limit = 0.01f;
private Vector3 _raycastOffset = new Vector3(0f, 0.005f, 0f);
private float _raycastSearchDistance = 100f;
private Transform _transform;
public JumpDistance(Transform transform,
int distance_list_limit,
float ground_distance_limit,
float raycastSearchDistance) {
_transform = transform;
_distance_list_limit = distance_list_limit;
_ground_distance_limit = ground_distance_limit;
_raycastSearchDistance = raycastSearchDistance;
}
public bool isFalling() {
check();
if (_old_distance_diff_list.Average() < 0) return true;
return false;
}
public bool isLanding() {
check();
if (_old_distance_list.Average() < _ground_distance_limit) {
return true;
}
return false;
}
public void check() {
RaycastHit hit;
var layerMask = LayerMask.GetMask("Ground");
// プレイヤーの位置から下向きにRaycast
// レイヤーマスクでGroundを設定しているので、
// 地面のGameObjectにGroundのレイヤーを設定しておけば、
// Groundのレイヤーを持つGameObjectで一番近いものが一つだけヒットする
var isGroundHit = Physics.Raycast(
_transform.position + _raycastOffset,
_transform.TransformDirection(Vector3.down),
out hit,
_raycastSearchDistance,
layerMask
);
if (isGroundHit) {
distance = hit.distance;
} else {
// ヒットしなかった場合はキャラの下方に地面が存在しないものとして扱う
distance = float.MaxValue;
}
}
}
}
isFalling
は上昇中に、isLanding
は下降中に毎フレーム呼ばれるので、check
メソッドをここで呼ぶようにし、接地中はraycastの判定をしないようにしています。
より負荷を軽減するなら、2フレームか3フレームに1回だけ check
するような形にしても、動作上はそこまで影響はないかと思います。
1点気をつけるとすれば、実は今回の処理では isLanding
の設定判定に使っている _ground_distance_limit
の値を、ちょっと遠目にしています。
接地前からアニメーションを開始しておくことで、アニメーション上の着地と実際の着地が近いタイミングになるようにしているので、フレームを飛ばすならそこは再調整が必要になりそうです。
また、今回は完全にジャンプ処理だけに注目しているコードなので isFalling
、isLanding
の中で check
するようにしましたが、実際に3Dゲームで使う際に、ジャンプではなく段差を降りるなどで下降モーションを起こしたい場合、ジャンプ中しか距離計測していないのではアクションが起こせないので、そういう場合も再検討が必要だと考えられます。
JumpStateFalling
下降中の状態を管理します。
着地したら着地状態へ移行します。
using UnityEngine;
using JumpState = animJump.JumpState;
namespace animJump
{
class JumpStateFalling : IJumpState
{
private Animator _animator;
private JumpDistance _jump_distance;
private string _anim_name = "Falling";
public JumpStateFalling(Animator animator, JumpDistance jump_distance) {
_animator = animator;
_jump_distance = jump_distance;
}
public JumpState stayUpdate() {
if (_animator.isName(_anim_name) && _jump_distance.isLanding()) {
return JumpState.LANDING;
}
return JumpState.FALLING;
}
public void enter() {
_animator.animationStart(_anim_name);
}
public void stayFixedUpdate() {}
public void exit() {}
}
}
JumpStateLanding
着地の状態を管理します。
着地モーションが完了したらアイドル状態へ移行します。
using UnityEngine;
using UniRx;
using JumpState = animJump.JumpState;
namespace animJump
{
class JumpStateLanding : IJumpState
{
private Animator _animator;
private string _anim_name = "Landing";
public JumpStateLanding(Animator animator) {
_animator = animator;
}
public JumpState stayUpdate() {
if (_animator.animationEnd(_anim_name)) {
return JumpState.IDLE;
}
return JumpState.LANDING;
}
public void enter() {
_animator.animationStart(_anim_name);
}
public void stayFixedUpdate() {}
public void exit() {}
}
}
animationEnd
は、animationStart
と一緒に追加した拡張メソッドで、再生中のアニメーションが終了したかどうかをbool値で返してくれます。
また isName
と同等の処理を animationEnd
の中で行っているため、ここでは不要です。
管理クラス
各状態を管理するクラスを作ってきたので、最後は状態の変遷を管理するクラスを作成します。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using animJump;
using JumpState = animJump.JumpState;
public class PlayerStateController : MonoBehaviour
{
///<summary>
/// ジャンプ処理に使用するRigidbody
///</summary>
private Rigidbody _rigidBody;
///<summary>
/// ジャンプアニメーションを担当するAnimator
///</summary>
private Animator _animator;
///<summary>
/// ジャンプの各状態を保持しておく辞書リスト
///</summary>
private Dictionary<JumpState, IJumpState> _jump_state_list;
///<summary>
/// ジャンプの以前の状態を記憶しておく
/// これと比較することで状態の変更を識別する
///</summary>
private JumpState _state_old = JumpState.IDLE;
///<summary>
/// 現在のジャンプの状態を保持
///</summary>
private IJumpState _state_instance;
///<summary>
/// ジャンプ力に関する情報を保持
///</summary>
private JumpData _jump_data;
///<summary>
/// プレイヤーキャラクターと地面間の距離に関する情報を保持
///</summary>
private JumpDistance _jump_distance;
///<summary>
/// キーを押している間に溜めるジャンプ力の1フレーム分
///</summary>
[SerializeField] private float _jump_power_up;
///<summary>
/// ジャンプ力の上限
///</summary>
[SerializeField] private float _jump_power_max;
///<summary>
/// 上昇中から下降中に切り替わる変化の検知精度
///</summary>
[SerializeField] int distance_list_limit;
///<summary>
/// 下降中から接地したと判定する距離
///</summary>
[SerializeField] float ground_distance_limit;
///<summary>
/// プレイヤーキャラクターと地面間の計測距離上限
/// 最高高度より高い値でないと、ジャンプ頂点での下降モーションへの切り替わりが出来ません
///</summary>
[SerializeField]float raycastSearchDistance;
public void Start()
{
_rigidBody = GetComponent<Rigidbody>();
_animator = GetComponent<Animator>();
_jump_data = new JumpData(_jump_power_up, _jump_power_max);
_jump_distance = new JumpDistance(
transform,
distance_list_limit,
ground_distance_limit,
raycastSearchDistance
);
_jump_state_list = new Dictionary<JumpState, IJumpState> {
{ JumpState.IDLE, new JumpStateIdle(_animator) },
{ JumpState.WAITING, new JumpStateWaiting(_animator, _jump_data) },
{ JumpState.RISING, new JumpStateRising(_animator, _jump_data, _jump_distance, _rigidBody) },
{ JumpState.FALLING, new JumpStateFalling(_animator, _jump_distance) },
{ JumpState.LANDING, new JumpStateLanding(_animator) },
};
_state_instance = _jump_state_list[JumpState.IDLE];
_state_instance.enter();
}
public void Update()
{
var state = _state_instance.stayUpdate();
if (state == _state_old) return;
_state_instance.exit();
_state_instance = _jump_state_list[state];
_state_instance.enter();
_state_old = state;
}
public void FixedUpdate()
{
_state_instance.stayFixedUpdate();
}
}
基本構造は記事の最初の方で出てきた状態管理のものそのままです。
初期化処理では、 JumpData
と JumpDistance
を生成し、情報が必要な状態にだけ渡しています。
それから状態とクラスのリストを作成し、最初の状態 JumpState.IDLE
を設定、開始しています。
FixedUpdate
では、各状態の stayFixedUpdate
を呼び出していますが、今回処理が行われているのは JumpStateRising
だけです。
空メソッドの呼び出しだけならそこまで高コストにはならないので恐らく許容範囲と思いますが、必要であれば特定の状態のときだけ stayFixedUpdate
を呼び出すような形にすることも考えられます。
UnityちゃんにAttachするのは、このPlayerStateControllerだけです。
動かす
スペースキーを押すとジャンプ待機、離すとジャンプし、待機時間でジャンプの強さが変わります。
あとがき
アニメーションを分割してコードから操作するようにしたら、タイミング調整とか、今回はやってませんが途中でアニメーションを入れ替えたりとかも出来るのでめっちゃ便利になりますよね!っていう記事を3回に分けて書いてきたのですが、最終的に辿り着いたのは「状態管理って難しいしめんどくさいよね」でした。
内容的には初心者はクリアした初級者向けあたりになるのかな?と疑問に思いつつ、意図的にUniRxの記述を避けてきたのですが、外部の誰かがジャンプの状態を知りたい状況でなければ使うメリットがそれほど無いのではと思っています。
あるいは現在の状態をReactivePropertyにして、状態が切り替わったらアニメーションを切り替える形なら、現在の「状態がアニメーションを管理する」のではなく、「状態の変更条件のチェック」と「アニメーションの管理」を切り離すことは出来るかもしれません。
もしくは、ジャンプキー入力やraycastで取っている地面との距離をReactivePropertyにすれば、必要なタイミングを教えてくれるように出来て、メソッドに起こさなくてもラムダでその時その時の処理を書けば良いだけに出来るかもしれませんね。
もっとコンパクトでスマートなコードになるのなら検討する価値は大いにありそうです。(手のひらクルーが速い)
Rxもオブジェクト指向もDDDも、すべては関心の分離のために行われている気がしてきているので、そっちの方向でプログラミング出来るならより良いコードになりそうです。