ターン制RPGの作り方#4|コマンドをデータにして、メニューに並べよう

「スライムは どうする?」と出て、左下に「たたかう・まほう・どうぐ・ぼうぎょ・にげる」のボタン。→キーで▶が「まほう」に動きEnterで決めると「フェアリーは どうする?」。Escでスライムちゃんに戻り「どうぐ」。フェアリーは「たたかう」、キョンシーの番ではボタンが「たたかう・わざ・どうぐ・ぼうぎょ・にげる」に変わり、「ぼうぎょ」を選ぶと「コマンドが きまった!(どうぐ・たたかう・ぼうぎょ)」

RPGのバトルといえば、「たたかう」「まほう」「どうぐ」・・・とコマンドを選ぶ画面ですよね。キーボードの矢印キーやゲームパッドの十字キーで▶を動かして、決定ボタンで決める。今回は、コマンドを1つずつのデータ(ScriptableObject)にして、仲間ごとに持たせたコマンドを左下のウインドに並べ、3人分のコマンドを順番に選べるようにしてみたいと思います!

目次

今回作るもの

  • コマンドの共通の形BattleCommandと、それを受け継いだ5つのコマンド
  • 仲間ごとに、使えるコマンドのリストを持たせる(キョンシーだけ「まほう」が「わざ」)
  • 仲間のコマンドから、ボタンを作って並べるメニュー
  • 3人分のコマンドを順番に選び、キャンセルで前の人に戻る
スクリプト役割付ける場所
BattleCommandコマンド1つ分の共通の形(abstract)(付けない。受け継いで使う)
AttackCommand ほか4つたたかう・まほう・どうぐ・ぼうぎょ・にげる(付けない。アセットを作る)
BattleAction1人分の「どのコマンドを・だれに」(付けない)
BattleContextコマンドが使う、バトルの道具をまとめたもの(付けない)
CharacterDataコマンドのリストを足す(#2のものに足す)-
MenuCursor選ばれたら▶を出すCommandButtonのプレハブ
CommandMenuコマンドからボタンを作り、押されたものを返すCommandWindow
BattleLineup3人分のコマンドを順に選ぶBattle

キーボードとゲームパッドでUIを操作する仕組みは9-8 キーボードとゲームパッドでUIを操作しよう、継承は6-14 クラスを受け継ごう:継承とabstract、待つ処理は6-16 待つ処理をすっきり書こうで紹介しています。

コマンドを1つずつのデータにしよう

どうして1つずつに分けるの?

コマンドを作るとき、「たたかう=0、まほう=1・・・」のように番号(enum)で区別して、使うところでswitchで分ける作り方もあります。ただ、その作り方だと次のようなところで困ります。

  • コマンドを1つ足すたびに、番号・ボタン・switchなど、何か所も直す必要がある
  • 全員が同じコマンドになりやすく、「キョンシーだけのコマンド」のような個性が出しにくい

そこでこのシリーズでは、コマンドを1つずつ独立したクラスにして、データ(ScriptableObject)として作ります。

  • 共通の形(名前を持つ・選ばれたら何かを選ぶ)は、親のBattleCommandにまとめる
  • 「たたかう」なら相手を選ぶ、「まほう」なら一覧を開く、のような違いは、それぞれの子クラスに書く
  • 仲間のデータに「使えるコマンドのリスト」を持たせて、メニューはそのリストからボタンを作る

こうしておくと、新しいコマンドを足すときは、クラスを1つ作って、仲間のリストに入れるだけになります。ほかのコマンドのコードには触らないので、足したせいで別のコマンドが壊れる心配もありません。

スライム

まほうの種類が増えるたびに、コマンドのクラスも増えちゃうんですか・・・?

フェアリー

まほうの1つ1つは、#13でデータ(まほうのデータ)として作るよ。「まほう」コマンドは、その一覧を開くだけ。だからコマンドのクラスは、たたかう・まほう・どうぐ・ぼうぎょ・にげるの5つで足りるんだ

BattleActionとBattleContext

先に、コマンドとやり取りする小さなクラスを2つ作ります。どちらもMonoBehaviourではない、ふつうのクラスです。

  • 「BattleAction」スクリプトを作る
// 1人分の「どのコマンドを・だれに」使うか(バトルの流れで、すばやさ順に実行する)
public class BattleAction
{
    public BattleCommand Command { get; }
    public UnitView Target { get; } // 相手がいないコマンド(ぼうぎょ など)は null

    public BattleAction(BattleCommand command, UnitView target)
    {
        Command = command;
        Target = target;
    }
}
  • 「BattleContext」スクリプトを作る
using TMPro;

// コマンドが相手を選んだり、メッセージを出したりするときに使う、バトルの道具をまとめたもの
public class BattleContext
{
    public TMP_Text MessageText; // 上のメッセージ
}
  • BattleAction
    • コマンドを選び終わったときの答え。{ get; }だけのプロパティは、作るとき(コンストラクタ)にだけ値を入れられて、あとから書き換えられない
  • BattleContext
    • コマンドのデータ(ScriptableObject)は、シーンのオブジェクトをInspectorでつないでおけない。そこで、使う道具を1つの入れ物にまとめて、選ぶときに渡します
    • 今はメッセージだけですが、#6で「相手を選ぶ道具」や「敵の一覧」を足します

BattleCommandと5つのコマンド

  • TurnBasedRpg/Scriptsに「Commands」フォルダを作り、その中に「BattleCommand」スクリプトを作る
using UnityEngine;

// コマンド1つ分の共通の形(たたかう・まほう・どうぐ・ぼうぎょ・にげる は、これを受け継いで作る)
public abstract class BattleCommand : ScriptableObject
{
    [SerializeField] private string commandName; // ボタンに出す名前

    public string CommandName => commandName;

    // コマンドを選んだあとに、相手などを選ぶ。キャンセルされたら null を返す
    // 何も選ばないコマンドは、このまま決まる
    public virtual async Awaitable<BattleAction> SelectAsync(BattleContext context)
    {
        // ほかのコマンドと同じ「待って答えを返す」形にそろえるため、1フレームだけ待つ
        await Awaitable.NextFrameAsync();
        return new BattleAction(this, null);
    }
}
  • abstract class BattleCommand : ScriptableObject
    • ScriptableObjectを受け継いだ、コマンドの土台。abstractなので、BattleCommandそのもののアセットは作れない。作るのは、受け継いだ子クラスのアセットだけ
  • commandName
    • ボタンに出す名前。クラスとは別に名前を持たせているので、同じクラスでも、アセットごとに違う名前にできる
  • virtual SelectAsync
    • 「コマンドを選んだあとに、何を選ぶか」。virtualなので、子クラスでoverrideして書き換えられる(#6で、たたかうが相手を選ぶように書き換えます)
    • 何も書き換えなければ、相手なし(null)のBattleActionを返して、そのまま決まる

続けて、5つのコマンドのクラスを作ります(Commandsフォルダの中に、1つずつ別のファイルで)。中身はまだ空っぽで、それぞれのコマンドが必要になった回で、自分のクラスにだけ中身を足していきます。

using UnityEngine;

// たたかう(#6 で、相手を選ぶようにする)
[CreateAssetMenu(fileName = "Attack", menuName = "TurnBasedRpg/Commands/Attack")]
public class AttackCommand : BattleCommand
{
}
using UnityEngine;

// まほう・わざ(#13 で、まほうの一覧を開くようにする)
[CreateAssetMenu(fileName = "Magic", menuName = "TurnBasedRpg/Commands/Magic")]
public class MagicCommand : BattleCommand
{
}
using UnityEngine;

// どうぐ(#16 で、持ち物の一覧を開くようにする)
[CreateAssetMenu(fileName = "Item", menuName = "TurnBasedRpg/Commands/Item")]
public class ItemCommand : BattleCommand
{
}
using UnityEngine;

// ぼうぎょ(何も選ばない。実行は #9 で作る)
[CreateAssetMenu(fileName = "Guard", menuName = "TurnBasedRpg/Commands/Guard")]
public class GuardCommand : BattleCommand
{
}
using UnityEngine;

// にげる(何も選ばない。実行は #11 で作る)
[CreateAssetMenu(fileName = "Run", menuName = "TurnBasedRpg/Commands/Run")]
public class RunCommand : BattleCommand
{
}

ScriptableObjectのクラスは、ファイル名とクラス名を同じにして、1つのファイルに1つずつ書きます。名前が違うと、アセットを作ったときに「スクリプトが見つからない」状態になります。

コマンドのアセットを作る

  • TurnBasedRpg/Dataに「Commands」フォルダを作り、その中で右クリック>Create>TurnBasedRpg>Commands から、次の6つを作る
作るものアセットの名前Command Name
AttackAttackたたかう
MagicMagicまほう
MagicSkillわざ
ItemItemどうぐ
GuardGuardぼうぎょ
RunRunにげる

「わざ」は、キョンシーの「まほう」の代わりです。キョンシーはまほうが苦手なので、拳法の「わざ」を使います。クラスは同じMagicCommandで、名前だけが違うアセットにしています。

Data>Commandsの中にAttack・Guard・Item・Magic・Run・Skill。Skillを選択したInspectorで、ScriptがMagicCommand、Command Nameが「わざ」

仲間にコマンドのリストを持たせる

#2で作ったCharacterDataに、コマンドのリストを足します。

    [SerializeField] private BattleCommand[] commands; // コマンドのボタンに並べるもの(上から順に)

    public BattleCommand[] Commands => commands;
  • 変数はgrowthの下に、プロパティはGrowthの下に足す
  • BattleCommand[]
    • 親の型の配列には、子クラスのアセット(AttackCommandもGuardCommandも)を入れられる。これが継承のうれしいところです

4人のデータのCommandsに、次の順でアセットをドラッグします。

仲間Commands
スライムちゃん・フェアリー・メドゥーサAttack・Magic・Item・Guard・Run
キョンシーAttack・Skill・Item・Guard・Run
JiangshiのデータのInspectorで、CommandsにAttack・Skill・Item・Guard・Runの5つが入っている

コマンドのボタンをプレハブにしよう

ボタンは、仲間のコマンドの数だけスクリプトから作ります。まずは1つ分をプレハブにします。

2列に並べる

左下のウインドは高さ260しかないので、5つを縦に並べると窮屈です。Grid Layout Groupで2列に並べます(Layout Groupの基本は9-6 Scroll ViewとLayout Groupでリストを作ろう)。

  • CommandWindowにAdd Component>Grid Layout Group
    • Padding:Left・Right・Top・Bottom 24
    • Cell Size:X 180、Y 60
    • Spacing:X 12、Y 12
    • Constraint:Fixed Column Count、Constraint Count:2

Grid Layout Groupは、子を表のマス目のように並べてくれます。Cell Sizeが1マスの大きさで、Constraint Countが列の数です。

ボタンを横にも並べるので、ButtonのNavigationはAutomaticのままにしておきます。矢印キーの上下左右で、近いボタンへ動いてくれます。

CommandButtonを作る

  • CommandWindowの子に、UI (Canvas)>Button – TextMeshPro を1つ作り、名前を「CommandButton」にする
    • 子の文字(Text (TMP))は Font Size:32
    • ButtonのHighlighted ColorとSelected Colorを黄色(255, 220, 90)にする
  • CommandButtonの子に、Text – TextMeshPro で「Cursor」を作る
    • 文字:「▶」、Font Size:28、Color:#333333
    • アンカー:middle-left、Pos X:22、Width・Height:36
    • Text (TMP)より下に置く(スクリプトが、上にある方の文字をコマンドの名前に書き換えるため)

選ばれたボタンに▶を出すのは、9-8 キーボードとゲームパッドでUIを操作しようで作ったMenuCursorです。「MenuCursor」スクリプトを作ります(9-8と同じものです)。

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

// 選ばれているあいだだけ、▶のカーソルを出す
public class MenuCursor : MonoBehaviour, ISelectHandler, IDeselectHandler, IPointerEnterHandler
{
    [SerializeField] private GameObject cursor;

    private Selectable selectable;

    private void Awake()
    {
        selectable = GetComponent<Selectable>();
        cursor.SetActive(false);
    }

    private void OnDisable()
    {
        // 選ばれたまま隠れたときに、▶が残らないようにする
        cursor.SetActive(false);
    }

    // 選ばれたとき(キーボード・ゲームパッド・クリック)
    public void OnSelect(BaseEventData eventData)
    {
        cursor.SetActive(true);
    }

    // 選択が外れたとき
    public void OnDeselect(BaseEventData eventData)
    {
        cursor.SetActive(false);
    }

    // マウスを乗せたら、そのボタンを選ぶ(マウスとキーボードで、▶の場所をそろえる)
    public void OnPointerEnter(PointerEventData eventData)
    {
        if (selectable.IsInteractable())
        {
            EventSystem.current.SetSelectedGameObject(gameObject);
        }
    }
}
  • CommandButtonにMenuCursorを付けて、Cursorに子のCursorをドラッグ
  • CommandButtonをTurnBasedRpg/Prefabsにドラッグしてプレハブにし、シーンのCommandButtonは消す
CommandButtonのプレハブの編集画面。HierarchyにCommandButton>Text (TMP)・Cursor。InspectorにButton(Highlighted Color・Selected Colorが黄色)とMenu Cursor(CursorにCursor)

選ばれたコマンドを受け取ろう

InputSystem_Actionsをスクリプトで使えるようにする

キャンセル(Escキーやゲームパッドの右のボタン)を受け取るのに、InputSystem_Actionsのクラスを使います。

  • ProjectウインドでAssets>Settings>InputSystem_Actionsを選ぶ
  • InspectorのGenerate C# Classにチェックを入れて、Apply
Assets>Settings>InputSystem_ActionsのInspectorで、Generate C# Classにチェックが入り、C# Class FileがAssets/Settings/InputSystem_Actions.cs

くわしくは6-7 Input Systemで入力を受け取ろうで紹介しています。

CommandMenu

  • 「CommandMenu」スクリプトを作る
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

// 左下のコマンドのウインド。仲間のコマンドからボタンを作り、押されたコマンドを返す
public class CommandMenu : MonoBehaviour
{
    [SerializeField] private Button buttonPrefab; // CommandButton のプレハブ

    // 選ばれたコマンドが入る箱(キャンセルなら null が入る)
    private readonly AwaitableCompletionSource<BattleCommand> commandSource = new AwaitableCompletionSource<BattleCommand>();
    private readonly List<Button> buttons = new List<Button>();
    private InputSystem_Actions inputActions;
    private bool isWaiting;
    private GameObject lastSelected;

    private void Awake()
    {
        inputActions = new InputSystem_Actions();
    }

    private void OnEnable()
    {
        inputActions.Enable();
    }

    private void OnDisable()
    {
        inputActions.Disable();
    }

    private void Update()
    {
        if (!isWaiting)
        {
            return;
        }

        // キャンセル(Esc・ゲームパッドの右のボタン)
        if (inputActions.UI.Cancel.WasPressedThisFrame())
        {
            Choose(null);
            return;
        }

        // 何もないところをクリックして選択が外れたら、前に選んでいたボタンを選び直す
        GameObject current = EventSystem.current.currentSelectedGameObject;
        if (current == null)
        {
            if (lastSelected != null)
            {
                EventSystem.current.SetSelectedGameObject(lastSelected);
            }
        }
        else
        {
            lastSelected = current;
        }
    }

    // 仲間のコマンドでボタンを作り、どれかが押されるまで待つ(キャンセルなら null)
    public async Awaitable<BattleCommand> WaitForCommandAsync(BattleCommand[] commands)
    {
        // 前の人の決定(Enter など)が、まだこのフレームに残っていることがあるので、1フレーム待ってから始める
        await Awaitable.NextFrameAsync(destroyCancellationToken);
        CreateButtons(commands);
        commandSource.Reset();
        isWaiting = true;
        lastSelected = buttons[0].gameObject;
        EventSystem.current.SetSelectedGameObject(lastSelected);

        BattleCommand command = await commandSource.Awaitable;

        isWaiting = false;
        return command;
    }

    // コマンドの数だけボタンを作る(前のボタンは消す)
    private void CreateButtons(BattleCommand[] commands)
    {
        foreach (Button oldButton in buttons)
        {
            // Destroy はフレームの最後に消えるので、先に隠して、並びから外しておく
            oldButton.gameObject.SetActive(false);
            Destroy(oldButton.gameObject);
        }
        buttons.Clear();

        foreach (BattleCommand command in commands)
        {
            Button button = Instantiate(buttonPrefab, transform);
            button.GetComponentInChildren<TMP_Text>().text = command.CommandName;
            button.onClick.AddListener(() => Choose(command));
            buttons.Add(button);
        }
    }

    private void Choose(BattleCommand command)
    {
        // 待っていないときに押されても、何もしない
        if (isWaiting)
        {
            commandSource.TrySetResult(command);
        }
    }
}
  • WaitForCommandAsync(BattleCommand[] commands)
    • 仲間のコマンドのリストを受け取って、その数だけボタンを作る。押されたボタンのコマンドを返し、キャンセルならnullを返す
    • メニューは「どんなコマンドがあるか」を知らない。データに入っているものを並べるだけなので、コマンドが増えてもCommandMenuは直さなくていい
  • AwaitableCompletionSource
    • 6-16 待つ処理をすっきり書こうで使った「あとで結果が入る箱」。ボタンが押されたらTrySetResultで箱にコマンドを入れ、await commandSource.Awaitableで待っていた側が動き出す
  • await Awaitable.NextFrameAsync(…)
    • 前の人のコマンドをEnterで決めると、次の人の待ちが、同じフレームの中で始まることがある。そのままだと、まだ残っているEnterで、次の人のボタンまで押されてしまう。1フレーム待ってから始めると、その心配がなくなります
  • button.onClick.AddListener(() => Choose(command))
    • そのボタンが押されたら、そのボタンのコマンドでChooseを呼ぶ。() =>の中のcommandは、ボタンごとに違うものが入る
  • GetComponentInChildren()
    • 子の中から、一番上にある文字を探す。CommandButtonでは、Text (TMP)をCursorより上に置いたので、Text (TMP)が見つかる
  • SetActive(false) してから Destroy
    • Destroyはフレームの最後まで残るので、そのままだとGrid Layout Groupが古いボタンも数えて、一瞬ずれて並ぶ。先に隠しておくと、並びから外れる

TrySetResultの「Try」は、もう結果が入っていたら何もしない、という意味です。ボタンを素早く2回押しても、2回目は無視されるので、1人分のコマンドが2人分に進んでしまうことはありません。

CommandWindowにCommandMenuを付けて、Button PrefabにPrefabsのCommandButtonをドラッグします。

CommandWindowのInspector。Grid Layout GroupはCell Size 180×60・Spacing 12×12・Fixed Column Count 2、Command MenuのButton PrefabにCommandButton

3人分を順に選ぼう

最後に、BattleLineupで3人のコマンドを順番に選びます。バトルの流れは#9でBattleManagerにまとめるので、今回はBattleLineupに仮に書いておきます。

using TMPro;
using UnityEngine;

// データから仲間と敵を並べる(バトルの流れは #9 で BattleManager に移す)
public class BattleLineup : MonoBehaviour
{
    [SerializeField] private CharacterData[] party;  // バトルに出る3人
    [SerializeField] private UnitView[] partyViews;  // PartyArea の3人
    [SerializeField] private EnemyData[] enemies;    // 出てくる敵(1〜4体)
    [SerializeField] private UnitView enemyPrefab;
    [SerializeField] private RectTransform enemyArea;
    [SerializeField] private float enemySpacing = 300f; // 敵の横の間隔
    [SerializeField] private StatusWindow statusWindow;
    [SerializeField] private CommandMenu commandMenu;
    [SerializeField] private TMP_Text messageText;

    private StatusRow[] statusRows;
    private BattleContext context;

    private async void Start()
    {
        statusRows = new StatusRow[party.Length];
        for (int i = 0; i < partyViews.Length; i++)
        {
            CharacterData character = party[i];
            partyViews[i].SetupCharacter(character);
            statusRows[i] = statusWindow.AddRow(character);
        }

        for (int i = 0; i < enemies.Length; i++)
        {
            UnitView view = Instantiate(enemyPrefab, enemyArea);
            // 真ん中をそろえて横に並べ、1体おきに上下へずらす
            float x = (i - (enemies.Length - 1) / 2f) * enemySpacing;
            float y = (i % 2 == 0) ? 60f : -60f;
            ((RectTransform)view.transform).anchoredPosition = new Vector2(x, y);
            view.SetupEnemy(enemies[i]);
        }

        // コマンドに渡す道具をまとめる
        context = new BattleContext();
        context.MessageText = messageText;

        try
        {
            while (true)
            {
                await SelectActionsAsync();
                await Awaitable.WaitForSecondsAsync(2f, destroyCancellationToken);
            }
        }
        catch (System.OperationCanceledException)
        {
            // オブジェクトが消えたら、そこで終わり
        }
    }

    // 3人のコマンドを順に選ぶ(キャンセルで前の人に戻る)
    private async Awaitable SelectActionsAsync()
    {
        BattleAction[] actions = new BattleAction[party.Length];
        int index = 0;
        while (index < party.Length)
        {
            messageText.text = $"{party[index].CharacterName}は どうする?";

            BattleCommand command = await commandMenu.WaitForCommandAsync(party[index].Commands);
            BattleAction action = null;
            if (command != null)
            {
                action = await command.SelectAsync(context);
            }

            if (command == null)
            {
                // コマンドでキャンセルしたら、前の人に戻る(1人目なら、そのまま1人目)
                if (index > 0)
                {
                    index--;
                }
                continue;
            }

            if (action == null)
            {
                // 相手選びなどでキャンセルしたら、同じ人のコマンドを選び直す
                continue;
            }

            actions[index] = action;
            index++;
        }

        messageText.text = $"コマンドが きまった!({ActionText(actions[0])}・{ActionText(actions[1])}・{ActionText(actions[2])})";
    }

    // 画面に出す説明(相手がいれば、相手の名前も付ける)
    private string ActionText(BattleAction action)
    {
        if (action.Target != null)
        {
            return $"{action.Command.CommandName}({action.Target.name})";
        }
        return action.Command.CommandName;
    }
}
  • private async void Start()
  • WaitForCommandAsync(party[index].Commands)
    • その人のデータに入っているコマンドでボタンを作って待つ。キョンシーの番になると、ボタンが「わざ」に変わる
  • command.SelectAsync(context)
    • 選ばれたコマンドに「このあと何を選ぶか」をまかせる。BattleLineupは、どのコマンドが選ばれたかをswitchで見分けていない
  • command が null / action が null
    • コマンドでキャンセルしたら前の人に、相手選びなどでキャンセルしたら同じ人のコマンドに戻る。今はどのコマンドも相手を選ばないので、actionがnullになるのは#6からです
  • index と while
    • 今だれのコマンドを選んでいるかをindexで持つ。forではなくwhileにしているのは、戻ることがあるので、回数が決まっていないから

BattleのBattle Lineupに、Command MenuにCommandWindow、Message TextにMessageTextをドラッグします。

新しいコマンドを足すときは、①BattleCommandを受け継いだクラスを作る、②アセットを作って名前を付ける、③仲間のCommandsに入れる、の3つだけです。CommandMenuにもBattleLineupにも、手を入れる必要はありません。

BattleのBattle Lineupで、Command MenuにCommandWindow、Message TextにMessageTextが入っている

動かして確認!

再生して、キーボードで操作してみましょう。

キーボードで3人のコマンドを選ぶ。Escで前の人に戻れる。キョンシーの番では、ボタンが「たたかう・わざ・どうぐ・ぼうぎょ・にげる」に変わる。最後に「コマンドが きまった!(どうぐ・たたかう・ぼうぎょ)」

#4の動作確認

  • 再生すると「スライムは どうする?」と出て、5つのコマンドのボタンが並ぶ
  • 矢印キー(またはゲームパッドの十字キー)で▶が動き、Enter(下のボタン)で決まる
  • 決めると次の仲間に進み、Esc(右のボタン)で前の人に戻る
  • キョンシーの番では、「まほう」の代わりに「わざ」が並ぶ
  • 3人分選ぶと「コマンドが きまった!」と出て、2秒後にまた1人目から選べる
スライム

キョンシーのボタンだけ「わざ」になりました!データを変えるだけなんですね

試しに、キョンシーのCommandsから「Run」を外して再生してみてください。キョンシーの番だけ、ボタンが4つになります。スクリプトを1行も変えずに、仲間ごとにコマンドを変えられます。

あとがき

今回は、コマンドを1つずつのデータにして、仲間ごとのコマンドをメニューに並べました。BattleLineupが「どのコマンドか」を知らなくても、コマンドが自分で動くので、あとからコマンドを足しても安心です。次回は、今だれのコマンドを選んでいるのかが、ひと目で分かるようにしたいと思います!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメント一覧 (2件)

コメントする

目次