Unityでアクションゲームを作り始めると、つい Update() の中に「入力処理」「移動」「当たり判定」「エフェクト制御」「カメラ制御」などを全部まとめて書いてしまいがちですよね。
最初は動くのですが、
- 処理が複雑になってバグの原因が分からない
- 別のシーン・別のキャラで同じ演出を使い回したいのにコピペ地獄
- カメラ演出だけ直したいのに、プレイヤーのコードまでいじる必要がある
といった「Godクラス化」の問題が一気に噴き出します。
そこでこの記事では、「攻撃ヒット時、一瞬だけカメラをターゲットへズームインさせる」機能を、カメラ専用の小さなコンポーネントとして切り出してみます。
プレイヤー側は「ヒットしたよ」とイベントを飛ばすだけ、ズーム演出は HitStopZoom コンポーネントに丸投げする構成ですね。
【Unity】一瞬の迫力を簡単追加!「HitStopZoom」コンポーネント
ここでは、以下のような仕様のコンポーネントを作ります。
- カメラにアタッチして使う
- スクリプトから
PlayZoom(Transform target)を呼ぶと、
カメラがターゲット方向に一瞬ズームインしてから、元の位置・FOVに戻る - ヒットストップっぽく、ズーム中は時間を少しだけスローにするオプション付き
- 複数回連続で呼ばれても、綺麗に上書きして動作する
フルコード:HitStopZoom.cs
using System.Collections;
using UnityEngine;
namespace Samples.CameraEffects
{
/// <summary>
/// 攻撃ヒット時などに、一瞬だけカメラをターゲット側へズームインさせるコンポーネント。
/// カメラにアタッチして、外部から PlayZoom を呼び出して使います。
/// </summary>
[RequireComponent(typeof(Camera))]
public class HitStopZoom : MonoBehaviour
{
[Header("ズーム設定")]
[SerializeField]
[Tooltip("ズームイン時にカメラが近づく距離(ワールド座標単位)。正の値でターゲット方向へ近づきます。")]
private float zoomDistance = 2.0f;
[SerializeField]
[Tooltip("ズームイン時のFOV(視野角)。現在のFOVからこの値まで補間します。0以下ならFOVは変更しません。")]
private float zoomFov = 40f;
[SerializeField]
[Tooltip("ズームインにかける時間(秒)。短いほどキビキビした演出になります。")]
private float zoomInDuration = 0.05f;
[SerializeField]
[Tooltip("ズームアウトにかける時間(秒)。少し長めにすると気持ちよく戻ります。")]
private float zoomOutDuration = 0.15f;
[Header("ヒットストップ風スロー設定")]
[SerializeField]
[Tooltip("ズーム中に時間をスローにするかどうか。")]
private bool useTimeSlow = true;
[SerializeField]
[Tooltip("スロー中のTime.timeScale。0に近いほど停止に近づきます。1で通常速度。")]
private float slowTimeScale = 0.2f;
[SerializeField]
[Tooltip("スロー状態を維持する実時間(秒)。Time.timeScaleに関係なく実際の経過時間で管理します。")]
private float slowDurationRealTime = 0.08f;
[Header("補間カーブ")]
[SerializeField]
[Tooltip("ズームイン/アウトの補間カーブ。未設定なら線形補間します。")]
private AnimationCurve zoomCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
private Camera _camera;
// ズーム前のカメラ状態を保存
private Vector3 _originalLocalPosition;
private float _originalFov;
private Coroutine _zoomRoutine;
private Coroutine _slowRoutine;
private void Awake()
{
_camera = GetComponent<Camera>();
_originalLocalPosition = transform.localPosition;
_originalFov = _camera.fieldOfView;
}
/// <summary>
/// ヒット時に呼び出すメインAPI。
/// 指定したターゲット方向に一瞬だけズームインさせます。
/// </summary>
/// <param name="target">注目させたいTransform(敵やヒット位置のダミーなど)</param>
public void PlayZoom(Transform target)
{
if (target == null)
{
Debug.LogWarning("[HitStopZoom] target が null です。ズームは実行されません。");
return;
}
// 進行中のズームがあればリセットしてから開始
if (_zoomRoutine != null)
{
StopCoroutine(_zoomRoutine);
ResetCameraInstant();
}
_zoomRoutine = StartCoroutine(ZoomRoutine(target));
// スロー演出も同時に開始(オプション)
if (useTimeSlow)
{
if (_slowRoutine != null)
{
StopCoroutine(_slowRoutine);
// Time.timeScale は最後のコルーチンで戻すので、ここでは触らない
}
_slowRoutine = StartCoroutine(TimeSlowRoutine());
}
}
/// <summary>
/// カメラのズームイン/アウトを行うコルーチン。
/// </summary>
private IEnumerator ZoomRoutine(Transform target)
{
// ズーム開始時点の状態を保存
_originalLocalPosition = transform.localPosition;
_originalFov = _camera.fieldOfView;
// ターゲット方向へのオフセットを計算
Vector3 toTarget = (target.position - transform.position).normalized;
Vector3 zoomOffsetWorld = toTarget * zoomDistance;
// ローカル座標に変換してから適用したい場合はこちら
// Vector3 zoomOffsetLocal = transform.InverseTransformDirection(zoomOffsetWorld);
Vector3 startPos = transform.position;
Vector3 targetPos = startPos + zoomOffsetWorld;
float startFov = _camera.fieldOfView;
float targetFov = (zoomFov > 0f) ? zoomFov : startFov;
// --- ズームイン ---
if (zoomInDuration > 0f)
{
float t = 0f;
while (t < zoomInDuration)
{
t += Time.unscaledDeltaTime; // スロー中でも一定速度で補間するために unscaled を使用
float normalized = Mathf.Clamp01(t / zoomInDuration);
float curve = (zoomCurve != null) ? zoomCurve.Evaluate(normalized) : normalized;
// 位置補間(ワールド座標)
transform.position = Vector3.Lerp(startPos, targetPos, curve);
// FOV補間
_camera.fieldOfView = Mathf.Lerp(startFov, targetFov, curve);
yield return null;
}
}
else
{
// ズームイン時間が0以下なら即時反映
transform.position = targetPos;
_camera.fieldOfView = targetFov;
}
// --- ズームアウト ---
if (zoomOutDuration > 0f)
{
Vector3 outStartPos = transform.position;
float outStartFov = _camera.fieldOfView;
float t = 0f;
while (t < zoomOutDuration)
{
t += Time.unscaledDeltaTime;
float normalized = Mathf.Clamp01(t / zoomOutDuration);
float curve = (zoomCurve != null) ? zoomCurve.Evaluate(normalized) : normalized;
transform.position = Vector3.Lerp(outStartPos, _originalLocalPosition, curve);
_camera.fieldOfView = Mathf.Lerp(outStartFov, _originalFov, curve);
yield return null;
}
}
// 最終的に元の状態へスナップ
ResetCameraInstant();
_zoomRoutine = null;
}
/// <summary>
/// 時間を一時的にスローにするコルーチン。
/// 実時間ベースで slowDurationRealTime のあいだだけスローにします。
/// </summary>
private IEnumerator TimeSlowRoutine()
{
float originalTimeScale = Time.timeScale;
// すでに他のシステムがTime.timeScaleを変更している可能性もあるので、
// ここでは「上書き」して、終了時に元の値へ戻します。
Time.timeScale = slowTimeScale;
Time.fixedDeltaTime = 0.02f * Time.timeScale; // 物理ステップも追従させる
float elapsed = 0f;
while (elapsed < slowDurationRealTime)
{
elapsed += Time.unscaledDeltaTime; // 実時間ベース
yield return null;
}
// 元のTimeScaleへ戻す
Time.timeScale = originalTimeScale;
Time.fixedDeltaTime = 0.02f * Time.timeScale;
_slowRoutine = null;
}
/// <summary>
/// カメラの位置とFOVを、記録しておいた元の状態へ即座に戻します。
/// </summary>
private void ResetCameraInstant()
{
// 位置はローカル座標を基準に戻す
transform.localPosition = _originalLocalPosition;
_camera.fieldOfView = _originalFov;
}
/// <summary>
/// デバッグ用:インスペクタからテスト発火できるようにする。
/// </summary>
[ContextMenu("Test Zoom (Self as Target)")]
private void TestZoomSelf()
{
PlayZoom(transform);
}
}
}
使い方の手順
ここからは、実際にプレイヤーや敵の攻撃に組み込む手順を見ていきましょう。
手順①:カメラに HitStopZoom をアタッチする
- シーン内のメインカメラ(
Main Camera)を選択 Add ComponentボタンからHitStopZoomを追加- インスペクタで以下のパラメータを調整
- Zoom Distance: 1.5 ~ 3.0 くらいから試す
- Zoom Fov: 通常が60なら、40~50あたりが無難
- Zoom In Duration: 0.03 ~ 0.07 くらい(短いとキレが出る)
- Zoom Out Duration: 0.1 ~ 0.2 くらい(余韻を持たせる)
- Use Time Slow: ONにするとヒットストップ感UP
手順②:攻撃スクリプトから PlayZoom を呼ぶ
プレイヤーの近接攻撃を例にします。
「敵に当たった瞬間」に HitStopZoom.PlayZoom() を呼び出すだけです。
using UnityEngine;
public class PlayerAttack : MonoBehaviour
{
[SerializeField]
private float attackRange = 1.5f;
[SerializeField]
private LayerMask enemyLayer;
[SerializeField]
private Transform attackOrigin;
private Camera _mainCamera;
private Samples.CameraEffects.HitStopZoom _hitStopZoom;
private void Start()
{
_mainCamera = Camera.main;
if (_mainCamera != null)
{
_hitStopZoom = _mainCamera.GetComponent<Samples.CameraEffects.HitStopZoom>();
}
}
private void Update()
{
// 簡易的にマウス左クリックで攻撃
if (Input.GetMouseButtonDown(0))
{
TryAttack();
}
}
private void TryAttack()
{
// 攻撃の当たり判定(シンプルなSphereCast例)
Vector3 origin = attackOrigin.position;
Vector3 direction = attackOrigin.forward;
if (Physics.SphereCast(origin, 0.3f, direction, out RaycastHit hit, attackRange, enemyLayer))
{
// 敵にダメージを与える処理(省略)
// hit.collider.GetComponent<Enemy>()?.TakeDamage(1);
// カメラにズーム演出を依頼
if (_hitStopZoom != null)
{
// 敵の中心に向かってズームしたいので、敵のTransformを渡す
_hitStopZoom.PlayZoom(hit.collider.transform);
}
}
}
private void OnDrawGizmosSelected()
{
if (attackOrigin == null) return;
Gizmos.color = Color.red;
Gizmos.DrawLine(attackOrigin.position, attackOrigin.position + attackOrigin.forward * attackRange);
}
}
ポイントは、プレイヤー側は「ヒットしたかどうか」だけを知っていればよく、カメラの補間ロジックは一切書いていないことです。
「責務の分離」ができていると、カメラ演出を変えたいときに HitStopZoom だけを差し替えられます。
手順③:敵や動く床など、別オブジェクトでも再利用する
たとえば「トゲ床に乗ったときにカメラをズームさせる」「巨大なボスの攻撃が当たったときだけズームを強めにする」といった演出も、同じコンポーネントを使い回せます。
敵の攻撃スクリプト側で:
using UnityEngine;
public class EnemyAttack : MonoBehaviour
{
[SerializeField]
private Transform hitEffectPoint;
private Samples.CameraEffects.HitStopZoom _hitStopZoom;
private void Start()
{
Camera mainCam = Camera.main;
if (mainCam != null)
{
_hitStopZoom = mainCam.GetComponent<Samples.CameraEffects.HitStopZoom>();
}
}
// プレイヤーに当たったときに呼ばれる想定
public void OnHitPlayer()
{
if (_hitStopZoom != null && hitEffectPoint != null)
{
// ボスの攻撃なので、プレイヤーではなく攻撃の着弾地点をターゲットにする
_hitStopZoom.PlayZoom(hitEffectPoint);
}
}
}
このように、「ヒットしたらターゲットTransformを渡すだけ」というインターフェイスにしておくと、プレイヤー・敵・ギミック問わず簡単に使い回せます。
手順④:パラメータを変えてゲーム全体の手触りを調整する
- 通常攻撃:
Zoom Distance = 1.5,Zoom Fov = 45,Use Time Slow = ON - 必殺技:
Zoom Distance = 3.0,Zoom Fov = 35,Slow Duration = 0.15など強めに - 環境ギミック(トゲ床など):
Use Time Slow = OFFでカメラだけ軽く寄る
同じ演出コンポーネントでも、プレハブごとにパラメータを変えるだけで「強弱」を演出できるのがコンポーネント指向の良いところですね。
メリットと応用
メリット①:カメラ演出を完全にカプセル化できる
HitStopZoom は、
- カメラの位置補間
- FOVの補間
- Time.timeScale の一時変更
といった「演出としてのカメラ制御」をすべて自分の中で完結させています。
攻撃スクリプト側は「ヒットした・ターゲットはこれ」の2つだけを知っていればOKなので、
- プレイヤーコードが肥大化しない
- 別のシーン・別のプロジェクトでも簡単に移植できる
- 演出担当の人がカメラコンポーネントだけを触って調整できる
といったメリットがあります。
メリット②:プレハブ単位で演出を差し替えやすい
たとえば、
- 「通常ステージ用カメラ」プレハブ:
Use Time Slow = ON - 「タイムアタック用カメラ」プレハブ:
Use Time Slow = OFF
といったように、同じゲームロジックでも、カメラプレハブを差し替えるだけでゲームの手触りを変えられます。
レベルデザイン時にも、シーンごとにカメラの演出テイストを変えたいときに便利ですね。
メリット③:他のカメラコンポーネントとも共存しやすい
カメラの追従やシェイクなどを別コンポーネントで実装していても、HitStopZoom は「一時的に位置/FOVを上書きしてすぐ戻す」だけなので、比較的共存しやすい構造になっています。
さらに分離したい場合は、Cinemachine や独自のカメラマネージャーと連携するラッパーを用意するのも良いですね。
改造案:ズーム完了時にコールバックを受け取る
「ズーム演出が終わったら、必殺技の次のフェーズに進めたい」といったケースでは、
簡単なイベントコールバックを追加しておくと便利です。
例えば、こんなメソッドを HitStopZoom に追加して、ズーム終了時に呼んであげると拡張性が上がります。
public System.Action OnZoomFinished;
private void InvokeZoomFinished()
{
// 登録されているリスナーがあれば呼び出す
OnZoomFinished?.Invoke();
}
そして ZoomRoutine の最後(ResetCameraInstant(); の直後など)に InvokeZoomFinished(); を差し込めば、
外部から
_hitStopZoom.OnZoomFinished += () =>
{
// 必殺技の次のフェーズへ進む処理
};
といった形で、演出の「つなぎ」を綺麗に構成できます。
このように、小さな責務のコンポーネントにしておくと、
後から「もう一歩だけ欲しい機能」を安全に積み増しできるのが嬉しいですね。




