
コマンドと相手が選べるようになったので、いよいよ攻撃です!「スライムの こうげき!」「こぼれたインクに 10の ダメージ!」・・・。RPGらしいメッセージが1文字ずつ出てきて、敵のHPが減り、0になったら倒せる。今回は、ダメージの計算とメッセージを作って、「たたかう」を実際に動かしてみたいと思います!
- シリーズの目次:ターン制RPGの作り方
- 前回:#6 たたかう相手を選ぼう
今回作るもの
- バトル中の1人(仲間も敵も)の、今のHPを持つ
BattleUnit - ゲームの乱数をまとめた
GameRandom(シードを決めると、毎回同じ結果になる) - ダメージを計算する
DamageCalculator - メッセージを1文字ずつ出して、読み終わるまで待つ
MessageWindow - 「たたかう」を実行する(
AttackCommandに中身を足す)
| スクリプト | 役割 | 付ける場所 |
|---|---|---|
| BattleUnit | バトル中の1人。今のHPと、倒れたか | (付けない) |
| GameRandom | ゲームの乱数をまとめたもの | (付けない) |
| DamageCalculator | ダメージの計算 | (付けない) |
| MessageWindow | メッセージを1文字ずつ出して待つ | MessageWindow |
| UnitView | 自分が表しているBattleUnitを覚える(1行足す) | - |
| BattleCommand・AttackCommand | 実行するExecuteAsyncを足す | - |
| BattleContext | MessageWindowを足す | - |
| BattleLineup | 選び終わったら、順番に実行する | Battle |
バトル中の1人を表すBattleUnit
#2で作ったCharacterDataやEnemyDataは、「最初の能力」が入ったデータです。バトル中に減っていくHPを、データに書き込むわけにはいきません(ScriptableObjectに書き込むと、再生を止めても残ってしまいます)。そこで、バトル中の1人分の「今のHP」を持つクラスを作ります。
- 「BattleUnit」スクリプトを作る
using UnityEngine;
// バトル中の1人(仲間も敵も)。データは読むだけにして、今のHPはここで持つ
public class BattleUnit
{
public string Name { get; }
public Stats Stats { get; } // 能力(データのものを読むだけ)
public int Hp { get; private set; }
public UnitView View { get; } // 画面の見た目
public bool IsDead => Hp <= 0;
// 仲間のデータから作る(今はLv1の能力)
public BattleUnit(CharacterData data, UnitView view)
{
Name = data.CharacterName;
Stats = data.BaseStats;
Hp = Stats.maxHp;
View = view;
view.Unit = this;
}
// 敵のデータから作る
public BattleUnit(EnemyData data, UnitView view)
{
Name = data.EnemyName;
Stats = data.Stats;
Hp = Stats.maxHp;
View = view;
view.Unit = this;
}
// ダメージを受ける(0より下にはしない)
public void TakeDamage(int damage)
{
Hp = Mathf.Max(0, Hp - damage);
}
}
- Hp { get; private set; }
- 外からは読むだけ。HPを変えられるのは、このクラスの中(
TakeDamage)だけにしておくと、どこでHPが変わったのかを追いやすい
- 外からは読むだけ。HPを変えられるのは、このクラスの中(
- IsDead => Hp <= 0
- HPが0なら倒れている。毎回HPを見て答えるプロパティ
- コンストラクタが2つ
- 仲間のデータからでも、敵のデータからでも作れるようにしている。作ったあとは、仲間も敵も同じBattleUnitとして扱える
- view.Unit = this
- 見た目(UnitView)に「あなたが表しているのはこのBattleUnitだよ」と教えておく。相手を選ぶとき、TargetSelectorは見た目を返すので、そこから中身をたどれるように
#4のUnitViewに、次の1行を足します。
public BattleUnit Unit { get; set; } // この見た目が表している、バトル中の1人
乱数をGameRandomにまとめよう
ダメージには、毎回少しだけばらつきを付けます(同じ相手を同じように殴っても、9だったり11だったり)。ばらつきには乱数を使いますが、UnityのRandom.Rangeをそのまま使わずに、1か所にまとめておきます。
- 「GameRandom」スクリプトを作る
// ゲームの中身に関わる乱数(ダメージのばらつき・行動の順番など)をまとめたもの
// シードを決めると、毎回同じ順番で同じ数が出る
public static class GameRandom
{
private static System.Random random = new System.Random();
// シードを決める(同じシードなら、同じ結果を再現できる)
public static void SetSeed(int seed)
{
random = new System.Random(seed);
}
// min 以上 max 未満の小数
public static float Range(float min, float max)
{
return min + (float)random.NextDouble() * (max - min);
}
}
- static class
- インスタンスを作らずに、
GameRandom.Range(…)のように、どこからでも呼べるクラス
- インスタンスを作らずに、
- System.Random と シード
- コンピューターの乱数は、実は「決まった計算で作った、ばらばらに見える数の並び」。シード(種)が同じなら、何回やっても同じ並びの数が出る
- シードを決めておけば、「さっきの戦いでボスに負けた」を何度でも同じように再現して、バランスを確かめられます
どうしてUnityのRandom.Rangeを使わないの?と思うかもしれません。Random.Rangeは、パーティクルのばらつきなど、ほかのところでも使われます。演出が1つ増えただけで乱数の順番がずれて、同じシードでもダメージが変わってしまうことがあります。ゲームの結果に関わる乱数だけを、別の箱に分けておくと、演出をあとから足しても結果が変わりません。
ダメージを計算しよう
ダメージの式は、DamageCalculatorの1か所にだけ書きます。
- 「DamageCalculator」スクリプトを作る
using UnityEngine;
// ダメージの計算(式はここだけに書く)
public static class DamageCalculator
{
// たたかう:(こうげき − ぼうぎょ ÷ 2)× 0.9〜1.1。最低でも1
public static int Physical(BattleUnit attacker, BattleUnit defender)
{
float baseDamage = attacker.Stats.attack - defender.Stats.defense / 2f;
float damage = baseDamage * GameRandom.Range(0.9f, 1.1f);
return Mathf.Max(1, Mathf.RoundToInt(damage));
}
}
- こうげき − ぼうぎょ ÷ 2
- 守りが固い相手ほど、ダメージが減る。ぼうぎょをそのまま引くと、少しの差で0になりやすいので、半分だけ引いている
- × 0.9〜1.1
- 毎回、±10%だけばらつかせる。いつも同じ数字だと、戦いが作業のように感じてしまうため
- Mathf.Max(1, …)
- どんなに固い相手でも、最低1は減るようにする。0だと、何回殴っても倒せない相手ができてしまう
実際に計算してみると、次のくらいになります。
| 攻撃する人 | こうげき | 相手 | ぼうぎょ | ダメージ |
|---|---|---|---|---|
| スライムちゃん | 12 | こぼれたインク(HP 18) | 4 | 9〜11 |
| キョンシー | 14 | こぼれたインク | 4 | 11〜13 |
| フェアリー | 6 | こぼれたインク | 4 | 4(3〜4) |
| スライムちゃん | 12 | 本の虫(HP 22) | 6 | 8〜10 |
こぼれたインクは、スライムちゃんかキョンシーで2回たたけば倒せます。
ダメージの式をDamageCalculatorの1か所にだけ書いておくと、あとで「全体的にダメージが少なすぎる」と感じたときに、ここだけ直せば済みます。#24のバランス調整でも、この式を見直します。 :::フェアリーはこうげきが低いので、#13のまほう(回復・補助)で活躍してもらいます。
メッセージを順に出そう
RPGのメッセージは、1文字ずつ出てきて、読み終わったら次に進みますよね。上のメッセージのウインドに、その仕組みを作ります。
- 「MessageWindow」スクリプトを作る
using TMPro;
using UnityEngine;
// 上のメッセージ。1文字ずつ出して、読み終わるまで待つ(決定キーで早送り)
public class MessageWindow : MonoBehaviour
{
[SerializeField] private TMP_Text messageText;
[SerializeField] private float charSeconds = 0.03f; // 1文字を出す間隔
[SerializeField] private float holdSeconds = 0.8f; // 出し終わってから待つ時間
private InputSystem_Actions inputActions;
private void Awake()
{
inputActions = new InputSystem_Actions();
}
private void OnEnable()
{
inputActions.Enable();
}
private void OnDisable()
{
inputActions.Disable();
}
// メッセージを1文字ずつ出して、読み終わるまで待つ
public async Awaitable ShowAsync(string message)
{
// 前の決定(Enter)がまだこのフレームに残っていることがあるので、1フレーム待ってから始める
await Awaitable.NextFrameAsync(destroyCancellationToken);
messageText.text = message;
messageText.maxVisibleCharacters = 0;
// 1文字ずつ出す(決定キーで、全部出す)
float timer = 0f;
int shown = 0;
while (shown < message.Length)
{
if (inputActions.UI.Submit.WasPressedThisFrame())
{
break;
}
timer += Time.deltaTime;
shown = Mathf.Min(message.Length, Mathf.FloorToInt(timer / charSeconds));
messageText.maxVisibleCharacters = shown;
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
// 全部見せる(このあとほかの文字に書き換えても、途中で切れないように大きな数にしておく)
messageText.maxVisibleCharacters = int.MaxValue;
// 読む時間(決定キーで早送り)
float waited = 0f;
while (waited < holdSeconds)
{
await Awaitable.NextFrameAsync(destroyCancellationToken);
if (inputActions.UI.Submit.WasPressedThisFrame())
{
break;
}
waited += Time.deltaTime;
}
}
}
- maxVisibleCharacters
- TextMeshProの「最初から何文字目までを見せるか」。文字は全部入れておいて、見せる数だけを増やしていくと、1文字ずつ出てくるように見える
- timer / charSeconds
- たまった時間を、1文字の間隔で割ると「今、何文字見せるか」になる。0.03秒なら、1秒で33文字ぐらい
- 決定キーで早送り
- 文字を出している途中で押すと、全部出す。読んでいる途中で押すと、すぐ次へ。早く進めたい人も、ゆっくり読みたい人も困らない
- 最初の NextFrameAsync
maxVisibleCharactersを最後に大きな数に戻さないと、そのあと「フェアリーは どうする?」のように文字を直接書き換えたときに、前のメッセージの文字数で切れて、途中までしか出ないことがあります。
MessageWindow(上のウインド)にMessageWindowスクリプトを付けて、Message TextにMessageTextをドラッグします。

「たたかう」を実行しよう
BattleCommandに「実行する」を足す
#4で作ったBattleCommandに、選んだコマンドを実行するメソッドを足します。
// 選んだコマンドを実行する(まだ中身を作っていないコマンドは、このメッセージだけ)
public virtual async Awaitable ExecuteAsync(BattleUnit user, BattleAction action, BattleContext context)
{
await context.Message.ShowAsync($"{user.Name}は ようすを みている。");
}
- virtual
- 子クラスで書き換えられる。今回は「たたかう」だけを書き換えて、ほかのコマンド(まほう・どうぐ・ぼうぎょ・にげる)は、それぞれの回で書き換えるまで、このメッセージだけを出す
- user / action / context
- だれが(user)、どのコマンドをだれに(action)、どんな道具で(context)。実行に要るものは、全部引数でもらう
BattleContextには、MessageWindowを足します。
using TMPro;
// コマンドが相手を選んだり、メッセージを出したりするときに使う、バトルの道具をまとめたもの
public class BattleContext
{
public TMP_Text MessageText; // 上のメッセージ(すぐに書き換えるとき)
public MessageWindow Message; // 上のメッセージ(1文字ずつ出して待つとき)
public TargetSelector TargetSelector; // 相手を選ぶ道具
public UnitView[] Party; // 仲間の見た目
public UnitView[] Enemies; // 敵の見た目
}
AttackCommandに中身を書く
#6のAttackCommandに、ExecuteAsyncを足します。
using UnityEngine;
// たたかう:敵を1体選んで、ダメージを与える
[CreateAssetMenu(fileName = "Attack", menuName = "TurnBasedRpg/Commands/Attack")]
public class AttackCommand : BattleCommand
{
// 敵を1体選ぶ。キャンセルされたら null を返す
public override async Awaitable<BattleAction> SelectAsync(BattleContext context)
{
context.MessageText.text = "だれを ねらう?";
UnitView target = await context.TargetSelector.SelectTargetAsync(context.Enemies, context.Party);
if (target == null)
{
return null;
}
return new BattleAction(this, target);
}
// 選んだ敵にダメージを与えて、HPが0になったら消す
public override async Awaitable ExecuteAsync(BattleUnit user, BattleAction action, BattleContext context)
{
BattleUnit target = action.Target.Unit;
if (target.IsDead)
{
// ほかの仲間が先に倒していた(ねらい直すのは #9 で作る)
await context.Message.ShowAsync($"{target.Name}は もう たおれている!");
return;
}
await context.Message.ShowAsync($"{user.Name}の こうげき!");
int damage = DamageCalculator.Physical(user, target);
target.TakeDamage(damage);
await context.Message.ShowAsync($"{target.Name}に {damage}の ダメージ!");
if (target.IsDead)
{
// 消える演出は #8 で作る。今はすぐに消す
target.View.gameObject.SetActive(false);
await context.Message.ShowAsync($"{target.Name}を たおした!");
}
}
}
- action.Target.Unit
- 相手選びで決めたのは見た目(UnitView)。さっき足した
Unitから、HPを持っている中身(BattleUnit)をたどる
- 相手選びで決めたのは見た目(UnitView)。さっき足した
- await context.Message.ShowAsync(…) が続く
- 「こうげき!」→ 計算 → 「ダメージ!」→「たおした!」と、上から読んだ順番どおりに、メッセージを1つずつ待って出せる。asyncで書くうれしさが一番出るところです
- target.IsDead を先に見る
- 2人で同じ敵をねらって、1人目で倒してしまったとき、2人目は「もう たおれている!」で終わる
スライムほかのコマンドのクラスは、何も変えていないんですね



そう!「たたかう」の実行は、たたかうのクラスの中だけ。ぼうぎょやにげるも、作る回でそのクラスに書き足すだけだよ
選び終わったら、順番に実行しよう
最後に、BattleLineupを書き換えて、3人分のコマンドが決まったら順番に実行するようにします。変わったところは、BattleUnitを作るところと、ExecuteActionsAsync・LivingEnemyViewsです。
using System.Collections.Generic;
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;
[SerializeField] private MessageWindow messageWindow;
[SerializeField] private TargetSelector targetSelector;
[SerializeField] private int randomSeed = 0; // 0以外にすると、毎回同じ結果になる(確かめるとき用)
private StatusRow[] statusRows;
private BattleUnit[] partyUnits;
private readonly List<BattleUnit> enemyUnits = new List<BattleUnit>();
private BattleContext context;
private async void Start()
{
if (randomSeed != 0)
{
GameRandom.SetSeed(randomSeed);
}
statusRows = new StatusRow[party.Length];
partyUnits = new BattleUnit[party.Length];
for (int i = 0; i < partyViews.Length; i++)
{
CharacterData character = party[i];
UnitView view = partyViews[i];
view.SetupCharacter(character);
statusRows[i] = statusWindow.AddRow(character);
partyUnits[i] = new BattleUnit(character, view);
}
for (int i = 0; i < enemies.Length; i++)
{
EnemyData enemy = enemies[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(enemy);
enemyUnits.Add(new BattleUnit(enemy, view));
}
// コマンドに渡す道具をまとめる
context = new BattleContext();
context.MessageText = messageText;
context.Message = messageWindow;
context.TargetSelector = targetSelector;
context.Party = partyViews;
try
{
while (true)
{
// ねらえるのは、まだ倒れていない敵だけ
context.Enemies = LivingEnemyViews();
if (context.Enemies.Length == 0)
{
await messageWindow.ShowAsync("てきを ぜんぶ たおした!");
break;
}
BattleAction[] actions = await SelectActionsAsync();
await ExecuteActionsAsync(actions);
}
}
catch (System.OperationCanceledException)
{
// オブジェクトが消えたら、そこで終わり
}
}
// 3人のコマンドを順に選ぶ(キャンセルで前の人に戻る)
private async Awaitable<BattleAction[]> SelectActionsAsync()
{
BattleAction[] actions = new BattleAction[party.Length];
int index = 0;
while (index < party.Length)
{
messageText.text = $"{party[index].CharacterName}は どうする?";
partyViews[index].SetHighlight(true);
statusRows[index].SetHighlight(true);
DimPartyExcept(index);
BattleCommand command = await commandMenu.WaitForCommandAsync(party[index].Commands);
BattleAction action = null;
if (command != null)
{
action = await command.SelectAsync(context);
}
partyViews[index].SetHighlight(false);
statusRows[index].SetHighlight(false);
if (command == null)
{
// コマンドでキャンセルしたら、前の人に戻る(1人目なら、そのまま1人目)
if (index > 0)
{
index--;
}
continue;
}
if (action == null)
{
// 相手選びなどでキャンセルしたら、同じ人のコマンドを選び直す
continue;
}
actions[index] = action;
index++;
}
BrightenParty();
return actions;
}
// 決まったコマンドを、仲間の並び順に実行する(すばやさ順にするのは #9)
private async Awaitable ExecuteActionsAsync(BattleAction[] actions)
{
for (int i = 0; i < actions.Length; i++)
{
BattleAction action = actions[i];
await action.Command.ExecuteAsync(partyUnits[i], action, context);
}
}
// まだ倒れていない敵の見た目
private UnitView[] LivingEnemyViews()
{
List<UnitView> living = new List<UnitView>();
foreach (BattleUnit unit in enemyUnits)
{
if (!unit.IsDead)
{
living.Add(unit.View);
}
}
return living.ToArray();
}
// index の人以外の仲間を暗くする(index が -1 なら、全員暗くする)
private void DimPartyExcept(int index)
{
for (int i = 0; i < partyViews.Length; i++)
{
partyViews[i].SetDimmed(i != index);
}
}
// 仲間を全員、もとの明るさに戻す
private void BrightenParty()
{
foreach (UnitView view in partyViews)
{
view.SetDimmed(false);
}
}
}
- new BattleUnit(…)
- 並べるときに、仲間と敵のBattleUnitを作っておく。見た目にも
Unitが入る
- 並べるときに、仲間と敵のBattleUnitを作っておく。見た目にも
- await action.Command.ExecuteAsync(…)
- 決まったコマンドに「実行して」とたのむだけ。たたかうなのか、ぼうぎょなのかを、BattleLineupは見分けていない
- LivingEnemyViews()
- 倒れた敵は、相手選びの候補から外す。全部倒したら「てきを ぜんぶ たおした!」で終わる(ちゃんとした勝ち負けは#11で作ります)
- randomSeed
- 0以外の数を入れると、毎回同じダメージになる。記事の画面と同じ数字になるか確かめたいときは、42を入れてみてください
BattleのBattle Lineupに、Message WindowにMessageWindowをドラッグします。


動かして確認!
再生して、たたかってみましょう。


#7の動作確認
- 3人分のコマンドを決めると、「〜の こうげき!」「〜に ◯の ダメージ!」が1文字ずつ出る
- メッセージの途中でEnterを押すと、全部出る。読んでいる途中で押すと、すぐ次へ
- たたかう以外のコマンドは「〜は ようすを みている。」と出る
- HPが0になった敵は消え、次からは相手に選べない
- 全部の敵を倒すと「てきを ぜんぶ たおした!」と出る
- Random Seedに同じ数(42など)を入れると、何回やっても同じダメージになる



倒したインクが、パッと消えるだけなのは、ちょっとさみしいですね・・・
あとがき
今回は、ダメージを計算して、メッセージを1文字ずつ出しながら「たたかう」を動かしました。データは読むだけ、今のHPはBattleUnit、計算はDamageCalculator、乱数はGameRandomと、役割ごとに分けたので、どこを直せばいいかが分かりやすい形になりました。次回は、たたかうときに前に出て、敵が光ってのけぞり、ダメージの数字が飛び出す、攻撃の手応えを作りたいと思います!
コメント
コメント一覧 (2件)
[…] 前回:#7 たたかう:ダメージを計算してメッセージを出そう […]
[…] ダメージの式(#7):敵の数字を変えるだけで、ちょうどいいところに入ったので、式はそのままです。式を変えると、全部の階が一度に変わってしまうので、まずは数字から直すのがおすすめです […]