ターン制RPGの作り方#9|バトルの流れを作ろう:すばやさ順に動いて、次のターンへ

1ターン目はフェアリーが最初に身を守り、キョンシー、スライムちゃんの順に攻撃してこぼれたインクを倒す。2ターン目は3人とも紙ひこうきをねらい、キョンシーが倒したあと、フェアリーとスライムちゃんは本の虫を攻撃する

ここまでで、3人のコマンドを選んで「たたかう」ができるようになりました。でも、今は上に並んだ順(スライムちゃん → フェアリー → キョンシー)に動いていて、足の速いキョンシーも最後です。また、先に倒れた敵をねらっていた人は「もう たおれている!」で何もできずに終わってしまいます。今回は、すばやさの順に動いて、次のターンへ進むというバトルの流れを、1つのスクリプトにまとめてみたいと思います!

目次

今回作るもの

  • バトルの場面(始まり・コマンドを選ぶ・行動する・勝った)に名前を付けて、場面ごとに進めるBattleManager
  • すばやさの順に行動を並べるActionOrder
  • ねらっていた敵が先に倒れていたら、ほかの敵にねらいを変える
  • 「ぼうぎょ」のコマンド(そのターンに受けるダメージが半分)
スクリプト役割付ける場所
BattlePhaseバトルの場面の名前(enum)(付けない)
BattleManagerバトルの流れ。場面ごとに進めるBattle
BattleLineup並べるだけにする(流れは BattleManager へ)Battle
ActionOrderすばやさ順に並べる(付けない)
BattleUnit選べるコマンドと、ぼうぎょ中かを足す-
BattleAction行動する人を足す-
BattleContextまだ倒れていない敵を探すメソッドを足す-
BattleCommand・GuardCommandぼうぎょ を作る-
AttackCommand倒れた敵をねらっていたら、ねらいを変える-
DamageCalculatorぼうぎょ 中はダメージを半分に-

バトルの場面に名前を付けよう

バトルは「場面」のくり返し

ターン制のバトルは、次の場面をくり返しています。

  1. 始まり:敵があらわれる
  2. コマンドを選ぶ:3人のコマンドを順に選ぶ
  3. 行動する:選んだ行動を、すばやさの順に実行する
  4. 敵が全員倒れていたら勝ち。まだいれば、2に戻る

この「今どの場面か」に名前を付けておくと、流れが読みやすくなります。名前を付けるのに使うのが、6-15 選択肢に名前を付けよう:enum(列挙型)とswitch文で紹介したenumです。

  • 「BattlePhase」スクリプトを作る
// バトルの場面
public enum BattlePhase
{
    Start,   // 始まり(敵があらわれる)
    Command, // 3人のコマンドを選ぶ
    Action,  // すばやさの順に行動する
    Victory, // 勝った
    End,     // バトルが終わった
}
  • 負け(#10)と、にげた(#11)は、その回で足します

並べるだけのBattleLineup

#2 キャラクターと敵のデータから使ってきたBattleLineupは、「並べる」と「バトルを進める」の両方をしていました。バトルの流れはこれから大きくなっていくので、流れは新しいBattleManagerに移して、BattleLineupは並べるだけにします。

まず、BattleUnitに、選べるコマンドと「ぼうぎょ中か」を足します。

    public BattleCommand[] Commands { get; } // 選べるコマンド(仲間だけ。敵は null)
    public bool IsGuarding { get; set; }     // ぼうぎょ しているか(ターンの始めに戻す)
  • Viewの下に足す
  • 仲間のデータから作るコンストラクタ(CharacterDataを受け取る方)の最後に、次の1行を足す
        Commands = data.Commands;

BattleLineupを、次のように書き換えます。

using System.Collections.Generic;
using UnityEngine;

// データから仲間と敵を並べて、バトル中の1人ずつ(BattleUnit)を作る
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;

    public BattleUnit[] PartyUnits { get; private set; } // 仲間(上から順に)
    public StatusRow[] StatusRows { get; private set; }  // 仲間のステータスの行(上から順に)
    public List<BattleUnit> EnemyUnits { get; } = new List<BattleUnit>(); // 敵(左から順に)
    public UnitView[] PartyViews => partyViews;

    // 並べる(BattleManager が、バトルの始めに呼ぶ)
    public void Setup()
    {
        PartyUnits = new BattleUnit[party.Length];
        StatusRows = new StatusRow[party.Length];
        for (int i = 0; i < party.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));
        }
    }
}
  • Startをやめて、SetupをBattleManagerから呼ぶ。どちらのStartが先に動くかは決まっていないので、「並べてから、バトルを始める」順番を確実にするため
  • 作ったBattleUnitやステータスの行は、BattleManagerから読めるようにプロパティで出す。{ get; private set; }は「外からは読むだけ、書き換えるのはこのクラスの中だけ」

場面ごとに進めるBattleManager

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

// バトルの流れ:コマンドを選ぶ → すばやさ順に動く → 勝ったかを調べる、をくり返す
public class BattleManager : MonoBehaviour
{
    [SerializeField] private BattleLineup lineup;
    [SerializeField] private CommandMenu commandMenu;
    [SerializeField] private TMP_Text messageText;
    [SerializeField] private MessageWindow messageWindow;
    [SerializeField] private TargetSelector targetSelector;
    [SerializeField] private BattleEffects battleEffects;
    [SerializeField] private int randomSeed = 0; // 0以外にすると、毎回同じ結果になる(確かめるとき用)
    [SerializeField] private BattlePhase phase;  // 今の場面(再生中に Inspector で見られるように)

    private BattleContext context;
    private BattleAction[] actions; // このターンに選んだ、3人の行動

    private async void Start()
    {
        try
        {
            await RunAsync();
        }
        catch (System.OperationCanceledException)
        {
            // オブジェクトが消えたら、そこで終わり
        }
    }

    // バトルの流れ。今の場面のことをして、次の場面を決める
    private async Awaitable RunAsync()
    {
        phase = BattlePhase.Start;
        while (phase != BattlePhase.End)
        {
            switch (phase)
            {
                case BattlePhase.Start:
                    await StartBattleAsync();
                    phase = BattlePhase.Command;
                    break;

                case BattlePhase.Command:
                    actions = await SelectActionsAsync();
                    phase = BattlePhase.Action;
                    break;

                case BattlePhase.Action:
                    await ExecuteActionsAsync();
                    if (LivingEnemyViews().Length == 0)
                    {
                        phase = BattlePhase.Victory;
                    }
                    else
                    {
                        phase = BattlePhase.Command; // 次のターン
                    }
                    break;

                case BattlePhase.Victory:
                    await messageWindow.ShowAsync("てきを ぜんぶ たおした!");
                    phase = BattlePhase.End;
                    break;
            }
        }
    }

    // 始まり:並べて、コマンドに渡す道具をまとめる
    private async Awaitable StartBattleAsync()
    {
        if (randomSeed != 0)
        {
            GameRandom.SetSeed(randomSeed);
        }
        lineup.Setup();

        context = new BattleContext();
        context.MessageText = messageText;
        context.Message = messageWindow;
        context.TargetSelector = targetSelector;
        context.Effects = battleEffects;
        context.Party = lineup.PartyViews;

        await messageWindow.ShowAsync($"{lineup.EnemyUnits[0].Name}たちが あらわれた!");
    }

    // 3人のコマンドを順に選ぶ(キャンセルで前の人に戻る)
    private async Awaitable<BattleAction[]> SelectActionsAsync()
    {
        // 新しいターン:ぼうぎょ を解いて、ねらえる敵を調べ直す
        foreach (BattleUnit unit in lineup.PartyUnits)
        {
            unit.IsGuarding = false;
        }
        context.Enemies = LivingEnemyViews();

        BattleUnit[] party = lineup.PartyUnits;
        BattleAction[] selected = new BattleAction[party.Length];
        int index = 0;
        while (index < party.Length)
        {
            BattleUnit unit = party[index];
            StatusRow row = lineup.StatusRows[index];
            messageText.text = $"{unit.Name}は どうする?";
            unit.View.SetHighlight(true);
            row.SetHighlight(true);
            DimPartyExcept(index);

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

            unit.View.SetHighlight(false);
            row.SetHighlight(false);

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

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

            action.User = unit;
            selected[index] = action;
            index++;
        }

        BrightenParty();
        return selected;
    }

    // 選んだ行動を、すばやさの順に実行する
    private async Awaitable ExecuteActionsAsync()
    {
        ActionOrder.Sort(actions);
        foreach (BattleAction action in actions)
        {
            if (LivingEnemyViews().Length == 0)
            {
                // 途中で敵がいなくなったら、残りの行動はしない
                break;
            }
            await action.Command.ExecuteAsync(action.User, action, context);
        }
    }

    // まだ倒れていない敵の見た目
    private UnitView[] LivingEnemyViews()
    {
        List<UnitView> living = new List<UnitView>();
        foreach (BattleUnit unit in lineup.EnemyUnits)
        {
            if (!unit.IsDead)
            {
                living.Add(unit.View);
            }
        }
        return living.ToArray();
    }

    // index の人以外の仲間を暗くする
    private void DimPartyExcept(int index)
    {
        for (int i = 0; i < lineup.PartyViews.Length; i++)
        {
            lineup.PartyViews[i].SetDimmed(i != index);
        }
    }

    // 仲間を全員、もとの明るさに戻す
    private void BrightenParty()
    {
        foreach (UnitView view in lineup.PartyViews)
        {
            view.SetDimmed(false);
        }
    }
}

SelectActionsAsyncやLivingEnemyViewsなどは、BattleLineupにあったものを移しただけです。新しいところを見ていきます。

  • RunAsync
    • phaseが今の場面。switchで場面ごとにやることをして、最後に次の場面をphaseに入れる。Endになったら、whileを抜けて終わり
    • 1ターンは「Command → Action → Command → …」のくり返し。Actionのあとに敵が残っていなければVictoryへ進む
    • 負けやにげるが入っても、caseと「次の場面」を足すだけ。バトルの流れが、RunAsyncを上から読めば分かる形になっています
  • phase に [SerializeField]
    • 再生中にBattleを選ぶと、InspectorのPhaseで今の場面が見られる。うまく進まないときに、どこで止まっているかが分かります
  • StartBattleAsync:BattleLineupのStartでしていた準備。最後に「〇〇たちが あらわれた!」を出す
  • SelectActionsAsync:ターンの始めに ぼうぎょ を解き、ねらえる敵を調べ直す。決まった行動には、action.User = unit;で「だれの行動か」を入れておく
  • ExecuteActionsAsync:ActionOrder.Sortで並べ替えてから、順に実行する。全員倒したら、残りの人は動かない

Battleに付ける

  • BattleにAdd Component>BattleManager
    • Lineup:Battle(Battle Lineupが付いている、同じオブジェクト)
    • Command Menu:CommandWindow、Message Text:MessageText、Message Window:MessageWindow
    • Target Selector:TargetSelector、Battle Effects:Effects
  • Battle Lineupの欄は、並べる分(Party・Party Views・Enemies・Enemy Prefab・Enemy Area・Enemy Spacing・Status Window)だけになる
BattleのInspectorで、Battle Lineupの下にBattle Manager(Lineup・Command Menu・Message Text・Message Window・Target Selector・Battle Effects・Random Seed・Phase)

すばやさの順に並べよう

行動する人を覚える

並べ替えるには、それぞれの行動が「だれの行動か」が必要です。BattleActionに、行動する人を足します。

// 1人分の「どのコマンドを・だれに」使うか(バトルの流れで、すばやさ順に実行する)
public class BattleAction
{
    public BattleCommand Command { get; }
    public UnitView Target { get; } // 相手がいないコマンド(ぼうぎょ など)は null
    public BattleUnit User { get; set; } // 行動する人(選び終わったあとに BattleManager が入れる)

    public BattleAction(BattleCommand command, UnitView target)
    {
        Command = command;
        Target = target;
    }
}
  • コマンドのSelectAsyncは「だれが使うか」を知らないので、Userはあとから入れられるようにsetにしている

ActionOrder

  • 「ActionOrder」スクリプトを作る
using System;

// 行動の順番を決める:すばやさ × 0.8〜1.2 の大きい順
public static class ActionOrder
{
    public static void Sort(BattleAction[] actions)
    {
        // 1人ずつ、この順番を決めるための速さを出す
        float[] speeds = new float[actions.Length];
        for (int i = 0; i < actions.Length; i++)
        {
            BattleAction action = actions[i];
            float speed = action.User.Stats.speed * GameRandom.Range(0.8f, 1.2f);
            if (action.Command.ActsFirst)
            {
                speed += 1000f; // ぼうぎょ などは、だれよりも先に動く
            }
            speeds[i] = speed;
        }

        // speeds を小さい順に並べると、actions も同じように並びかわる。それを逆にして大きい順に
        Array.Sort(speeds, actions);
        Array.Reverse(actions);
    }
}
  • すばやさ × 0.8〜1.2
    • すばやさだけで決めると、毎ターン同じ順番になってしまう。少しばらつかせて、すばやさが近い人どうしは、順番が入れかわることがあるようにしている
    • キョンシー(15)はだいたい一番、フェアリー(12)とスライムちゃん(9)は、たまに入れかわります
    • ばらつきは結果に関わるので、#7 たたかう:ダメージを計算してメッセージを出そうで作ったGameRandomを使う(シードを決めれば、毎回同じ順番になる)
  • Array.Sort(speeds, actions)
    • 2つの配列を渡すと、1つ目(speeds)を小さい順に並べながら、2つ目(actions)も同じように並べかえてくれる
    • 欲しいのは大きい順なので、Array.Reverseで逆にする
    • Arrayを使うので、1行目にusing System;を書いている

ActsFirstは、次の ぼうぎょ で作ります。

ぼうぎょのコマンド

ぼうぎょは、「そのターンに受けるダメージを半分にする」コマンドです。敵より先に構えないと意味がないので、すばやさに関係なく、ターンの最初に動くようにします。

BattleCommandに、次のプロパティを足します(CommandNameの下)。

    // true なら、すばやさに関係なく、ターンの最初に動く(ぼうぎょ など)
    public virtual bool ActsFirst => false;

GuardCommandを、次のように書き換えます。

using UnityEngine;

// ぼうぎょ:そのターンに受けるダメージを半分にする
[CreateAssetMenu(fileName = "Guard", menuName = "TurnBasedRpg/Commands/Guard")]
public class GuardCommand : BattleCommand
{
    // 敵より先に構えるので、ターンの最初に動く
    public override bool ActsFirst => true;

    public override async Awaitable ExecuteAsync(BattleUnit user, BattleAction action, BattleContext context)
    {
        user.IsGuarding = true;
        await context.Message.ShowAsync($"{user.Name}は みを まもっている。");
    }
}
  • virtualのプロパティも、メソッドと同じようにoverrideで中身を変えられる。ほかのコマンドは何も書かなければfalseのまま
  • ぼうぎょ中かどうかは、BattleUnitのIsGuardingに入れておく。BattleManagerがターンの始めにfalseに戻すので、効くのはそのターンだけ

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);
        if (defender.IsGuarding)
        {
            damage /= 2f;
        }
        return Mathf.Max(1, Mathf.RoundToInt(damage));
    }
}
フェアリー

まだ敵が攻撃してこないから、ぼうぎょ の効き目は見えないね

スライム

次の回で敵が攻撃してくるようになったら、ダメージが半分になるのを確かめましょう!

倒れた敵をねらっていたら、ねらいを変えよう

すばやさの順に動くと、「キョンシーが先に倒した敵を、あとのスライムちゃんもねらっていた」ということがよく起きます。そのときは、まだ立っている敵に、ねらいを変えることにします。

BattleContextに、まだ倒れていない敵を探すメソッドを足します。

using TMPro;

// コマンドが相手を選んだり、メッセージを出したりするときに使う、バトルの道具をまとめたもの
public class BattleContext
{
    public TMP_Text MessageText;          // 上のメッセージ(すぐに書き換えるとき)
    public MessageWindow Message;         // 上のメッセージ(1文字ずつ出して待つとき)
    public TargetSelector TargetSelector; // 相手を選ぶ道具
    public BattleEffects Effects;         // 当たったときの演出
    public UnitView[] Party;              // 仲間の見た目
    public UnitView[] Enemies;            // 敵の見た目(ターンの始めに、倒れていない敵)

    // まだ倒れていない敵を、左から探して1体返す(いなければ null)
    public UnitView FindLivingEnemy()
    {
        foreach (UnitView view in Enemies)
        {
            if (!view.Unit.IsDead)
            {
                return view;
            }
        }
        return null;
    }
}

AttackCommandのExecuteAsyncの最初を、次のように書き換えます。

    // 前に出て、振って、当てて、戻る。HPが0になったら、しぼんで消える
    public override async Awaitable ExecuteAsync(BattleUnit user, BattleAction action, BattleContext context)
    {
        UnitView targetView = action.Target;
        if (targetView.Unit.IsDead)
        {
            // ねらっていた敵が、先に倒れていた。まだ立っている敵に、ねらいを変える
            targetView = context.FindLivingEnemy();
        }
        BattleUnit target = targetView.Unit;

        await context.Message.ShowAsync($"{user.Name}の こうげき!");
  • if (target.IsDead)で「もう たおれている!」を出していたところを、ねらいを変えるように書き換える
  • await context.Message.ShowAsync($"{user.Name}の こうげき!");から下は、#8 攻撃の手応えを作ろうのまま
  • 敵が全員倒れていたら、BattleManagerがその前に止めるので、FindLivingEnemyがnullを返すことはない

動かして確認!

再生して、2ターン戦ってみましょう。

  • 1ターン目:スライムちゃんとキョンシーは「たたかう」でこぼれたインク、フェアリーは「ぼうぎょ」
  • 2ターン目:3人とも「たたかう」で紙ひこうき
「フェアリーは みを まもっている。」のあと、キョンシーが12、スライムちゃんが9のダメージでこぼれたインクを倒す
キョンシーが紙ひこうきを倒したあと、紙ひこうきをねらっていたフェアリーとスライムちゃんは、本の虫を攻撃する
再生中のBattle ManagerのPhaseがCommand(Random Seedは撮影のために42)

#9の動作確認

  • 3人のコマンドを選ぶと、ぼうぎょ → すばやさの順(だいたいキョンシー → フェアリー → スライムちゃん)に動く
  • 動き終わったら、次のターンのコマンドを選ぶ
  • 先に倒れた敵をねらっていた人は、ほかの敵を攻撃する
  • 敵を全員倒したら「てきを ぜんぶ たおした!」で止まる
  • 再生中にBattleを選ぶと、InspectorのPhaseで今の場面が分かる

コマンドを選んだあと何も起きないときは、BattleのBattle Managerの欄が空になっていないか確かめましょう。BattleLineupから欄を消したので、前にBattle Lineupに入れていたもの(Command Menu など)は、Battle Managerに入れ直します。

あとがき

今回は、バトルの流れをBattleManagerにまとめて、すばやさの順に動くようにしました。場面に名前を付けておくと、「今どこで、次にどこへ進むか」がはっきりしますね。次回は、いよいよ敵が攻撃してきます。受けたときの演出と、仲間が倒れたときの扱いを作りたいと思います!

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

コメント

コメント一覧 (1件)

コメントする

目次