【Unity入門】2Dアクションを作ろう【各種イベント作成】

この記事は本のように順を追って解説しています。この記事は途中のページになります。この記事を見ていて、現在の状況がわからない場合や忘れてしまった事などが出てきたら↓のリンクから目次ページへ飛べますので立ち戻って見てください。

<プレイヤーが範囲内に入っているかどうかを判定する>

さて、今回は様々なイベントを作っていこうと思います。

その前に、特定のポイントにプレイヤーが来たらイベントを起こすようにしたいので、プレイヤーが範囲内にいるかどうか判定するスクリプトを書いていきたいと思います。

クリックすると展開します
using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;
 
 public class playerTriggerIn : MonoBehaviour
 {
     private string playerTag = "Player";
     private bool isIn = false;
     private bool callFixed = false;
 
     /// <summary>
     /// プレイヤーが判定の範囲内にいるかどうか
     /// </summary>
     /// <returns><c>true</c>, if player on was ised, <c>false</c> otherwise.</returns>
     public bool IsPlayerIn()
     {
         return isIn;
     }
     
 
     private void LateUpdate()
     {
                 if (callFixed)
         {
             //フラグを元に戻します
             isIn = false;
             callFixed = false;
         }
     }
     
     private void OnTriggerEnter2D(Collider2D collision)
     {
         if (collision.tag == playerTag)
         {
             isIn = true;
         }
     }
 
     private void OnTriggerStay2D(Collider2D collision)
     {
         if (collision.tag == playerTag)
         {
             isIn = true;
         }
         
     }
     
     private void OnTriggerExit2D(Collider2D collision)
     {
         if (collision.tag == playerTag)
         {
             isIn = false;
         }
     }
 }

Exitでフラグを下ろしていますが、どーにも不安定な時があるので念の為LateUpdateでフラグを下ろしています。

OnTrigger系はFixedUpdateの後に呼ばれるのでFixedUpdateとLateUpdateの処理の関係上、FixedUpdateが呼ばれたことを確認してからフラグを降ろします。

あとはカラのゲームオブジェクトにBox Collider 2DをくっつけIs Triggerにチェックを入れて、このスクリプトをくっつけたらプレイヤーが範囲内にいるかどうかを判定できるようになります。

player trigger in

緑の枠の中にプレイヤーが入ったら判定がオンになります。

いろんなものに使えるのでプレハブにしてしまいましょう。

trigger prefab

このプレハブからPrefab Variantsで派生したり、Nested Prefabにすれば色々作れそうです。

Nested PrefabとPrefab Variantsが何かわからない人は↓の記事で解説しています。

<メッセージを表示する>

プレイヤーが特定の場所に近づいたらメッセージを表示する事で様々な事ができます。アクションゲームにストーリー性を持たせることもできますし、最初に操作方法を表示するなどチュートリアルも作成する事ができます。

単純にメッセージを表示するだけならプレイヤーが範囲内に入ったらSetActive(true)にしてあげればいいだけですが、パッと出てパッと消えるとちょっと無機質なので演出を加えます。

まずCanvasを作成しましょう。

create ugui canvas

Render ModeをWorld SpaceにしてEvent CameraにMain Cameraをアタッチします。

canvas world space

キャンバス配下にPanelとTextを作成します。

create massage plate

デザイン的なのは後に置いておいて、↓のような感じにします。

massage plate

そして、PanelにCanvas Groupというコンポーネントをつけます。

panel inspector

このコンポーネントはそのUGUIとその子オブジェクト全てのUGUIのアルファ値をコントロールする事ができます。

そして、↑で作成したプレハブを追加し、プレイヤーが範囲内に入った事がわかるようにします。

Canvas配下に起きましょう。

set trigger

次に↓のスクリプトをCanvasにくっつけます。

クリックすると展開します
using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;
 
 public class fadeActiveUGUI : MonoBehaviour
 {
     [Header("フェードスピード")] public float speed = 1.0f;
     [Header("上昇量")] public float moveDis = 10.0f;
     [Header("上昇時間")] public float moveTime = 1.0f;
     [Header("キャンバスグループ")] public CanvasGroup cg;
     [Header("プレイヤー判定")] public playerTriggerIn trigger;
 
     private Vector3 defaltPos;
     private float timer = 0.0f;
 
     private void Start()
     {
         //初期化
         if (cg == null && trigger == null)
         {
             Debug.Log("インスペクターの設定が足りません");
             Destroy(this);
         }
         else
         {
             cg.alpha = 0.0f;
             defaltPos = cg.transform.position;
             cg.transform.position = defaltPos - Vector3.up * moveDis;
         }
     }
 
     private void Update()
     {
         //プレイヤーが範囲内に入った
         if (trigger.IsPlayerIn())
         {
             //上昇しながらフェードインする
             if(cg.transform.position.y < defaltPos.y || cg.alpha < 1.0f) 
             {
                 cg.alpha = timer / moveTime;
                 cg.transform.position += Vector3.up * (moveDis/ moveTime) * speed * Time.deltaTime ;
                 timer += speed * Time.deltaTime;
             }
             //フェードイン完了
             else
             {
                 cg.alpha = 1.0f;
                 cg.transform.position = defaltPos;
             }
         }
         //プレイヤーが範囲内にいない
         else
         {
             //下降しながらフェードアウトする
             if (cg.transform.position.y > defaltPos.y - moveDis || cg.alpha > 0.0f) 
             {
                 cg.alpha = timer / moveTime;
                 cg.transform.position -= Vector3.up * (moveDis / moveTime) * speed * Time.deltaTime;
                 timer -= speed * Time.deltaTime;
             }
             //フェードアウト完了
             else
             {
                 timer = 0.0f;
                 cg.alpha = 0.0f;
                 cg.transform.position = defaltPos - Vector3.up * moveDis;
             }
         }
     }
 }

スクリプトの中身はコメントを読んでいただければわかるかなと思います。

インスペクターの値を設定します。上昇量というのはちょっと上に上がりながらフェードしてほしかったので入れています。上に上がる必要がなければ0を入れればOKです。

それと、このスクリプトを使う上で、注意が必要なところがあります。

canvas-renderer-cull-transparent-mesh

PanelとTextのCanvas RendererのCull Transparent Meshにチェックを入れましょう。

透明なオブジェクトというのは存在するだけで重くなってしまうので、このように対策する必要があります。フェードアウトしている時は消えていただきましょう。

fade ugui

はい。こんな感じでフェードしながらメッセージを表示する事ができました。

<コンティニューポイント>

続いてコンティニューポイントを作って行きます。

まぁ、メッセージの表示ができたならほぼ一緒です。

continue point

まぁ、下書きなのでデザインは適当にして、↑のような感じで作りました。

そして、以前に作成したゲームマネージャーと、ステージコントローラーを使用してスクリプトを書きます。

作っていない方は↓の記事を参考にしてみてください。

クリックすると展開します
using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;
 
 public class ContinuePoint : MonoBehaviour
 {
     [Header("コンティニュー番号")] public int continueNum;
     [Header("音")] public AudioClip se;
     [Header("プレイヤー判定")] public playerTriggerIn trigger;
     [Header("スピード")] public float speed = 3.0f;
     [Header("動く幅")] public float moveDis = 3.0f;
 
     private bool on = false;
     private float kakudo = 0.0f;
     private Vector3 defaultPos;
     void Start()
     {
         //初期化
         if (trigger == null)
         {
             Debug.Log("インスペクターの設定が足りません");
             Destroy(this);
         }
         defaultPos = transform.position;
     }
 
     // Update is called once per frame
     void Update()
     {
         //プレイヤーが範囲内に入った
         if (trigger.IsPlayerIn() && !on)
         {
             GManager.instance.continueNum = continueNum;
             GManager.instance.PlaySE(se);
             on = true;
         }
 
         if (on)
         {
             if (kakudo < 180.0f)
             {
                 //sinカーブで振動させる
                 transform.position = defaultPos + Vector3.up * moveDis * Mathf.Sin(kakudo * Mathf.Deg2Rad);
                 
                 //途中からちっちゃくなる
                 if(kakudo > 90.0f)
                 {
                     transform.localScale = Vector3.one * (1 - ((kakudo - 90.0f) / 90.0f));
                 }
                 kakudo += 180.0f * Time.deltaTime * speed;
             }
             else
             {
                 gameObject.SetActive(false);
                 on = false;
             }
         }
     }
 }

プレイヤーが範囲内に入ったら音を鳴らして、コンティニュー位置をゲームマネージャーに報告します。

        //プレイヤーが範囲内に入った
         if (trigger.IsPlayerIn() && !on)
         {
             GManager.instance.continueNum = continueNum;
             GManager.instance.PlaySE(se);
             on = true;
         }

ステージコントローラーのインスペクターに追加してあげればコンティニューできるようになっています。

stage ctrl

Element1に入っているのでインスペクターで自分のコンティニュー番号を1にしてあげればコンティニューポイントの位置からスタートします。

continue point inspector

コンティニューポイントに接触した場合の演出をちょっと凝ってみました。

            if (kakudo < 180.0f)
             {
                 //sinカーブで振動させる
                 transform.position = defaultPos + Vector3.up * moveDis * Mathf.Sin(kakudo * Mathf.Deg2Rad);
                 
                 //途中からちっちゃくなる
                 if(kakudo > 90.0f)
                 {
                     transform.localScale = Vector3.one * (1 - ((kakudo - 90.0f) / 90.0f));
                 }
                 kakudo += 180.0f * Time.deltaTime * speed;

サインカーブを用いて上下させています。変数名がkakudoなのでわかりやすいと思いますが、Mathf.Sinの中にはラジアンを入れてあげなければいけません。

Mathf.Sin(kakudo * Mathf.Deg2Rad);

↑で角度をラジアンに直しています。角度にMathf.Deg2Radというものを掛ければOKです。

まぁ、わからなくても別に困ることではないので、わからなかったらなんかこんな方法もあるんだ程度に理解していただければと思います。

↓のような感じになります。

continue point

<ステージクリアー>

次はステージクリアーを実装していきましょう。

コンティニューができれば正直新しく覚えることは特にありません。

clear stage text

UGUIのテキストで適当にクリアーを作りました。なんども言いますが、下書きなので適当でOKです。

クリックすると展開します
using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;
 public class ClearEffect : MonoBehaviour
 {
     [Header("拡大縮小のアニメーションカーブ")] public AnimationCurve curve;
     [Header("ステージコントローラー")] public stageCtrl ctrl;
     private bool comp = false;     
     private float timer;
     private void Start() 
     {
          transform.localScale = Vector3.zero; 
     }

     private void Update()
     {
          if (!comp)
          {
              if (timer < 1.0f)
              {
                  transform.localScale = Vector3.one * curve.Evaluate(timer);              
                  timer += Time.deltaTime;
              }
             else
              {
                  transform.localScale = Vector3.one;
                  ctrl.ChangeScene(GManager.instance.stageNum + 1);
                  comp = true;
              }
          }
     }
 }

今度はアニメーションカーブを使用してみたいと思います。コンティニューの演出は上昇と縮小のタイミングを同期させたかったので計算でやりましたが、動きが単一ならアニメーションカーブを使えば簡単に実装できます。

animation curve clear

インスペクターでアニメーションカーブを設定しましょう。ポイントとしては、最初0から始まって、最高点が1.5になり、最後1になっている点です。

要は、ステージクリアーが最初大きさ0で始まって1.5倍の大きさまで大きくなった後、1倍の大きさまで戻るというアニメーションになります。

こういったアニメーションは普通にAnimationでやればいいのでは?と思う方もいらっしゃるかと思いますが、わざわざAnimatorをくっつけてAnimationで制御すると後々わかりづらくなってしまうのでスクリプトでします。

Animationは階層の変更ができなくなったり、制御を追いづらくなったりするので複雑な動きはAnimationで、簡単な動きはスクリプトでと分けた方がいいかなと筆者は思います。

物を動かそうとしたのにAnimationに掴まれているせいで動かせないということはよくあります。特にチーム開発で。誰かにAnimation制御されてしまうとそれはもう触れません。

後はゲームマネージャーとステージコントローラー、プレイヤーにステージクリアーした時の処理を追加します。

すいません。ホームページを編集するソフトが突然前触れもなく変になってしまって、頑張って修正したのですが、頑張った結果、元のソースコードに近い状態にするには非常に無駄な空白が間に入ってしまうという事態になってしまいました。申し訳ありません。なので不自然な改行については無視してください。この意味不明な空白が入っていないと、全文字が改行されなくなってしまいます。

おそらく空白が2つ以上あるとソースコードと認識し<code>タグを勝手に挿入し、空白と改行を削除します。その後<code>タグで囲まれているにも関わらず、< >をHTMLタグと認識しています。そのためGetComponent< >をHTMLタグとみなしているようです。意味がわからん。謎改悪です。

クリックすると展開します

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement;

public class GManager : MonoBehaviour {

public static GManager instance = null; public int score = 0; public int stageNum = 1; public int continueNum = 0; public int heartNum = 3; public int defaultHeartNum = 3; public bool isGameOver = false; public bool isStageClear = false; private AudioSource audioSource = null; private void Awake() {

if(instance == null) { instance = this; DontDestroyOnLoad(this.gameObject); } else { Destroy(this.gameObject); }

}

private void Start() {

audioSource = GetComponent<AudioSource>();

}

/// <summary> /// 残機を1つ増やす /// </summary> public void AddHeartNum() { if(heartNum < 99) { ++heartNum; } }

/// <summary> /// 残機を1つ減らす /// </summary> public void SubHeartNum() { if(heartNum > 0) { --heartNum; } else { isGameOver = true; } }

/// <summary> /// 最初から始める時の処理 /// </summary> public void RetryGame() { isGameOver = false; heartNum = defaultHeartNum; score = 0; stageNum = 1; continueNum = 0; } /// <summary> /// SEを鳴らす /// </summary> public void PlaySE(AudioClip clip) { audioSource.PlayOneShot(clip); } }

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; public class stageCtrl : MonoBehaviour {

[Header("プレイヤーゲームオブジェクト")]public GameObject playerObj; [Header("コンティニュー位置")]public GameObject[] continuePoint; [Header("ゲームオーバー")]public GameObject GameOverObj; [Header("ステージクリア")] public GameObject StageClearObj; [Header("ステージクリア判定")] public playerTriggerIn stageClearTrigger; [Header("フェード")]public FadeImage fade; [Header("ステージクリアーSE")] AudioClip clip;

private player p; private ClearEffect clear; private bool doClear = false; private int nextStageNum; private bool startFade = false;

void Start() {

//プレイヤーをスタート位置に if (playerObj != null && continuePoint != null && continuePoint.Length> 0) { playerObj.transform.position = continuePoint[0].transform.position; p = playerObj.GetComponent<player>(); } //ゲームオーバーのオブジェクトを非表示に if(GameOverObj != null) { GameOverObj.SetActive(false); } //ステージクリアーのオブジェクトを非表示に if(StageClearObj != null) { StageClearObj.SetActive(false); }

}

/// <summary> /// プレイヤーをコンティニューポイントへ移動する /// </summary> public void PlayerSetContinuePoint() { playerObj.transform.position = continuePoint[GManager.instance.continueNum].transform.position; p.ContinuePlayer(); } /// <summary> /// 最初から始める /// </summary> public void Retry() { GManager.instance.RetryGame(); ChangeScene(1); } /// <summary> /// ステージをクリアした /// </summary> public void StageClear() { GManager.instance.isStageClear = true; StageClearObj.SetActive(true); GManager.instance.PlaySE(clip); } /// <summary> /// ステージを切り替えます。 /// </summary> /// <param name="num">Number. public void ChangeScene(int num) { if (fade != null) { nextStageNum = num; fade.StartFadeOut(); startFade = true; } } // Update is called once per frame void Update() { if (GManager.instance != null) { //ゲームオーバー if (GManager.instance.isGameOver) { GameOverObj.SetActive(true); } //コンティニュー else if (continuePoint.Length > GManager.instance.continueNum) { if (p.IsDownAnimEnd()) { PlayerSetContinuePoint(); } } } //ステージを切り替える if (fade != null && startFade) { if (fade.compFadeOut) { GManager.instance.stageNum = nextStageNum; GManager.instance.isStageClear = false; SceneManager.LoadScene("stage" + nextStageNum); } } if (stageClearTrigger != null && stageClearTrigger.IsPlayerIn() && !doClear) { StageClear(); doClear = true; } }

}

using System.Collections; using System.Collections.Generic; using UnityEngine; public class player : MonoBehaviour {

#region//インスペクターで設定する [Header("移動速度")]public float speed; [Header("重力")]public float gravity; [Header("踏みつけ判定の高さの割合")] public float stepOnRate; [Header("ジャンプ速度")]public float jumpSpeed; [Header("ジャンプする高さ")]public float jumpHeight; [Header("ダッシュの速さ表現")]public AnimationCurve dashCurve; [Header("ジャンプの速さ表現")]public AnimationCurve jumpCurve; [Header("ジャンプする時に鳴らすSE")]public AudioClip jumpSE; [Header("やられた鳴らすSE")]public AudioClip downSE; [Header("コンティニュー時に鳴らすSE")]public AudioClip continueSE; #endregion #region//プライベート変数 private Animator anim = null; private Rigidbody2D rb = null; private CapsuleCollider2D capcol = null; private SpriteRenderer sr = null; private string groundTag = "Ground"; private string enemyTag = "Enemy"; private bool isGroundEnter, isGroundStay, isGroundExit; private bool isGround = false; private bool isJump = false; private bool isOtherJump = false; private bool isRun = false; private bool isDown = false; private bool isContinue = false; private bool isClearMotion = false; private float jumpPos = 0.0f; private float otherJumpHeight = 0.0f; private float dashTime, jumpTime,continueTime,blinkTime; private float beforeKey; #endregion

private void Start() {

//コンポーネントのインスタンスを捕まえる

anim = GetComponent<Animator>(); rb = GetComponent<Rigidbody2D>(); capcol = GetComponent<CapsuleCollider2D>(); sr = GetComponent<SpriteRenderer>();

}

private void Update() {

if (isContinue) {

//明滅 ついている時に戻る

if(blinkTime> 0.2f) { sr.enabled = true; blinkTime = 0.0f; } //明滅 消えているとき else if(blinkTime > 0.1f) { sr.enabled = false; } //明滅 ついているとき else { sr.enabled = true; } //1秒たったら明滅終わり if(continueTime > 1.0f) { isContinue = false; blinkTime = 0f; continueTime = 0f; sr.enabled = true; } else { blinkTime += Time.deltaTime; continueTime += Time.deltaTime; }

}

}

private void FixedUpdate() { if (!isDown && !GManager.instance.isGameOver && !GManager.instance.isStageClear) { GroundCheck(); rb.velocity = new Vector2(SetX(), SetY()); SetAnimation(); } else { if(!isClearMotion && GManager.instance.isStageClear) { anim.Play("player_clear"); rb.velocity = new Vector2(0,-gravity); isClearMotion = true; } } }

/// <summary> /// ダウンアニメーションが終わっているかどうか /// </summary> public bool IsDownAnimEnd() { if (isDown && anim != null) { AnimatorStateInfo currentState = anim.GetCurrentAnimatorStateInfo(0); if (currentState.IsName("player_down")) { if (currentState.normalizedTime >= 1) { return true; } } } return false; }

/// <summary> /// コンティニューする /// </summary> public void ContinuePlayer() { isDown = false; anim.Play("player_stand"); isJump = false; isOtherJump = false; isRun = false; isContinue = true; GManager.instance.PlaySE(continueSE); }

/// <summary> /// 接地しているかどうかの判定をとる /// </summary> private void GroundCheck() { if(isGroundEnter || isGroundStay) { isGround = true; } else if(isGroundExit) { isGround = false; } isGroundEnter = false; isGroundStay = false; isGroundExit = false; }

/// <summary> /// Y成分で必要な計算をし、速度を返す。 /// </summary> private float SetY() {

float verticalKey = Input.GetAxis("Vertical"); float ySpeed = -gravity; //地面にいるとき

if(isGround) {

if (verticalKey> 0) { ySpeed = jumpSpeed; jumpPos = transform.position.y; //ジャンプした位置を記録する GManager.instance.PlaySE(jumpSE); isJump = true; } else { isJump = false; } isOtherJump = false; jumpTime = 0.0f;

} //何かを踏んだ際のジャンプ

else if (isOtherJump) {

//現在の高さがジャンプした位置から自分の決めた位置より下ならジャンプを継続する

if (jumpPos + otherJumpHeight> transform.position.y) { ySpeed = jumpSpeed; jumpTime += Time.deltaTime; } else { isOtherJump = false; jumpTime = 0.0f; }

} //ジャンプ中 else if(isJump) {

//上ボタンを押されている。かつ、現在の高さがジャンプした位置から自分の決めた位置より下ならジャンプを継続する

if (verticalKey> 0 && jumpPos + jumpHeight > transform.position.y) { ySpeed = jumpSpeed; jumpTime += Time.deltaTime; } else { isJump = false; jumpTime = 0.0f; }

} if (isJump || isOtherJump) { ySpeed *= jumpCurve.Evaluate(jumpTime); } return ySpeed;

}

/// <summary> /// X成分で必要な計算をし、速度を返す。 /// </summary> private float SetX() { float xSpeed; float horizontalKey = Input.GetAxis("Horizontal"); if (horizontalKey > 0) { transform.localScale = new Vector3(1, 1, 1); isRun = true; xSpeed = speed; dashTime += Time.deltaTime; } else if (horizontalKey < 0) { transform.localScale = new Vector3(-1, 1, 1); isRun = true; xSpeed = -speed; dashTime += Time.deltaTime; } else { isRun = false; xSpeed = 0.0f; dashTime = 0.0f; } if (horizontalKey > 0 && beforeKey < 0) { dashTime = 0.0f; } else if (horizontalKey <0 && beforeKey> 0) { dashTime = 0.0f; } xSpeed *= dashCurve.Evaluate(dashTime); beforeKey = horizontalKey; return xSpeed; }

/// <summary> /// アニメーションを設定する /// </summary> private void SetAnimation() { anim.SetBool("jump", isJump || isOtherJump); anim.SetBool("ground", isGround); anim.SetBool("run", isRun); }

#region//接地判定 private void OnTriggerEnter2D(Collider2D collision) { if (collision.tag == groundTag) { isGroundEnter = true; } } private void OnTriggerStay2D(Collider2D collision) { if (collision.tag == groundTag) { isGroundStay = true; } } private void OnTriggerExit2D(Collider2D collision) { if (collision.tag == groundTag) { isGroundExit = true; } } #endregion

#region//接触判定 private void OnCollisionEnter2D(Collision2D collision) { if (collision.collider.tag == enemyTag) { //踏みつけ判定になる高さ
float stepOnHeight = (capcol.size.y * (stepOnRate / 100f));
//踏みつけ判定のワールド座標
float judgePos = transform.position.y - (capcol.size.y / 2f) + stepOnHeight;
foreach (ContactPoint2D p in collision.contacts)
{ {
ObjectCollision o = collision.gameObject.GetComponent<ObjectCollision>();
if (o != null)
{ otherJumpHeight = o.boundHeight; //踏んづけたものから跳ねる高さを取得する o.playerStepOn = true; //踏んづけたものに対して踏んづけた事を通知する isOtherJump = true; isJump = false; jumpTime = 0.0f; } else { Debug.Log("ObjectCollisionが付いてないよ!"); } } else { anim.Play("player_down"); isDown = true; GManager.instance.SubHeartNum(); GManager.instance.PlaySE(downSE); break; } } } } #endregion

}

配置としてはこんな感じです。Stage Clearとなっていますが、これが↑でプレハブにしたプレイヤーの侵入判定です。

stage clear object

ステージクリアーはUGUIの表示順の関係上ゲームオーバーの上に持ってくるといいと思います。

インスペクターの設定を忘れずに

clear effect inspector

できたら↓のようになります。

stage clear

ちょっとフェードアウトするのが早い気がしますが、この辺は下書きから本ちゃんにした時にちゃんとしましょう。

以上、ステージクリアーでした。

<わからない事があったら>

このサイトの説明ではよくわからなかったとか、もっと知りたい事などがあれば、また別の勉強方法があるので違った切り口を使ってみるのもいいと思います。

<オススメの本>

本で詳しい解説がされているので書籍を買ってみるというのも手の一つです。最近はKindle版があるので届くまで待つ事もなく場所も取らないのでとても良いです。

<オンラインスクール>

オンラインスクールでは人に質問する事ができるので、行き詰まってしまった方にオススメです。 無料体験もあるので試しに見てみるのも手ですよ

タイトルとURLをコピーしました