
RPGのバトルといえば、「たたかう」「まほう」「どうぐ」・・・とコマンドを選ぶ画面ですよね。キーボードの矢印キーやゲームパッドの十字キーで▶を動かして、決定ボタンで決める。今回は、コマンドを1つずつのデータ(ScriptableObject)にして、仲間ごとに持たせたコマンドを左下のウインドに並べ、3人分のコマンドを順番に選べるようにしてみたいと思います!
- シリーズの目次:ターン制RPGの作り方
- 前回:#3 仲間のHPとMPを表示するステータスのウインドを作ろう
今回作るもの
- コマンドの共通の形
BattleCommandと、それを受け継いだ5つのコマンド - 仲間ごとに、使えるコマンドのリストを持たせる(キョンシーだけ「まほう」が「わざ」)
- 仲間のコマンドから、ボタンを作って並べるメニュー
- 3人分のコマンドを順番に選び、キャンセルで前の人に戻る
| スクリプト | 役割 | 付ける場所 |
|---|---|---|
| BattleCommand | コマンド1つ分の共通の形(abstract) | (付けない。受け継いで使う) |
| AttackCommand ほか4つ | たたかう・まほう・どうぐ・ぼうぎょ・にげる | (付けない。アセットを作る) |
| BattleAction | 1人分の「どのコマンドを・だれに」 | (付けない) |
| BattleContext | コマンドが使う、バトルの道具をまとめたもの | (付けない) |
| CharacterData | コマンドのリストを足す(#2のものに足す) | - |
| MenuCursor | 選ばれたら▶を出す | CommandButtonのプレハブ |
| CommandMenu | コマンドからボタンを作り、押されたものを返す | CommandWindow |
| BattleLineup | 3人分のコマンドを順に選ぶ | 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そのもののアセットは作れない。作るのは、受け継いだ子クラスのアセットだけ
- ScriptableObjectを受け継いだ、コマンドの土台。
- 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 |
|---|---|---|
| Attack | Attack | たたかう |
| Magic | Magic | まほう |
| Magic | Skill | わざ |
| Item | Item | どうぐ |
| Guard | Guard | ぼうぎょ |
| Run | Run | にげる |
「わざ」は、キョンシーの「まほう」の代わりです。キョンシーはまほうが苦手なので、拳法の「わざ」を使います。クラスは同じMagicCommandで、名前だけが違うアセットにしています。


仲間にコマンドのリストを持たせる
#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 |


コマンドのボタンをプレハブにしよう
ボタンは、仲間のコマンドの数だけスクリプトから作ります。まずは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が列の数です。
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は消す


選ばれたコマンドを受け取ろう
InputSystem_Actionsをスクリプトで使えるようにする
キャンセル(Escキーやゲームパッドの右のボタン)を受け取るのに、InputSystem_Actionsのクラスを使います。
- ProjectウインドでAssets>Settings>InputSystem_Actionsを選ぶ
- InspectorのGenerate C# Classにチェックを入れて、Apply


くわしくは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で待っていた側が動き出す
- 6-16 待つ処理をすっきり書こうで使った「あとで結果が入る箱」。ボタンが押されたら
- 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をドラッグします。


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()
- 入口なので
async voidにして、中でtry〜catchを書く(6-16 待つ処理をすっきり書こうの決まりごと)
- 入口なので
- WaitForCommandAsync(party[index].Commands)
- その人のデータに入っているコマンドでボタンを作って待つ。キョンシーの番になると、ボタンが「わざ」に変わる
- command.SelectAsync(context)
- 選ばれたコマンドに「このあと何を選ぶか」をまかせる。BattleLineupは、どのコマンドが選ばれたかを
switchで見分けていない
- 選ばれたコマンドに「このあと何を選ぶか」をまかせる。BattleLineupは、どのコマンドが選ばれたかを
- 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にも、手を入れる必要はありません。


動かして確認!
再生して、キーボードで操作してみましょう。


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



キョンシーのボタンだけ「わざ」になりました!データを変えるだけなんですね
試しに、キョンシーのCommandsから「Run」を外して再生してみてください。キョンシーの番だけ、ボタンが4つになります。スクリプトを1行も変えずに、仲間ごとにコマンドを変えられます。
あとがき
今回は、コマンドを1つずつのデータにして、仲間ごとのコマンドをメニューに並べました。BattleLineupが「どのコマンドか」を知らなくても、コマンドが自分で動くので、あとからコマンドを足しても安心です。次回は、今だれのコマンドを選んでいるのかが、ひと目で分かるようにしたいと思います!
コメント
コメント一覧 (2件)
[…] 前回:#4 コマンドをデータにして、メニューに並べよう […]
[…] 「たたかう」を選んだら、次はどの敵をねらうかを決めます。敵が何体もいるとき、ねらっている敵がはっきり分からないと、思っていたのと違う敵をたたいてしまいますよね。今回は、#4で作った「たたかう」のコマンドに自分で相手を選ぶ仕事を持たせて、矢印キーやゲームパッド、マウスで攻撃する相手を選べるようにしてみたいと思います! […]