
ここまでは、仲間が一方的に攻撃するだけでした。今回は、敵も攻撃してくるようにします。攻撃を受けた仲間は痛がってのけぞり、画面のふちが赤く光って、HPのゲージが減ります。HPが0になった仲間は戦闘不能になり、全員が倒れたら負けです。いよいよバトルらしくなってきますよ!
- シリーズの目次:ターン制RPGの作り方
- 前回:#9 バトルの流れを作ろう
今回作るもの
- 敵が、使うコマンドと相手を選ぶ
EnemyBrain(敵も「たたかう」のコマンドを使う) - 受けたときの演出:痛がる絵(damage_62)・画面のふちが赤く光る
DamageVignette・HPのゲージが減って、減った分が遅れて縮む - 戦闘不能:眠った絵(sleep_03)と灰色。コマンドを選ばず、ねらわれない
- 仲間が全員倒れたら、負け
| スクリプト | 役割 | 付ける場所 |
|---|---|---|
| EnemyData・BattleUnit・GameRandom | 敵のコマンド、敵か仲間か、整数の乱数を足す | - |
| EnemyBrain | 敵が、コマンドと相手を選ぶ | (付けない) |
| BattleContext・AttackCommand | 敵も仲間をねらえるようにする | - |
| CharacterData・UnitView | 痛がる絵と、戦闘不能の絵を足す | - |
| DamageVignette | 画面のふちを赤く光らせる | DamageVignette(新しく作る) |
| Gauge | 減った分を赤く残して、遅れて縮める | StatusRow のプレハブ |
| BattleEffects | ゲージを減らし、仲間が受けたら赤いふち | - |
| BattlePhase・BattleManager | 敵の行動を足す、戦闘不能を飛ばす、負け | - |
敵も行動を選ぼう
敵にもコマンドを持たせる
#4 コマンドをデータにして、メニューに並べようで、コマンドを1つずつのクラス(BattleCommandを受け継ぐ)にしました。敵の攻撃も、仲間と同じ「たたかう」(AttackCommand)をそのまま使います。前に出る・当たる・光る・のけぞる、の演出も全部そのまま動きます。
EnemyDataに、使うコマンドを足します。
[SerializeField] private BattleCommand[] commands; // 使うコマンド(この中から選ぶ)
public BattleCommand[] Commands => commands;
- 変数は
goldの下に、プロパティはGoldの下に足す - Data/Enemiesの3体(SpilledInk・PaperPlane・Bookworm)のCommandsに、Data/CommandsのAttackを入れる(「かみつく」などの敵だけの行動は、#16・#22・#23で足します)

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 BattleCommand[] Commands { get; } // 選べるコマンド
public bool IsGuarding { get; set; } // ぼうぎょ しているか(ターンの始めに戻す)
public bool IsEnemy { get; } // 敵なら true
public StatusRow Row { get; set; } // ステータスの行(仲間だけ。敵は null)
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;
Commands = data.Commands;
}
// 敵のデータから作る
public BattleUnit(EnemyData data, UnitView view)
{
Name = data.EnemyName;
Stats = data.Stats;
Hp = Stats.maxHp;
View = view;
view.Unit = this;
Commands = data.Commands;
IsEnemy = true;
}
// ダメージを受ける(0より下にはしない)
public void TakeDamage(int damage)
{
Hp = Mathf.Max(0, Hp - damage);
}
}
IsEnemy:敵のコンストラクタだけでtrueにする。仲間は何もしないのでfalseのままRow:仲間のHPが減ったら、ステータスのゲージも減らしたいので、その人の行を覚えておく
BattleLineupのSetupで、行を入れます。仲間を並べるループの中を、次のように書き換えます。作った行とBattleUnitは何度か使うので、いったん変数に入れてから配列にしまいます。
CharacterData character = party[i];
UnitView view = partyViews[i];
view.SetupCharacter(character);
StatusRow row = statusWindow.AddRow(character);
BattleUnit unit = new BattleUnit(character, view);
unit.Row = row;
StatusRows[i] = row;
PartyUnits[i] = unit;
EnemyBrain
GameRandomに、整数の乱数を足します(Rangeの下)。
// 0 以上 count 未満の整数(配列やリストから1つ選ぶとき)
public static int Index(int count)
{
return random.Next(count);
}
- 「EnemyBrain」スクリプトを作る
using System.Collections.Generic;
// 敵が、使うコマンドと相手を選ぶ(今は、どちらもランダム)
public static class EnemyBrain
{
public static BattleAction Decide(BattleUnit enemy, BattleUnit[] party)
{
BattleCommand command = enemy.Commands[GameRandom.Index(enemy.Commands.Length)];
// 相手は、まだ倒れていない仲間から選ぶ
List<BattleUnit> living = new List<BattleUnit>();
foreach (BattleUnit member in party)
{
if (!member.IsDead)
{
living.Add(member);
}
}
BattleUnit target = living[GameRandom.Index(living.Count)];
BattleAction action = new BattleAction(command, target.View);
action.User = enemy;
return action;
}
}
- 仲間は
SelectAsyncで、メニューと矢印を使って選びます。敵は画面で選ばないので、SelectAsyncは使わず、ここでBattleActionを直接作る - コマンドも相手もランダム。敵ごとの行動の選び方やボスの決まった行動などは、#22・#23でここに足していきます
- 相手を選ぶ乱数は結果に関わるので、
GameRandomを使う(シードを決めると、毎回同じ相手をねらう)
敵が使っても動くように
AttackCommandは、ねらった相手が先に倒れていたら「まだ立っている敵」にねらいを変えていました。敵が使うときは「まだ立っている仲間」に変える必要があります。BattleContextのFindLivingEnemyを、使う人の相手から探すFindLivingOpponentに書き換えます。
// user の相手(仲間なら敵、敵なら仲間)から、まだ倒れていない1人を返す(いなければ null)
public UnitView FindLivingOpponent(BattleUnit user)
{
UnitView[] opponents = user.IsEnemy ? Party : Enemies;
foreach (UnitView view in opponents)
{
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.FindLivingOpponent(user);
}
BattleUnit target = targetView.Unit;
await context.Message.ShowAsync($"{user.Name}の こうげき!");
await user.View.StepAsync(true); // 前に出る
await user.View.PlayAttackPoseAsync(); // 振りかぶって、振る(ポーズの絵が無い敵は、何もしない)
int damage = DamageCalculator.Physical(user, target);
target.TakeDamage(damage);
await context.Effects.PlayHitAsync(target.View, damage); // 当たった!
// もとの位置に戻りながら、メッセージを出す
Awaitable stepBack = user.View.StepAsync(false);
await context.Message.ShowAsync($"{target.Name}に {damage}の ダメージ!");
await stepBack;
if (target.IsDead)
{
if (target.IsEnemy)
{
await target.View.DisappearAsync();
await context.Message.ShowAsync($"{target.Name}を たおした!");
}
else
{
target.View.ShowDown();
await context.Message.ShowAsync($"{target.Name}は たおれてしまった!");
}
}
}
FindLivingOpponent(user)に変えたところと、最後のif (target.IsEnemy)で分けたところが変わっています。ShowDownは、このあと戦闘不能のところで作ります- 前に出る
StepAsyncは、#8 攻撃の手応えを作ろうでforward(敵は-1)を掛けておいたので、敵は左へ前に出る。のけぞりも、仲間は左へのけぞります
スライム同じAttackCommandを、仲間にも敵にも使えるんですね



そう!#4でコマンドを1つずつのクラスにしておいたから、敵に持たせるだけで済んだんだよ
受けたときの演出


仲間が攻撃を受けたときは、敵が受けたときよりも大げさに知らせます。自分たちが受けたダメージは、ひと目で分かってほしいからです。デッキ構築カードゲームの#11 守る・受ける・回復するときの演出と同じ考え方です。
痛がる絵と、戦闘不能の絵
CharacterDataに、2枚の絵を足します(変数はattackFramesの下、プロパティはAttackFramesの下)。
[SerializeField] private Sprite damageSprite; // 痛がる絵(damage_62)
[SerializeField] private Sprite downSprite; // 戦闘不能の絵(sleep_03)
public Sprite DamageSprite => damageSprite;
public Sprite DownSprite => downSprite;
- 4人のデータのDamage Spriteに
<名前>_damage_62、Down Spriteに<名前>_sleep_03をドラッグする


UnitViewに足します。まず変数です(attackFramesの下)。
private Sprite damageSprite; // 痛がる絵(仲間だけ)
private Sprite downSprite; // 戦闘不能の絵(仲間だけ)
private bool isDown; // 戦闘不能になったか
SetupCharacterの最後に2行足します。
damageSprite = data.DamageSprite;
downSprite = data.DownSprite;
HitAsyncを、次のように書き換えます。
// 当たった:一瞬だけ真っ白に光って、後ろへのけぞり、元に戻る(仲間は痛がる絵にする)
public async Awaitable HitAsync()
{
if (damageSprite != null)
{
idleBounce.enabled = false; // 待機の弾みを止めて、痛がる絵にする
image.sprite = damageSprite;
}
flashImage.sprite = image.sprite;
flashImage.color = Color.white;
flashImage.enabled = true;
Vector2 pushed = homePosition - new Vector2(knockbackDistance * forward, 0f);
rectTransform.anchoredPosition = pushed;
await Awaitable.WaitForSecondsAsync(hitFlashSeconds, destroyCancellationToken);
flashImage.enabled = isHighlighted;
float time = 0f;
while (time < recoverSeconds)
{
time += Time.deltaTime;
rectTransform.anchoredPosition = Vector2.Lerp(pushed, homePosition, time / recoverSeconds);
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
rectTransform.anchoredPosition = homePosition;
if (damageSprite != null && !Unit.IsDead)
{
idleBounce.enabled = true; // 倒れていなければ、待機に戻る
}
}
- 最初と最後の
ifを足しただけです。痛がる絵は、のけぞって戻るまで(0.3秒ぐらい)見えます - HPが0になったときは、待機に戻さない(このあとすぐ戦闘不能の絵にするので)
画面のふちを赤く(DamageVignette)
- 素材のTurnBasedRpgAssets.zipに、画面のふちの絵
Sprites/Effects/fx_vignette.png(真ん中が透明で、ふちほど白い)を足しました。ダウンロードし直して、プロジェクトに入れる - Canvasの子にDamageVignette(UI (Canvas)>Image)
- PopupLayerのすぐ上(PopupLayerより1つ前)に置く。数字は赤いふちより手前に出ます
- アンカー:stretch-stretch、Left・Top・Right・Bottom:0
- Source Image:fx_vignette、Color:#E8433A
- Raycast Targetのチェックを外す
- 「DamageVignette」スクリプトを作って、DamageVignetteに付ける
using UnityEngine;
using UnityEngine.UI;
// 画面のふちを赤く光らせて、だんだん消す(仲間がダメージを受けたとき)
[RequireComponent(typeof(Image))]
public class DamageVignette : MonoBehaviour
{
[SerializeField] private float fadeSeconds = 0.5f; // 消えるまでの時間
[SerializeField] private float maxAlpha = 0.6f; // 一番濃いときの濃さ
private Image image;
private float timeLeft;
private void Awake()
{
image = GetComponent<Image>();
SetAlpha(0f);
}
public void Play()
{
timeLeft = fadeSeconds;
}
private void Update()
{
if (timeLeft <= 0f)
{
return;
}
// ヒットストップ中も消えていくように、unscaledDeltaTime で減らす
timeLeft = Mathf.Max(0f, timeLeft - Time.unscaledDeltaTime);
SetAlpha(maxAlpha * timeLeft / fadeSeconds);
}
private void SetAlpha(float alpha)
{
Color color = image.color;
color.a = alpha;
image.color = color;
}
}
Playで残り時間を0.5秒にして、Updateで濃さを0.6から0に下げていく- 光らせたら待つ必要がない(ほかの演出と一緒に消えていけばいい)ので、asyncにせず
Updateで消しています - エディタでは赤いふちが見えたままでOK。再生すると
Awakeで透明になります


遅れて縮むHPのゲージ
HPのゲージがパッと短くなるだけだと、どれだけ減ったかが見えにくいので、減った分をいったん赤く残して、少しあとから縮めるようにします。
- Prefabs/StatusRowを開いて、HpGauge>Fillを選び、Ctrl+Dで複製して名前をLagにする
- LagをBackとFillの間(Fillより上=Fillより後ろに描かれる)に置く。HpGaugeの子は、上からBack・Lag・Fill・Label・Valueの順になる
- Colorを#F06A5E(明るい赤)にする。白にすると、右はしの「24/26」の白い数字が見えなくなるので、白い文字が読める色にしています


Gaugeを、次のように書き換えます。
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// ゲージの長さと「38/38」の数字を、今の値と最大値から決める
public class Gauge : MonoBehaviour
{
[SerializeField] private Image fill; // Image Type:Filled
[SerializeField] private TMP_Text valueText; // 「38/38」
[SerializeField] private Image lag; // fill の後ろの赤いゲージ(無ければ空のまま)
[SerializeField] private float lagDelay = 0.4f; // 減ってから、赤いゲージが縮み始めるまでの時間
[SerializeField] private float lagSpeed = 1.2f; // 赤いゲージが縮む速さ(1秒でゲージ何本分か)
private int current;
private int max;
private float lagTimer; // 縮み始めるまでの残り時間
public void SetValue(int newCurrent, int newMax)
{
current = newCurrent;
max = newMax;
float newFill = max > 0 ? (float)current / max : 0f;
if (lag != null)
{
if (newFill < fill.fillAmount)
{
lagTimer = lagDelay; // 減ったら、赤いゲージは少し待ってから縮む
}
else
{
lag.fillAmount = newFill; // 増えたときは、すぐにそろえる
}
}
fill.fillAmount = newFill;
valueText.text = $"{current}/{max}";
}
private void Update()
{
if (lag == null)
{
return;
}
if (lagTimer > 0f)
{
lagTimer -= Time.deltaTime;
return;
}
// 赤いゲージを、今の長さまで一定の速さで縮める
lag.fillAmount = Mathf.MoveTowards(lag.fillAmount, fill.fillAmount, lagSpeed * Time.deltaTime);
}
// 確認用:Inspector の右上の︙から、ゲージを半分にしてみる
[ContextMenu("半分にしてみる")]
private void TestHalf()
{
SetValue(current / 2, max);
}
}
SetValueで減ったときは、0.4秒のタイマーを始める。待ち終わったらUpdateで、Mathf.MoveTowardsを使って今の長さまで一定の速さで縮めるMoveTowards(今, 目標, 1回に進める量)は、目標を追いこさずに近づく計算です
- 増えたとき(回復)は、赤いゲージもすぐにそろえる
lagが空なら何もしないので、MPのゲージは今までどおり- StatusRowのプレハブで、HpGaugeのGaugeのLagに、Lagをドラッグ
BattleEffectsで、ゲージと赤いふちを出す
BattleEffectsに、赤いふちを足します(stageの下)。
[SerializeField] private DamageVignette damageVignette; // 仲間が受けたときに、画面のふちを赤く
PlayHitAsyncの最初に、ゲージを減らして、仲間なら赤いふちを光らせる行を足します。
// 当たった瞬間の演出を、まとめて出す
public async Awaitable PlayHitAsync(UnitView target, int damage)
{
BattleUnit unit = target.Unit;
if (unit.Row != null)
{
unit.Row.SetHp(unit.Hp, unit.Stats.maxHp); // ステータスのゲージを減らす(仲間だけ)
}
if (!unit.IsEnemy)
{
damageVignette.Play(); // 仲間が受けたら、画面のふちを赤く
}
DamagePopup popup = Instantiate(damagePopupPrefab, popupLayer);
DamagePopup popup = …から下は、#8 攻撃の手応えを作ろうのまま- EffectsのBattle EffectsのDamage Vignetteに、DamageVignetteをドラッグ
戦闘不能
HPが0になった仲間は、戦闘不能にします。
- 眠った絵(sleep_03)にして、灰色にする
- コマンドを選ばない(順番が来ても飛ばす)
- 敵にねらわれない(EnemyBrainは、倒れていない仲間から選ぶようにしました)
眠った絵にする
UnitViewに、ShowDownを足して、SetDimmedを書き換えます。
// 暗くする(false で、もとの色に戻す。戦闘不能の人は暗いまま)
public void SetDimmed(bool isDimmed)
{
image.color = (isDimmed || isDown) ? dimColor : Color.white;
}
// 戦闘不能:待機の弾みを止めて、眠った絵にして、暗くする
public void ShowDown()
{
isDown = true;
idleBounce.enabled = false;
image.sprite = downSprite;
image.color = dimColor;
}
SetDimmedは、コマンドを選ぶたびに「ほかの仲間を暗く → 全員もとの明るさに」と呼ばれます。戦闘不能の人が明るく戻らないように、isDownなら暗いままにしています
BattleManagerで、戦闘不能の人を飛ばす
BattlePhaseに、負けを足します(Victoryの下)。
Defeat, // 負けた(仲間が全員倒れた)
BattleManagerを直します。まず、RunAsyncのCommand・Actionのところと、Defeatを書き換えます。
case BattlePhase.Command:
BattleAction[] partyActions = await SelectActionsAsync();
actions = AddEnemyActions(partyActions);
phase = BattlePhase.Action;
break;
case BattlePhase.Action:
await ExecuteActionsAsync();
if (LivingEnemyViews().Length == 0)
{
phase = BattlePhase.Victory;
}
else if (IsPartyDown())
{
phase = BattlePhase.Defeat;
}
else
{
phase = BattlePhase.Command; // 次のターン
}
break;
case BattlePhase.Victory:
await messageWindow.ShowAsync("てきを ぜんぶ たおした!");
phase = BattlePhase.End;
break;
case BattlePhase.Defeat:
await messageWindow.ShowAsync("みんな たおれてしまった…");
phase = BattlePhase.End;
break;
- コマンドを選んだら、
AddEnemyActionsで敵の行動を足してから、行動する場面へ。敵の行動も仲間の行動と一緒に、すばやさの順に並びます - 行動のあとで、敵が全員倒れていたら勝ち、仲間が全員倒れていたら負け
- 勝ったとき・負けたときの画面は、次の#11で作ります
SelectActionsAsyncのwhileの最初と、コマンドでキャンセルしたところを書き換えます。
while (index < party.Length)
{
BattleUnit unit = party[index];
if (unit.IsDead)
{
// 倒れている人は、コマンドを選ばない
selected[index] = null;
index++;
continue;
}
StatusRow row = lineup.StatusRows[index];
if (command == null)
{
// コマンドでキャンセルしたら、前の人に戻る(倒れている人は飛ばす)
index = PreviousLivingIndex(index);
continue;
}
ExecuteActionsAsyncを書き換えて、メソッドを3つ足します。
// 選んだ行動を、すばやさの順に実行する
private async Awaitable ExecuteActionsAsync()
{
ActionOrder.Sort(actions);
foreach (BattleAction action in actions)
{
if (LivingEnemyViews().Length == 0 || IsPartyDown())
{
// 途中で勝ち負けが決まったら、残りの行動はしない
break;
}
if (action.User.IsDead)
{
// 自分の番が来る前に、倒されていた
continue;
}
await action.Command.ExecuteAsync(action.User, action, context);
}
}
// 仲間の行動(倒れていて選ばなかった人の分は入れない)に、敵の行動を足す
private BattleAction[] AddEnemyActions(BattleAction[] partyActions)
{
List<BattleAction> all = new List<BattleAction>();
foreach (BattleAction action in partyActions)
{
if (action != null)
{
all.Add(action);
}
}
foreach (BattleUnit enemy in lineup.EnemyUnits)
{
if (!enemy.IsDead)
{
all.Add(EnemyBrain.Decide(enemy, lineup.PartyUnits));
}
}
return all.ToArray();
}
// 仲間が全員倒れたか
private bool IsPartyDown()
{
foreach (BattleUnit unit in lineup.PartyUnits)
{
if (!unit.IsDead)
{
return false;
}
}
return true;
}
// index より前で、倒れていない一番近い人の番号(いなければ index のまま)
private int PreviousLivingIndex(int index)
{
for (int i = index - 1; i >= 0; i--)
{
if (!lineup.PartyUnits[i].IsDead)
{
return i;
}
}
return index;
}
- action.User.IsDead:すばやい敵に先に倒された仲間は、選んでいた行動をしない。倒された敵も同じ
- PreviousLivingIndex:2人目が倒れているときに3人目でキャンセルしたら、1人目に戻る
動かして確認!
再生して、何ターンか戦ってみましょう。


戦闘不能と負けを確かめるときは、仲間がすぐ倒れるように、条件を決めて試しましょう。
- Data/CharactersのFairy・Slime・JiangshiのBase StatsのMax Hpを、一時的に5にする
- 再生して、3人とも「ぼうぎょ」以外を選んで、何ターンか進める
- 確かめたら、Max Hpを元に戻す(フェアリー26・スライムちゃん38・キョンシー32)


#10の動作確認
- 仲間の行動のあと(すばやい敵は先に)、敵が前に出て攻撃してくる
- 受けた仲間は痛がる絵になって白く光り、左へのけぞる。画面のふちが赤く光る
- HPのゲージが減り、減った分が赤く残ってから縮む
- HPが0になった仲間は、眠った絵になって暗くなり、コマンドを選ばない。敵にもねらわれない
- 全員が倒れると「みんな たおれてしまった…」で止まる
敵が攻撃してこないときは、Data/Enemiesの3体のCommandsにAttackが入っているか確かめましょう。空だと、EnemyBrainでエラーになります。
あとがき
今回は、敵が攻撃してくるようにして、受けたときの演出と戦闘不能を作りました。同じAttackCommandを敵にも使えたのは、コマンドを1つずつのクラスにしておいたおかげですね。次回は、勝ったとき・負けたときの画面と、「にげる」を作りたいと思います!
コメント
コメント一覧 (1件)
[…] 前回:#10 敵の行動と、受けるときの演出 […]