
前回、まほう図書館のロビーを作って、バトルでもらったお金を持ち越せるようにしました。せっかくお金がたまっても、使い道がないとさみしいですね・・・。今回は、ロビーの「売店」を作って、どうぐや装備を買えるようにしたいと思います!
- シリーズの目次:ターン制RPGの作り方
- 前回:#17 ロビーと休憩室
今回作るもの

- ロビーの「売店」を押すと、売り物の一覧が開く。名前は左、値段は右にそろえて出す
- 選ぶとお金を払って、どうぐは持ち物に、装備は「装備のふくろ」に入る
- お金が足りないものは灰色になって選べない
- 右上のお金は、パッと変わるのではなく、数字が動いてウインドが弾む
| 売り物 | 値段 | 効果 |
|---|---|---|
| ハーブティー | 8 G | HPを30回復 |
| まほうのクッキー | 20 G | MPを10回復 |
| どくけしキャンディ | 10 G | どくを治す |
| ペーパーナイフ(ぶき) | 40 G | こうげき +3 |
| ブックカバー(ぼうぐ) | 30 G | ぼうぎょ +3 |
- めざましどけいや、もっと強い装備は、#21で地下2階・地下3階に行けるようになったら並べます
- 買った装備を付けるのは、次の#19(装備の画面)です。今回は買ってふくろに入れるところまでです
| スクリプト | 役割 | 付ける場所 |
|---|---|---|
| IShopGoods | 売り物(名前・値段・買ったら持ち物に入れる) | (付けない) |
| ItemData | どうぐに値段を付ける | (付けない) |
| EquipmentData | 装備1つ分のデータ(ぶき・ぼうぐ、上がる能力) | (付けない) |
| GameSession | 装備のふくろ | - |
| CommandMenu | 文字だけでボタンを並べて、選んだ番号を返す | - |
| Shop | 売店:一覧から選んで買う | Lobby の Shop |
| GoldCounter | お金の数字を動かして、ウインドを弾ませる | Lobby の GoldWindow |
| LobbyMenu | 「売店」のボタン | - |
売り物のデータ

この節では、売店に並べるもののデータを作ります。
売店には、どうぐ(ItemData)と装備(EquipmentData)が並びます。ただ、ItemDataはBattleCommandを受け継いでいて、装備はコマンドではありません。2つは別々の親を持つクラスです。そこで、「売り物」という共通の約束(インターフェース)を作って、どちらにも付けます。
- 「IShopGoods」スクリプトを作る
// 売店に並べられるもの(どうぐ・装備)。どちらも、名前と値段を持っていて、買ったら持ち物に入る
public interface IShopGoods
{
string GoodsName { get; } // 一覧に出す名前
int Price { get; } // 値段(ゴールド)
// 買ったものを、session の持ち物に入れる
void AddTo(GameSession session);
}
- インターフェースは「このプロパティとメソッドを持っています」という約束です。中身は、付けたクラスのほうに書きます
- クラスの親は1つしか持てませんが、インターフェースはいくつでも付けられます。ItemDataは「BattleCommandを受け継いでいて、売り物でもある」になります
- 名前の頭に
Iを付けるのは、C#でインターフェースによく使う決まりです - インターフェースについては、9-7 UIをドラッグ&ドロップしようでも、マウスのイベントを受け取るのに使いました
どうぐに値段を付ける

ItemDataに、IShopGoodsを付けます。BattleCommandの後ろに、,で区切って書きます。
public class ItemData : BattleCommand, IShopGoods
値段の欄と、約束したプロパティとメソッドを足します(effectPrefabの下)。
[SerializeField] private int price; // 売店での値段
public string GoodsName => CommandName;
public int Price => price;
// 買ったら、持ち物に1つ足す
public void AddTo(GameSession session)
{
session.Inventory.Add(this, 1);
}
- 名前は、コマンドの名前(ハーブティーなど)をそのまま使います
- 約束したもの(
GoodsName・Price・AddTo)が1つでも足りないと、エラーになって教えてくれます
- Data/ItemsのどうぐのPriceを、HerbTea 8、MagicCookie 20、AntidoteCandy 10、AlarmClock 60 にする
EquipmentData

装備1つ分のデータを作ります。
- 「EquipmentData」スクリプトを作る
using UnityEngine;
// 装備する場所
public enum EquipSlot
{
Weapon, // ぶき
Armor, // ぼうぐ
}
// 装備1つ分のデータ(ぶき・ぼうぐ)。装備すると、bonus の分だけ能力が上がる
[CreateAssetMenu(fileName = "NewEquipment", menuName = "TurnBasedRpg/Equipment")]
public class EquipmentData : ScriptableObject, IShopGoods
{
[SerializeField] private string equipmentName; // 名前
[SerializeField] private EquipSlot slot; // ぶき か ぼうぐ か
[SerializeField] private int price; // 値段
[SerializeField] private Stats bonus; // 上がる能力(こうげき +3 なら attack に 3)
public string GoodsName => equipmentName;
public int Price => price;
public EquipSlot Slot => slot;
public Stats Bonus => bonus;
// 買ったら、装備のふくろに入れる(装備するのは #19 の装備の画面)
public void AddTo(GameSession session)
{
session.Equipments.Add(this);
}
}
- slot:ぶき か ぼうぐ か。6-15 選択肢に名前を付けよう:enum(列挙型)とswitch文のenumです。1人がぶきとぼうぐを1つずつ装備できるようにします(#19)
- bonus:装備したときに上がる能力です。#2で作った
Statsをそのまま使うので、HPやすばやさが上がる装備も作れます - AddTo:装備は、持ち物(Inventory)ではなく、次に作る「装備のふくろ」に入れます
GameSessionに、装備のふくろを足します(Inventoryの下)。
public List<EquipmentData> Equipments { get; } = new List<EquipmentData>(); // 装備のふくろ(買って、まだ装備していないもの)
- 同じ装備を2つ買ったら、2つ入ります(2人に同じペーパーナイフを持たせられるように)
データを作ります。
- Dataフォルダの中に「Equipments」フォルダを作る
- 右クリック>Create>TurnBasedRpg>Equipmentで、次の6つを作る
| ファイル | Equipment Name | Slot | Price | Bonus |
|---|---|---|---|---|
| PaperKnife | ペーパーナイフ | Weapon | 40 | Attack 3 |
| Paperweight | ぶんちん | Weapon | 120 | Attack 7 |
| MagicPen | まほうのペン | Weapon | 300 | Attack 12・Magic 4 |
| BookCover | ブックカバー | Armor | 30 | Defense 3 |
| LibrarianApron | 司書のエプロン | Armor | 100 | Defense 6 |
| MagicCape | まほうのマント | Armor | 260 | Defense 10・Magic 2 |
- 今回売店に並べるのは、ペーパーナイフとブックカバーだけです。ほかの4つは、#21で地下2階・地下3階に行けたら並べます
一覧を文字で並べられるようにしよう

この節では、#16で作ったCommandMenu(まほう・どうぐの一覧)を、売店でも使えるようにします。
今のCommandMenuは、BattleCommandの配列を受け取ってボタンを作ります。でも、装備はBattleCommandではありません。そこで、ボタンに出す文字と、押せるかどうかだけを受け取って、押されたボタンの番号を返すメソッドを作ります。今までのWaitForCommandAsyncは、そのメソッドを使う形に書き直します。
選ばれたものを入れる箱を、コマンドから番号に変えます。
// 押されたボタンの番号が入る箱(キャンセルなら -1 が入る)
private readonly AwaitableCompletionSource<int> choiceSource = new AwaitableCompletionSource<int>();
Updateのキャンセルのところも、番号の-1にします。
Choose(-1);
WaitForCommandAsyncとCreateButtonsを、次のように書き換えます。
// user のコマンドでボタンを作り、どれかが押されるまで待つ(キャンセルなら null)
// values を渡すと、ボタンの右はしにそれを出す(どうぐの個数、まほうの MP)
public async Awaitable<BattleCommand> WaitForCommandAsync(BattleCommand[] commands, BattleUnit user, string[] values = null)
{
string[] labels = new string[commands.Length];
bool[] usables = new bool[commands.Length];
for (int i = 0; i < commands.Length; i++)
{
BattleCommand command = commands[i];
labels[i] = command.MenuLabel;
usables[i] = command.CanUse(user); // user が使えないコマンドは灰色
}
int index = await WaitForChoiceAsync(labels, values, usables);
if (index < 0)
{
return null;
}
return commands[index];
}
// labels の文字でボタンを作り、どれかが押されるまで待つ。押されたボタンの番号を返す(キャンセルなら -1)
// values は右はしに出す文字(無ければ null)、usables が false のボタンは灰色
// 最初は firstIndex のボタンを選ぶ(灰色なら、使えるボタンの一番上)
public async Awaitable<int> WaitForChoiceAsync(string[] labels, string[] values, bool[] usables, int firstIndex = 0)
{
// 前の人の決定(Enter など)が、まだこのフレームに残っていることがあるので、1フレーム待ってから始める
await Awaitable.NextFrameAsync(destroyCancellationToken);
CreateButtons(labels, values, usables);
choiceSource.Reset();
isWaiting = true;
lastSelected = buttons[0].gameObject;
if (firstIndex < buttons.Count && buttons[firstIndex].interactable)
{
lastSelected = buttons[firstIndex].gameObject;
}
else
{
foreach (Button button in buttons)
{
if (button.interactable)
{
lastSelected = button.gameObject;
break;
}
}
}
EventSystem.current.SetSelectedGameObject(lastSelected);
int index = await choiceSource.Awaitable;
isWaiting = false;
return index;
}
// labels の数だけボタンを作る(前のボタンは消す)
private void CreateButtons(string[] labels, string[] values, bool[] usables)
{
foreach (Button oldButton in buttons)
{
// Destroy はフレームの最後に消えるので、先に隠して、並びから外しておく
oldButton.gameObject.SetActive(false);
Destroy(oldButton.gameObject);
}
buttons.Clear();
// スクロールする一覧は Content の子に、そうでなければ自分の子にボタンを作る
Transform parent = transform;
if (scrollRect != null)
{
parent = scrollRect.content;
scrollRect.content.anchoredPosition = Vector2.zero; // 一覧の一番上から見せる
}
for (int i = 0; i < labels.Length; i++)
{
int index = i; // ボタンが押されたときに使うので、ボタンごとに別の変数にしておく
Button button = Instantiate(buttonPrefab, parent);
button.GetComponentInChildren<TMP_Text>().text = labels[i];
if (values != null)
{
// 一覧のボタン(ListButton)は、右はしの ValueText に出す
button.transform.Find("ValueText").GetComponent<TMP_Text>().text = values[i];
}
button.interactable = usables[i];
button.onClick.AddListener(() => Choose(index));
buttons.Add(button);
}
}
最後に、Chooseも番号を受け取るようにします。
private void Choose(int index)
{
// 待っていないときに押されても、何もしない
if (isWaiting)
{
choiceSource.TrySetResult(index);
}
}
- WaitForChoiceAsync:ボタンの文字(labels)・右はしの文字(values)・押せるか(usables)を受け取って、押されたボタンの番号を返します。キャンセルなら
-1です - firstIndex:最初に選んでおくボタンです。売店で1つ買ったあとも、同じものを続けて選べるようにします
- WaitForCommandAsync:コマンドから文字と「使えるか」を作って、
WaitForChoiceAsyncに渡します。バトルのほうは、今までどおり動きます int index = i;:ボタンが押されたときのChoose(index)は、ボタンを作り終わったずっとあとで動きます。Choose(i)と書くと、そのときのi(最後まで数え終わった数)が使われてしまうので、ボタンごとに別の変数に入れておきます
売店を作ろう

この節では、ロビーに売店の一覧を置いて、買えるようにします。
ShopWindow

一覧のウインドは、BattleのItemWindow(#16で作ったScroll View)をそのまま使います。
- Battleシーンを開き、HierarchyのItemWindowを選んでCtrl+C
- Lobbyシーンを開き、Canvasを右クリック>Paste As Child
- 名前を「ShopWindow」にして、GoldWindowのすぐ下に置く(ScreenFade・SceneFadeより上)
- Pos Xを 1450、Pos Yを 560 にする(右の本棚の前)
- 一番上のチェックを外して、隠しておく(Battleで隠していたので、はじめから外れています)
Shop

売店のスクリプトを作ります。
- 「Shop」スクリプトを作る
using System.Collections.Generic;
using TMPro;
using UnityEngine;
// 売店:売り物の一覧から選んで、お金を払って持ち物に入れる
public class Shop : MonoBehaviour
{
[SerializeField] private ItemData[] items; // 売っている どうぐ
[SerializeField] private EquipmentData[] equipments; // 売っている 装備
[SerializeField] private CommandMenu goodsMenu; // 売り物の一覧(ShopWindow)
[SerializeField] private MessageWindow messageWindow;
[SerializeField] private TMP_Text messageText; // 上のメッセージ(「なにを かう?」をすぐ出す)
[SerializeField] private GoldCounter goldCounter;
// 売店を開いて、キャンセルされるまで買い物をする
public async Awaitable OpenAsync()
{
GameSession session = GameSession.Instance;
List<IShopGoods> goodsList = new List<IShopGoods>();
goodsList.AddRange(items);
goodsList.AddRange(equipments);
goodsMenu.gameObject.SetActive(true);
int index = 0; // 前に選んでいたもの(買ったあとも、同じところから選べるように)
while (true)
{
// 名前・値段と、お金が足りるか(足りないものは灰色)
string[] names = new string[goodsList.Count];
string[] prices = new string[goodsList.Count];
bool[] canBuy = new bool[goodsList.Count];
for (int i = 0; i < goodsList.Count; i++)
{
IShopGoods product = goodsList[i];
names[i] = product.GoodsName;
prices[i] = $"<mspace=0.6em>{product.Price,3}</mspace> G";
canBuy[i] = session.Gold >= product.Price;
}
messageText.text = "いらっしゃい! なにを かう?";
index = await goodsMenu.WaitForChoiceAsync(names, prices, canBuy, index);
if (index < 0)
{
break; // キャンセル:売店を出る
}
IShopGoods goods = goodsList[index];
session.Gold -= goods.Price;
goods.AddTo(session);
Awaitable count = goldCounter.CountToAsync(session.Gold);
await messageWindow.ShowAsync($"{goods.GoodsName}を かった!");
await count;
}
goodsMenu.gameObject.SetActive(false);
}
}
- goodsList:どうぐと装備を、
IShopGoodsのListにまとめます。どちらも「売り物」なので、1つのListに入れられます - 一覧を出すたびに、今のお金で買えるかを調べます。1つ買ってお金が減ると、足りなくなったものが灰色になります
- 値段は、#16の個数と同じく
<mspace>で数字の幅をそろえ、3けたの幅で右はしにそろえます - AddTo:どうぐか装備かを調べなくても、それぞれのクラスに書いた
AddToが呼ばれて、持ち物かふくろに入ります。インターフェースを使うと、こういう「種類ごとにちがう動き」をすっきり書けます - お金の数字を動かす
GoldCounterは、次の節で作ります(ここではまだエラーになります)
- Hierarchyで、Canvasの外に空のオブジェクトを作り、名前を「Shop」にして、「Shop」スクリプトを付ける
- Itemsに HerbTea・MagicCookie・AntidoteCandy、Equipmentsに PaperKnife・BookCover
- Goods Menuに ShopWindow、Message Windowに MessageWindow、Message Textに MessageWindow>MessageText、Gold Counterに GoldWindow(次の節で付けます)
LobbyMenu

LobbyMenuに、売店のボタンをつなぎます。
右上のお金は、文字を直接書きかえるのではなく、次の節のGoldCounterに任せます。goldTextの欄を、次のように書き換えます。
[SerializeField] private GoldCounter goldCounter; // 右上のお金
restButtonの下に、欄を足します。
[SerializeField] private Button shopButton; // 売店
[SerializeField] private Shop shop;
[SerializeField] private CanvasGroup menuGroup; // メニューのボタン(売店にいる間は、押せないようにする)
[SerializeField] private TMP_Text messageText; // 上のメッセージ(売店から戻ったら、元の文に)
Startで、ボタンが押されたときのメソッドを登録します(restButtonの下)。
shopButton.onClick.AddListener(OnShopClicked);
ShowPartyの最後の、お金を出す行を書き換えます。
goldCounter.ShowInstant(session.Gold);
RestAsyncで休憩室のお金を払うところも、GoldCounterで数字を動かします。
session.Gold -= restPrice;
Awaitable count = goldCounter.CountToAsync(session.Gold);
await messageWindow.ShowAsync($"{restPrice}ゴールドで ひとやすみ。");
await count;
OnRestClickedの下に、売店のボタンのメソッドを足します。
private async void OnShopClicked()
{
if (isBusy)
{
return;
}
isBusy = true;
menuGroup.interactable = false; // 売店の一覧から、メニューのボタンへ移れないように
try
{
await shop.OpenAsync();
}
catch (System.OperationCanceledException)
{
return;
}
menuGroup.interactable = true;
messageText.text = "ここは まほう図書館の ロビー。";
EventSystem.current.SetSelectedGameObject(shopButton.gameObject);
isBusy = false;
}
- menuGroup:キーボードやゲームパッドで一覧を上下に動かしているうちに、左下のメニューのボタンに移ってしまわないように、売店にいる間はメニューのボタンを押せなくします
- Canvas Groupは、子のボタンをまとめて押せなくできるコンポーネントです。
interactableをfalseにすると、子のボタンが全部灰色になります - 売店から出たら、メッセージを元に戻して、「売店」のボタンを選び直します
- LobbyのLobbyWindowに、Add Component>Canvas Groupを付ける
- ShopButtonのButtonのInteractableにチェックを入れる(#17で外していました)
- Lobbyオブジェクトの Lobby Menu に、Gold Counter(GoldWindow)・Shop Button(ShopButton)・Shop(Shop)・Menu Group(LobbyWindow)・Message Text(MessageText)を入れる
お金が減るのを見せよう

この節では、お金が減ったことがひと目で分かるようにします。数字がパッと変わるだけだと、買ったことに気づきにくいですね。数字を少しずつ動かして、ウインドを弾ませます!
- 「GoldCounter」スクリプトを作る
using TMPro;
using UnityEngine;
// 右上のお金。お金が変わったら、数字を少しずつ動かして、ウインドを弾ませる
public class GoldCounter : MonoBehaviour
{
[SerializeField] private TMP_Text goldText;
[SerializeField] private float countSeconds = 0.4f; // 数字を動かす時間
[SerializeField] private float bumpScale = 1.15f; // 弾んだときの大きさ
private int shownGold; // 今、表示している数
// すぐその数にする(シーンの始め)
public void ShowInstant(int gold)
{
shownGold = gold;
goldText.text = $"{gold} G";
}
// 今の数から gold まで、countSeconds 秒かけて数字を動かす。始めに少し大きくして、元の大きさに戻す
public async Awaitable CountToAsync(int gold)
{
int startGold = shownGold;
shownGold = gold;
float time = 0f;
while (time < countSeconds)
{
time += Time.deltaTime;
float rate = Mathf.Clamp01(time / countSeconds);
goldText.text = $"{Mathf.RoundToInt(Mathf.Lerp(startGold, gold, rate))} G";
transform.localScale = Vector3.one * Mathf.Lerp(bumpScale, 1f, rate);
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
goldText.text = $"{gold} G";
transform.localScale = Vector3.one;
}
}
- ShowInstant:シーンの始めは、数字を動かさずにそのまま出します(#17のゲージと同じ考え方です)
- CountToAsync:
Mathf.Lerpで、始めの数から新しい数までを、時間に合わせて動かします。大きさも、1.15倍から1倍へ戻していきます shownGoldは、数字を動かし始めるときに新しい数にしておきます。動いている途中でもう一度呼ばれても、正しい数から始められます

- LobbyのGoldWindowに「GoldCounter」スクリプトを付けて、Gold Textに GoldText を入れる
- GoldWindowの基準点(Pivot)は右上なので、右上を中心に大きくなります
スライム数字がチャラララって減ると、「買った!」って感じがしますね・・・



あとで、お金の音も付けたいわね!
動かして確認!


- Lobbyシーンを開いて再生し、「売店」を選ぶ
- ハーブティーを買うと、お金が8ゴールド減る。続けて、ペーパーナイフを買う
- お金が足りなくなったものは、灰色になって選べない
- Escで売店を出て、「地下へ」でバトルに行き、「どうぐ」にハーブティーが1つ増えていることを確かめる
#18の動作確認
- 「売店」で、右に売り物の一覧が開く。名前は左、値段は右はしにそろって出る
- 売店にいる間は、左下のメニューが灰色になり、キーボードで一覧からメニューに移らない
- 買うと「〇〇を かった!」と出て、右上のお金の数字が動いて減り、ウインドが弾む
- 1つ買ったあとも、同じものが選ばれたままで、続けて買える
- お金が足りないものは灰色になる。全部足りなくなっても、Escで売店を出られる
- 買ったどうぐは、バトルの「どうぐ」に増えている
- 休憩室でも、お金の数字が動いて減る
- バトルの「まほう」「どうぐ」の一覧は、今までどおり動く
あとがき
今回は、ロビーに売店を作って、どうぐと装備を買えるようにしました。どうぐと装備は親のちがうクラスですが、IShopGoodsという約束を付けたことで、売店では同じ「売り物」として1つの一覧に並べられましたね。次回は、買った装備を仲間に付けて、こうげきやぼうぎょが上がるようにしたいと思います!
コメント
コメント一覧 (1件)
[…] 前回:#18 売店 […]