
前回までで、遊びの仕組みはひととおり動くようになりました。でも今の状態で遊んでみると、なんだか手応えがありません。敵は音もなく消えるし、当たったのかどうかも分かりません。ゲームの気持ちよさは、実はこういう当たった感じで決まります。今回はヴァンサバ風サバイバーゲームのシリーズの8回目として、攻撃が当たったこと・敵が倒れたこと・レベルが上がったことを、目で分かるようにしてみたいと思います!
- シリーズの目次:ヴァンサバ風ゲームの作り方
- 前回:#7 レベルアップで3択の強化を選ばせよう
なぜ演出が要るのか
ここまでは、敵のHPが10で武器の攻撃力も10でした。必ず一撃で倒れるので、演出が無くても「当たった=消えた」で分かります。
ところが次回以降、硬い敵(カチカチあめ)が出てきます。そうなると・・・
- 3発当てないと倒れない
- でも1発目と2発目は画面に何も起きない
- 遊ぶ人は「当たってないのかな?」「この武器、効いてないのかな?」と思う
これは実際に作っていて何度もぶつかりました。自分でも当たっているか分からないものは、遊ぶ人にはもっと分かりません。演出は飾りではなくて、ゲームが今どうなっているかを伝える情報です。
今回作るもの
- 攻撃が当たったら、ダメージの数字がポンと出る
- 当たった敵がのけぞって、一瞬ふくらむ
- 倒れた敵はしぼんで消える
- レベルアップの文字がポンと出る
- 敵のHPと武器の攻撃力を調整して、一撃では倒れないようにする
新しく作るスクリプトが3つ、直すスクリプトが3つです。
| スクリプト | 役割 | 付ける場所 |
|---|---|---|
| DamageText(新しく作る) | 数字が上に浮かびながら消える | ダメージ数字のプレハブ |
| DamageTextSpawner(新しく作る) | 数字を出して、使い回す | DamageTexts |
| EnemyEffect(新しく作る) | 被弾でふくらむ・のけぞる・しぼんで消える | 敵のプレハブ |
| Enemy(直す) | ダメージを受けたときに、演出を呼ぶ | 敵のプレハブ |
| EnemySpawner(直す) | 敵を出すときに、数字の係を渡す | EnemySpawner |
| StatusView(直す) | レベルアップの文字をポンと出す | Canvas |
前回の続きから
- Unityのバージョンは6.3 LTS
- 記事作成時点では6000.3.22f1で確認しています
- #7の状態:レベルアップで3択の強化が出て、選ぶと強くなる
時間を少しずつ進める書き方(コルーチン)は6-11 時間を扱う処理で紹介しています。
ダメージの数字を出そう
まずは一番はっきり分かる演出、ダメージの数字です。
SurvivorsLike/Scriptsに「DamageText」を作る
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.Pool;
// 出たあと、上に浮かびながら消えるダメージの数字
[RequireComponent(typeof(TextMeshPro))]
public class DamageText : MonoBehaviour
{
[SerializeField] private float riseSpeed = 1.2f; // 1秒に上がる高さ
[SerializeField] private float life = 0.6f; // 消えるまでの秒数
[SerializeField] private float popScale = 1.4f; // 出た瞬間の大きさ
private TextMeshPro label;
private IObjectPool<DamageText> pool;
private void Awake()
{
label = GetComponent<TextMeshPro>();
}
// 出すときに、DamageTextSpawnerから呼んでもらう
public void Show(Vector2 position, int amount, IObjectPool<DamageText> newPool)
{
transform.position = position;
label.text = amount.ToString();
pool = newPool;
StopAllCoroutines();
StartCoroutine(Play());
}
private IEnumerator Play()
{
float timer = 0f;
while (timer < life)
{
timer += Time.deltaTime;
float t = timer / life;
// 出た瞬間だけ大きく、すぐ元の大きさに戻る
transform.localScale = Vector3.one * Mathf.Lerp(popScale, 1f, Mathf.Min(t * 4f, 1f));
transform.position += Vector3.up * riseSpeed * Time.deltaTime;
label.alpha = 1f - t; // だんだん透ける
yield return null;
}
pool.Release(this);
}
}
- コルーチン(
IEnumeratorとyield return null)yield return nullは「ここでいったん終わって、次のフレームから続きをやる」という意味ですwhileの中で少しずつ動かすと、アニメーションを作らなくても動きが付けられます
Mathf.Min(t * 4f, 1f)- 大きさだけ4倍の速さで元に戻します。出た瞬間だけポンと大きい、という形です
Time.deltaTimeで動かす- #7で
Time.timeScale = 0にしたので、強化を選んでいる間はこの演出もきちんと止まります
- #7で
数字のプレハブを作ろう
- Hierarchyで右クリック>3D Object>Text – TextMeshPro、名前を「DamageText」にする
- UIの
Text - TextMeshProではなく、ゲームの世界に置くほうの「3D Object」です。敵と同じ場所に出したいので、こちらを使います - Font Size:4、Alignment:中央・中央、Color:こい赤
- Rect TransformのWidth・Heightは3・1ぐらい
- Add Component>DamageText
- InspectorのExtra Settings>Order in Layerを10にする(敵より手前)
- UIの
SurvivorsLike/Prefabsにドラッグ&ドロップしてプレハブにし、Hierarchyのものは削除する

出す係を作ろう
数字は敵を殴るたびに出るので、#3の敵や#6の金平糖と同じくプールで使い回します。
SurvivorsLike/Scriptsに「DamageTextSpawner」を作る
using UnityEngine;
using UnityEngine.Pool;
// ダメージの数字を出して、使い回す
public class DamageTextSpawner : MonoBehaviour
{
[SerializeField] private DamageText textPrefab;
private ObjectPool<DamageText> pool;
private void Awake()
{
pool = new ObjectPool<DamageText>(
createFunc: () => Instantiate(textPrefab, transform),
actionOnGet: text => text.gameObject.SetActive(true),
actionOnRelease: text => text.gameObject.SetActive(false),
actionOnDestroy: text => { if (text != null) Destroy(text.gameObject); },
defaultCapacity: 30,
maxSize: 200);
}
// 敵がダメージを受けたときに、Enemyから呼んでもらう
public void Show(Vector2 position, int amount)
{
pool.Get().Show(position, amount, pool);
}
}
- Hierarchyで右クリック>Create Empty、名前を「DamageTexts」にする
- Positionは0, 0, 0
- Add Component>DamageTextSpawner、Text Prefabに
DamageTextのプレハブを入れる
敵に当たった感じを付けよう
SurvivorsLike/Scriptsに「EnemyEffect」を作る
using System;
using System.Collections;
using UnityEngine;
// 敵の見た目の演出。被弾でふくらみ、倒れるときはしぼんで消える
public class EnemyEffect : MonoBehaviour
{
[SerializeField] private SpriteRenderer spriteRenderer;
[SerializeField] private float hitScale = 1.25f; // 被弾で一瞬大きくなる倍率
[SerializeField] private float hitSeconds = 0.12f; // 戻るまでの秒数
[SerializeField] private float deathSeconds = 0.3f; // しぼんで消えるまでの秒数
// 出てくるたびに、見た目を元に戻す(使い回すので)
public void ResetLook()
{
StopAllCoroutines();
transform.localScale = Vector3.one;
spriteRenderer.color = Color.white;
}
public void Hit()
{
StopAllCoroutines();
StartCoroutine(HitRoutine());
}
private IEnumerator HitRoutine()
{
float timer = 0f;
while (timer < hitSeconds)
{
timer += Time.deltaTime;
transform.localScale = Vector3.one * Mathf.Lerp(hitScale, 1f, timer / hitSeconds);
yield return null;
}
transform.localScale = Vector3.one;
}
// 倒れる演出。終わったら onFinished を呼ぶ(そこでプールに戻す)
public void PlayDeath(Action onFinished)
{
StopAllCoroutines();
StartCoroutine(DeathRoutine(onFinished));
}
private IEnumerator DeathRoutine(Action onFinished)
{
float timer = 0f;
while (timer < deathSeconds)
{
timer += Time.deltaTime;
float t = timer / deathSeconds;
transform.localScale = Vector3.one * Mathf.Lerp(1f, 0.2f, t);
spriteRenderer.color = new Color(1f, 1f, 1f, 1f - t);
yield return null;
}
onFinished();
}
}
Action onFinishedで「終わったら教える」- 倒れる演出が終わるまでは、プールに戻してはいけません。戻した瞬間に消えてしまい、演出が見えなくなります
- 「終わったらこれを呼んでね」と渡しておくのが、いちばん素直なやり方です
ResetLookを忘れない- プールで使い回すので、小さいまま・透けたままの敵が出てこないように戻します
被弾の演出というと「一瞬白く光る」が定番ですが、あれはシェーダー(材質の設定)を自分で用意する必要があります。SpriteRendererのColorは絵の色に掛け算されるので、白を入れても元の色に戻るだけで、明るくは出来ません。
このシリーズでは、大きさの変化と数字で当たったことを伝えます。シェーダーを使わずに済んで、しかもどれだけ効いたかまで分かるので、こちらのほうが情報量は多いです。
敵のプレハブに付ける
- Projectウインドで
SurvivorsLike/PrefabsのEnemyをダブルクリックで開く - Add Component>EnemyEffect、Sprite Rendererの欄に自分自身のSprite Rendererを入れる
- Max Hpを20にする(一撃では倒れないように。理由は後で書きます)

Enemyから演出を呼ぼう
Enemyを直します。のけぞりもここで付けます。
using UnityEngine;
using UnityEngine.Pool;
// こげたお菓子の敵。スライムちゃんを追いかけ、遠くに離れたらプールに戻る
[RequireComponent(typeof(Rigidbody2D))]
public class Enemy : MonoBehaviour
{
[SerializeField] private float moveSpeed = 1.5f; // 1秒に進むマス数
[SerializeField] private float despawnDistance = 22f; // これより離れたら引っ込む
[SerializeField] private float keepDistance = 0.5f; // これ以上は近づかない
[SerializeField] private int maxHp = 20; // 体力
[SerializeField] private float knockback = 2.5f; // のけぞる強さ
[SerializeField] private EnemyEffect effect;
private Rigidbody2D body;
private Transform target;
private IObjectPool<Enemy> pool;
private GemSpawner gemSpawner;
private DamageTextSpawner damageTexts;
private int hp;
private bool dying;
private void Awake()
{
body = GetComponent<Rigidbody2D>();
}
// 出てくるときに、EnemySpawnerから呼んでもらう
public void Spawn(Vector2 position, Transform newTarget, IObjectPool<Enemy> newPool,
GemSpawner newGemSpawner, DamageTextSpawner newDamageTexts)
{
transform.position = position;
target = newTarget;
pool = newPool;
gemSpawner = newGemSpawner;
damageTexts = newDamageTexts;
hp = maxHp; // 使い回すので、体力を戻しておく
dying = false;
effect.ResetLook();
}
// 攻撃が当たったときに、武器のほうから呼んでもらう
public void TakeDamage(int damage)
{
if (dying) // もう倒れている(同じ瞬間に2発当たったとき用)
{
return;
}
hp -= damage;
damageTexts.Show(transform.position + Vector3.up * 0.5f, damage);
if (hp > 0)
{
effect.Hit();
Knockback();
return;
}
dying = true;
body.linearVelocity = Vector2.zero;
gemSpawner.Drop(transform.position);
effect.PlayDeath(() => pool.Release(this)); // 演出が終わってからプールに戻す
}
// 攻撃はプレイヤーのほうから飛んでくるので、プレイヤーと反対に押す
private void Knockback()
{
if (target == null)
{
return;
}
Vector2 away = body.position - (Vector2)target.position;
if (away.sqrMagnitude < 0.0001f)
{
return;
}
body.linearVelocity = away.normalized * knockback;
}
private void FixedUpdate()
{
if (target == null || dying)
{
return;
}
Vector2 toTarget = (Vector2)target.position - body.position;
float distance = toTarget.magnitude;
// 真上に乗ってしまわないよう、一定の距離まで近づいたら止まる
body.linearVelocity = distance > keepDistance ? toTarget.normalized * moveSpeed : Vector2.zero;
if (distance > despawnDistance)
{
pool.Release(this);
}
}
}
dyingを足した理由- 倒れる演出をしている0.3秒のあいだ、敵はまだ画面にいます。そのままだと追いかけ続けるし、もう一度ダメージも受けてしまいます
- 「もう倒れた」という印を付けて、動きもダメージも止めます
- のけぞりは
linearVelocityを一瞬だけ書き換える- 次の
FixedUpdateでまた追いかける速度に戻るので、押された分だけ後ろにずれて、すぐ向き直る動きになります
- 次の
Spawnの引数が5つになった- 増えてきたので、そろそろ「敵に渡すもの」を1つにまとめたくなる頃です。まとめ方は#11の「作り直すなら」で触れます
- Enemyのプレハブを開き、EnemyのEffectの欄に、同じプレハブに付けたEnemyEffectを入れる
Effectの欄を入れ忘れると、敵が出てきた瞬間にNullReferenceExceptionになります。Spawnの中でeffect.ResetLook()を呼んでいるためです。エラーが出たら、まずプレハブのEnemyのInspectorを見てください。
EnemySpawnerも、数字の係を渡すように直します。
[SerializeField] private DamageTextSpawner damageTexts;
Enemy enemy = pool.Get();
enemy.Spawn(position, target, pool, gemSpawner, damageTexts);
- HierarchyでEnemySpawnerを選び、Damage TextsにHierarchyの
DamageTextsをドラッグ
レベルアップの文字をポンと出そう
#6で作った「LEVEL UP!」は、出て消えるだけでした。出た瞬間に大きくして、目に留まるようにします。
その前に、#7で困ったことが1つ起きています。レベルアップすると強化のパネルも同時に出るので、「LEVEL UP!」がパネルの暗幕の裏に隠れて見えません。
スライムせっかくの「LEVEL UP!」が、ずっと暗幕の裏に隠れていたなんて・・・
- HierarchyでLevelUpLabelを、UpgradePanelより下にドラッグして並べ替える
- UIはHierarchyで下にあるものほど手前に描かれます。カードより手前に出したいので、一番下にします
- Pos Yを330にする(カードの上に出るように。#6では200でした)
StatusViewのShowLabelを書き換えます。
[SerializeField] private float labelPopScale = 1.6f; // 出た瞬間の大きさ
private void ShowLabel()
{
levelUpLabel.SetActive(true);
labelTimer = labelSeconds;
StopAllCoroutines();
StartCoroutine(PopLabel());
}
private IEnumerator PopLabel()
{
float timer = 0f;
float popSeconds = 0.2f;
while (timer < popSeconds)
{
timer += Time.unscaledDeltaTime;
float scale = Mathf.Lerp(labelPopScale, 1f, timer / popSeconds);
levelUpLabel.transform.localScale = Vector3.one * scale;
yield return null;
}
levelUpLabel.transform.localScale = Vector3.one;
}
using System.Collections;を先頭に足してください
#7でレベルアップ中はTime.timeScale = 0にしました。Time.deltaTimeはこの影響を受けるので0になり、ポンと大きくなったまま固まってしまいます。そこでここだけTime.unscaledDeltaTime(timeScaleの影響を受けない時間)を使います。
反対に、「何秒出しておくか」を数えるlabelTimerはTime.deltaTimeのままにします。こうすると、強化を選んでいる間は減らないので、カードの上に「LEVEL UP!」が出たままになり、選び終わってから1秒で消えてくれます。
同じ演出の中でも、止まってほしい時間と止まってほしくない時間があります。敵のダメージ演出はゲームの一部なので、全部Time.deltaTimeのままでOKです。
強さを調整しよう
演出が付いたので、一撃では倒れない強さに直します。ここまでは「当たったかどうか」を見るために、わざと一撃で倒れるようにしていました(#5でお約束した調整です)。
| 直すもの | 前 | 後 | ねらい |
|---|---|---|---|
| Enemyのプレハブ Max Hp | 10 | 20 | 計量スプーンなら2発。数字が出てから倒れる |
| SpoonData Damage | 10 | 10 | そのまま。基本の1発 |
| WhipperData Damage | 10 | 6 | 回っている間に何度も当たるので控えめに |
| StrainerData Damage | 10 | 12 | 3秒に1回しか出ないので強めに |
同じ「10ダメージ」でも、当たる回数が違えば強さはまったく違います。ホイッパーは0.5秒ごとに当たるので、実際は1秒に12ダメージ。ストレーナーは3秒に1回だけなので12でも1秒あたり4です。「1秒あたりいくつ削れるか」で考えると、バランスを取りやすくなります。



数字が出るようになっただけで、ぜんぜん違うゲームみたいです!



「効いてる」が見えるとうれしいんだよね。演出は飾りじゃなくて、遊ぶ人への説明なんだ!
動かして確認!




#8の動作確認
- 攻撃が当たると、敵の頭の上にダメージの数字がポンと出て、浮かびながら消える
- 当たった敵が一瞬ふくらんで、後ろにのけぞる
- 何発か当てるとしぼんで消え、その場所に金平糖が落ちる
- 倒れている途中の敵には、もうダメージが入らない(数字が重ならない)
- レベルアップの「LEVEL UP!」が大きく出てから元の大きさになる
- 強化を選んでいる間(
Time.timeScale = 0)は、数字も演出も止まっている - Hierarchyの
DamageTextsの下で、数字が使い回されている(非表示のまま残る)


演出を足すときの考え方
今回入れたものは、どれも数行で書けるものばかりです。凝ったものを作らなくても、次の3つを押さえるだけで手応えは大きく変わります。
| 伝えたいこと | 使った手 |
|---|---|
| 当たった | 大きさが変わる・のけぞる |
| どれだけ効いた | 数字が出る |
| 終わった(倒れた) | しぼんで消える |
大事なのは「変化があること」です。音を足すともっと良くなるので、#11で効果音を付けるときに、この3か所に鳴らす予定です。
あとがき
今回は演出だけの回でした。派手なことは何もしていませんが、遊んでみると別のゲームのように気持ちよくなっているはずです。演出は最後に足すおまけだと思われがちですが、実際は「今どうなっているか」を伝えるための仕組みで、ゲームの分かりやすさそのものだと思います!次回はスライムちゃんにHPを持たせて、敵に触られるとダメージを受けるようにします。今度は自分がやられる側の演出です。
コメント
コメント一覧 (1件)
[…] 前回:#8 手応えのある攻撃の演出を作ろう […]