【Cocos Creator 3.8】HitStopZoomの実装:アタッチするだけで「攻撃ヒット時に一瞬だけカメラをターゲットへズームイン」させる汎用スクリプト
この記事では、Cocos Creator 3.8.7 + TypeScript で使える、「攻撃がヒットした瞬間に、カメラを一瞬だけターゲットへズームインさせる」ための汎用コンポーネント HitStopZoom を実装します。
このコンポーネントをカメラのノードにアタッチしておくだけで、任意のタイミングでメソッドを呼び出すと「一瞬だけズームイン → 自動で元に戻る」 演出が行えます。外部の GameManager やシングルトンに依存せず、スクリプト単体で完結する設計になっています。
コンポーネントの設計方針
1. 機能要件の整理
- カメラにアタッチして使うコンポーネント。
- 攻撃ヒット時など任意のタイミングで API を呼ぶと、カメラが一瞬ズームインしてから元に戻る。
- ズーム対象(ターゲット)となるノードをインスペクタから指定できる。
- ズーム量・ズーム時間・戻り時間などをインスペクタから調整できる。
- ターゲットが指定されていない場合は、位置はそのままズームだけ行う。
- ズーム中に再度トリガーされた場合、前のズーム状態から上書き or 再スタートできる(今回は「毎回リセットしてやり直し」のシンプル仕様)。
- 外部スクリプトに依存せず、このコンポーネント単体で動作する。
2. 外部依存をなくす設計アプローチ
- 必要な標準コンポーネントは Camera のみ。
onLoadでthis.getComponent(Camera)を実行し、見つからない場合はエラーログを出す(防御的実装)。- ズーム対象(ターゲットノード)は
@property(Node)でインスペクタから指定可能にし、null でも動作する設計。 - 攻撃ヒット時に呼び出す API として、public メソッド
triggerZoom()を提供する。 - ズームは Camera.orthoHeight(2D)を前提に設計し、ズーム量を「倍率」で指定できるようにする。
3. インスペクタで設定可能なプロパティ
- target(Node)
- ズームイン時に注目したいターゲットノード。
- 例:ヒットした敵、プレイヤー、ボスなど。
- 未指定(null)の場合はカメラ位置はそのまま、ズームのみ行う。
- zoomFactor(number)
- ズーム倍率。1.0 が通常、0.5 で半分のサイズ(= 2 倍ズームイン)というイメージ。
- 値が小さいほど強くズームインする。
- 例:
0.7(ややズームイン)、0.5(しっかりズームイン)。
- zoomInDuration(number)
- ズームインにかける時間(秒)。
- 例:
0.05〜0.15くらいの短い値にすると、瞬間的なヒット感が出る。
- holdDuration(number)
- ズームインした状態を維持する時間(秒)。
- 例:
0.03〜0.1程度。
- zoomOutDuration(number)
- 元のズーム・位置に戻るまでの時間(秒)。
- 例:
0.1〜0.2程度にすると、自然な戻りになる。
- moveToTarget(boolean)
- true の場合、ズームイン中に カメラ位置もターゲットへ寄せる。
- false の場合、位置は固定したままズームのみ行う。
- positionLerpFactor(number)
- ターゲット位置へ寄せる強さ(0〜1)。
- 1.0 ならターゲット位置へ完全に移動、0.5 なら中間地点まで寄る。
moveToTargetが true のときのみ使用。
- ignoreZAxis(boolean)
- 2D ゲーム用に、カメラの Z 座標は固定し、X/Y のみターゲットへ寄せるかどうか。
- timeScaleIndependent(boolean)
- true の場合、Time.timeScale の影響を受けずに実時間ベースでズーム演出を行う。
- (Cocos Creator では Time.timeScale は直接はありませんが、将来的な拡張を想定し、ここでは
director.getDeltaTime()を使用しつつフラグだけ用意する例にしてもよいです。今回は エディタ上の挙動に影響しない説明用フラグとして残します。)
- allowInterrupt(boolean)
- true の場合、ズーム中に再度
triggerZoom()が呼ばれると、現在のズームを中断して最初からやり直す。 - false の場合、ズーム中の呼び出しは無視する。
- true の場合、ズーム中に再度
TypeScriptコードの実装
以下が完成した HitStopZoom.ts の全コードです。
import { _decorator, Component, Node, Camera, Vec3, v3, director } from 'cc';
const { ccclass, property } = _decorator;
/**
* HitStopZoom
* 攻撃ヒット時などに一瞬だけカメラをターゲットへズームインさせる汎用コンポーネント。
* - カメラノードにアタッチして使用します。
* - 外部の GameManager やシングルトンに依存しません。
* - public メソッド triggerZoom() を呼ぶだけで演出が発生します。
*/
@ccclass('HitStopZoom')
export class HitStopZoom extends Component {
@property({
type: Node,
tooltip: 'ズームイン時に注目したいターゲットノード。\n未設定の場合はカメラ位置はそのまま、ズームのみ行います。'
})
public target: Node | null = null;
@property({
tooltip: 'ズーム倍率。\n1.0 = 通常、0.5 = 2倍ズームイン。\n値が小さいほど強くズームインします。'
})
public zoomFactor: number = 0.7;
@property({
tooltip: 'ズームインにかける時間(秒)。\n0.05〜0.15 程度の短い値がおすすめです。'
})
public zoomInDuration: number = 0.08;
@property({
tooltip: 'ズームインした状態を維持する時間(秒)。'
})
public holdDuration: number = 0.05;
@property({
tooltip: '元のズーム・位置に戻る時間(秒)。'
})
public zoomOutDuration: number = 0.12;
@property({
tooltip: 'true の場合、ズームイン中にカメラ位置もターゲットへ寄せます。'
})
public moveToTarget: boolean = true;
@property({
tooltip: 'ターゲット位置へ寄せる強さ(0〜1)。\n1.0 = 完全にターゲット位置へ移動。\n0.5 = 中間地点まで寄る。',
min: 0,
max: 1,
slide: true
})
public positionLerpFactor: number = 0.6;
@property({
tooltip: 'true の場合、2Dゲーム想定でカメラのZ座標は固定し、X/Y のみターゲットへ寄せます。'
})
public ignoreZAxis: boolean = true;
@property({
tooltip: 'true の場合、ズーム中でも新しい triggerZoom 呼び出しで現在の演出を中断し、最初からやり直します。'
})
public allowInterrupt: boolean = true;
@property({
tooltip: '将来的な拡張用フラグ。現状は director.getDeltaTime() を使用しており、\ntrue/false いずれでも挙動は同じです。'
})
public timeScaleIndependent: boolean = true;
// 内部状態
private _camera: Camera | null = null;
private _originalOrthoHeight: number = 0;
private _originalPosition: Vec3 = v3();
private _isZooming: boolean = false;
private _elapsed: number = 0;
// フェーズ管理: 0 = idle, 1 = zoomIn, 2 = hold, 3 = zoomOut
private _phase: number = 0;
// 各フェーズの開始・終了時刻
private _zoomInEndTime: number = 0;
private _holdEndTime: number = 0;
private _zoomOutEndTime: number = 0;
// 内部で使用する一時ベクトル(GC削減のため)
private _tempVec3: Vec3 = v3();
onLoad() {
this._camera = this.getComponent(Camera);
if (!this._camera) {
console.error('[HitStopZoom] Camera コンポーネントが見つかりません。このスクリプトはカメラノードにアタッチしてください。');
return;
}
// Camera.orthoHeight を保存(2D 用)
this._originalOrthoHeight = this._camera.orthoHeight;
// カメラの初期位置を保存
this.node.getPosition(this._originalPosition);
}
start() {
// 特に初期処理は不要だが、将来の拡張を見越してメソッドを残しておく
}
/**
* 外部から呼び出してズーム演出を開始するメソッド。
* 例: 攻撃ヒット時のスクリプトから cameraNode.getComponent(HitStopZoom)?.triggerZoom();
*/
public triggerZoom(): void {
if (!this._camera) {
console.error('[HitStopZoom] Camera コンポーネントが見つからないため、triggerZoom() を実行できません。');
return;
}
if (this._isZooming && !this.allowInterrupt) {
// 演出中は新規トリガーを受け付けない
return;
}
// 現在のカメラ状態を保存してから開始
this._camera.orthoHeight = this._originalOrthoHeight;
this.node.getPosition(this._originalPosition);
this._isZooming = true;
this._elapsed = 0;
this._phase = 1;
this._zoomInEndTime = this.zoomInDuration;
this._holdEndTime = this._zoomInEndTime + this.holdDuration;
this._zoomOutEndTime = this._holdEndTime + this.zoomOutDuration;
}
update(deltaTime: number) {
if (!this._isZooming || !this._camera) {
return;
}
// 現状では timeScaleIndependent フラグに関わらず director.getDeltaTime を使用
const dt = director.getDeltaTime();
this._elapsed += dt;
if (this._phase === 1) {
// ズームインフェーズ
const t = this._clamp01(this._elapsed / this.zoomInDuration);
this._applyZoom(t);
this._applyPosition(t);
if (this._elapsed >= this._zoomInEndTime) {
this._phase = 2;
}
} else if (this._phase === 2) {
// ホールドフェーズ
this._applyZoom(1.0);
this._applyPosition(1.0);
if (this._elapsed >= this._holdEndTime) {
this._phase = 3;
}
} else if (this._phase === 3) {
// ズームアウトフェーズ
const tOut = this._clamp01(
(this._elapsed - this._holdEndTime) / this.zoomOutDuration
);
const t = 1.0 - tOut; // 1 → 0 へ戻す
this._applyZoom(t);
this._applyPosition(t);
if (this._elapsed >= this._zoomOutEndTime) {
// 完了
this._isZooming = false;
this._phase = 0;
// 念のため、元の状態を正確に復元
this._camera.orthoHeight = this._originalOrthoHeight;
this.node.setPosition(this._originalPosition);
}
}
}
/**
* t: 0〜1 でズームインの進行度を表す。
* t = 0: 元の orthoHeight
* t = 1: zoomFactor * 元の orthoHeight
*/
private _applyZoom(t: number): void {
const easedT = this._easeOutQuad(t);
const targetHeight = this._originalOrthoHeight * this.zoomFactor;
this._camera!.orthoHeight = this._lerp(this._originalOrthoHeight, targetHeight, easedT);
}
/**
* t: 0〜1 でターゲットへの寄り具合を表す。
*/
private _applyPosition(t: number): void {
if (!this.moveToTarget || !this.target) {
// 位置を動かさない設定
return;
}
// ターゲットのワールド座標を取得
this.target.getWorldPosition(this._tempVec3);
// カメラの親空間に合わせる(カメラが親を持っている場合を考慮)
const parent = this.node.parent;
if (parent) {
parent.inverseTransformPoint(this._tempVec3, this._tempVec3);
}
const targetPos = this._tempVec3;
// Z軸を保持したい場合は、元の Z を使う
if (this.ignoreZAxis) {
targetPos.z = this._originalPosition.z;
}
// positionLerpFactor で寄り具合を調整
const finalTargetX = this._lerp(this._originalPosition.x, targetPos.x, this.positionLerpFactor);
const finalTargetY = this._lerp(this._originalPosition.y, targetPos.y, this.positionLerpFactor);
const finalTargetZ = this._originalPosition.z; // Z は常に元の値に固定(2D前提)
const easedT = this._easeOutQuad(t);
const newX = this._lerp(this._originalPosition.x, finalTargetX, easedT);
const newY = this._lerp(this._originalPosition.y, finalTargetY, easedT);
const newZ = this._lerp(this._originalPosition.z, finalTargetZ, easedT);
this.node.setPosition(newX, newY, newZ);
}
private _clamp01(v: number): number {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
private _lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
// 少しだけメリハリをつけるための簡単なイージング
private _easeOutQuad(t: number): number {
return 1 - (1 - t) * (1 - t);
}
}
ライフサイクルと主要処理の解説
- onLoad()
this.getComponent(Camera)で Camera を取得。- 見つからない場合は
console.errorを出して終了(防御的実装)。 - カメラの初期設定(
orthoHeightと位置)を保存。
- triggerZoom()
- 外部から呼び出すための public API。
- 現在のカメラ状態(
orthoHeightと位置)を保存し直してから、内部タイマーとフェーズを初期化。 allowInterruptが false かつ演出中の場合は、新規トリガーを無視。
- update(deltaTime)
_isZoomingが true のときだけ処理。director.getDeltaTime()を使って経過時間_elapsedを進める。- フェーズ(
_phase)に応じて- ズームイン(phase 1)
- ホールド(phase 2)
- ズームアウト(phase 3)
を制御し、それぞれで
_applyZoom()と_applyPosition()を呼ぶ。 - ズームアウト完了後は、必ず元の状態を正確に復元し、
_isZooming = falseに戻す。
- _applyZoom(t)
- 0〜1 の進行度
tを受け取り、orthoHeightを線形補間。 - 簡単なイージング
_easeOutQuadをかけて、入りは素早く、終わりはなめらかに。
- 0〜1 の進行度
- _applyPosition(t)
moveToTargetが false またはtargetが null の場合は何もしない。- ターゲットのワールド座標を取得し、カメラの親空間に変換。
positionLerpFactorで「どれくらい寄るか」を決め、さらにt(進行度)とイージングで補間。- 2D 前提で Z 座標は固定。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ左下の Assets パネルで、任意のフォルダ(例:
assets/scripts)を右クリック。 - Create → TypeScript を選択。
- ファイル名を
HitStopZoom.tsに変更します。 - 作成された
HitStopZoom.tsをダブルクリックして開き、上記コードを全てコピー&ペーストして保存します。
2. カメラノードの準備
2D プロジェクトを想定した手順です。
- Hierarchy パネルで、既にある
Main Cameraノードを探します。- もしカメラが無い場合は、右クリック → Create → 3D Object → Camera から新規作成します。
Main Cameraノードを選択し、Inspector でCameraコンポーネントが付いていることを確認します。- もし付いていない場合は、Add Component → Camera から追加してください。
3. HitStopZoom コンポーネントをアタッチ
- Hierarchy で
Main Cameraノードを選択した状態で、Inspector を開きます。 - Inspector 下部の Add Component ボタンをクリック。
- Custom カテゴリの中から
HitStopZoomを選択して追加します。
4. プロパティの設定例
Inspector 上で、HitStopZoom の各プロパティを次のように設定してみます:
- target:
- Hierarchy から、プレイヤーや敵など「ヒット時に注目したいノード」をドラッグ&ドロップします。
- まずはプレイヤー(例:
Playerノード)を指定してみましょう。
- zoomFactor:
0.6- 少し強めにズームインします。
- zoomInDuration:
0.08 - holdDuration:
0.05 - zoomOutDuration:
0.15 - moveToTarget:
true - positionLerpFactor:
0.7 - ignoreZAxis:
true - allowInterrupt:
true - timeScaleIndependent:
true(現状はどちらでもよい)
5. ヒット時に triggerZoom を呼び出す
このコンポーネントは「アタッチするだけで内部状態は完結」していますが、いつズームさせるかはゲームロジック側から決める必要があります。
例えば、プレイヤーの攻撃スクリプト(例:PlayerAttack.ts)があると仮定して、敵にヒットした瞬間にカメラへ通知するコード例を示します。
// ※このスクリプトは例示用です。
// HitStopZoom 自体は他のスクリプトに依存せず単体で動作します。
import { _decorator, Component, Node } from 'cc';
import { HitStopZoom } from './HitStopZoom';
const { ccclass, property } = _decorator;
@ccclass('PlayerAttack')
export class PlayerAttack extends Component {
@property(Node)
public cameraNode: Node | null = null;
// 何らかの形で攻撃がヒットしたときに呼ばれるメソッド
onHitEnemy() {
if (!this.cameraNode) {
return;
}
const zoom = this.cameraNode.getComponent(HitStopZoom);
zoom?.triggerZoom();
}
}
このように、カメラノードにアタッチされた HitStopZoom の triggerZoom() を呼ぶだけで、ズーム演出が走ります。
6. エディタでの簡易動作確認(手動トリガー)
ゲームロジックがまだ無い場合でも、簡易的に動作を確認する方法があります。
- 一時的に
HitStopZoom.tsにテストコードを追加します(後で消す前提)。start()の中でthis.triggerZoom();を 1 回呼ぶようにします。
// HitStopZoom.ts 内の start() を一時的に次のように変更
start() {
// シーン開始時に一度だけズームテスト
this.triggerZoom();
}
- シーンを保存し、Play ボタンでゲームを実行します。
- シーン開始直後に、カメラが一瞬ターゲットへ寄ってズームイン → ゆっくり元に戻る動きが確認できれば成功です。
- 動作確認が終わったら、
start()内のテスト呼び出しは削除してください。
まとめ
HitStopZoomは、カメラにアタッチするだけで「攻撃ヒット時の一瞬ズームイン演出」を実現できる汎用コンポーネントです。- 外部の GameManager やシングルトンに依存せず、必要な情報はすべてインスペクタのプロパティから受け取る設計にしているため、どのプロジェクトにもそのまま持ち込めます。
zoomFactor / zoomInDuration / holdDuration / zoomOutDuration / moveToTarget / positionLerpFactorなどを調整することで、- 軽いヒット時は弱め&短めのズーム
- 必殺技ヒット時は強め&長めのズーム
といったバリエーションも簡単に作れます。
- ゲーム中の任意のタイミングで
triggerZoom()を呼ぶだけなので、既存の攻撃スクリプトやヒット判定処理に 1 行追加するだけで演出を強化できます。
このコンポーネントをベースに、「画面揺れ(カメラシェイク)」や「スローモーション」と組み合わせれば、さらにリッチなヒット演出も実現できます。まずは本記事の HitStopZoom をプロジェクトに組み込んで、ヒット感の向上を体感してみてください。




