
前回で「たたかう」が動くようになりました。でも、メッセージが出てHPが減るだけでは、なんだか手応えがありませんよね。攻撃する側が前に出て、当たった敵が光ってのけぞり、ダメージの数字が飛び出して、画面がブルッと揺れる。この「反応」があるかどうかで、ゲームの気持ちよさが全然ちがいます。今回は、たたいたときの手応えをまとめて作ってみたいと思います!
- シリーズの目次:ターン制RPGの作り方
- 前回:#7 たたかう:ダメージを計算してメッセージを出そう
今回作るもの
- 攻撃する仲間が前に出て、攻撃のポーズ(振りかぶる → 振る)をとって、元の位置に戻る
- 当たった敵が、一瞬だけ真っ白に光って、後ろへのけぞる
- ダメージの数字が飛び出す
- 火花のパーティクルが散る
- 当たった瞬間に時間が一瞬止まり(ヒットストップ)、画面が揺れる
- 倒れた敵は、しぼみながら消える
| スクリプト | 役割 | 付ける場所 |
|---|---|---|
| CharacterData | 攻撃のポーズの絵を足す | - |
| UnitView | 前に出る・ポーズ・のけぞる・消える動きを足す | 仲間3人・敵のプレハブ |
| DamagePopup | ダメージの数字が飛び出して消える | 数字のプレハブ |
| BattleEffects | 数字・火花・ヒットストップ・揺れをまとめて出す | Effects(新しく作る) |
| BattleContext | BattleEffectsを足す | - |
| AttackCommand | 演出の順番を書く | - |
| BattleLineup | BattleContextにBattleEffectsを入れる | Battle |
デッキ構築カードゲームの#10 攻撃の手応えを作ろうでも同じような演出を作りました。あちらはコルーチンで書きましたが、今回は6-16 待つ処理をすっきり書こう:async/awaitとAwaitableのasync/awaitで書きます。「前に出る → 振る → 当たる → 戻る」が上から順に読めるのが、asyncで書くうれしさです。
前に出て、攻撃のポーズをとろう
攻撃のポーズの絵
#1 プロジェクトの準備とバトル画面のレイアウトで取り込んだ素材には、右を向いて攻撃するポーズ(<名前>_attack_61・62)が入っています。61が振りかぶったところ、62が振ったところです。
#2 キャラクターと敵のデータで作ったCharacterDataに、攻撃のポーズの絵を足します。
[SerializeField] private Sprite[] attackFrames; // 攻撃のポーズ(attack_61・62)
public Sprite[] AttackFrames => attackFrames;
- 変数は
idleFramesの下に、プロパティはIdleFramesの下に足す - 4人のデータのAttack Framesに、それぞれの
_attack_61・_attack_62をドラッグする

UnitViewに動きを足す
UnitViewに、動きのメソッドを足して、次のように書き換えます。
using UnityEngine;
using UnityEngine.UI;
// 仲間や敵の見た目。データを受け取って、絵を表示する
public class UnitView : MonoBehaviour
{
[SerializeField] private Image image;
[SerializeField] private IdleBounce idleBounce; // 仲間だけ。敵は空のまま
[SerializeField] private Material flashMaterial; // 白く光らせるマテリアル(WhiteFlash)
[SerializeField] private Color flashColor = new Color(1f, 0.92f, 0.5f); // 光る色(白い敵でも分かるよう、少し黄色)
[SerializeField] private float blinkSpeed = 6f; // 点滅の速さ
[SerializeField] private float minFlash = 0.15f; // 一番弱いときの白さ
[SerializeField] private float maxFlash = 0.45f; // 一番強いときの白さ
[SerializeField] private Color dimColor = new Color(0.4f, 0.4f, 0.45f); // 暗くするときの色
[SerializeField] private float stepDistance = 120f; // 攻撃するときに前に出る距離
[SerializeField] private float stepSeconds = 0.12f; // 前に出る・戻るのにかける時間
[SerializeField] private float knockbackDistance = 40f; // のけぞる距離
[SerializeField] private float hitFlashSeconds = 0.08f; // 当たったときに真っ白になる時間
[SerializeField] private float recoverSeconds = 0.25f; // のけぞってから元の位置に戻るまでの時間
public BattleUnit Unit { get; set; } // この見た目が表している、バトル中の1人
private Image flashImage; // 自分の絵の上に重ねる、白いシルエット
private bool isHighlighted;
private RectTransform rectTransform;
private Vector2 homePosition; // もとの位置
private float forward = 1f; // 前の向き(仲間は右=1、敵は左=-1)
private Sprite[] attackFrames; // 攻撃のポーズ(仲間だけ)
private void Awake()
{
rectTransform = (RectTransform)transform;
// 自分の絵と同じ大きさの Image を子に作って、白く光らせるのに使う
GameObject flash = new GameObject("Flash", typeof(RectTransform), typeof(Image));
flash.transform.SetParent(transform, false);
RectTransform flashRect = (RectTransform)flash.transform;
flashRect.anchorMin = Vector2.zero;
flashRect.anchorMax = Vector2.one;
flashRect.offsetMin = Vector2.zero;
flashRect.offsetMax = Vector2.zero;
flashImage = flash.GetComponent<Image>();
flashImage.material = flashMaterial;
flashImage.preserveAspect = true;
flashImage.raycastTarget = false;
flashImage.enabled = false;
}
private void Start()
{
// 敵は Instantiate のあとで位置を決めるので、Start で覚える
homePosition = rectTransform.anchoredPosition;
}
private void Update()
{
if (isHighlighted)
{
UpdateFlash();
}
}
// 光る絵を、体と同じ形・今の白さにする
private void UpdateFlash()
{
// 待機のコマが変わっても、同じ形で光るようにする
flashImage.sprite = image.sprite;
// Sin で 0〜1 をくり返し、白さを minFlash〜maxFlash の間で変える
float wave = (Mathf.Sin(Time.time * blinkSpeed) + 1f) / 2f;
Color color = flashColor;
color.a = Mathf.Lerp(minFlash, maxFlash, wave);
flashImage.color = color;
}
// 仲間のデータで見た目を作る
public void SetupCharacter(CharacterData data)
{
name = data.CharacterName;
idleBounce.SetFrames(data.IdleFrames);
attackFrames = data.AttackFrames;
}
// 敵のデータで見た目を作る
public void SetupEnemy(EnemyData data)
{
name = data.EnemyName;
image.sprite = data.Sprite;
forward = -1f; // 敵は左を向いている
}
// 選ばれている間、白く点滅させる
public void SetHighlight(bool isOn)
{
isHighlighted = isOn;
if (isOn)
{
// 表示する前に、形と色を入れておく(入れる前に表示すると、1フレームだけ白い四角が出る)
UpdateFlash();
}
flashImage.enabled = isOn;
}
// 暗くする(false で、もとの色に戻す)
public void SetDimmed(bool isDimmed)
{
image.color = isDimmed ? dimColor : Color.white;
}
// 前に出る(isForward が false なら、もとの位置に戻って、待機の弾みに戻す)
public async Awaitable StepAsync(bool isForward)
{
Vector2 from = rectTransform.anchoredPosition;
Vector2 to = isForward ? homePosition + new Vector2(stepDistance * forward, 0f) : homePosition;
float time = 0f;
while (time < stepSeconds)
{
time += Time.deltaTime;
rectTransform.anchoredPosition = Vector2.Lerp(from, to, time / stepSeconds);
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
rectTransform.anchoredPosition = to;
if (!isForward && idleBounce != null)
{
idleBounce.enabled = true;
}
}
// 攻撃のポーズ:振りかぶって(61)、振る(62)。ポーズの絵が無い敵は、何もしない
public async Awaitable PlayAttackPoseAsync()
{
if (attackFrames == null || attackFrames.Length < 2)
{
return;
}
idleBounce.enabled = false; // 待機のコマが上書きしないように止める
image.sprite = attackFrames[0];
await Awaitable.WaitForSecondsAsync(0.1f, destroyCancellationToken);
image.sprite = attackFrames[1];
}
// 当たった:一瞬だけ真っ白に光って、後ろへのけぞり、元に戻る
public async Awaitable HitAsync()
{
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;
}
// 倒れた:しぼみながら薄くなって消える
public async Awaitable DisappearAsync()
{
const float seconds = 0.35f;
float time = 0f;
while (time < seconds)
{
time += Time.deltaTime;
float t = time / seconds;
transform.localScale = Vector3.one * (1f - t);
image.color = new Color(1f, 1f, 1f, 1f - t);
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
gameObject.SetActive(false);
}
}
- homePosition と forward
- もとの位置を覚えておき、前に出たり、のけぞったりしたあとは、そこへ戻る
- 仲間は右を、敵は左を向いているので、「前」の向きを
forward(1か-1)で持つ。forwardを掛けるだけで、仲間にも敵にも同じコードが使える(敵は#10で攻撃してきます)
- StepAsync
Lerpで、0.12秒かけて前に出る・戻る。戻ったら、止めていた待機の弾み(IdleBounce)を動かす
- PlayAttackPoseAsync
- 待機の弾みを止めてから、攻撃の2枚に切り替える。止めないと、IdleBounceが次のフレームで待機の絵に戻してしまう
- HitAsync
- #5 だれの番か、ひと目で分かるようにしようで作った光る絵を、今度は真っ白(Alpha 1)で0.08秒だけ出す。点滅と同じ仕組みで、当たった瞬間の「白く光る」も作れる
- 同時に後ろへ40だけ押して、0.25秒かけて元の位置に戻す
ダメージの数字を飛ばそう
数字を出す場所
数字は、敵や仲間より手前に出したいので、Canvasの一番下(一番手前)に入れ物を作ります。
- Canvasの子にPopupLayer(Create Empty)
- Canvasの子の一番下に置く
- アンカー:stretch-stretch、Left・Top・Right・Bottom:0
DamagePopup
- 「DamagePopup」スクリプトを作る
using TMPro;
using UnityEngine;
// ダメージの数字:大きく出て、放物線を描いて飛び、消える
[RequireComponent(typeof(TextMeshProUGUI))]
public class DamagePopup : MonoBehaviour
{
[SerializeField] private float lifeSeconds = 0.8f; // 消えるまでの時間
[SerializeField] private float jumpSpeed = 600f; // 上へ飛び出す速さ
[SerializeField] private float gravity = 1800f; // 下へ引っぱる強さ
private TextMeshProUGUI text;
private RectTransform rectTransform;
private Vector2 velocity;
private float time;
public void Setup(int damage)
{
text = GetComponent<TextMeshProUGUI>();
rectTransform = (RectTransform)transform;
text.text = damage.ToString();
text.fontSize = Mathf.Min(70f + damage * 3f, 140f); // 大きいダメージほど大きな文字に
velocity = new Vector2(Random.Range(-150f, 150f), jumpSpeed);
}
private void Update()
{
time += Time.deltaTime;
float t = time / lifeSeconds;
// 放物線:上へ飛び出して、重力で落ちてくる
velocity.y -= gravity * Time.deltaTime;
rectTransform.anchoredPosition += velocity * Time.deltaTime;
// 出た瞬間だけ大きく、後半で透明に
rectTransform.localScale = Vector3.one * Mathf.Lerp(1.8f, 1f, t / 0.15f);
text.alpha = Mathf.InverseLerp(1f, 0.6f, t);
if (time >= lifeSeconds)
{
Destroy(gameObject);
}
}
}
- 文字の大きさ:
70 + ダメージ×3(上限140)。数字の大きさでダメージの差が伝わるようにしている - 動き:上向きの速さで飛び出して、毎フレーム
gravityで下へ引っぱる。横の速さを少しずつ変えているので、続けて当たっても数字が重ならない - 透明度:
Mathf.InverseLerp(1f, 0.6f, t)で、残り4割の時間で1→0に - ここの
Random.Rangeは見た目のばらつきなので、#7で作ったGameRandomではなくUnityの乱数のままでOK
プレハブを作ります。
- PopupLayerの子にDamagePopup(UI (Canvas)>Text – TextMeshPro)
- Width:300、Height:160
- テキスト:10、Font Size:100、Font Style:B、Color:白、Alignment:中央
- Text Wrapping Mode:No Wrap、Raycast Targetのチェックを外す
- Add Component>DamagePopup
- Prefabsフォルダへドラッグしてプレハブにして、Hierarchyからは消す

火花のパーティクル
当たったところに、黄色〜オレンジの火花を散らします。パーティクルの基本は12-4 パーティクルで演出しよう:Particle Systemの基本で紹介しています。
UIより手前に描く「Effect」のSorting Layer
パーティクルは、何も設定しないとCanvas(背景やキャラクター)の後ろに隠れてしまいます。#1でCanvasを「Screen Space – Camera」にしたのは、このためでした。
- Edit>Project Settings>Tags and Layers のSorting Layersで「+」を押し、Effectを足す
- Defaultより下にあれば、Defaultより手前に描かれる

HitSparksを作る
- 火花の粒の絵
Sprites/Effects/fx_spark.pngを、素材のTurnBasedRpgAssets.zipに足しました。ダウンロードし直して、TurnBasedRpg/Sprites/Effectsフォルダごとプロジェクトに入れる- ドラッグ&ドロップで入れたときは、Sprite ModeをSingleにする
- Hierarchyで右クリック>Effects>Particle System、名前をHitSparks
- 下の表のとおりに設定する
| モジュール | 項目:値 |
|---|---|
| Main | Duration:0.5、Looping:チェックなし |
| Start Lifetime:Random Between Two Constants 0.25〜0.45 | |
| Start Speed:Random Between Two Constants 6〜12 | |
| Start Size:Random Between Two Constants 0.3〜0.6 | |
| Start Rotation:Random Between Two Constants 0〜360 | |
| Start Color:Random Between Two Colors #FFE066・#FF8A3D | |
| Gravity Modifier:1、Simulation Space:World、Stop Action:Destroy | |
| Emission | Rate over Time:0、Burstsの「+」で Time:0・Count:18 |
| Shape | Shape:Circle、Radius:0.1 |
| Size over Lifetime | チェックを入れて、Sizeのカーブを「1から0へ下がる」形に |
| Texture Sheet Animation | チェックを入れて、Mode:Sprites、Spriteに fx_spark |
| Renderer | Material:Sprite-Unlit-Default、Sorting Layer:Effect |

- 大きさと速さの単位は、Canvasの数字(1920×1080)ではなくワールドの単位。カメラのSizeが5なので、画面の縦が10、1がCanvasの約108に当たる
- Stop Action:Destroy:出し終わったら、パーティクルが自分で消える
HitSparksをPrefabsフォルダへドラッグしてプレハブにして、Hierarchyからは消します。
火花が見えないときは、RendererのSorting LayerがEffectになっているか確かめましょう。Defaultのままだと、背景の後ろに隠れます。ピンク色の四角になるときは、Materialが合っていません(Materialの右の丸いボタンで「Sprite-Unlit-Default」を検索して選びます)。
まとめて出すBattleEffects
数字・火花・ヒットストップ・画面の揺れを、1つにまとめます。
- 「BattleEffects」スクリプトを作る
using UnityEngine;
// 当たったときの演出(ダメージの数字・火花・ヒットストップ・画面の揺れ)をまとめて出す
public class BattleEffects : MonoBehaviour
{
[SerializeField] private DamagePopup damagePopupPrefab;
[SerializeField] private RectTransform popupLayer; // 数字を出す親(Canvas の一番手前)
[SerializeField] private ParticleSystem sparksPrefab;
[SerializeField] private RectTransform stage; // 揺らす画面
[SerializeField] private float hitStopSeconds = 0.06f; // 時間を止める長さ
[SerializeField] private float shakeSeconds = 0.25f; // 揺れている時間
[SerializeField] private float shakePerDamage = 2f; // ダメージ1あたりの揺れの幅
[SerializeField] private float maxShake = 30f; // 揺れの幅の上限
// 当たった瞬間の演出を、まとめて出す
public async Awaitable PlayHitAsync(UnitView target, int damage)
{
DamagePopup popup = Instantiate(damagePopupPrefab, popupLayer);
popup.transform.position = target.transform.position;
popup.Setup(damage);
Instantiate(sparksPrefab, target.transform.position, Quaternion.identity);
// 光る・のけぞると、画面の揺れは、待たずに始めておく(同時に動かす)
Awaitable hit = target.HitAsync();
Awaitable shake = ShakeAsync(damage);
await HitStopAsync();
await hit;
await shake;
}
// 一瞬だけ時間を止める
private async Awaitable HitStopAsync()
{
Time.timeScale = 0f;
try
{
// timeScale が 0 の間も進む unscaledDeltaTime で数える
float time = 0f;
while (time < hitStopSeconds)
{
await Awaitable.NextFrameAsync(destroyCancellationToken);
time += Time.unscaledDeltaTime;
}
}
finally
{
// 途中で止まっても、必ず時間を戻す
Time.timeScale = 1f;
}
}
// 戦いの画面(Stage)を揺らす。ダメージが大きいほど強く、だんだん弱く
private async Awaitable ShakeAsync(int damage)
{
float strength = Mathf.Min(damage * shakePerDamage, maxShake);
Vector2 start = stage.anchoredPosition;
float timeLeft = shakeSeconds;
while (timeLeft > 0f)
{
float power = strength * (timeLeft / shakeSeconds);
stage.anchoredPosition = start + Random.insideUnitCircle * power;
await Awaitable.NextFrameAsync(destroyCancellationToken);
// ヒットストップ中も揺れるように、unscaledDeltaTime で減らす
timeLeft -= Time.unscaledDeltaTime;
}
stage.anchoredPosition = start;
}
}
同時に動かすには
- Awaitable hit = target.HitAsync();
- asyncのメソッドは、呼んだ瞬間に動き始める。
awaitを付けずに呼んで、戻ってきたAwaitableを変数に入れておくと、「始めておいて、あとで待つ」ができる - ここでは、のけぞりと揺れを始めておいて、その間にヒットストップを待ち、最後に
await hit;・await shake;で両方が終わるのを待っている - Unity 6.3には「全部終わるまで待つ」(
WhenAll)が無いので、このように1つずつawaitします
- asyncのメソッドは、呼んだ瞬間に動き始める。
- ヒットストップ(HitStopAsync)
Time.timeScale = 0fで、ゲームの時間を0.06秒だけ止める。Time.deltaTimeで動いているもの(のけぞり・数字・火花)が全部止まり、「バシッ」と当たった重さが出る- 止まっている間の時間は、
Time.unscaledDeltaTimeで数える。deltaTimeだと0のまま増えず、いつまでも止まったままになる try〜finallyのfinallyは、途中でキャンセルされても必ず通る。時間を止めたままにしないための書き方です
- 揺れ(ShakeAsync)
Random.insideUnitCircle(半径1の円の中のランダムな点)に幅を掛けて、Stageを毎フレームずらす。揺らすのはStageだけなので、メッセージやコマンドのウインドは揺れない- 揺れは
unscaledDeltaTimeで数えるので、ヒットストップで止まっている間も揺れ続ける。「全部止まっているのに、画面だけがブルッと震える」ので、当たった瞬間が強く感じられます
- Hierarchyに空のGameObject「Effects」を作り、BattleEffectsを付ける
- Damage Popup Prefab:Prefabs/DamagePopup、Popup Layer:PopupLayer
- Sparks Prefab:Prefabs/HitSparks、Stage:Canvas>Stage

「たたかう」に演出の順番を書こう
BattleContextに、BattleEffectsを足します。
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; // 敵の見た目
}
AttackCommandのExecuteAsyncを、次のように書き換えます(SelectAsyncはそのままです)。
// 前に出て、振って、当てて、戻る。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}の こうげき!");
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)
{
await target.View.DisappearAsync();
await context.Message.ShowAsync($"{target.Name}を たおした!");
}
}
- 演出の順番が、上から読んだとおりに並んでいる。「前に出る → 振る → 当たる → 戻る → 消える」を、時間を数えたり、終わったら次を呼ぶ仕組みを作ったりせずに書けます
- Awaitable stepBack
- 戻る動きを始めておいて、その間にメッセージを出す。ここでも「始めておいて、あとで待つ」を使っている
最後に、BattleLineupで道具を入れます。#7のBattleLineupに、次の2か所を足します。
[SerializeField] private BattleEffects battleEffects;
context.Effects = battleEffects;
- 1つ目は
targetSelectorの下に、2つ目はcontext.TargetSelector = targetSelector;の下に足す - BattleのBattle LineupのBattle Effectsに、Effectsをドラッグ
スライムたたかう以外のコマンドは、何も変えなくていいんですね



そう!まほうの回でも、MagicCommandの中で、この演出のメソッドを組み合わせて使うだけだよ
動かして確認!
再生して、たたかってみましょう。




#8の動作確認
- 攻撃する仲間が前に出て、攻撃のポーズをとり、元の位置に戻る
- 当たった敵が一瞬真っ白に光って、後ろへのけぞり、元に戻る
- ダメージの数字が飛び出して、ふわっと消える。大きいダメージほど大きな数字
- 火花が散り、当たった瞬間に一瞬止まって、画面が揺れる
- 倒れた敵は、しぼみながら消える
あとがき
今回は、たたいたときの手応えをまとめて作りました。同じ「10のダメージ」でも、光って、のけぞって、数字が飛び出すだけで、気持ちよさが全然ちがいますね。次回は、3人の行動をすばやさの順に並べて、バトルの流れを整理したいと思います!
コメント
コメント一覧 (2件)
[…] 前回:#8 攻撃の手応えを作ろう […]
[…] 当たったときの演出は、#8でBattleEffectsにまとめました。ここに足せば、たたかう・わざ・まほう・敵の攻撃の、どれでも鳴ります。 […]