ターン制RPGの作り方#7|たたかう:ダメージを計算してメッセージを出そう

スライムちゃんとキョンシーは「たたかう」でこぼれたインク、フェアリーは「ぼうぎょ」を選ぶと、「スライムの こうげき!」「こぼれたインクに 10の ダメージ!」「フェアリーは ようすを みている。」「キョンシーの こうげき!」「こぼれたインクに 11の ダメージ!」「こぼれたインクを たおした!」と1文字ずつ出て、インクが消える

コマンドと相手が選べるようになったので、いよいよ攻撃です!「スライムの こうげき!」「こぼれたインクに 10の ダメージ!」・・・。RPGらしいメッセージが1文字ずつ出てきて、敵のHPが減り、0になったら倒せる。今回は、ダメージの計算とメッセージを作って、「たたかう」を実際に動かしてみたいと思います!

目次

今回作るもの

  • バトル中の1人(仲間も敵も)の、今のHPを持つBattleUnit
  • ゲームの乱数をまとめたGameRandom(シードを決めると、毎回同じ結果になる)
  • ダメージを計算するDamageCalculator
  • メッセージを1文字ずつ出して、読み終わるまで待つMessageWindow
  • 「たたかう」を実行する(AttackCommandに中身を足す)
スクリプト役割付ける場所
BattleUnitバトル中の1人。今のHPと、倒れたか(付けない)
GameRandomゲームの乱数をまとめたもの(付けない)
DamageCalculatorダメージの計算(付けない)
MessageWindowメッセージを1文字ずつ出して待つMessageWindow
UnitView自分が表しているBattleUnitを覚える(1行足す)-
BattleCommand・AttackCommand実行するExecuteAsyncを足す-
BattleContextMessageWindowを足す-
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が変わったのかを追いやすい
  • 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)49〜11
キョンシー14こぼれたインク411〜13
フェアリー6こぼれたインク44(3〜4)
スライムちゃん12本の虫(HP 22)68〜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
    • #4・#6と同じく、前の決定のEnterを引きずらないように、1フレーム待ってから始める

maxVisibleCharactersを最後に大きな数に戻さないと、そのあと「フェアリーは どうする?」のように文字を直接書き換えたときに、前のメッセージの文字数で切れて、途中までしか出ないことがあります。

MessageWindow(上のウインド)にMessageWindowスクリプトを付けて、Message TextにMessageTextをドラッグします。

MessageWindowのMessage WindowのMessage TextにMessageText、Char Seconds 0.03、Hold Seconds 0.8

「たたかう」を実行しよう

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)をたどる
  • 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が入る
  • await action.Command.ExecuteAsync(…)
    • 決まったコマンドに「実行して」とたのむだけ。たたかうなのか、ぼうぎょなのかを、BattleLineupは見分けていない
  • LivingEnemyViews()
    • 倒れた敵は、相手選びの候補から外す。全部倒したら「てきを ぜんぶ たおした!」で終わる(ちゃんとした勝ち負けは#11で作ります)
  • randomSeed
    • 0以外の数を入れると、毎回同じダメージになる。記事の画面と同じ数字になるか確かめたいときは、42を入れてみてください

BattleのBattle Lineupに、Message WindowにMessageWindowをドラッグします。

BattleのBattle LineupのMessage WindowにMessageWindow、Random Seedは0

SelectActionsAsyncは、前回まで「コマンドが きまった!」を出して終わっていましたが、今回は決まったコマンドをBattleAction[]で返すようにしました。Awaitable<BattleAction[]>のように、待ったあとに配列を返すこともできます。

動かして確認!

再生して、たたかってみましょう。

3人分のコマンドを決めると、メッセージが1文字ずつ出て攻撃し、HPが0になったこぼれたインクが消える。次のコマンドに戻る

#7の動作確認

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

倒したインクが、パッと消えるだけなのは、ちょっとさみしいですね・・・

あとがき

今回は、ダメージを計算して、メッセージを1文字ずつ出しながら「たたかう」を動かしました。データは読むだけ、今のHPはBattleUnit、計算はDamageCalculator、乱数はGameRandomと、役割ごとに分けたので、どこを直せばいいかが分かりやすい形になりました。次回は、たたかうときに前に出て、敵が光ってのけぞり、ダメージの数字が飛び出す、攻撃の手応えを作りたいと思います!

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

コメント

コメント一覧 (2件)

コメントする

目次