見出し画像

【Unity】3Dアクションゲーム制作で活躍する!自立した敵の作り方

作り方は人それぞれ自由であれど、それゆえに自分に合った作り方を見つけることは難しい。

ということで、今回は3Dアクションゲームで汎用的に使える、”自立した敵キャラ”の実装方法の一例をご紹介します。
なお、こちらの動画でも概要を紹介しているので、合わせて見ていただけると嬉しいです。

また、今回紹介する方法はこちらのサイトの方法を参考に、自己流に落とし込んだ作り方なので、気になる方は見てみてください。

作り方に正解や決まった形はないので、この作り方でなくても問題ありません。しかし、この作り方は構造がシンプルで、アセットを使わなくても0から作ることが出来るので、敵キャラの作り方の仕組みを勉強したい方には、特にオススメです。

今回作る敵キャラとは?

まず、今回の作り方でどんな敵キャラを作るか?気になる方も多いと思います。
簡単に言うと、センサーでプレイヤーを感知して行動が変化する敵キャラを作ります。

必要なもの

今回の作り方は、下記の6つを用いて作っていきます。

  1. 敵キャラの仕様決め(行動のパターンや内容など)

  2. キャラモデル(モーション付き)

  3. センサー(プレイヤーを感知して行動を切り替えるため)

  4. キャラとセンサー用のスクリプト(キャラの行動を制御するため)

  5. タイムラインやアニメーションコントローラー(キャラのモーションを制御するため)

  6. VFXエフェクトやサウンドなどのエフェクト素材

敵キャラの仕様決め

敵キャラを作るときには、いつプレイヤーを追跡して、どんな状況でどんな攻撃をするのか、キャラの行動内容や、一連の行動パターンを決めておくと作りやすいのでオススメです。

1人で作る場合、情報を共有するための資料は必要ありませんが、どんな風に動く敵なのか、具体的なイメージやビジョンを見えるようにしておくと、作りやすくなるので何かしらの資料を用意しておきましょう。

モーション付きキャラモデル

キャラに動作を付けたい場合は、モーション付きのモデルが必要になります。

今回用意した魔神キャラは、無料モデリングツールのBlenderで自作したものなので、自分で動くキャラモデル作ってみたい方は、この機会に挑戦してみてはいかがでしょうか?

私がキャラを作る際によくお世話になっているチャンネル様のリンクを貼っておきます。解説が丁寧でとても分かりやすいので、興味のある方は是非参考にしてみてください!

プレイヤーを感知するためのセンサー

プレイヤーの位置や状態などの情報を取得して、その情報を遷移条件として状態を切り替えるためのセンサーです。

今回の場合は、キャラを中心とした1つのセンサーとして作っていますが、センサーの形状や数、配置に決まった形はないので、ちゃんとセンサーとして機能すれば好きなように作ってもらって大丈夫です。

センサーを付けた状態(赤いエリア:感知範囲)

キャラ&センサー用のスクリプト

キャラの行動や状態を制御するためや、センサーによってキャラの状態を切り替えるためのスクリプトです。

スクリプト処理については、この後で紹介しますが、簡単に説明すると、
キャラのスクリプトでは、①キャラ状態の定義、②状態ごとの行動内容に関する処理、③ある状態から別の状態への移行処理の3つ。
センサーのスクリプトでは、センサー内のプレイヤーの情報(位置や行動、状態など)でキャラ状態を移行する処理を行います。

あくまで、今回の作り方の場合は、上記のように処理しているだけなので、作る方のやりやすい処理の方法や内容に変えてもらっても全然大丈夫です!

タイムライン&アニメーションコントローラー

今回の作り方では、キャラのモーションを制御するために、タイムラインやアニメーションコントローラーの2つを使っています。

アニメーションコントローラーは、ループするモーションの制御や、複数のモーション間で色々と切り替えたい場合に使うのがオススメです。
私の場合は操作キャラのモーションを制御する際に活用しています。

タイムラインは、時間単位でモーションを制御したいときや、エフェクトと一緒に使用する場合などにオススメです。
こちらの動画でも紹介していますが、使い方の流れさえ掴んでしまえば大して難しくないので、初めてでもあまり身構える必要はありません。

エフェクトやサウンドなどの素材

モーションに合わせて、エフェクトやサウンドを再生させたいときに必要になります。

当たり判定のないエフェクトの場合は、VFXエフェクトでも問題ありませんが、エフェクトに干渉して何かしらの処理をさせたい場合は、ParticleSystemsでエフェクトを作る必要がありますので、ご注意ください。

タイムラインを活用してエフェクトとモーションを制御することで、こちらの動画のようなドラゴンのブレス攻撃を作ることも出来ます。

1. キャラとセンサーを作る

① 用意したキャラモデルをステージに配置する。

キャラモデルの配置

② センサー用オブジェクト(空オブジェクトにSphereコライダーを付けたオブジェクト)を作成する。

作成したセンサーのインスペクター設定
ステージに配置したセンサー

③ キャラモデルとセンサーを同じ親の子オブジェクトとして設定する。

オブジェクトの親子関係
共通の親に設定してキャラとセンサーを同じ位置に合わせる(x:0,y:0,z:0)

④ 親(Majin)、キャラモデル(majinModel)、センサー(Sensor)にそれぞれコンポーネントを追加する。
・親にはRigidbody、NavMeshAgent、Script(MajinControl)を追加。
・キャラモデルにはAnimatorを追加。
・センサーには、Script(SphereSensor)を追加。

今回の場合は、キャラモデルを差し替えるときの手間を考慮して、キャラモデルではなくキャラモデルの親にスクリプトをアタッチしています

※詳細な設定は、写真を参照してください。

親オブジェクトのインスペクター設定
キャラモデルのインスペクター設定
センサーのインスペクター設定

2. スクリプトでキャラ状態を制御する処理を作る

今回は、処理内容を分かりやすくするために、
①センサーの感知範囲内にプレイヤーがいなければ待機(Idle)状態
②プレイヤーが感知範囲内にいて、距離が遠ければ追跡(Chase)状態
③距離が近いと攻撃(Attack)状態→硬直(Freeze)状態→待機(idle)状態

といった感じで、比較的シンプルでよくありがちな行動パターンのスクリプト処理の書き方を紹介します。

まず、親(Majin)に追加したスクリプト(MajinControl)から説明していきます。
始めに状態を定義する必要があるので、下記のようにそれぞれの状態の名前を決めて書きます。

    // キャラ状態の定義
  public enum EnemyState
    {
        Idle,
        Chase,
        Attack,
        Freeze
    };

次に、上記の状態になったときに呼ばれる処理と、状態の情報を取得するための処理を書いていきます。
今回は、親のコンポーネントに追加したNavMeshAgentにアクセスして、キャラの移動を簡易的に制御できるようにしています。
また、ここでいう状態とは、あくまでスクリプト上で定義したものなので、状態≠モーションということになります…誤解のないようご注意ください。

Idle状態では、navMeshAgent.isStopped = trueでキャラの移動を止めて、Idleモーションをループで再生します。Chase状態からIdle状態になったときにChase⇒Idleモーションに変えるため、 animator.SetBool("chase", false);でフラグを切り替えます。

   // 敵キャラの状態を設定するためのメソッド 
  public void SetState(EnemyState tempState, Transform targetObject = null)
    {
        state = tempState;

        if (tempState == EnemyState.Idle)
        {
            navMeshAgent.isStopped = true//キャラの移動を止める
            animator.SetBool("chase"false); //アニメーションコントローラーのフラグ切替(Chase⇒Idle)
        }
  }

    // 敵キャラの状態を取得するためのメソッド
    public EnemyState GetState()
    {
        return state;
    }

Chase状態では、キャラの目的地をプレイヤーに設定し、Idle⇒Chaseモーションに移行します。
Update()では、Chase状態であるときにターゲットがいなければIdle状態に移行し、ターゲットがいればターゲットのほうに移動します。
GetDestination()を作った理由は、navMeshAgent.GetDestination()がないためです。そのため、一度SetDestination()に目的地の情報を格納して、その後にnavMeshAgent.SetDestination(GetDestination())で目的地を設定しています。

  void Update()
    {
        //プレイヤーを目的地にして追跡する
        if (state == EnemyState.Chase)
        {
            if (targetTransform == null)
            {
                SetState(EnemyState.Idle);
            }
            else
            {
                SetDestination(targetTransform.position);
                navMeshAgent.SetDestination(GetDestination());
            }
            // 敵の向きをプレイヤーの方向に少しづつ変える
            var dir = (GetDestination() - transform.position).normalized;
            dir.y = 0;
            Quaternion setRotation = Quaternion.LookRotation(dir);
            // 算出した方向の角度を敵の角度に設定
            transform.rotation = Quaternion.Slerp(transform.rotation, setRotation, navMeshAgent.angularSpeed * 0.1f * Time.deltaTime);
        }
    }

   // 敵キャラの状態を設定するためのメソッド 
    public void SetState(EnemyState tempState, Transform targetObject = null)
    {
        state = tempState;

        if (tempState == EnemyState.Chase)
        {
            targetTransform = targetObject; //ターゲットの情報を更新
            navMeshAgent.SetDestination(targetTransform.position); //目的地をターゲットの位置に設定
            navMeshAgent.isStopped = false//キャラを動けるようにする
            animator.SetBool("chase"true); //アニメーションコントローラーのフラグ切替(Idle⇒Chase)
        }
  }

    // 敵キャラの状態を取得するためのメソッド
    public EnemyState GetState()
    {
        return state;
    }

  // 目的地を設定する
    public void SetDestination(Vector3 position)
    {
        destination = position;
    }

    // 目的地を取得する
    public Vector3 GetDestination()
    {
        return destination;
    }

Attack状態では、アニメーションコントローラーのモーションをChase⇒Idleに移行し、タイムラインでAttackモーションを再生します。
そして、タイムラインのSignalを使い、Attackモーションが終わったタイミングでFreeze状態に移行させます。

    public void SetState(EnemyState tempState, Transform targetObject = null)
    {
        state = tempState;

        if (tempState == EnemyState.Attack)
        {
            navMeshAgent.isStopped = true//キャラの移動を止める
            animator.SetBool("chase"false); //アニメーションコントローラーのフラグ切替(Chase⇒Idle)
            timeline.Play();//攻撃用のタイムラインを再生する

        }
  }

    // 敵キャラの状態を取得するためのメソッド
    public EnemyState GetState()
    {
        return state;
    }
    // タイムラインで状態をFreeze状態に設定するためのメソッド
    public void FreezeState()
    {
        SetState(EnemyState.Freeze); ;
    }

Freeze状態では、Invokeを使って、2秒後にIdle状態に移行するようにしています。

    public void SetState(EnemyState tempState, Transform targetObject = null)
    {
        state = tempState;

        if (tempState == EnemyState.Freeze)
        {
        Invoke("ResetState",2.0f);
        }
  }

    // 敵キャラの状態を取得するためのメソッド
    public EnemyState GetState()
    {
        return state;
    }

    // 状態をIdle状態に設定するためのメソッド
    private void ResetState()
    {
        SetState(EnemyState.Idle); ;
    }

これらの内容をまとめると、下記のようになります。

using UnityEngine.AI; //NavMeshAgentを使うための宣言
using UnityEngine.Playables; //PlayableDirectorを使うための宣言

public class MajinControl : MonoBehaviour
{
    public enum EnemyState
    {
        Idle,
        Chase,
        Attack,
        Freeze
    };

    //パラメータ関数の定義
  public EnemyState state; //キャラの状態
    private Transform targetTransform; //ターゲットの情報
    private NavMeshAgent navMeshAgent; //NavMeshAgentコンポーネント
    public Animator animator; //Animatorコンポーネント
    [SerializeField]
    private PlayableDirector timeline; //PlayableDirectorコンポーネント
    private Vector3 destination; //目的地の位置情報を格納するためのパラメータ

    void Start()
    {
        //キャラのNavMeshAgentコンポーネントとnavMeshAgentを関連付ける
        navMeshAgent = GetComponent<NavMeshAgent>();

        //キャラモデルのAnimatorコンポーネントとanimatorを関連付ける
        animator = this.gameObject.transform.GetChild(0).GetComponent<Animator>();

     SetState(EnemyState.Idle); //初期状態をIdle状態に設定する
    }

  void Update()
    {
        //プレイヤーを目的地にして追跡する
        if (state == EnemyState.Chase)
        {
            if (targetTransform == null)
            {
                SetState(EnemyState.Idle);
            }
            else
            {
                SetDestination(targetTransform.position);
                navMeshAgent.SetDestination(GetDestination());
            }
            // 敵の向きをプレイヤーの方向に少しづつ変える
            var dir = (GetDestination() - transform.position).normalized;
            dir.y = 0;
            Quaternion setRotation = Quaternion.LookRotation(dir);
            // 算出した方向の角度を敵の角度に設定
            transform.rotation = Quaternion.Slerp(transform.rotation, setRotation, navMeshAgent.angularSpeed * 0.1f * Time.deltaTime);
        }
    }

    //状態移行時に呼ばれる処理
    public void SetState(EnemyState tempState, Transform targetObject = null)
    {
        state = tempState;

        if (tempState == EnemyState.Idle)
        {
            navMeshAgent.isStopped = true//キャラの移動を止める
            animator.SetBool("chase"false); //アニメーションコントローラーのフラグ切替(Chase⇒IdleもしくはFreeze⇒Idle)
        }
        else if (tempState == EnemyState.Chase)
        {
            targetTransform = targetObject; //ターゲットの情報を更新
            navMeshAgent.SetDestination(targetTransform.position); //目的地をターゲットの位置に設定
            navMeshAgent.isStopped = false//キャラを動けるようにする
            animator.SetBool("chase"true); //アニメーションコントローラーのフラグ切替(Idle⇒Chase)
        }
        else if (tempState == EnemyState.Attack)
        {
            navMeshAgent.isStopped = true//キャラの移動を止める
            animator.SetBool("chase"false);
            timeline.Play();//攻撃用のタイムラインを再生する

        }
        else if (tempState == EnemyState.Freeze)
        {
        Invoke("ResetState",2.0f);
        }
    }

    // 敵キャラクターの状態取得メソッド
    public EnemyState GetState()
    {
        return state;
    }

  // 目的地を設定する
    public void SetDestination(Vector3 position)
    {
        destination = position;
    }

    // 目的地を取得する
    public Vector3 GetDestination()
    {
        return destination;
    }

    public void FreezeState()
    {
        SetState(EnemyState.Freeze); ;
    }

    private void ResetState()
    {
        SetState(EnemyState.Idle); ;
    }
}
キャラスクリプトの様子

次に、センサー(Sensor)に追加したスクリプト(SphereSensor)について説明していきます。
ここでは、センサー内のプレイヤーの情報で敵キャラの状態を切り替えるための処理を書きます。

こちらの内容は、先ほどよりも簡単です。
OnTriggerStay()で特定の条件を満たすときに、キャラを特定の状態に設定しているだけです。

    private MajinControl enemyMove = default;
    private void Start()
    {
        enemyMove = transform.parent.GetComponent<MajinControl>();
    }
    private void OnTriggerStay(Collider target)
    {
        if (target.tag == "Player")
        {
          enemyMove.SetState(MajinControl.EnemyState.Attack);
        }
    }

また、OnDrawGizmos()でセンサーの感知範囲を視覚で確認できるようにしています。

using UnityEditor;
 #if  UNITY_EDITOR
    // サーチする角度表示
    private void OnDrawGizmos()
    {
        Handles.color = Color.red;
        Handles.DrawSolidArc(transform.position, Vector3.up, Quaternion.Euler(0f, -searchAngle, 0f) * transform.forward, searchAngle * 2f, searchArea.radius);
    } #endif 

今回の条件内容は、①センサー内にいるのがプレイヤーかどうか②指定角度範囲内にいるか③プレイヤーと敵の間に障害物がないか④プレイヤーがセンサー内のどこにいるか⑤意図したキャラ状態であるかの5つです。

・センサーの感知範囲内にプレイヤーがいなければ待機(Idle)状態
の処理は、下記のようになります。

 private void OnTriggerStay(Collider target)
    {
        if (target.tag == "Player")
        {
            var playerDirection = target.transform.position - transform.position;

            var angle = Vector3.Angle(transform.forward, playerDirection);

            if (angle > searchAngle) // 感知範囲外の場合
            {
       enemyMove.SetState(MajinControl.EnemyState.Idle);
            }
        }
    }

・プレイヤーが感知範囲内にいて、距離が遠ければ追跡(Chase)状態
の処理は、下記のようになります。
条件の中に、virusMove.state == MajinControl.EnemyState.Idleを入れている理由は、Attack状態であるときにChase状態に移行すると、攻撃をしながらプレイヤーを追ってしまうからです。
場合によっては、追いながら攻撃するような行動も必要になるので、敵の仕様によって使い分けてもらえば大丈夫です。

private void OnTriggerStay(Collider target)
{
   if (target.tag == "Player")
   {
      var playerDirection = target.transform.position - transform.position;

      var angle = Vector3.Angle(transform.forward, playerDirection);

      if (angle <= searchAngle) // 感知範囲内の場合
      {
         obstacleLayer = LayerMask.GetMask("Block""Wall");

         if (!Physics.Linecast(transform.position + Vector3.up, target.transform.position + Vector3.up, obstacleLayer)) //プレイヤーとの間に障害物がないとき
         {
            if (Vector3.Distance(target.transform.position, transform.position) <= searchArea.radius 
                && Vector3.Distance(target.transform.position, transform.position) >= searchArea.radius * 0.5f 
                && enemyMove.state == MajinControl.EnemyState.Idle)
            {
               enemyMove.SetState(MajinControl.EnemyState.Chase, target.transform); // センサーに入ったプレイヤーをターゲットに設定して、追跡状態に移行する。
            }
         }
       }
   }
}

・距離が近いと攻撃(Attack)状態→硬直(Freeze)状態→待機(idle)状態
の処理は、下記のようになります。
Freezeとidle状態への移行処理は、MajinControlで行っているため、ここではAttack状態に設定するだけです。

private void OnTriggerStay(Collider target)
{
   if (target.tag == "Player")
   {
      var playerDirection = target.transform.position - transform.position;

      var angle = Vector3.Angle(transform.forward, playerDirection);

      if (angle <= searchAngle) // 感知範囲内の場合
      {
         obstacleLayer = LayerMask.GetMask("Block""Wall");

           if (!Physics.Linecast(transform.position + Vector3.up, target.transform.position + Vector3.up, obstacleLayer)) //プレイヤーとの間に障害物がないとき
           {
             if (Vector3.Distance(target.transform.position, transform.position) <= searchArea.radius * 0.5f 
                 && Vector3.Distance(target.transform.position, transform.position) >= searchArea.radius * 0.05f)
             {
                enemyMove.SetState(MajinControl.EnemyState.Attack);
             }
           }
       }
   }
}

これらの内容をまとめると、下記のようになります。

using UnityEngine;
using UnityEditor;

public class SphereSensor : MonoBehaviour
{
    [SerializeField]
    private SphereCollider searchArea = default;
    [SerializeField]
    private float searchAngle = 45f;
    private LayerMask obstacleLayer = default;
    private MajinControl enemyMove = default;

    private void Start()
    {
        enemyMove = transform.parent.GetComponent<MajinControl>();
    }

    private void OnTriggerStay(Collider target)
    {
        if (target.tag == "Player")
        {
            var playerDirection = target.transform.position - transform.position;

            var angle = Vector3.Angle(transform.forward, playerDirection);

            if (angle <= searchAngle)
            {
                //obstacleLayer = LayerMask.GetMask("Block", "Wall");

                if (!Physics.Linecast(transform.position + Vector3.up, target.transform.position + Vector3.up, obstacleLayer)) //プレイヤーとの間に障害物がないとき
                {
                    if (Vector3.Distance(target.transform.position, transform.position) <= searchArea.radius * 0.5f 
                        && Vector3.Distance(target.transform.position, transform.position) >= searchArea.radius * 0.05f)
                    {
                        enemyMove.SetState(MajinControl.EnemyState.Attack);
                    }
                    else if (Vector3.Distance(target.transform.position, transform.position) <= searchArea.radius 
                        && Vector3.Distance(target.transform.position, transform.position) >= searchArea.radius * 0.5f 
                        && enemyMove.state == MajinControl.EnemyState.Idle)
                    {
                        enemyMove.SetState(MajinControl.EnemyState.Chase, target.transform); // センサーに入ったプレイヤーをターゲットに設定して、追跡状態に移行する。
                    }
                }
            }
            else if (angle > searchAngle)
            {
                enemyMove.SetState(MajinControl.EnemyState.Idle);
            }
        }
    }
 #if  UNITY_EDITOR
    // サーチする角度表示
    private void OnDrawGizmos()
    {
        Handles.color = Color.red;
        Handles.DrawSolidArc(transform.position, Vector3.up, Quaternion.Euler(0f, -searchAngle, 0f) * transform.forward, searchAngle * 2f, searchArea.radius);
    } #endif 
}
センサースクリプトの様子

今回紹介したスクリプト処理の書き方は、あくまで一例に過ぎません。
このスクリプト処理で実行していることが理解できれば、色んな状況に合わせた敵キャラの挙動や状態を作ることが出来ると思います。

3.タイムラインとアニメーションコントローラーを作る

敵キャラのモーションを制御するためのタイムラインとアニメーションコントローラーを作っていきます。
今回は、Attackモーションの制御をタイムラインで、Idle⇔Chaseモーションの制御をアニメーションコントローラーで行い、そのタイミングを先ほど説明したスクリプトで行っています。

まず、Attackモーションを制御するためのタイムラインの作り方から説明していきます。
タイムラインは、Project内で右クリックして、Create→Timelineで新規に作ることができます。

ドラッグ&ドロップでSceneに追加したら、あとはこのタイムライン内に再生させたいモーションやエフェクトなどの要素を追加して配置していくだけです。

今回は、キャラのAttackモーションと、攻撃モーション終了時にFreezeモーションに移行するためのSignalをこのタイムライン内に追加します。

今回作ったタイムライン

Signalは、タイムラインと同様の手順で作ることが出来ますが、スクリプトをドラッグ&ドロップでタイムラインに追加しても作れます。
Signalに名前”StateChange”を付けたら、スクリプト下のSignal Receiverから作成したSignalを選び、実行したいメソッドを選択します。

Signal Receiverの設定

最後に、作成したタイムラインをスクリプトにアタッチして、スクリプトからタイムラインを再生すると、Attackモーションが再生されて、Attackモーション終了時にFreeze状態に移行することが出来ます。

タイムラインの使い方は、こちらの動画でも簡単に解説しているので、分からない方は見てみてください。

次に、Idle⇔Chaseモーションを制御するためのアニメーションコントローラーの作り方です。

こちらもタイムラインのときと同様の手順で作ることが出来ます。
新規で作成したら、①Idleモーション、②Chaseモーションの順にドラッグ&ドロップで追加していきます。
Idleモーション上で右クリックしてMakeTransitionを選択し、Chaseモーションに繋げます。同様にChaseモーションもIdleモーションに繋げます。

Transitionの作成

左上のParametersで+ボタンを押し、Boolを作成して”chase”という名前に変更します。
そして先ほど作ったIdle⇒ChaseモーションのTransitionを選択し、Conditionsをchase.trueとします。逆に、Chase⇒IdleモーションのTransitionでは、chase.falseとします。
このとき、一緒にSettingsのExitTimeを0に変更して、Has Exit Timeのチェックを外しておきましょう!

Idle⇒ChaseのTransition設定
※画像はwalkになってます

アニメーションコントローラーの説明はこれで以上です。

あとは、作成したアニメーションコントローラーをキャラモデルのAnimatorに適用したり、IdleやChaseのモーションがループするように設定を変更したりするぐらいです。

アニメーションコントローラーのアタッチ
モーションのループ設定

4.エフェクトやサウンドなどの素材を適用する

タイムラインが作れたら、VFXエフェクトやサウンドをタイムラインに追加して、Attackモーションのタイミングに配置してあげれば完成です。

VFXエフェクトは、UnityのVFX Effect Graphで作ることが出来ます。
こちらの動画や記事でも紹介していますので、まだ使ったことがない方は、ぜひ参考にしてみてください。

サウンドは、自分で録音したり、ツールで作ってみてもいいですが、フリー音源サイトなどで自分のイメージに合った素材があれば、それを代用として使っても良いと思います。

まとめ

今回は、汎用的で様々な形にアレンジできる作り方だったため、記事にして紹介してみましたが、いかがだったでしょうか?
個人的には、かなり分かりやすく内容を整理して簡略化したつもりですが、思ったほど分かりやすくはなってないかもしれないですね…(;^_^A

この作り方の原理は、Unity以外のツールでゲームを作るときにも活かすことができますので、覚えておいて損することはないと思います。

1度見ただけでは理解することが難しいかもしれませんが、やっていることの原理さえ分かってしまえば、有名なアクションゲームの敵キャラの挙動も自分で再現できるようになりますので、ぜひ諦めずに時間を掛けて頑張ってください。

この記事が気に入ったらサポートをしてみませんか?