ぱぴブログ

さんたろのブログ

ゲーム開発が好きな人のひとりごと

Unityを使って3Dアクションゲーム 壁キック(壁ジャンプ)編

はじめに

みなさんこんにちは。やっとポートフォリオサイトを作ったさんたろです。
さて、最近はUnityを用いて3Dアクションゲームを作っています。
某配管工おじさんのようなアクションをまずは作ろうということで。

その中でも、今回は壁キックについての内容です。
壁ジャンプとも呼ばれていますね。

壁に張り付いている間の話も多いです(むしろそっちがメイン)

(以降いつのまにかである調になっていました。気にしないでください><)

デモ動画

youtu.be

開発環境

・Unity2019.4.0f1
・Windows10 Home
・SDユニティちゃん 1.01 ※利用規約を守りましょう

unity-chan.com

前提条件(空中時の移動等)

・接地判定、移動はCharacterControllerを使用

毎フレームの処理の流れ

1.プレイヤーの入力を受け取る
2.ジャンプなどの特定のアクションがあれば、処理
3.現在の状態に応じて、入力に対する移動処理
4.アクション(2)以外での状態遷移(立つ→走る、落ちる→立つ、etc...)
5.(2)以外でのアニメーション遷移(走っている、壁に張り付いている、etc...)

最終的な実装

WallKickTrigger

WallKickTrigger: UnityChanPrefabの壁張り付き判定用に、Colliderと以下のクラスをアタッチした子gameObjectを追加する。

public class WallKickTrigger : MonoBehaviour
{
    [SerializeField] private PlayerMovementBasedCamera playerMoveController;

    private void OnCollisionEnter(Collision collision)
    {
        if (collision.transform.CompareTag("Stage"))
        {
            Vector3 normalVector = collision.contacts[0].normal;
            if(normalVector.y < 0.02f) //壁が一定以上垂直方向から傾いていなければ張り付く
            {
                this.playerMoveController.StickWall(normalVector);
            }
        }
    }

    private void OnCollisionExit(Collision collision)
    {
        if (collision.transform.CompareTag("Stage"))
        {
            this.playerMoveController.StopStickingWall();
        }
    }
}

PlayerMovementBasedCamera

カメラの向きと、プレイヤーの入力によって、移動やアクションをおこなう。
UnityChanPrefabにアタッチする。以下のコードは実際のものから一部抜粋したもの

public class PlayerMovementBasedCamera : MonoBehaviour
{
    [SerializeField] private float wallKickHorizontalSpeed = 3f;
    [SerializeField] private float wallKickVerticalSpeed = 3f;
    [SerializeField] private float maxStickingWallFallSpeed = 1f;

    private bool _isGrounded = false;
    private E_State currentState = E_State.Standing;
    private E_ActionFlag waitingAction = E_ActionFlag.None;
    private CharacterController _characterController;
    private Vector3 _velocity;

    /// <summary>
    /// x: Horizontal, y: Vertical
    /// </summary>
    private Vector2 inputVelocity = Vector2.zero;

    /// <summary>
    /// 張り付いている壁の法線ベクトル
    /// </summary>
    public Vector3 NormalOfStickingWall { get; private set; } = Vector3.zero;

    private void Awake()
    {
        this._rigidbody = GetComponent<Rigidbody>();
        this._playerAnimation = GetComponent<PlayerAnimation>();
        this._characterController = GetComponent<CharacterController>();

        this._characterController
            .ObserveEveryValueChanged(x => x.isGrounded)
            .ThrottleFrame(5)
            .Subscribe(x => this._isGrounded = x);
    }

    private void Update()
    {
        this.waitingAction = E_ActionFlag.None;

        UpdateInput();
        UpdateAction();
        UpdateMovement();
        UpdateState();
        UpdateAnimation();
    }

    /// <summary>
    /// プレイヤーの入力を受け取る
    /// </summary>
    private void UpdateInput()
    {
        this.inputVelocity = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));

        switch (this.currentState)
        {
            case E_State.StickingWall:
                if (Input.GetButtonDown("Jump")) this.waitingAction = E_ActionFlag.WallKick;
                else if (Input.GetButtonDown("HipDrop")) this.waitingAction = E_ActionFlag.HipDrop;
                break;
        }
    }

    private void UpdateAction()
    {
        switch (this.waitingAction)
        {
            case E_ActionFlag.HipDrop:
                HipDrop();
                break;
            case E_ActionFlag.WallKick:
                WallKick();
                break;
        }
    }

    /// <summary>
    /// currentStateで分岐された、移動入力処理
    /// </summary>
    private void UpdateMovement()
    {
        if (this._isGrounded) this._velocity = Vector3.zero;
        if(this.currentState != E_State.HipDropping) this._velocity.y -= this.gravityVerticalForce * Time.deltaTime;

        switch (this.currentState)
        {
            case E_State.Standing:
            case E_State.Running:
                MoveOnGround();
                break;
            case E_State.StickingWall:
                MoveStickWall();
                break;
            case E_State.JumpToTop: 
            case E_State.TopOfJump:
            case E_State.Falling:
            case E_State.LongJumpToTop:
                MoveNormalAir();
                break;
        }

        //velocityに応じた移動処理
        this._characterController.Move(this._velocity * Time.deltaTime);
    }

    /// <summary>
    /// プレイヤーの移動速度に応じて状態を変える(ジャンプなどの入力は別)
    /// </summary>
    private void UpdateState()
    {

        //ここで接地判定をおこなうため、jumpなどの処理時に念のため_isGrounded = falseにするべき
        if (this._isGrounded)
        {
            if (this.inputVelocity == Vector2.zero) this.currentState = E_State.Standing;
            else this.currentState = E_State.Running;
        }
        else
        {
            if (this.currentState == E_State.JumpToTop && this._velocity.y > 0f) this.currentState = E_State.TopOfJump;
            else if (this._velocity.y <= -0f && (this.currentState != E_State.SpinJumping && this.currentState != E_State.StickingWall)) this.currentState = E_State.Falling;
        }
    }

    /// <summary>
    /// 壁キック
    /// </summary>
    public void WallKick()
    {
        if (this._velocity.y >= -0.05f) return;

        this._isGrounded = false;
        this.currentState = E_State.JumpToTop;

        //プレイヤーの正面をを法線ベクトルに
        this.transform.forward = this.NormalOfStickingWall;

        //移動させる
        this._velocity = new Vector3(this.NormalOfStickingWall.x * this.wallKickHorizontalSpeed + this._velocity.x, 
        this.wallKickVerticalSpeed, this.NormalOfStickingWall.z * this.wallKickHorizontalSpeed + this._velocity.z);

        //animation
        this._playerAnimation.Play(PlayerAnimation.E_PlayerAnimationType.JumpToTop);
    }

    private void MoveStickWall()
    {
        Vector3 cameraForward = Vector3.Scale(Camera.main.transform.forward, new Vector3(1, 0, 1)).normalized;
        Vector3 moveForward = cameraForward * this.inputVelocity.y + Camera.main.transform.right * this.inputVelocity.x;
        Vector3 moveVelocity = moveForward * this.normalAirHorizontalForce * Time.deltaTime + new Vector3(this._velocity.x, 0f, this._velocity.z);


        //壁方向への速度は0にする(いつでもすぐに壁から離れられるようにするため)
        float incidenceAngle = Vector3.SignedAngle(moveVelocity, -1 * this.NormalOfStickingWall, Vector3.up); //移動ベクトルと壁の法線ベクトル間の角度

        if(incidenceAngle > -90 && incidenceAngle < 90) //壁方向への速度があるとき→壁方向の速さのみ0にする
        {
            //壁に平行方向への速さに(ベクトルはそのまま)
            moveVelocity *= Mathf.Abs(Mathf.Sin(incidenceAngle * Mathf.Deg2Rad));

            //速度ベクトルを壁に平行方向に回転
            if (incidenceAngle <= 0) moveVelocity = Quaternion.Euler(0f, 90 + incidenceAngle, 0f) * moveVelocity; //正面右方向
            else if (incidenceAngle > 0) moveVelocity = Quaternion.Euler(0f, -1 * (90 - incidenceAngle), 0f) * moveVelocity; //正面左方向
            
        }

        //最高速度を超えていなければ入力した水平方向に加速
        if (Mathf.Sqrt(moveVelocity.x * moveVelocity.x + moveVelocity.z * moveVelocity.z) < this.maxNormalAirHorizontalSpeed) 
        this._velocity = new Vector3(moveVelocity.x, this._velocity.y, moveVelocity.z);

        if (this._velocity.y < -1 * this.maxStickingWallFallSpeed) this._velocity.y = -1 * this.maxStickingWallFallSpeed;
    }

    /// <summary>
    /// 壁に張り付く。張り付いたときに壁方向を向き、水平方向の速度を0にする
    /// </summary>
    /// <param name="normalOfStickingWall">壁面の垂直方向(自身のほうへの)</param>
    public void StickWall(Vector3 normalOfStickingWall)
    {
        if (this._isGrounded) return;

        this._velocity = new Vector3(0f, this._velocity.y, 0f);
        this.transform.rotation = Quaternion.LookRotation(-1 * normalOfStickingWall, Vector3.up);
        this.NormalOfStickingWall = normalOfStickingWall;
        this.currentState = E_State.StickingWall;
        this._playerAnimation.Play(PlayerAnimation.E_PlayerAnimationType.StickingWall);
    }

    /// <summary>
    /// 壁張り付き状態から、落下状態へ移行
    /// </summary>
    public void StopStickingWall()
    {
        if (this._isGrounded) return;

        this.currentState = E_State.Falling;
        this.NormalOfStickingWall = Vector3.zero;
        this._playerAnimation.Play(PlayerAnimation.E_PlayerAnimationType.TopToGround);
    }
}

実際のコードはこちらから↓

github.com

github.com

参考文献

CharacterControllerのisGrounded判定

qiita.com

カメラの向きを基準にした移動

tech.pjin.jp

コードの解説

壁キック(WallKick関数)

(速度)
・予め壁の法線ベクトル(プレイヤー方向)を壁張り付き時に記憶しておく。
・水平方向のvelocityに、法線ベクトル * 壁ジャンプ水平方向の速さを足す。
・垂直方向のvelocityを、壁ジャンプ垂直方向の速さで初期化。

(向き)
・法線ベクトルの向きを、プレイヤーの正面の向きにする。

壁に張り付いたとき(StickWall関数)

壁面の法線ベクトルを記憶
(速度)
・水平方向の速さを0に。垂直方向はそのまま

(向き)
・壁の法線ベクトル*-1の向きを、プレイヤーの正面の向きにする(壁を向くように)

(状態)
現在の状態を壁張り付き状態に(空中にいるとき)

壁張り付きから離れたとき(StopStickWall関数)

(状態)
現在の状態を落下に(空中にいるとき)

壁張り付き状態時の移動(MoveStickWall関数)

(速度)
・カメラの向きに合わせて、プレイヤーの入力に応じた水平方向にvelocityを増加させる。
・velocityと壁の法線ベクトル間の角度を求める。
・求めた角度から、壁に向かって進もうとしているのか、壁から離れる方向に進もうとしているのか判断。
→壁に向かっての場合 →・Sin(入射角度)によって、速度増加量を壁面方向のみの大きさにする。
→・Quaternion.Eulerによって、増加速度の方向を壁面方向にする。

・空中移動の最大速度を超えていなければ、水平方向の速度を更新
・最大落下速度を超えていなければ、垂直方向の速度を更新

失敗談

最初の実装

壁張り付き時に、水平方向のvelocityを0にする。
この場合も一見するといい感じの動きになる。
しかし、張り付いている状態でも水平方向の速度は変化し続けている。

これによって、
・壁から離れたいのに、壁方向の速度が残っていて離れられない
・壁に張り付きながら、横に移動し、壁の端に到達、更に横に移動したときに、壁から離れるが、
このときに壁方向のvelocityが残っているので、一気に前進してしまう(説明が難しい)

結局壁方向への加速はさせないに尽きる。

壁に張り付いているときに移動させない

壁に張り付いているときに、プレイヤーの入力によって水平移動をさせない、というアイデア
確かに、これならば「壁に張り付いている間は下に落下のみ。壁キックで、水平方向のvelocityを初期化」という動きができる。

しかし、これだと壁キックしたときに必ずプレイヤーの横移動が0から始まってしまう。
つまり、張り付く位置によって、移動ルートがかなり制限される。

それはそれで面白いが、今回はもう少し融通を利かせたかった。

今後の改善点

カメラが自由に回転できるが故だが、斜めに張り付いたときに、壁から離れる方向を入力してしまいやすい。
例えば壁に張り付いて数フレームは壁から離れる方向への加速はしないなどしたほうがいいだろう。
現実でも、凄い速さで壁につっこめば、数秒は壁に張り付くはずだ。

おわりに

最近ゲーム開発が楽しすぎて、正直ブログを書く時間も惜しいのだが、
開発過程などは忘れてしまいそうで、覚えているうちに。
それにしては失敗談が短い。そう忘れたのである。

ではでは

© Unity Technologies Japan/UCL